0.0.9
parent
bda13bebab
commit
78e48a6ee3
|
|
@ -20,7 +20,8 @@
|
|||
"Bash(yarn build)",
|
||||
"Bash(source:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(uvicorn:*)"
|
||||
"Bash(uvicorn:*)",
|
||||
"Bash(cat:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ class Settings(BaseSettings):
|
|||
app_name: str = "Cosmo - Deep Space Explorer"
|
||||
api_prefix: str = "/api"
|
||||
|
||||
# CORS settings
|
||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
# CORS settings - allow all origins for development (IP access support)
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
# Cache settings
|
||||
cache_ttl_days: int = 3
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class CelestialBody(BaseModel):
|
|||
|
||||
id: str = Field(..., description="JPL Horizons ID")
|
||||
name: str = Field(..., description="Display name")
|
||||
name_zh: str | None = Field(None, description="Chinese name")
|
||||
type: Literal["planet", "probe", "star"] = Field(..., description="Body type")
|
||||
positions: list[Position] = Field(
|
||||
default_factory=list, description="Position history"
|
||||
|
|
@ -52,6 +53,7 @@ CELESTIAL_BODIES = {
|
|||
# Probes
|
||||
"-31": {
|
||||
"name": "Voyager 1",
|
||||
"name_zh": "旅行者1号",
|
||||
"type": "probe",
|
||||
"description": "离地球最远的人造物体,已进入星际空间",
|
||||
"launch_date": "1977-09-05",
|
||||
|
|
@ -59,6 +61,7 @@ CELESTIAL_BODIES = {
|
|||
},
|
||||
"-32": {
|
||||
"name": "Voyager 2",
|
||||
"name_zh": "旅行者2号",
|
||||
"type": "probe",
|
||||
"description": "唯一造访过天王星和海王星的探测器",
|
||||
"launch_date": "1977-08-20",
|
||||
|
|
@ -66,6 +69,7 @@ CELESTIAL_BODIES = {
|
|||
},
|
||||
"-98": {
|
||||
"name": "New Horizons",
|
||||
"name_zh": "新视野号",
|
||||
"type": "probe",
|
||||
"description": "飞掠冥王星,正处于柯伊伯带",
|
||||
"launch_date": "2006-01-19",
|
||||
|
|
@ -73,6 +77,7 @@ CELESTIAL_BODIES = {
|
|||
},
|
||||
"-96": {
|
||||
"name": "Parker Solar Probe",
|
||||
"name_zh": "帕克太阳探测器",
|
||||
"type": "probe",
|
||||
"description": "正在'触摸'太阳,速度最快的人造物体",
|
||||
"launch_date": "2018-08-12",
|
||||
|
|
@ -80,6 +85,7 @@ CELESTIAL_BODIES = {
|
|||
},
|
||||
"-61": {
|
||||
"name": "Juno",
|
||||
"name_zh": "朱诺号",
|
||||
"type": "probe",
|
||||
"description": "正在木星轨道运行",
|
||||
"launch_date": "2011-08-05",
|
||||
|
|
@ -87,6 +93,7 @@ CELESTIAL_BODIES = {
|
|||
},
|
||||
"-82": {
|
||||
"name": "Cassini",
|
||||
"name_zh": "卡西尼号",
|
||||
"type": "probe",
|
||||
"description": "土星探测器(已于2017年撞击销毁)",
|
||||
"launch_date": "1997-10-15",
|
||||
|
|
@ -94,6 +101,7 @@ CELESTIAL_BODIES = {
|
|||
},
|
||||
"-168": {
|
||||
"name": "Perseverance",
|
||||
"name_zh": "毅力号",
|
||||
"type": "probe",
|
||||
"description": "火星探测车",
|
||||
"launch_date": "2020-07-30",
|
||||
|
|
@ -102,51 +110,61 @@ CELESTIAL_BODIES = {
|
|||
# Planets
|
||||
"10": {
|
||||
"name": "Sun",
|
||||
"name_zh": "太阳",
|
||||
"type": "star",
|
||||
"description": "太阳,太阳系的中心",
|
||||
},
|
||||
"199": {
|
||||
"name": "Mercury",
|
||||
"name_zh": "水星",
|
||||
"type": "planet",
|
||||
"description": "水星,距离太阳最近的行星",
|
||||
},
|
||||
"299": {
|
||||
"name": "Venus",
|
||||
"name_zh": "金星",
|
||||
"type": "planet",
|
||||
"description": "金星,太阳系中最热的行星",
|
||||
},
|
||||
"399": {
|
||||
"name": "Earth",
|
||||
"name_zh": "地球",
|
||||
"type": "planet",
|
||||
"description": "地球,我们的家园",
|
||||
},
|
||||
"301": {
|
||||
"name": "Moon",
|
||||
"name_zh": "月球",
|
||||
"type": "planet",
|
||||
"description": "月球,地球的天然卫星",
|
||||
},
|
||||
"499": {
|
||||
"name": "Mars",
|
||||
"name_zh": "火星",
|
||||
"type": "planet",
|
||||
"description": "火星,红色星球",
|
||||
},
|
||||
"599": {
|
||||
"name": "Jupiter",
|
||||
"name_zh": "木星",
|
||||
"type": "planet",
|
||||
"description": "木星,太阳系中最大的行星",
|
||||
},
|
||||
"699": {
|
||||
"name": "Saturn",
|
||||
"name_zh": "土星",
|
||||
"type": "planet",
|
||||
"description": "土星,拥有美丽的光环",
|
||||
},
|
||||
"799": {
|
||||
"name": "Uranus",
|
||||
"name_zh": "天王星",
|
||||
"type": "planet",
|
||||
"description": "天王星,侧躺着自转的行星",
|
||||
},
|
||||
"899": {
|
||||
"name": "Neptune",
|
||||
"name_zh": "海王星",
|
||||
"type": "planet",
|
||||
"description": "海王星,太阳系最外层的行星",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@ class HorizonsService:
|
|||
if start_jd == end_jd:
|
||||
epochs = start_jd
|
||||
else:
|
||||
# Create range with step
|
||||
epochs = {"start": start_time.isoformat(), "stop": end_time.isoformat(), "step": step}
|
||||
# Create range with step - use JD (Julian Date) format for Horizons
|
||||
# JD format is more reliable than ISO strings
|
||||
epochs = {"start": str(start_jd), "stop": str(end_jd), "step": step}
|
||||
|
||||
logger.info(f"Querying Horizons for body {body_id} from {start_time} to {end_time}")
|
||||
|
||||
|
|
@ -139,6 +140,7 @@ class HorizonsService:
|
|||
body = CelestialBody(
|
||||
id=body_id,
|
||||
name=info["name"],
|
||||
name_zh=info.get("name_zh"),
|
||||
type=info["type"],
|
||||
positions=positions,
|
||||
description=info["description"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
[
|
||||
{
|
||||
"name": "Orion",
|
||||
"name_zh": "猎户座",
|
||||
"stars": [
|
||||
{"name": "Betelgeuse", "ra": 88.79, "dec": 7.41},
|
||||
{"name": "Bellatrix", "ra": 81.28, "dec": 6.35},
|
||||
{"name": "Alnitak", "ra": 85.19, "dec": -1.94},
|
||||
{"name": "Alnilam", "ra": 84.05, "dec": -1.20},
|
||||
{"name": "Mintaka", "ra": 83.00, "dec": -0.30},
|
||||
{"name": "Saiph", "ra": 86.94, "dec": -9.67},
|
||||
{"name": "Rigel", "ra": 78.63, "dec": -8.20}
|
||||
],
|
||||
"lines": [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
[2, 5],
|
||||
[5, 6]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ursa Major",
|
||||
"name_zh": "大熊座",
|
||||
"stars": [
|
||||
{"name": "Dubhe", "ra": 165.93, "dec": 61.75},
|
||||
{"name": "Merak", "ra": 165.46, "dec": 56.38},
|
||||
{"name": "Phecda", "ra": 178.46, "dec": 53.69},
|
||||
{"name": "Megrez", "ra": 183.86, "dec": 57.03},
|
||||
{"name": "Alioth", "ra": 193.51, "dec": 55.96},
|
||||
{"name": "Mizar", "ra": 200.98, "dec": 54.93},
|
||||
{"name": "Alkaid", "ra": 206.89, "dec": 49.31}
|
||||
],
|
||||
"lines": [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
[4, 5],
|
||||
[5, 6]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cassiopeia",
|
||||
"name_zh": "仙后座",
|
||||
"stars": [
|
||||
{"name": "Caph", "ra": 2.29, "dec": 59.15},
|
||||
{"name": "Schedar", "ra": 10.13, "dec": 56.54},
|
||||
{"name": "Navi", "ra": 14.18, "dec": 60.72},
|
||||
{"name": "Ruchbah", "ra": 21.45, "dec": 60.24},
|
||||
{"name": "Segin", "ra": 25.65, "dec": 63.67}
|
||||
],
|
||||
"lines": [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Leo",
|
||||
"name_zh": "狮子座",
|
||||
"stars": [
|
||||
{"name": "Regulus", "ra": 152.09, "dec": 11.97},
|
||||
{"name": "Denebola", "ra": 177.26, "dec": 14.57},
|
||||
{"name": "Algieba", "ra": 154.99, "dec": 19.84},
|
||||
{"name": "Zosma", "ra": 168.53, "dec": 20.52},
|
||||
{"name": "Chertan", "ra": 173.95, "dec": 15.43}
|
||||
],
|
||||
"lines": [
|
||||
[0, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
[4, 1],
|
||||
[1, 0]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Scorpius",
|
||||
"name_zh": "天蝎座",
|
||||
"stars": [
|
||||
{"name": "Antares", "ra": 247.35, "dec": -26.43},
|
||||
{"name": "Shaula", "ra": 263.40, "dec": -37.10},
|
||||
{"name": "Sargas", "ra": 264.33, "dec": -43.00},
|
||||
{"name": "Dschubba", "ra": 240.08, "dec": -22.62},
|
||||
{"name": "Lesath", "ra": 262.69, "dec": -37.29}
|
||||
],
|
||||
"lines": [
|
||||
[3, 0],
|
||||
[0, 1],
|
||||
[1, 4],
|
||||
[1, 2]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
[
|
||||
{
|
||||
"name": "Andromeda Galaxy",
|
||||
"name_zh": "仙女座星系",
|
||||
"type": "spiral",
|
||||
"distance_mly": 2.537,
|
||||
"ra": 10.68,
|
||||
"dec": 41.27,
|
||||
"magnitude": 3.44,
|
||||
"diameter_kly": 220,
|
||||
"color": "#CCDDFF"
|
||||
},
|
||||
{
|
||||
"name": "Triangulum Galaxy",
|
||||
"name_zh": "三角座星系",
|
||||
"type": "spiral",
|
||||
"distance_mly": 2.73,
|
||||
"ra": 23.46,
|
||||
"dec": 30.66,
|
||||
"magnitude": 5.72,
|
||||
"diameter_kly": 60,
|
||||
"color": "#AACCEE"
|
||||
},
|
||||
{
|
||||
"name": "Large Magellanic Cloud",
|
||||
"name_zh": "大麦哲伦云",
|
||||
"type": "irregular",
|
||||
"distance_mly": 0.163,
|
||||
"ra": 80.89,
|
||||
"dec": -69.76,
|
||||
"magnitude": 0.9,
|
||||
"diameter_kly": 14,
|
||||
"color": "#DDCCFF"
|
||||
},
|
||||
{
|
||||
"name": "Small Magellanic Cloud",
|
||||
"name_zh": "小麦哲伦云",
|
||||
"type": "irregular",
|
||||
"distance_mly": 0.197,
|
||||
"ra": 12.80,
|
||||
"dec": -73.15,
|
||||
"magnitude": 2.7,
|
||||
"diameter_kly": 7,
|
||||
"color": "#CCBBEE"
|
||||
},
|
||||
{
|
||||
"name": "Milky Way Center",
|
||||
"name_zh": "银河系中心",
|
||||
"type": "galactic_center",
|
||||
"distance_mly": 0.026,
|
||||
"ra": 266.42,
|
||||
"dec": -29.01,
|
||||
"magnitude": -1,
|
||||
"diameter_kly": 100,
|
||||
"color": "#FFFFAA"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
[
|
||||
{
|
||||
"name": "Proxima Centauri",
|
||||
"name_zh": "比邻星",
|
||||
"distance_ly": 4.24,
|
||||
"ra": 217.43,
|
||||
"dec": -62.68,
|
||||
"magnitude": 11.05,
|
||||
"color": "#FF9966"
|
||||
},
|
||||
{
|
||||
"name": "Alpha Centauri A",
|
||||
"name_zh": "南门二A",
|
||||
"distance_ly": 4.37,
|
||||
"ra": 219.90,
|
||||
"dec": -60.83,
|
||||
"magnitude": -0.01,
|
||||
"color": "#FFFFAA"
|
||||
},
|
||||
{
|
||||
"name": "Barnard's Star",
|
||||
"name_zh": "巴纳德星",
|
||||
"distance_ly": 5.96,
|
||||
"ra": 269.45,
|
||||
"dec": 4.69,
|
||||
"magnitude": 9.54,
|
||||
"color": "#FF6666"
|
||||
},
|
||||
{
|
||||
"name": "Sirius",
|
||||
"name_zh": "天狼星",
|
||||
"distance_ly": 8.6,
|
||||
"ra": 101.29,
|
||||
"dec": -16.72,
|
||||
"magnitude": -1.46,
|
||||
"color": "#FFFFFF"
|
||||
},
|
||||
{
|
||||
"name": "Epsilon Eridani",
|
||||
"name_zh": "天苑四",
|
||||
"distance_ly": 10.5,
|
||||
"ra": 53.23,
|
||||
"dec": -9.46,
|
||||
"magnitude": 3.73,
|
||||
"color": "#FFDDAA"
|
||||
},
|
||||
{
|
||||
"name": "Procyon",
|
||||
"name_zh": "南河三",
|
||||
"distance_ly": 11.4,
|
||||
"ra": 114.83,
|
||||
"dec": 5.22,
|
||||
"magnitude": 0.34,
|
||||
"color": "#FFFFDD"
|
||||
},
|
||||
{
|
||||
"name": "Tau Ceti",
|
||||
"name_zh": "天仓五",
|
||||
"distance_ly": 11.9,
|
||||
"ra": 26.02,
|
||||
"dec": -15.94,
|
||||
"magnitude": 3.50,
|
||||
"color": "#FFFFCC"
|
||||
},
|
||||
{
|
||||
"name": "Vega",
|
||||
"name_zh": "织女星",
|
||||
"distance_ly": 25,
|
||||
"ra": 279.23,
|
||||
"dec": 38.78,
|
||||
"magnitude": 0.03,
|
||||
"color": "#AACCFF"
|
||||
},
|
||||
{
|
||||
"name": "Arcturus",
|
||||
"name_zh": "大角星",
|
||||
"distance_ly": 37,
|
||||
"ra": 213.92,
|
||||
"dec": 19.18,
|
||||
"magnitude": -0.05,
|
||||
"color": "#FFCC99"
|
||||
},
|
||||
{
|
||||
"name": "Altair",
|
||||
"name_zh": "牛郎星",
|
||||
"distance_ly": 17,
|
||||
"ra": 297.70,
|
||||
"dec": 8.87,
|
||||
"magnitude": 0.77,
|
||||
"color": "#FFFFFF"
|
||||
},
|
||||
{
|
||||
"name": "Betelgeuse",
|
||||
"name_zh": "参宿四",
|
||||
"distance_ly": 548,
|
||||
"ra": 88.79,
|
||||
"dec": 7.41,
|
||||
"magnitude": 0.42,
|
||||
"color": "#FF4444"
|
||||
},
|
||||
{
|
||||
"name": "Rigel",
|
||||
"name_zh": "参宿七",
|
||||
"distance_ly": 860,
|
||||
"ra": 78.63,
|
||||
"dec": -8.20,
|
||||
"magnitude": 0.13,
|
||||
"color": "#AADDFF"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"Voyager 1": "/models/voyager_1.glb",
|
||||
"Voyager 2": "/models/voyager_2.glb",
|
||||
"Juno": "/models/juno.glb",
|
||||
"Cassini": "/models/cassini.glb",
|
||||
"New Horizons": null,
|
||||
"Parker Solar Probe": "/models/parker_solar_probe.glb",
|
||||
"Perseverance": null
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
{
|
||||
"bodies": [
|
||||
{
|
||||
"id": "10",
|
||||
"name": "Sun",
|
||||
"name_zh": "太阳",
|
||||
"type": "star",
|
||||
"description": "太阳,太阳系的中心"
|
||||
},
|
||||
{
|
||||
"id": "199",
|
||||
"name": "Mercury",
|
||||
"name_zh": "水星",
|
||||
"type": "planet",
|
||||
"description": "水星,距离太阳最近的行星"
|
||||
},
|
||||
{
|
||||
"id": "299",
|
||||
"name": "Venus",
|
||||
"name_zh": "金星",
|
||||
"type": "planet",
|
||||
"description": "金星,太阳系中最热的行星"
|
||||
},
|
||||
{
|
||||
"id": "399",
|
||||
"name": "Earth",
|
||||
"name_zh": "地球",
|
||||
"type": "planet",
|
||||
"description": "地球,我们的家园"
|
||||
},
|
||||
{
|
||||
"id": "301",
|
||||
"name": "Moon",
|
||||
"name_zh": "月球",
|
||||
"type": "planet",
|
||||
"description": "月球,地球的天然卫星"
|
||||
},
|
||||
{
|
||||
"id": "499",
|
||||
"name": "Mars",
|
||||
"name_zh": "火星",
|
||||
"type": "planet",
|
||||
"description": "火星,红色星球"
|
||||
},
|
||||
{
|
||||
"id": "599",
|
||||
"name": "Jupiter",
|
||||
"name_zh": "木星",
|
||||
"type": "planet",
|
||||
"description": "木星,太阳系中最大的行星"
|
||||
},
|
||||
{
|
||||
"id": "699",
|
||||
"name": "Saturn",
|
||||
"name_zh": "土星",
|
||||
"type": "planet",
|
||||
"description": "土星,拥有美丽的光环"
|
||||
},
|
||||
{
|
||||
"id": "799",
|
||||
"name": "Uranus",
|
||||
"name_zh": "天王星",
|
||||
"type": "planet",
|
||||
"description": "天王星,侧躺着自转的行星"
|
||||
},
|
||||
{
|
||||
"id": "899",
|
||||
"name": "Neptune",
|
||||
"name_zh": "海王星",
|
||||
"type": "planet",
|
||||
"description": "海王星,太阳系最外层的行星"
|
||||
},
|
||||
{
|
||||
"id": "-31",
|
||||
"name": "Voyager 1",
|
||||
"name_zh": "旅行者1号",
|
||||
"type": "probe",
|
||||
"description": "离地球最远的人造物体,已进入星际空间",
|
||||
"launch_date": "1977-09-05",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "-32",
|
||||
"name": "Voyager 2",
|
||||
"name_zh": "旅行者2号",
|
||||
"type": "probe",
|
||||
"description": "唯一造访过天王星和海王星的探测器",
|
||||
"launch_date": "1977-08-20",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "-98",
|
||||
"name": "New Horizons",
|
||||
"name_zh": "新视野号",
|
||||
"type": "probe",
|
||||
"description": "飞掠冥王星,正处于柯伊伯带",
|
||||
"launch_date": "2006-01-19",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "-96",
|
||||
"name": "Parker Solar Probe",
|
||||
"name_zh": "帕克太阳探测器",
|
||||
"type": "probe",
|
||||
"description": "正在'触摸'太阳,速度最快的人造物体",
|
||||
"launch_date": "2018-08-12",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "-61",
|
||||
"name": "Juno",
|
||||
"name_zh": "朱诺号",
|
||||
"type": "probe",
|
||||
"description": "正在木星轨道运行",
|
||||
"launch_date": "2011-08-05",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"id": "-82",
|
||||
"name": "Cassini",
|
||||
"name_zh": "卡西尼号",
|
||||
"type": "probe",
|
||||
"description": "土星探测器(已于2017年撞击销毁)",
|
||||
"launch_date": "1997-10-15",
|
||||
"status": "inactive"
|
||||
},
|
||||
{
|
||||
"id": "-168",
|
||||
"name": "Perseverance",
|
||||
"name_zh": "毅力号",
|
||||
"type": "probe",
|
||||
"description": "火星探测车",
|
||||
"launch_date": "2020-07-30",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2025-11-27",
|
||||
"description": "Solar system celestial bodies catalog including planets, moons, and space probes. Future updates may include comets, asteroids, and other objects."
|
||||
}
|
||||
}
|
||||
|
|
@ -2,19 +2,47 @@
|
|||
* Cosmo - Deep Space Explorer
|
||||
* Main application component
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSpaceData } from './hooks/useSpaceData';
|
||||
import { useHistoricalData } from './hooks/useHistoricalData';
|
||||
import { useTrajectory } from './hooks/useTrajectory';
|
||||
import { Scene } from './components/Scene';
|
||||
import { ProbeList } from './components/ProbeList';
|
||||
import { TimelineController } from './components/TimelineController';
|
||||
import { Loading } from './components/Loading';
|
||||
import type { CelestialBody } from './types';
|
||||
|
||||
function App() {
|
||||
const { bodies, loading, error } = useSpaceData();
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [isTimelineMode, setIsTimelineMode] = useState(false);
|
||||
|
||||
// Use real-time data or historical data based on mode
|
||||
const { bodies: realTimeBodies, loading: realTimeLoading, error: realTimeError } = useSpaceData();
|
||||
const { bodies: historicalBodies, loading: historicalLoading, error: historicalError } = useHistoricalData(selectedDate);
|
||||
|
||||
const bodies = isTimelineMode ? historicalBodies : realTimeBodies;
|
||||
const loading = isTimelineMode ? historicalLoading : realTimeLoading;
|
||||
const error = isTimelineMode ? historicalError : realTimeError;
|
||||
|
||||
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
|
||||
const { trajectoryPositions } = useTrajectory(selectedBody);
|
||||
|
||||
// Handle time change from timeline controller
|
||||
const handleTimeChange = useCallback((date: Date) => {
|
||||
setSelectedDate(date);
|
||||
}, []);
|
||||
|
||||
// Toggle timeline mode
|
||||
const toggleTimelineMode = useCallback(() => {
|
||||
setIsTimelineMode((prev) => !prev);
|
||||
if (!isTimelineMode) {
|
||||
// Entering timeline mode, set initial date to Cassini launch (1997)
|
||||
setSelectedDate(new Date(1997, 0, 1));
|
||||
} else {
|
||||
setSelectedDate(null);
|
||||
}
|
||||
}, [isTimelineMode]);
|
||||
|
||||
// Filter probes and planets from all bodies
|
||||
const probes = bodies.filter((b) => b.type === 'probe');
|
||||
const planets = bodies.filter((b) => b.type === 'planet');
|
||||
|
|
@ -50,6 +78,16 @@ function App() {
|
|||
<p className="text-xs text-gray-400 mt-1">
|
||||
{selectedBody ? `聚焦: ${selectedBody.name}` : `${bodies.length} 个天体`}
|
||||
</p>
|
||||
<button
|
||||
onClick={toggleTimelineMode}
|
||||
className={`mt-2 px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isTimelineMode
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isTimelineMode ? '🕐 时间轴模式 (点击退出)' : '📅 切换到时间轴模式'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Probe List Sidebar */}
|
||||
|
|
@ -67,6 +105,15 @@ function App() {
|
|||
trajectoryPositions={trajectoryPositions}
|
||||
/>
|
||||
|
||||
{/* Timeline Controller */}
|
||||
{isTimelineMode && (
|
||||
<TimelineController
|
||||
onTimeChange={handleTimeChange}
|
||||
minDate={new Date(1997, 0, 1)} // Cassini launch date
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Instructions overlay */}
|
||||
<div className="absolute bottom-4 right-4 z-50 text-white text-xs bg-black bg-opacity-70 p-3 rounded">
|
||||
{selectedBody ? (
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ function Planet({ body, size, emissive, emissiveIntensity }: {
|
|||
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
|
||||
|
|
@ -185,7 +184,7 @@ function Planet({ body, size, emissive, emissiveIntensity }: {
|
|||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{body.name}
|
||||
{body.name_zh || body.name}
|
||||
<br />
|
||||
<span style={{ fontSize: '8px', opacity: 0.7 }}>
|
||||
{distance.toFixed(2)} AU
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Constellations component - renders major constellations with connecting lines
|
||||
*/
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Line, Text, Billboard } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface ConstellationStar {
|
||||
name: string;
|
||||
ra: number; // Right Ascension in degrees
|
||||
dec: number; // Declination in degrees
|
||||
}
|
||||
|
||||
interface Constellation {
|
||||
name: string;
|
||||
name_zh: string;
|
||||
stars: ConstellationStar[];
|
||||
lines: [number, number][]; // Indices of stars to connect
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RA/Dec to Cartesian coordinates
|
||||
* Use fixed distance for constellation stars to create celestial sphere effect
|
||||
*/
|
||||
function raDecToCartesian(ra: number, dec: number, distance: number = 100) {
|
||||
const raRad = (ra * Math.PI) / 180;
|
||||
const decRad = (dec * Math.PI) / 180;
|
||||
|
||||
const x = distance * Math.cos(decRad) * Math.cos(raRad);
|
||||
const y = distance * Math.cos(decRad) * Math.sin(raRad);
|
||||
const z = distance * Math.sin(decRad);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
export function Constellations() {
|
||||
const [constellations, setConstellations] = useState<Constellation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load constellation data
|
||||
fetch('/data/constellations.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setConstellations(data))
|
||||
.catch((err) => console.error('Failed to load constellations:', err));
|
||||
}, []);
|
||||
|
||||
const constellationLines = useMemo(() => {
|
||||
return constellations.map((constellation) => {
|
||||
// Convert all stars to 3D positions
|
||||
const starPositions = constellation.stars.map((star) =>
|
||||
raDecToCartesian(star.ra, star.dec)
|
||||
);
|
||||
|
||||
// Create line segments based on connection indices
|
||||
const lineSegments = constellation.lines.map(([startIdx, endIdx]) => ({
|
||||
start: starPositions[startIdx],
|
||||
end: starPositions[endIdx],
|
||||
}));
|
||||
|
||||
// Calculate center position for label (average of all stars)
|
||||
const center = starPositions.reduce(
|
||||
(acc, pos) => acc.add(pos),
|
||||
new THREE.Vector3()
|
||||
).divideScalar(starPositions.length);
|
||||
|
||||
return {
|
||||
name: constellation.name,
|
||||
nameZh: constellation.name_zh,
|
||||
starPositions,
|
||||
lineSegments,
|
||||
center,
|
||||
};
|
||||
});
|
||||
}, [constellations]);
|
||||
|
||||
if (constellationLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{constellationLines.map((constellation) => (
|
||||
<group key={constellation.name}>
|
||||
{/* Render constellation stars */}
|
||||
{constellation.starPositions.map((pos, idx) => (
|
||||
<mesh key={`${constellation.name}-star-${idx}`} position={pos}>
|
||||
<sphereGeometry args={[0.3, 8, 8]} />
|
||||
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.8} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Render connecting lines */}
|
||||
{constellation.lineSegments.map((segment, idx) => (
|
||||
<Line
|
||||
key={`${constellation.name}-line-${idx}`}
|
||||
points={[segment.start, segment.end]}
|
||||
color="#4488FF"
|
||||
lineWidth={1}
|
||||
transparent
|
||||
opacity={0.5}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Constellation name label */}
|
||||
<Billboard position={constellation.center}>
|
||||
<Text
|
||||
fontSize={2}
|
||||
color="#88AAFF"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.1}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{constellation.nameZh}
|
||||
</Text>
|
||||
</Billboard>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Galaxies component - renders distant galaxies as billboards
|
||||
*/
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Billboard, Text, useTexture } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface Galaxy {
|
||||
name: string;
|
||||
name_zh: string;
|
||||
type: string;
|
||||
distance_mly: number; // Distance in millions of light years
|
||||
ra: number; // Right Ascension in degrees
|
||||
dec: number; // Declination in degrees
|
||||
magnitude: number;
|
||||
diameter_kly: number; // Diameter in thousands of light years
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a procedural galaxy texture
|
||||
*/
|
||||
function createGalaxyTexture(color: string, type: string): THREE.Texture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const radius = canvas.width / 2;
|
||||
|
||||
// Create radial gradient for galaxy glow
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
|
||||
// Parse color
|
||||
const tempColor = new THREE.Color(color);
|
||||
const r = Math.floor(tempColor.r * 255);
|
||||
const g = Math.floor(tempColor.g * 255);
|
||||
const b = Math.floor(tempColor.b * 255);
|
||||
|
||||
if (type === 'spiral') {
|
||||
// Spiral galaxy: bright core with arms
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, 1.0)`);
|
||||
gradient.addColorStop(0.1, `rgba(${r}, ${g}, ${b}, 0.9)`);
|
||||
gradient.addColorStop(0.3, `rgba(${r}, ${g}, ${b}, 0.6)`);
|
||||
gradient.addColorStop(0.6, `rgba(${r}, ${g}, ${b}, 0.3)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
} else if (type === 'irregular') {
|
||||
// Irregular galaxy: more diffuse
|
||||
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.8)`);
|
||||
gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.5)`);
|
||||
gradient.addColorStop(0.8, `rgba(${r}, ${g}, ${b}, 0.2)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
} else {
|
||||
// Galactic center or elliptical: bright concentrated core
|
||||
gradient.addColorStop(0, `rgba(255, 255, 220, 1.0)`);
|
||||
gradient.addColorStop(0.2, `rgba(${r}, ${g}, ${b}, 0.9)`);
|
||||
gradient.addColorStop(0.5, `rgba(${r}, ${g}, ${b}, 0.5)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
}
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Add some star-like points for detail (only for spiral galaxies)
|
||||
if (type === 'spiral') {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = Math.random() * radius * 0.7;
|
||||
const x = centerX + Math.cos(angle) * dist;
|
||||
const y = centerY + Math.sin(angle) * dist;
|
||||
const size = Math.random() * 1.5;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RA/Dec to Cartesian coordinates for distant objects
|
||||
*/
|
||||
function raDecToCartesian(ra: number, dec: number, distance: number) {
|
||||
const raRad = (ra * Math.PI) / 180;
|
||||
const decRad = (dec * Math.PI) / 180;
|
||||
|
||||
const x = distance * Math.cos(decRad) * Math.cos(raRad);
|
||||
const y = distance * Math.cos(decRad) * Math.sin(raRad);
|
||||
const z = distance * Math.sin(decRad);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visual size based on actual diameter and distance
|
||||
*/
|
||||
function calculateAngularSize(diameterKly: number, distanceMly: number): number {
|
||||
// Angular diameter in radians
|
||||
const angularDiameter = diameterKly / (distanceMly * 1000);
|
||||
// Significantly reduced multiplier for much smaller galaxies
|
||||
return Math.max(0.5, angularDiameter * 200);
|
||||
}
|
||||
|
||||
export function Galaxies() {
|
||||
const [galaxies, setGalaxies] = useState<Galaxy[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load galaxy data
|
||||
fetch('/data/galaxies.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setGalaxies(data))
|
||||
.catch((err) => console.error('Failed to load galaxies:', err));
|
||||
}, []);
|
||||
|
||||
const galaxyData = useMemo(() => {
|
||||
return galaxies.map((galaxy) => {
|
||||
// Place galaxies on celestial sphere at fixed distance for visualization
|
||||
const visualDistance = 200; // Fixed distance for celestial sphere
|
||||
const position = raDecToCartesian(galaxy.ra, galaxy.dec, visualDistance);
|
||||
|
||||
// Calculate visual size based on actual properties
|
||||
const size = galaxy.type === 'galactic_center'
|
||||
? 2 // Smaller for Milky Way center
|
||||
: calculateAngularSize(galaxy.diameter_kly, galaxy.distance_mly);
|
||||
|
||||
// Create procedural texture for this galaxy
|
||||
const texture = createGalaxyTexture(galaxy.color, galaxy.type);
|
||||
|
||||
return {
|
||||
...galaxy,
|
||||
position,
|
||||
size,
|
||||
texture,
|
||||
};
|
||||
});
|
||||
}, [galaxies]);
|
||||
|
||||
if (galaxyData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{galaxyData.map((galaxy) => (
|
||||
<group key={galaxy.name}>
|
||||
<Billboard
|
||||
position={galaxy.position}
|
||||
follow={true}
|
||||
lockX={false}
|
||||
lockY={false}
|
||||
lockZ={false}
|
||||
>
|
||||
{/* Galaxy texture */}
|
||||
<mesh>
|
||||
<planeGeometry args={[galaxy.size * 3, galaxy.size * 3]} />
|
||||
<meshBasicMaterial
|
||||
map={galaxy.texture}
|
||||
transparent
|
||||
opacity={0.8}
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
</Billboard>
|
||||
|
||||
{/* Galaxy name label - positioned slightly outward from galaxy */}
|
||||
<Billboard position={galaxy.position.clone().multiplyScalar(1.03)}>
|
||||
<Text
|
||||
fontSize={1.5}
|
||||
color="#DDAAFF"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.1}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{galaxy.name_zh}
|
||||
</Text>
|
||||
</Billboard>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Probe component - renders space probes with 3D models
|
||||
*/
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { Group } from 'three';
|
||||
import { useGLTF, Html } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
|
|
@ -12,6 +12,12 @@ interface ProbeProps {
|
|||
body: CelestialBody;
|
||||
}
|
||||
|
||||
// Load probe model mapping from data file
|
||||
const loadProbeModels = async (): Promise<Record<string, string | null>> => {
|
||||
const response = await fetch('/data/probe-models.json');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Separate component for each probe type to properly use hooks
|
||||
function ProbeModel({ body, modelPath }: { body: CelestialBody; modelPath: string }) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
|
|
@ -110,7 +116,7 @@ function ProbeModel({ body, modelPath }: { body: CelestialBody; modelPath: strin
|
|||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
🛰️ {body.name}
|
||||
🛰️ {body.name_zh || body.name}
|
||||
<br />
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||
{distance.toFixed(2)} AU
|
||||
|
|
@ -168,7 +174,7 @@ function ProbeFallback({ body }: { body: CelestialBody }) {
|
|||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
🛰️ {body.name}
|
||||
🛰️ {body.name_zh || body.name}
|
||||
<br />
|
||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||
{distance.toFixed(2)} AU
|
||||
|
|
@ -180,18 +186,19 @@ function ProbeFallback({ body }: { body: CelestialBody }) {
|
|||
|
||||
export function Probe({ body }: ProbeProps) {
|
||||
const position = body.positions[0];
|
||||
if (!position) return null;
|
||||
const [modelMap, setModelMap] = useState<Record<string, string | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 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
|
||||
};
|
||||
// Load model mapping on mount
|
||||
useEffect(() => {
|
||||
loadProbeModels().then((data) => {
|
||||
setModelMap(data);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!position) return null;
|
||||
if (isLoading) return null; // Wait for model map to load
|
||||
|
||||
const modelPath = modelMap[body.name];
|
||||
|
||||
|
|
@ -203,15 +210,10 @@ export function Probe({ body }: ProbeProps) {
|
|||
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);
|
||||
// Preload available models from data file
|
||||
loadProbeModels().then((modelMap) => {
|
||||
const modelsToPreload = Object.values(modelMap).filter((path): path is string => path !== null);
|
||||
modelsToPreload.forEach((path) => {
|
||||
useGLTF.preload(path);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody }: Probe
|
|||
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"
|
||||
className="absolute top-36 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">
|
||||
|
|
@ -54,7 +54,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody }: Probe
|
|||
}
|
||||
|
||||
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="absolute top-36 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">
|
||||
🌍 天体列表
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
* Main 3D Scene component
|
||||
*/
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Stars } from '@react-three/drei';
|
||||
import { OrbitControls, Stars as BackgroundStars } 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 { Stars } from './Stars';
|
||||
import { Constellations } from './Constellations';
|
||||
import { Galaxies } from './Galaxies';
|
||||
import { scalePosition } from '../utils/scaleDistance';
|
||||
import type { CelestialBody as CelestialBodyType, Position } from '../types';
|
||||
|
||||
|
|
@ -64,8 +67,8 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneP
|
|||
{/* Additional directional light to illuminate planets */}
|
||||
<directionalLight position={[10, 10, 5]} intensity={0.3} />
|
||||
|
||||
{/* Stars background */}
|
||||
<Stars
|
||||
{/* Stars background (procedural) */}
|
||||
<BackgroundStars
|
||||
radius={300}
|
||||
depth={60}
|
||||
count={5000}
|
||||
|
|
@ -74,6 +77,15 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [] }: SceneP
|
|||
fade={true}
|
||||
/>
|
||||
|
||||
{/* Nearby stars (real data) */}
|
||||
<Stars />
|
||||
|
||||
{/* Major constellations */}
|
||||
<Constellations />
|
||||
|
||||
{/* Distant galaxies */}
|
||||
<Galaxies />
|
||||
|
||||
{/* Render planets and stars */}
|
||||
{planets.map((body) => (
|
||||
<CelestialBody key={body.id} body={body} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Stars component - renders nearby stars in 3D space
|
||||
*/
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Text, Billboard } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface Star {
|
||||
name: string;
|
||||
name_zh: string;
|
||||
distance_ly: number;
|
||||
ra: number; // Right Ascension in degrees
|
||||
dec: number; // Declination in degrees
|
||||
magnitude: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RA/Dec to Cartesian coordinates
|
||||
* RA: Right Ascension (0-360 degrees)
|
||||
* Dec: Declination (-90 to 90 degrees)
|
||||
* Distance: fixed distance for celestial sphere
|
||||
*/
|
||||
function raDecToCartesian(ra: number, dec: number, distance: number = 150) {
|
||||
// Convert to radians
|
||||
const raRad = (ra * Math.PI) / 180;
|
||||
const decRad = (dec * Math.PI) / 180;
|
||||
|
||||
// Convert to Cartesian coordinates
|
||||
const x = distance * Math.cos(decRad) * Math.cos(raRad);
|
||||
const y = distance * Math.cos(decRad) * Math.sin(raRad);
|
||||
const z = distance * Math.sin(decRad);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale star brightness based on magnitude
|
||||
* Lower magnitude = brighter star
|
||||
*/
|
||||
function magnitudeToSize(magnitude: number): number {
|
||||
// Brighter stars (lower magnitude) should be slightly larger
|
||||
// But all stars should be very small compared to planets
|
||||
const normalized = Math.max(-2, Math.min(12, magnitude));
|
||||
return Math.max(0.15, 0.6 - normalized * 0.04);
|
||||
}
|
||||
|
||||
export function Stars() {
|
||||
const [stars, setStars] = useState<Star[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load star data
|
||||
fetch('/data/nearby-stars.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setStars(data))
|
||||
.catch((err) => console.error('Failed to load stars:', err));
|
||||
}, []);
|
||||
|
||||
const starData = useMemo(() => {
|
||||
return stars.map((star) => {
|
||||
// Place all stars on a celestial sphere at fixed distance (150 units)
|
||||
// This way they appear as background objects, similar to constellations
|
||||
const position = raDecToCartesian(star.ra, star.dec, 150);
|
||||
|
||||
// Size based on brightness (magnitude)
|
||||
const size = magnitudeToSize(star.magnitude);
|
||||
|
||||
return {
|
||||
...star,
|
||||
position,
|
||||
size,
|
||||
};
|
||||
});
|
||||
}, [stars]);
|
||||
|
||||
if (starData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{starData.map((star) => (
|
||||
<group key={star.name}>
|
||||
{/* Star sphere */}
|
||||
<mesh position={star.position}>
|
||||
<sphereGeometry args={[star.size, 16, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={star.color}
|
||||
transparent
|
||||
opacity={0.9}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Star glow */}
|
||||
<mesh position={star.position}>
|
||||
<sphereGeometry args={[star.size * 2, 16, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={star.color}
|
||||
transparent
|
||||
opacity={0.2}
|
||||
blending={THREE.AdditiveBlending}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Star name label - positioned radially outward from star */}
|
||||
<Billboard position={star.position.clone().multiplyScalar(1.05)}>
|
||||
<Text
|
||||
fontSize={1.2}
|
||||
color="#FFFFFF"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{star.name_zh}
|
||||
</Text>
|
||||
</Billboard>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* TimelineController - controls time for viewing historical positions
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export interface TimelineState {
|
||||
currentDate: Date;
|
||||
isPlaying: boolean;
|
||||
speed: number; // days per second
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
interface TimelineControllerProps {
|
||||
onTimeChange: (date: Date) => void;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
}
|
||||
|
||||
export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineControllerProps) {
|
||||
const startDate = minDate || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // 1 year ago
|
||||
const endDate = maxDate || new Date();
|
||||
|
||||
const [currentDate, setCurrentDate] = useState<Date>(startDate); // Start from minDate instead of maxDate
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState(30); // 30 days per second
|
||||
const animationFrameRef = useRef<number>();
|
||||
const lastUpdateRef = useRef<number>(Date.now());
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
const now = Date.now();
|
||||
const deltaSeconds = (now - lastUpdateRef.current) / 1000;
|
||||
lastUpdateRef.current = now;
|
||||
|
||||
setCurrentDate((prev) => {
|
||||
const newDate = new Date(prev.getTime() + speed * deltaSeconds * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Loop back to start if we reach the end
|
||||
if (newDate > endDate) {
|
||||
return new Date(startDate);
|
||||
}
|
||||
|
||||
return newDate;
|
||||
});
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
lastUpdateRef.current = Date.now();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, speed, startDate, endDate]);
|
||||
|
||||
// Notify parent of time changes
|
||||
useEffect(() => {
|
||||
onTimeChange(currentDate);
|
||||
}, [currentDate, onTimeChange]);
|
||||
|
||||
const handlePlayPause = useCallback(() => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleSpeedChange = useCallback((newSpeed: number) => {
|
||||
setSpeed(newSpeed);
|
||||
}, []);
|
||||
|
||||
const handleSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
const totalRange = endDate.getTime() - startDate.getTime();
|
||||
const newDate = new Date(startDate.getTime() + (value / 100) * totalRange);
|
||||
setCurrentDate(newDate);
|
||||
setIsPlaying(false);
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const currentProgress = ((currentDate.getTime() - startDate.getTime()) / (endDate.getTime() - startDate.getTime())) * 100;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-1/2 transform -translate-x-1/2 z-50 bg-black bg-opacity-80 p-4 rounded-lg shadow-lg min-w-96">
|
||||
<div className="text-white text-center mb-2">
|
||||
<div className="text-sm font-bold mb-1">时间轴</div>
|
||||
<div className="text-xs text-gray-300">{currentDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={currentProgress}
|
||||
onChange={handleSliderChange}
|
||||
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer mb-3"
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
{isPlaying ? '⏸ 暂停' : '▶ 播放'}
|
||||
</button>
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-xs">速度:</span>
|
||||
<select
|
||||
value={speed}
|
||||
onChange={(e) => handleSpeedChange(Number(e.target.value))}
|
||||
className="bg-gray-700 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
<option value="1">1x (1天/秒)</option>
|
||||
<option value="7">7x (1周/秒)</option>
|
||||
<option value="30">30x (1月/秒)</option>
|
||||
<option value="365">365x (1年/秒)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentDate(new Date(startDate));
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded text-xs"
|
||||
>
|
||||
⏮ 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Custom hook for fetching historical celestial data
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchCelestialPositions } from '../utils/api';
|
||||
import type { CelestialBody } from '../types';
|
||||
|
||||
export function useHistoricalData(selectedDate: Date | null) {
|
||||
const [bodies, setBodies] = useState<CelestialBody[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHistoricalData = useCallback(async (date: Date) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// For historical data, we just need a single snapshot at the given date
|
||||
// Set start and end to the same date, or use a small range
|
||||
const startDate = new Date(date);
|
||||
const endDate = new Date(date);
|
||||
endDate.setDate(endDate.getDate() + 1); // Add 1 day to ensure valid range
|
||||
|
||||
const data = await fetchCelestialPositions(
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
'1d'
|
||||
);
|
||||
|
||||
setBodies(data.bodies);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch historical data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
loadHistoricalData(selectedDate);
|
||||
}
|
||||
}, [selectedDate, loadHistoricalData]);
|
||||
|
||||
return { bodies, loading, error };
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export interface Position {
|
|||
export interface CelestialBody {
|
||||
id: string;
|
||||
name: string;
|
||||
name_zh?: string;
|
||||
type: CelestialBodyType;
|
||||
positions: Position[];
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,60 @@
|
|||
import axios from 'axios';
|
||||
import type { CelestialDataResponse, BodyInfo } from '../types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000/api';
|
||||
// Dynamically determine the API base URL
|
||||
// If VITE_API_BASE_URL is set, use it; otherwise use the current host with port 8000
|
||||
const getApiBaseUrl = () => {
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
console.log('[API] Using VITE_API_BASE_URL:', import.meta.env.VITE_API_BASE_URL);
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
}
|
||||
|
||||
// Use the same host as the frontend, but with port 8000
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
const apiUrl = `${protocol}//${hostname}:8000/api`;
|
||||
console.log('[API] Constructed API URL:', apiUrl);
|
||||
console.log('[API] Protocol:', protocol, 'Hostname:', hostname);
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
console.log('[API] Final API_BASE_URL:', API_BASE_URL);
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Add request interceptor for debugging
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log('[API Request]', config.method?.toUpperCase(), config.url, config.params);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API Request Error]', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor for debugging
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log('[API Response]', response.config.url, response.status, 'Data:', response.data);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API Error]', error.config?.url, error.message);
|
||||
if (error.response) {
|
||||
console.error('[API Error Response]', error.response.status, error.response.data);
|
||||
} else if (error.request) {
|
||||
console.error('[API Error Request]', error.request);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch celestial positions
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,4 +4,8 @@ import react from '@vitejs/plugin-react'
|
|||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0', // Listen on all network interfaces
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue