main
mula.liu 2025-11-27 18:14:25 +08:00
parent bda13bebab
commit 78e48a6ee3
23 changed files with 1211 additions and 40 deletions

View File

@ -20,7 +20,8 @@
"Bash(yarn build)",
"Bash(source:*)",
"Bash(python:*)",
"Bash(uvicorn:*)"
"Bash(uvicorn:*)",
"Bash(cat:*)"
],
"deny": [],
"ask": []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ export interface Position {
export interface CelestialBody {
id: string;
name: string;
name_zh?: string;
type: CelestialBodyType;
positions: Position[];
description?: string;

View File

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

View File

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