初步完成了太阳系内的行星显示

main
mula.liu 2025-11-27 13:16:19 +08:00
commit bda13bebab
71 changed files with 5876 additions and 0 deletions

BIN
.DS_Store vendored 100644

Binary file not shown.

View File

@ -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": []
}
}

View File

@ -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. 所有提交必须通过基本测试

225
PROJECT.md 100644
View File

@ -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生产环境

200
QUICKSTART.md 100644
View File

@ -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)

371
README.md 100644
View File

@ -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 倍的固定比例放大,等整个流程跑通了,再加入动态缩放的逻辑来提升体验。

151
STATUS.md 100644
View File

@ -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 个天体的精确位置!

View File

@ -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

47
backend/.gitignore vendored 100644
View File

@ -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/

76
backend/README.md 100644
View File

@ -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
```

View File

View File

View File

@ -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"}

View File

@ -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()

View File

@ -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",
)

View File

View File

@ -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": "海王星,太阳系最外层的行星",
},
}

View File

View File

@ -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()

View File

@ -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()

View File

@ -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

24
frontend/.gitignore vendored 100644
View File

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

74
frontend/README.md 100644
View File

@ -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

View File

@ -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,
},
},
])

View File

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

View File

@ -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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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;
}

View File

@ -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;

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,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;
}

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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);
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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;
}

View File

@ -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>,
)

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -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"]
}

View File

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

View File

@ -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"]
}

View File

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

2443
frontend/yarn.lock 100644

File diff suppressed because it is too large Load Diff