Compare commits

...

9 Commits

38 changed files with 3282 additions and 669 deletions

View File

@ -1,5 +1,5 @@
# Application Settings
APP_NAME=Cosmo - Deep Space Explorer
APP_NAME=COSMO - Deep Space Explorer
API_PREFIX=/api
# CORS Settings (comma-separated list)

View File

@ -51,7 +51,7 @@ REDIS_MAX_CONNECTIONS=50 # 最大连接数
### 3. 应用配置
```bash
APP_NAME=Cosmo - Deep Space Explorer
APP_NAME=COSMO - Deep Space Explorer
API_PREFIX=/api
CORS_ORIGINS=["*"] # 开发环境允许所有来源
CACHE_TTL_DAYS=3 # NASA API 缓存天数

View File

@ -25,6 +25,7 @@ class CelestialBodyCreate(BaseModel):
name_zh: Optional[str] = None
type: str
description: Optional[str] = None
details: Optional[str] = None
is_active: bool = True
extra_data: Optional[Dict[str, Any]] = None
@ -34,6 +35,7 @@ class CelestialBodyUpdate(BaseModel):
name_zh: Optional[str] = None
type: Optional[str] = None
description: Optional[str] = None
details: Optional[str] = None
is_active: Optional[bool] = None
extra_data: Optional[Dict[str, Any]] = None
@ -58,7 +60,8 @@ async def create_celestial_body(
@router.get("/search")
async def search_celestial_body(
name: str = Query(..., description="Body name or ID to search in NASA Horizons")
name: str = Query(..., description="Body name or ID to search in NASA Horizons"),
db: AsyncSession = Depends(get_db)
):
"""
Search for a celestial body in NASA Horizons database by name or ID
@ -68,7 +71,7 @@ async def search_celestial_body(
logger.info(f"Searching for celestial body: {name}")
try:
result = horizons_service.search_body_by_name(name)
result = await horizons_service.search_body_by_name(name, db)
if result["success"]:
logger.info(f"Found body: {result['full_name']}")
@ -172,6 +175,7 @@ async def get_body_info(body_id: str, db: AsyncSession = Depends(get_db)):
name=body.name,
type=body.type,
description=body.description,
details=body.details,
launch_date=extra_data.get("launch_date"),
status=extra_data.get("status"),
)
@ -211,6 +215,7 @@ async def list_bodies(
"name_zh": body.name_zh,
"type": body.type,
"description": body.description,
"details": body.details,
"is_active": body.is_active,
"resources": resources_by_type,
"has_resources": len(resources) > 0,

View File

@ -76,7 +76,8 @@ async def get_celestial_positions(
# Check Redis cache first (persistent across restarts)
start_str = "now"
end_str = "now"
redis_key = make_cache_key("positions", start_str, end_str, step)
body_ids_str = body_ids if body_ids else "all"
redis_key = make_cache_key("positions", start_str, end_str, step, body_ids_str)
redis_cached = await redis_cache.get(redis_key)
if redis_cached is not None:
logger.info("Cache hit (Redis) for recent positions")
@ -194,7 +195,8 @@ async def get_celestial_positions(
# Cache in Redis for persistence across restarts
start_str = start_dt.isoformat() if start_dt else "now"
end_str = end_dt.isoformat() if end_dt else "now"
redis_key = make_cache_key("positions", start_str, end_str, step)
body_ids_str = body_ids if body_ids else "all"
redis_key = make_cache_key("positions", start_str, end_str, step, body_ids_str)
await redis_cache.set(redis_key, bodies_data, get_ttl_seconds("current_positions"))
return CelestialDataResponse(bodies=bodies_data)
else:
@ -204,7 +206,8 @@ async def get_celestial_positions(
# Check Redis cache first (persistent across restarts)
start_str = start_dt.isoformat() if start_dt else "now"
end_str = end_dt.isoformat() if end_dt else "now"
redis_key = make_cache_key("positions", start_str, end_str, step)
body_ids_str = body_ids if body_ids else "all" # Include body_ids in cache key
redis_key = make_cache_key("positions", start_str, end_str, step, body_ids_str)
redis_cached = await redis_cache.get(redis_key)
if redis_cached is not None:
logger.info("Cache hit (Redis) for positions")
@ -222,7 +225,9 @@ async def get_celestial_positions(
# Filter bodies if body_ids specified
if body_id_list:
logger.info(f"Filtering bodies from {len(all_bodies)} total. Requested IDs: {body_id_list}")
all_bodies = [b for b in all_bodies if b.id in body_id_list]
logger.info(f"After filtering: {len(all_bodies)} bodies. IDs: {[b.id for b in all_bodies]}")
use_db_cache = True
db_cached_bodies = []
@ -334,15 +339,15 @@ async def get_celestial_positions(
# Special handling for Cassini (mission ended 2017-09-15)
elif body.id == "-82":
cassini_date = datetime(2017, 9, 15, 11, 58, 0)
pos_data = horizons_service.get_body_positions(body.id, cassini_date, cassini_date, step)
pos_data = await horizons_service.get_body_positions(body.id, cassini_date, cassini_date, step)
positions_list = [
{"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z}
for p in pos_data
]
else:
# Query NASA Horizons for other bodies
pos_data = horizons_service.get_body_positions(body.id, start_dt, end_dt, step)
# Download from NASA Horizons
pos_data = await horizons_service.get_body_positions(body.id, start_dt, end_dt, step)
positions_list = [
{"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z}
for p in pos_data

View File

@ -217,7 +217,8 @@ async def download_positions(
continue
# Download from NASA Horizons
positions = horizons_service.get_body_positions(
logger.info(f"Downloading position for body {body_id} on {date_str}")
positions = await horizons_service.get_body_positions(
body_id=body_id,
start_time=target_date,
end_time=target_date,
@ -225,6 +226,7 @@ async def download_positions(
)
if positions and len(positions) > 0:
logger.info(f"Received position data for body {body_id}: x={positions[0].x}, y={positions[0].y}, z={positions[0].z}")
# Save to database
position_data = [{
"time": target_date,
@ -242,6 +244,17 @@ async def download_positions(
source="nasa_horizons",
session=db
)
logger.info(f"Saved position for body {body_id} on {date_str}")
# Invalidate caches for this date to ensure fresh data is served
from app.services.redis_cache import redis_cache, make_cache_key
start_str = target_date.isoformat()
end_str = target_date.isoformat()
# Clear both "all bodies" cache and specific body cache
for body_ids_str in ["all", body_id]:
redis_key = make_cache_key("positions", start_str, end_str, "1d", body_ids_str)
await redis_cache.delete(redis_key)
logger.debug(f"Invalidated cache: {redis_key}")
body_results["dates"].append({
"date": date_str,
@ -282,3 +295,89 @@ async def download_positions(
except Exception as e:
logger.error(f"Download failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/delete")
async def delete_positions(
request: DownloadPositionRequest,
db: AsyncSession = Depends(get_db)
):
"""
Delete position data for specified bodies on specified dates
Args:
- body_ids: List of celestial body IDs
- dates: List of dates (YYYY-MM-DD format)
Returns:
- Summary of deleted data
"""
logger.info(f"Deleting positions for {len(request.body_ids)} bodies on {len(request.dates)} dates")
try:
total_deleted = 0
from sqlalchemy import text
for body_id in request.body_ids:
# Invalidate caches for this body
from app.services.redis_cache import redis_cache, make_cache_key
# We need to loop dates to delete specific records
for date_str in request.dates:
try:
# Parse date
target_date = datetime.strptime(date_str, "%Y-%m-%d")
# End of day
end_of_day = target_date.replace(hour=23, minute=59, second=59, microsecond=999999)
# Execute deletion
# Using text() for raw SQL is often simpler for range deletes,
# but ORM is safer. Let's use ORM with execute.
# But since position_service might not have delete, we do it here.
stmt = text("""
DELETE FROM positions
WHERE body_id = :body_id
AND time >= :start_time
AND time <= :end_time
""")
result = await db.execute(stmt, {
"body_id": body_id,
"start_time": target_date,
"end_time": end_of_day
})
deleted_count = result.rowcount
total_deleted += deleted_count
if deleted_count > 0:
logger.info(f"Deleted {deleted_count} records for {body_id} on {date_str}")
# Invalidate cache for this specific date/body combo
# Note: This is approximate as cache keys might cover ranges
start_str = target_date.isoformat()
end_str = target_date.isoformat()
# Clear both "all bodies" cache and specific body cache
for body_ids_str in ["all", body_id]:
# We try to clear '1d' step cache
redis_key = make_cache_key("positions", start_str, end_str, "1d", body_ids_str)
await redis_cache.delete(redis_key)
except Exception as e:
logger.error(f"Failed to delete data for {body_id} on {date_str}: {e}")
await db.commit()
# Clear general patterns to be safe if ranges were cached
await redis_cache.clear_pattern("positions:*")
return {
"message": f"Successfully deleted {total_deleted} position records",
"total_deleted": total_deleted
}
except Exception as e:
await db.rollback()
logger.error(f"Delete failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -16,7 +16,7 @@ class Settings(BaseSettings):
)
# Application
app_name: str = "Cosmo - Deep Space Explorer"
app_name: str = "COSMO - Deep Space Explorer"
api_prefix: str = "/api"
# CORS settings - stored as string in env, converted to list
@ -67,6 +67,7 @@ class Settings(BaseSettings):
# Proxy settings (for accessing NASA JPL Horizons API in China)
http_proxy: str = ""
https_proxy: str = ""
nasa_api_timeout: int = 30
@property
def proxy_dict(self) -> dict[str, str] | None:

View File

@ -45,6 +45,7 @@ class BodyInfo(BaseModel):
name: str
type: Literal["planet", "probe", "star", "dwarf_planet", "satellite", "comet"]
description: str
details: str | None = None
launch_date: str | None = None
status: str | None = None
@ -200,4 +201,26 @@ CELESTIAL_BODIES = {
"type": "dwarf_planet",
"description": "鸟神星,柯伊伯带中第二亮的天体",
},
# Comets / Interstellar Objects
"1I": {
"name": "1I/'Oumuamua",
"name_zh": "奥陌陌",
"type": "comet",
"description": "原定名 1I/2017 U1是已知第一颗经过太阳系的星际天体。它于2017年10月18日UT在距离地球约0.2 AU30,000,000 km19,000,000 mi处被泛星1号望远镜发现并在极端双曲线的轨道上运行。",
"status": "active",
},
"3I": {
"name": "3I/ATLAS",
"name_zh": "3I/ATLAS",
"type": "comet",
"description": "又称C/2025 N1 (ATLAS)是一颗星际彗星由位于智利里奥乌尔塔多的小行星陆地撞击持续报警系统于2025年7月1日发现",
"status": "active",
},
"90000030": {
"name": "1P/Halley",
"name_zh": "哈雷彗星",
"type": "comet",
"description": "哈雷彗星正式名称为1P/Halley是著名的短周期彗星每隔75-76年就能从地球上被观测到[5],亦是唯一能用肉眼直接从地球看到的短周期彗星,人的一生中可能经历两次其来访。",
"status": "active",
},
}

View File

@ -18,6 +18,7 @@ class CelestialBody(Base):
name_zh = Column(String(200), nullable=True, comment="Chinese name")
type = Column(String(50), nullable=False, comment="Body type")
description = Column(Text, nullable=True, comment="Description")
details = Column(Text, nullable=True, comment="Detailed description (Markdown)")
is_active = Column(Boolean, nullable=True, comment="Active status for probes (True=active, False=inactive)")
extra_data = Column(JSONB, nullable=True, comment="Extended metadata (JSON)")
created_at = Column(TIMESTAMP, server_default=func.now())

View File

@ -2,12 +2,12 @@
NASA JPL Horizons data query service
"""
from datetime import datetime, timedelta
from astroquery.jplhorizons import Horizons
from astropy.time import Time
import logging
import re
import httpx
import os
from sqlalchemy.ext.asyncio import AsyncSession # Added this import
from app.models.celestial import Position, CelestialBody
from app.config import settings
@ -21,15 +21,7 @@ class HorizonsService:
def __init__(self):
"""Initialize the service"""
self.location = "@sun" # Heliocentric coordinates
# Set proxy for astroquery if configured
# astroquery uses standard HTTP_PROXY and HTTPS_PROXY environment variables
if settings.http_proxy:
os.environ['HTTP_PROXY'] = settings.http_proxy
logger.info(f"Set HTTP_PROXY for astroquery: {settings.http_proxy}")
if settings.https_proxy:
os.environ['HTTPS_PROXY'] = settings.https_proxy
logger.info(f"Set HTTPS_PROXY for astroquery: {settings.https_proxy}")
# Proxy is handled via settings.proxy_dict in each request
async def get_object_data_raw(self, body_id: str) -> str:
"""
@ -56,13 +48,13 @@ class HorizonsService:
try:
# Configure proxy if available
client_kwargs = {"timeout": 5.0}
client_kwargs = {"timeout": settings.nasa_api_timeout}
if settings.proxy_dict:
client_kwargs["proxies"] = settings.proxy_dict
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
async with httpx.AsyncClient(**client_kwargs) as client:
logger.info(f"Fetching raw data for body {body_id}")
logger.info(f"Fetching raw data for body {body_id} with timeout {settings.nasa_api_timeout}s")
response = await client.get(url, params=params)
if response.status_code != 200:
@ -73,7 +65,7 @@ class HorizonsService:
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
raise
def get_body_positions(
async def get_body_positions(
self,
body_id: str,
start_time: datetime | None = None,
@ -99,157 +91,254 @@ class HorizonsService:
if end_time is None:
end_time = start_time
# Convert to astropy Time objects for single point queries
# For ranges, use ISO format strings which Horizons prefers
# Format time for Horizons
# NASA Horizons accepts: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'
# When querying a single point (same start/end date), we need STOP > START
# So we add 1 second and use precise time format
# Create time range
if start_time == end_time:
# Single time point - use JD format
epochs = Time(start_time).jd
if start_time.date() == end_time.date():
# Single day query - use the date at 00:00 and next second
start_str = start_time.strftime('%Y-%m-%d')
# For STOP, add 1 day to satisfy STOP > START requirement
# But use step='1d' so we only get one data point
end_time_adjusted = start_time + timedelta(days=1)
end_str = end_time_adjusted.strftime('%Y-%m-%d')
else:
# Time range - use ISO format (YYYY-MM-DD HH:MM)
# Horizons expects this format for ranges
start_str = start_time.strftime('%Y-%m-%d %H:%M')
end_str = end_time.strftime('%Y-%m-%d %H:%M')
epochs = {"start": start_str, "stop": end_str, "step": step}
# Multi-day range query
start_str = start_time.strftime('%Y-%m-%d')
end_str = end_time.strftime('%Y-%m-%d')
logger.info(f"Querying Horizons for body {body_id} from {start_time} to {end_time}")
logger.info(f"Querying Horizons (httpx) for body {body_id} from {start_str} to {end_str}")
# Query JPL Horizons
obj = Horizons(id=body_id, location=self.location, epochs=epochs)
vectors = obj.vectors()
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id
# 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)
params = {
"format": "text",
"COMMAND": cmd_val,
"OBJ_DATA": "NO",
"MAKE_EPHEM": "YES",
"EPHEM_TYPE": "VECTORS",
"CENTER": self.location,
"START_TIME": start_str,
"STOP_TIME": end_str,
"STEP_SIZE": step,
"CSV_FORMAT": "YES",
"OUT_UNITS": "AU-D"
}
logger.info(f"Successfully retrieved {len(positions)} positions for body {body_id}")
return positions
# Configure proxy if available
client_kwargs = {"timeout": settings.nasa_api_timeout}
if settings.proxy_dict:
client_kwargs["proxies"] = settings.proxy_dict
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(url, params=params)
if response.status_code != 200:
raise Exception(f"NASA API returned status {response.status_code}")
return self._parse_vectors(response.text)
except Exception as e:
logger.error(f"Error querying Horizons for body {body_id}: {str(e)}")
raise
def search_body_by_name(self, name: str) -> dict:
def _parse_vectors(self, text: str) -> list[Position]:
"""
Search for a celestial body by name in NASA Horizons database
Parse Horizons CSV output for vector data
Args:
name: Body name or ID to search for
Format looks like:
$$SOE
2460676.500000000, A.D. 2025-Jan-01 00:00:00.0000, 9.776737278236609E-01, -1.726677228793678E-01, -1.636678733289160E-05, ...
$$EOE
"""
positions = []
Returns:
Dictionary with search results:
{
"success": bool,
"id": str (extracted or input),
"name": str (short name),
"full_name": str (complete name from NASA),
"error": str (if failed)
}
# Extract data block between $$SOE and $$EOE
match = re.search(r'\$\$SOE(.*?)\$\$EOE', text, re.DOTALL)
if not match:
logger.warning("No data block ($$SOE...$$EOE) found in Horizons response")
# Log full response for debugging
logger.info(f"Full response for debugging:\n{text}")
return []
data_block = match.group(1).strip()
lines = data_block.split('\n')
for line in lines:
parts = [p.strip() for p in line.split(',')]
if len(parts) < 5:
continue
try:
# Index 0: JD, 1: Date, 2: X, 3: Y, 4: Z, 5: VX, 6: VY, 7: VZ
# Time parsing: 2460676.500000000 is JD.
# A.D. 2025-Jan-01 00:00:00.0000 is Calendar.
# We can use JD or parse the string. Using JD via astropy is accurate.
jd_str = parts[0]
time_obj = Time(float(jd_str), format="jd").datetime
x = float(parts[2])
y = float(parts[3])
z = float(parts[4])
# Velocity if available (indices 5, 6, 7)
vx = float(parts[5]) if len(parts) > 5 else None
vy = float(parts[6]) if len(parts) > 6 else None
vz = float(parts[7]) if len(parts) > 7 else None
pos = Position(
time=time_obj,
x=x,
y=y,
z=z,
vx=vx,
vy=vy,
vz=vz
)
positions.append(pos)
except ValueError as e:
logger.warning(f"Failed to parse line: {line}. Error: {e}")
continue
return positions
async def search_body_by_name(self, name: str, db: AsyncSession) -> dict:
"""
Search for a celestial body by name in NASA Horizons database using httpx.
This method replaces the astroquery-based search to unify proxy and timeout control.
"""
try:
logger.info(f"Searching Horizons for: {name}")
logger.info(f"Searching Horizons (httpx) for: {name}")
# Try to query with the name
obj = Horizons(id=name, location=self.location)
vec = obj.vectors()
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
cmd_val = f"'{name}'" # Name can be ID or actual name
# Get the full target name from response
targetname = vec['targetname'][0]
logger.info(f"Found target: {targetname}")
# Extract ID and name from targetname
# Possible formats:
# 1. "136472 Makemake (2005 FY9)" - ID at start
# 2. "Voyager 1 (spacecraft) (-31)" - ID in parentheses
# 3. "Mars (499)" - ID in parentheses
# 4. "Parker Solar Probe (spacecraft)" - no ID
# 5. "Hubble Space Telescope (spacecra" - truncated
numeric_id = None
short_name = None
# Check if input is already a numeric ID
input_is_numeric = re.match(r'^-?\d+$', name.strip())
if input_is_numeric:
numeric_id = name.strip()
# Extract name from targetname
# Remove leading ID if present
name_part = re.sub(r'^\d+\s+', '', targetname)
short_name = name_part.split('(')[0].strip()
else:
# Try to extract ID from start of targetname (format: "136472 Makemake")
start_match = re.match(r'^(\d+)\s+(.+)', targetname)
if start_match:
numeric_id = start_match.group(1)
short_name = start_match.group(2).split('(')[0].strip()
else:
# Try to extract ID from parentheses (format: "Name (-31)" or "Name (499)")
id_match = re.search(r'\((-?\d+)\)', targetname)
if id_match:
numeric_id = id_match.group(1)
short_name = targetname.split('(')[0].strip()
else:
# No numeric ID found, use input name as ID
numeric_id = name
short_name = targetname.split('(')[0].strip()
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": targetname,
"error": None
params = {
"format": "text",
"COMMAND": cmd_val,
"OBJ_DATA": "YES", # Request object data to get canonical name/ID
"MAKE_EPHEM": "NO", # Don't need ephemeris
"EPHEM_TYPE": "OBSERVER", # Arbitrary, won't be used since MAKE_EPHEM=NO
"CENTER": "@ssb" # Search from Solar System Barycenter for consistent object IDs
}
timeout = settings.nasa_api_timeout
client_kwargs = {"timeout": timeout}
if settings.proxy_dict:
client_kwargs["proxies"] = settings.proxy_dict
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(url, params=params)
if response.status_code != 200:
raise Exception(f"NASA API returned status {response.status_code}")
response_text = response.text
# Log full response for debugging (temporarily)
logger.info(f"Full NASA API response for '{name}':\n{response_text}")
# Check for "Ambiguous target name"
if "Ambiguous target name" in response_text:
logger.warning(f"Ambiguous target name for: {name}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID"
}
# Check for "No matches found" or "Unknown target"
if "No matches found" in response_text or "Unknown target" in response_text:
logger.warning(f"No matches found for: {name}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": "未找到匹配的天体,请检查名称或 ID"
}
# Try multiple parsing patterns for different response formats
# Pattern 1: "Target body name: Jupiter Barycenter (599)"
target_name_match = re.search(r"Target body name:\s*(.+?)\s+\((\-?\d+)\)", response_text)
if not target_name_match:
# Pattern 2: " Revised: Mar 12, 2021 Ganymede / (Jupiter) 503"
# This pattern appears in the header section of many bodies
revised_match = re.search(r"Revised:.*?\s{2,}(.+?)\s{2,}(\-?\d+)\s*$", response_text, re.MULTILINE)
if revised_match:
full_name = revised_match.group(1).strip()
numeric_id = revised_match.group(2).strip()
short_name = full_name.split('/')[0].strip() # Remove parent body info like "/ (Jupiter)"
logger.info(f"Found target (pattern 2): {full_name} with ID: {numeric_id}")
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": full_name,
"error": None
}
if not target_name_match:
# Pattern 3: Look for body name in title section (works for comets and other objects)
# Example: "JPL/HORIZONS ATLAS (C/2025 N1) 2025-Dec-"
title_match = re.search(r"JPL/HORIZONS\s+(.+?)\s{2,}", response_text)
if title_match:
full_name = title_match.group(1).strip()
# For this pattern, the ID was in the original COMMAND, use it
numeric_id = name.strip("'\"")
short_name = full_name.split('(')[0].strip()
logger.info(f"Found target (pattern 3): {full_name} with ID: {numeric_id}")
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": full_name,
"error": None
}
if target_name_match:
full_name = target_name_match.group(1).strip()
numeric_id = target_name_match.group(2).strip()
short_name = full_name.split('(')[0].strip() # Remove any part after '('
logger.info(f"Found target (pattern 1): {full_name} with ID: {numeric_id}")
return {
"success": True,
"id": numeric_id,
"name": short_name,
"full_name": full_name,
"error": None
}
else:
# Fallback if specific pattern not found, might be a valid but weird response
logger.warning(f"Could not parse target name/ID from response for: {name}. Response snippet: {response_text[:500]}")
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": f"未能解析 JPL Horizons 响应,请尝试精确 ID: {name}"
}
except Exception as e:
error_msg = str(e)
logger.error(f"Error searching for {name}: {error_msg}")
# Check for specific error types
if 'Ambiguous target name' in error_msg:
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID"
}
elif 'No matches found' in error_msg or 'Unknown target' in error_msg:
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": "未找到匹配的天体,请检查名称或 ID"
}
else:
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": f"查询失败: {error_msg}"
}
return {
"success": False,
"id": None,
"name": None,
"full_name": None,
"error": f"查询失败: {error_msg}"
}
# Singleton instance
horizons_service = HorizonsService()
horizons_service = HorizonsService()

View File

@ -60,7 +60,7 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List
success_count += 1
else:
# Download
positions = horizons_service.get_body_positions(
positions = await horizons_service.get_body_positions(
body_id=body_id,
start_time=target_date,
end_time=target_date,

View File

@ -150,7 +150,7 @@ class OrbitService:
try:
# Get positions from Horizons (synchronous call)
positions = horizons_service.get_body_positions(
positions = await horizons_service.get_body_positions(
body_id=body_id,
start_time=start_time,
end_time=end_time,

View File

@ -0,0 +1,68 @@
"""
Check database status: bodies, positions, resources
"""
import asyncio
import os
import sys
from datetime import datetime
# Add backend directory to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.database import get_db
from app.models.db.celestial_body import CelestialBody
from app.models.db.position import Position
from app.models.db.resource import Resource
from sqlalchemy import select, func
async def check_status():
"""Check database status"""
print("🔍 Checking database status...")
async for session in get_db():
try:
# 1. Check Celestial Bodies
stmt = select(func.count(CelestialBody.id))
result = await session.execute(stmt)
body_count = result.scalar()
print(f"✅ Celestial Bodies: {body_count}")
# 2. Check Positions
stmt = select(func.count(Position.id))
result = await session.execute(stmt)
position_count = result.scalar()
print(f"✅ Total Positions: {position_count}")
# Check positions for Sun (10) and Earth (399)
for body_id in ['10', '399']:
stmt = select(func.count(Position.id)).where(Position.body_id == body_id)
result = await session.execute(stmt)
count = result.scalar()
print(f" - Positions for {body_id}: {count}")
if count > 0:
# Get latest position date
stmt = select(func.max(Position.time)).where(Position.body_id == body_id)
result = await session.execute(stmt)
latest_date = result.scalar()
print(f" Latest date: {latest_date}")
# 3. Check Resources
stmt = select(func.count(Resource.id))
result = await session.execute(stmt)
resource_count = result.scalar()
print(f"✅ Total Resources: {resource_count}")
# Check resources for Sun (10)
stmt = select(Resource).where(Resource.body_id == '10')
result = await session.execute(stmt)
resources = result.scalars().all()
print(f" - Resources for Sun (10): {len(resources)}")
for r in resources:
print(f" - {r.resource_type}: {r.file_path}")
finally:
break
if __name__ == "__main__":
asyncio.run(check_status())

View File

@ -0,0 +1,50 @@
import asyncio
import os
import sys
from datetime import datetime
# Add backend directory to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.database import get_db
from app.models.db import Position
from sqlalchemy import select, func
async def check_sun_data():
"""Check data for 2025-12-04 00:00:00"""
async for session in get_db():
try:
target_time = datetime(2025, 12, 4, 0, 0, 0)
print(f"Checking data for all bodies at {target_time}...")
# Get all bodies
from app.models.db.celestial_body import CelestialBody
stmt = select(CelestialBody.id, CelestialBody.name, CelestialBody.type).where(CelestialBody.is_active != False)
result = await session.execute(stmt)
all_bodies = result.all()
print(f"Total active bodies: {len(all_bodies)}")
# Check positions for each
missing_bodies = []
for body_id, body_name, body_type in all_bodies:
stmt = select(func.count(Position.id)).where(
Position.body_id == body_id,
Position.time == target_time
)
result = await session.execute(stmt)
count = result.scalar()
if count == 0:
missing_bodies.append(f"{body_name} ({body_id}) [{body_type}]")
if missing_bodies:
print(f"❌ Missing data for {len(missing_bodies)} bodies:")
for b in missing_bodies:
print(f" - {b}")
else:
print("✅ All active bodies have data for this time!")
finally:
break
if __name__ == "__main__":
asyncio.run(check_sun_data())

View File

@ -0,0 +1,58 @@
"""
Fix missing Sun position
"""
import asyncio
import os
import sys
from datetime import datetime
# Add backend directory to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.database import get_db
from app.models.db import Position
async def fix_sun_position():
"""Insert missing position for Sun at 2025-12-04 00:00:00"""
async for session in get_db():
try:
target_time = datetime(2025, 12, 4, 0, 0, 0)
print(f"Fixing Sun position for {target_time}...")
# Check if it exists first (double check)
from sqlalchemy import select, func
stmt = select(func.count(Position.id)).where(
Position.body_id == '10',
Position.time == target_time
)
result = await session.execute(stmt)
count = result.scalar()
if count > 0:
print("✅ Position already exists!")
return
# Insert
new_pos = Position(
body_id='10',
time=target_time,
x=0.0,
y=0.0,
z=0.0,
vx=0.0,
vy=0.0,
vz=0.0,
source='calculated'
)
session.add(new_pos)
await session.commit()
print("✅ Successfully inserted Sun position!")
except Exception as e:
print(f"❌ Error: {e}")
await session.rollback()
finally:
break
if __name__ == "__main__":
asyncio.run(fix_sun_position())

View File

@ -0,0 +1,39 @@
import asyncio
import os
import sys
from sqlalchemy import select
from datetime import datetime
# Add backend directory to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.database import get_db
from app.models.db import Position
async def inspect_sun_positions():
async for session in get_db():
try:
# List all positions for Sun
stmt = select(Position.time).where(Position.body_id == '10').order_by(Position.time.desc()).limit(10)
result = await session.execute(stmt)
times = result.scalars().all()
print("Recent Sun positions:")
for t in times:
print(f" - {t} (type: {type(t)})")
# Check specifically for 2025-12-04
target = datetime(2025, 12, 4, 0, 0, 0)
stmt = select(Position).where(
Position.body_id == '10',
Position.time == target
)
result = await session.execute(stmt)
pos = result.scalar()
print(f"\nExact match for {target}: {pos}")
finally:
break
if __name__ == "__main__":
asyncio.run(inspect_sun_positions())

View File

@ -0,0 +1,53 @@
"""
Reset position data to fix units (KM -> AU)
"""
import asyncio
import os
import sys
# Add backend directory to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.database import get_db
from app.models.db import Position
from app.services.redis_cache import redis_cache
from sqlalchemy import text
async def reset_data():
"""Clear positions and cache to force re-fetch in AU"""
print("🧹 Clearing old data (KM) to prepare for AU...")
async for session in get_db():
try:
# Clear positions table
print(" Truncating positions table...")
await session.execute(text("TRUNCATE TABLE positions RESTART IDENTITY CASCADE"))
# Clear nasa_cache table (if it exists as a table, or if it's just redis?)
# nasa_cache is in db models?
# Let's check models/db directory...
# It seems nasa_cache is a table based on `nasa_cache_service`.
print(" Truncating nasa_cache table...")
try:
await session.execute(text("TRUNCATE TABLE nasa_cache RESTART IDENTITY CASCADE"))
except Exception as e:
print(f" (Note: nasa_cache might not exist or failed: {e})")
await session.commit()
print("✅ Database tables cleared.")
# Clear Redis
await redis_cache.connect()
await redis_cache.clear_pattern("positions:*")
await redis_cache.clear_pattern("nasa:*")
print("✅ Redis cache cleared.")
await redis_cache.disconnect()
except Exception as e:
print(f"❌ Error: {e}")
await session.rollback()
finally:
break
if __name__ == "__main__":
asyncio.run(reset_data())

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -18,9 +18,13 @@
"axios": "^1.13.2",
"html2canvas": "^1.4.1",
"lucide-react": "^0.555.0",
"markdown-it": "^14.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^7.9.6",
"remark-gfm": "^4.0.1",
"three": "^0.181.2"
},
"devDependencies": {

View File

@ -14,6 +14,7 @@ import { InterstellarTicker } from './components/InterstellarTicker';
import { ControlPanel } from './components/ControlPanel';
import { AuthModal } from './components/AuthModal';
import { MessageBoard } from './components/MessageBoard';
import { BodyDetailOverlay } from './components/BodyDetailOverlay'; // Import the new overlay component
import { auth } from './utils/auth';
import type { CelestialBody } from './types';
import { useToast } from './contexts/ToastContext';
@ -32,6 +33,7 @@ function App() {
const [showOrbits, setShowOrbits] = useState(true);
const [isSoundOn, setIsSoundOn] = useState(false);
const [showMessageBoard, setShowMessageBoard] = useState(false);
const [showDetailOverlayId, setShowDetailOverlayId] = useState<string | null>(null); // State for detail overlay
// Initialize state from localStorage
useEffect(() => {
@ -75,6 +77,19 @@ function App() {
const loading = isTimelineMode ? historicalLoading : realTimeLoading;
const error = isTimelineMode ? historicalError : realTimeError;
// Debug: log bodies when they change
useEffect(() => {
console.log('[App] Bodies updated:', {
isTimelineMode,
totalBodies: bodies.length,
bodiesWithPositions: bodies.filter(b => b.positions && b.positions.length > 0).length,
bodyTypes: bodies.reduce((acc, b) => {
acc[b.type] = (acc[b.type] || 0) + 1;
return acc;
}, {} as Record<string, number>)
});
}, [bodies, isTimelineMode]);
const [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
const { trajectoryPositions } = useTrajectory(selectedBody);
@ -94,6 +109,11 @@ function App() {
}
}, [isTimelineMode, cutoffDate]);
// Handle viewing body details
const handleViewDetails = useCallback((body: CelestialBody) => {
setShowDetailOverlayId(body.id);
}, []);
// Filter probes and planets from all bodies
const probes = bodies.filter((b) => b.type === 'probe');
const planets = bodies.filter((b) =>
@ -213,6 +233,7 @@ function App() {
onBodySelect={handleBodySelect}
resetTrigger={resetTrigger}
toast={toast}
onViewDetails={handleViewDetails}
/>
{/* Timeline Controller */}
@ -241,6 +262,12 @@ function App() {
</div>
</div>
)}
{/* Body Detail Overlay */}
<BodyDetailOverlay
bodyId={showDetailOverlayId}
onClose={() => setShowDetailOverlayId(null)}
/>
</div>
);
}

View File

@ -0,0 +1,145 @@
import { useRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { XCircle } from 'lucide-react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { request } from '../utils/request';
import { useToast } from '../contexts/ToastContext';
import { BodyViewer } from './BodyViewer';
import type { CelestialBody as CelestialBodyType } from '../types';
interface BodyDetailOverlayProps {
bodyId: string | null;
onClose: () => void;
}
// Custom camera control for automatic rotation
function AutoRotateCamera() {
useFrame((state) => {
state.camera.position.x = Math.sin(state.clock.elapsedTime * 0.1) * 3;
state.camera.position.z = Math.cos(state.clock.elapsedTime * 0.1) * 3;
state.camera.lookAt(0, 0, 0);
});
return null;
}
export function BodyDetailOverlay({ bodyId, onClose }: BodyDetailOverlayProps) {
const [bodyData, setBodyData] = useState<CelestialBodyType | null>(null);
const [loading, setLoading] = useState(false);
const toast = useToast();
useEffect(() => {
if (!bodyId) {
setBodyData(null);
return;
}
setLoading(true);
request.get(`/celestial/info/${bodyId}`)
.then(response => {
setBodyData(response.data);
})
.catch(error => {
console.error("Failed to fetch body details:", error);
toast.error("加载天体详情失败");
onClose(); // Close overlay on error
})
.finally(() => {
setLoading(false);
});
}, [bodyId, onClose, toast]);
if (!bodyId || !bodyData) {
return null;
}
// Create portal to render outside the main app div
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-lg">
<div className="relative w-[90vw] h-[90vh] bg-gray-900/90 border border-blue-500/30 rounded-lg shadow-2xl flex p-4 gap-4">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 text-gray-400 hover:text-red-400 transition-colors"
>
<XCircle size={32} />
</button>
{/* Left Panel: 3D Viewer */}
<div className="w-1/2 h-full bg-black rounded-lg border border-gray-700 relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center text-blue-300">...</div>
) : (
<Canvas camera={{ position: [3, 2, 3], fov: 60 }}>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1} />
<pointLight position={[-10, -10, -10]} intensity={0.5} color="#88aaff" />
<directionalLight position={[0, 0, 5]} intensity={0.5} /> {/* Frontal light */}
<directionalLight position={[0, 0, -5]} intensity={0.2} /> {/* Back light */}
<AutoRotateCamera /> {/* Auto rotate for presentation */}
<OrbitControls
enableZoom={true}
enablePan={false}
enableRotate={true}
minDistance={0.5}
maxDistance={10}
/>
<BodyViewer body={bodyData} />
</Canvas>
)}
</div>
{/* Right Panel: Details */}
<div className="w-1/2 h-full bg-gray-800 rounded-lg border border-gray-700 p-6 overflow-y-auto">
<h1 className="text-4xl font-bold text-white mb-2">{bodyData.name_zh || bodyData.name}</h1>
<p className="text-xl text-blue-300 mb-4">{bodyData.name}</p>
<div className="text-gray-400 text-sm mb-4">
<span className="font-semibold text-white">:</span> {bodyData.type}
{bodyData.description && <> <span className="mx-2">|</span> {bodyData.description}</>}
</div>
{bodyData.details ? (
<div className="prose prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Custom component styling without className prop
h1: ({node, ...props}) => <h1 className="text-3xl font-bold text-white mt-6 mb-4" {...props} />,
h2: ({node, ...props}) => <h2 className="text-2xl font-bold text-white mt-5 mb-3" {...props} />,
h3: ({node, ...props}) => <h3 className="text-xl font-bold text-white mt-4 mb-2" {...props} />,
p: ({node, ...props}) => <p className="text-gray-300 mb-3 leading-relaxed" {...props} />,
ul: ({node, ...props}) => <ul className="list-disc list-inside text-gray-300 mb-3 space-y-1" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal list-inside text-gray-300 mb-3 space-y-1" {...props} />,
li: ({node, ...props}) => <li className="text-gray-300" {...props} />,
strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} />,
em: ({node, ...props}) => <em className="text-blue-300 italic" {...props} />,
code: ({node, inline, ...props}: any) =>
inline
? <code className="bg-gray-700 text-blue-300 px-1.5 py-0.5 rounded text-sm font-mono" {...props} />
: <code className="block bg-gray-700 text-blue-300 p-3 rounded text-sm font-mono overflow-x-auto mb-3" {...props} />,
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-blue-500 pl-4 text-gray-400 italic mb-3" {...props} />,
a: ({node, ...props}) => <a className="text-blue-400 hover:text-blue-300 underline" {...props} />,
table: ({node, ...props}) => <table className="w-full border-collapse mb-3" {...props} />,
th: ({node, ...props}) => <th className="border border-gray-600 bg-gray-700 text-white px-3 py-2 text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-gray-600 text-gray-300 px-3 py-2" {...props} />,
}}
>
{bodyData.details}
</ReactMarkdown>
</div>
) : (
<p className="text-gray-500 italic"></p>
)}
</div>
</div>
</div>,
document.body // Render outside root element
);
}

View File

@ -0,0 +1,388 @@
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
import { Mesh, Group } from 'three';
import * as THREE from 'three';
import { useGLTF, useTexture } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import type { CelestialBody as CelestialBodyType } from '../types';
import { fetchBodyResources } from '../utils/api';
import { getCelestialSize } from '../config/celestialSizes';
interface BodyViewerProps {
body: CelestialBodyType;
}
// Reusable component to render just the 3D model/mesh of a celestial body
export function BodyViewer({ body }: BodyViewerProps) {
const meshRef = useRef<Mesh>(null);
const groupRef = useRef<Group>(null);
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [modelScale, setModelScale] = useState<number>(1.0);
const [loadError, setLoadError] = useState<boolean>(false);
// Determine size and appearance
const appearance = useMemo(() => {
if (body.type === 'star') {
return { size: 0.4, emissive: '#FDB813', emissiveIntensity: 1.5 };
}
if (body.type === 'comet') {
return { size: getCelestialSize(body.name, body.type), emissive: '#000000', emissiveIntensity: 0 };
}
if (body.type === 'satellite') {
return { size: getCelestialSize(body.name, body.type), emissive: '#888888', emissiveIntensity: 0.4 };
}
return { size: getCelestialSize(body.name, body.type), emissive: '#000000', emissiveIntensity: 0 };
}, [body.name, body.type]);
// Fetch resources (texture or model)
useEffect(() => {
setLoadError(false);
setModelPath(undefined);
setTexturePath(undefined);
const loadResources = async () => {
try {
const response = await fetchBodyResources(body.id, body.type === 'probe' ? 'model' : 'texture');
if (response.resources.length > 0) {
const mainResource = response.resources[0];
const fullPath = `/upload/${mainResource.file_path}`;
if (body.type === 'probe') {
useGLTF.preload(fullPath); // Preload GLTF
setModelPath(fullPath);
setModelScale(mainResource.extra_data?.scale || 1.0);
} else {
setTexturePath(fullPath);
}
} else {
// No resources found
if (body.type === 'probe') setModelPath(null);
else setTexturePath(null);
}
} catch (err) {
console.error(`Failed to load resource for ${body.name}:`, err);
setLoadError(true);
if (body.type === 'probe') setModelPath(null);
else setTexturePath(null);
}
};
loadResources();
}, [body.id, body.name, body.type]);
// Handle Probe Model rendering
if (body.type === 'probe' && modelPath !== undefined) {
if (modelPath === null || loadError) {
// Fallback sphere for probes if model fails
return (
<mesh ref={meshRef}>
<sphereGeometry args={[0.15, 32, 32]} />
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
</mesh>
);
}
return <ProbeModelViewer modelPath={modelPath} modelScale={modelScale} />;
}
// Handle Celestial Body (Planet, Star, etc.) rendering
if (texturePath !== undefined) {
return (
<PlanetModelViewer
body={body}
size={appearance.size}
emissive={appearance.emissive}
emissiveIntensity={appearance.emissiveIntensity}
texturePath={texturePath}
meshRef={meshRef}
/>
);
}
// Show nothing while loading resources
return null;
}
// Sub-component for Probe models
function ProbeModelViewer({ modelPath, modelScale }: { modelPath: string; modelScale: number }) {
const groupRef = useRef<Group>(null);
const gltf = useGLTF(modelPath);
const scene = gltf.scene;
const optimalScale = useMemo(() => {
if (!scene) return 1;
const box = new THREE.Box3().setFromObject(scene);
const size = new THREE.Vector3();
box.getSize(size);
const maxDimension = Math.max(size.x, size.y, size.z);
const targetSize = 0.35; // Standardize view
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.3;
const finalScale = Math.max(0.2, Math.min(1.0, calculatedScale));
return finalScale * modelScale;
}, [scene, modelScale]);
const configuredScene = useMemo(() => {
if (!scene) return null;
const clonedScene = scene.clone();
clonedScene.traverse((child: any) => {
if (child.isMesh) {
if (child.material) {
if (Array.isArray(child.material)) {
child.material = child.material.map((mat: any) => {
const clonedMat = mat.clone();
clonedMat.depthTest = true;
clonedMat.depthWrite = true;
return clonedMat;
});
} else {
child.material = child.material.clone();
child.material.depthTest = true;
child.material.depthWrite = true;
}
}
}
});
return clonedScene;
}, [scene]);
if (!configuredScene) return null;
return (
<Suspense fallback={null}>
<group ref={groupRef}>
<primitive object={configuredScene} scale={optimalScale} />
</group>
</Suspense>
);
}
// Sub-component for Planet models
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, meshRef }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
texturePath: string | null;
meshRef: React.RefObject<Mesh>;
}) {
const texture = texturePath ? useTexture(texturePath) : null;
// Rotation animation
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.1;
}
});
// Irregular Comet Nucleus - potato-like shape
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
const nucleusRef = useRef<Mesh>(null);
// Create irregular geometry by deforming a sphere
const geometry = useMemo(() => {
const geo = new THREE.SphereGeometry(size, 64, 64); // Higher resolution for detail view
const positions = geo.attributes.position;
// Deform vertices to create irregular shape
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const z = positions.getZ(i);
// Create multiple noise frequencies for complex shape
const noise1 = Math.sin(x * 3.5) * Math.cos(y * 2.7) * Math.sin(z * 4.2);
const noise2 = Math.cos(x * 5.1) * Math.sin(y * 4.3) * Math.cos(z * 3.8);
const noise3 = Math.sin(x * 7.2) * Math.sin(y * 6.1) * Math.cos(z * 5.5);
const deformation = 1 + (noise1 * 0.25 + noise2 * 0.15 + noise3 * 0.1);
positions.setXYZ(i, x * deformation, y * deformation, z * deformation);
}
geo.computeVertexNormals();
return geo;
}, [size]);
// Slow rotation
useFrame((_, delta) => {
if (nucleusRef.current) {
nucleusRef.current.rotation.y += delta * 0.05;
nucleusRef.current.rotation.z += delta * 0.02;
}
});
return (
<mesh ref={nucleusRef} geometry={geometry}>
{texture ? (
<meshStandardMaterial
map={texture}
roughness={0.9}
metalness={0.1}
color="#b8b8b8"
/>
) : (
<meshStandardMaterial
color="#6b6b6b"
roughness={0.95}
metalness={0.05}
/>
)}
</mesh>
);
}
// Enhanced Coma (gas/dust cloud) around comet nucleus
function CometComa({ radius }: { radius: number }) {
const positions = useMemo(() => {
const count = 300; // More particles for detail view
const p = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Denser near center, spreading outward
const r = radius * (0.8 + Math.pow(Math.random(), 1.5) * 4); // 0.8x to 4.8x radius
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
p[i * 3 + 2] = r * Math.cos(phi);
}
return p;
}, [radius]);
const pointsRef = useRef<THREE.Points>(null);
// Animate coma
useFrame((state, delta) => {
if (pointsRef.current) {
pointsRef.current.rotation.y += delta * 0.05;
// Pulsing effect
const scale = 1 + Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
pointsRef.current.scale.setScalar(scale);
}
});
return (
<>
{/* Inner bright coma */}
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute attach="position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
size={radius * 0.8}
color="#88ccff"
transparent
opacity={0.7}
sizeAttenuation={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
{/* Middle glow layer */}
<mesh>
<sphereGeometry args={[radius * 2.5, 32, 32]} />
<meshBasicMaterial
color="#66aadd"
transparent
opacity={0.15}
side={THREE.BackSide}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
{/* Outer diffuse glow */}
<mesh>
<sphereGeometry args={[radius * 4.5, 32, 32]} />
<meshBasicMaterial
color="#4488aa"
transparent
opacity={0.12}
side={THREE.BackSide}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</>
);
}
// 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, 32]} />
<meshBasicMaterial color="#D4B896" transparent opacity={0.7} side={THREE.DoubleSide} />
</mesh>
{/* Middle darker band */}
<mesh>
<ringGeometry args={[1.6, 1.75, 32]} />
<meshBasicMaterial color="#8B7355" transparent opacity={0.5} side={THREE.DoubleSide} />
</mesh>
{/* Outer bright ring */}
<mesh>
<ringGeometry args={[1.75, 2.0, 32]} />
<meshBasicMaterial color="#C4A582" transparent opacity={0.6} side={THREE.DoubleSide} />
</mesh>
{/* Cassini Division (gap) */}
<mesh>
<ringGeometry args={[2.0, 2.05, 32]} />
<meshBasicMaterial color="#000000" transparent opacity={0.2} side={THREE.DoubleSide} />
</mesh>
{/* A Ring (outer) */}
<mesh>
<ringGeometry args={[2.05, 2.2, 32]} />
<meshBasicMaterial color="#B89968" transparent opacity={0.5} side={THREE.DoubleSide} />
</mesh>
</group>
);
}
return (
<group>
{/* Use irregular nucleus for comets, regular sphere for others */}
{body.type === 'comet' ? (
<>
<IrregularNucleus size={size} texture={texture} />
<CometComa radius={size} />
</>
) : (
<mesh ref={meshRef} position={[0,0,0]}>
<sphereGeometry args={[size, 64, 64]} /> {/* Higher resolution for detail view */}
{texture ? (
<meshStandardMaterial
map={texture}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
roughness={body.type === 'star' ? 0 : 0.7}
metalness={0.1}
/>
) : (
<meshStandardMaterial
color="#888888"
emissive={emissive}
emissiveIntensity={emissiveIntensity}
roughness={0.7}
metalness={0.1}
/>
)}
</mesh>
)}
{/* Saturn Rings */}
{body.id === '699' && <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>
</>
)}
</group>
);
}

View File

@ -5,11 +5,12 @@ import { useRef, useMemo, useState, useEffect } from 'react';
import { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
import { useFrame } from '@react-three/fiber';
import { useTexture, Html } from '@react-three/drei';
import { useTexture, Billboard } from '@react-three/drei';
import type { CelestialBody as CelestialBodyType } from '../types';
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
import { fetchBodyResources } from '../utils/api';
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
import { createLabelTexture } from '../utils/labelTexture';
interface CelestialBodyProps {
body: CelestialBodyType;
@ -23,7 +24,7 @@ function SaturnRings() {
<group rotation={[Math.PI / 2, 0, 0]}>
{/* Inner bright ring */}
<mesh>
<ringGeometry args={[1.4, 1.6, 64]} />
<ringGeometry args={[1.4, 1.6, 32]} />
<meshBasicMaterial
color="#D4B896"
transparent
@ -33,7 +34,7 @@ function SaturnRings() {
</mesh>
{/* Middle darker band */}
<mesh>
<ringGeometry args={[1.6, 1.75, 64]} />
<ringGeometry args={[1.6, 1.75, 32]} />
<meshBasicMaterial
color="#8B7355"
transparent
@ -43,7 +44,7 @@ function SaturnRings() {
</mesh>
{/* Outer bright ring */}
<mesh>
<ringGeometry args={[1.75, 2.0, 64]} />
<ringGeometry args={[1.75, 2.0, 32]} />
<meshBasicMaterial
color="#C4A582"
transparent
@ -53,7 +54,7 @@ function SaturnRings() {
</mesh>
{/* Cassini Division (gap) */}
<mesh>
<ringGeometry args={[2.0, 2.05, 64]} />
<ringGeometry args={[2.0, 2.05, 32]} />
<meshBasicMaterial
color="#000000"
transparent
@ -63,7 +64,7 @@ function SaturnRings() {
</mesh>
{/* A Ring (outer) */}
<mesh>
<ringGeometry args={[2.05, 2.2, 64]} />
<ringGeometry args={[2.05, 2.2, 32]} />
<meshBasicMaterial
color="#B89968"
transparent
@ -139,49 +140,137 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
/>;
}
// Comet Particles Component
function CometParticles({ radius, count = 6, color = '#88ccff' }: { radius: number; count?: number; color?: string }) {
// Irregular Comet Nucleus - potato-like shape
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
const meshRef = useRef<Mesh>(null);
// Create irregular geometry by deforming a sphere
const geometry = useMemo(() => {
const geo = new THREE.SphereGeometry(size, 32, 32);
const positions = geo.attributes.position;
// Deform vertices to create irregular shape
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const z = positions.getZ(i);
// Create multiple noise frequencies for complex shape
const noise1 = Math.sin(x * 3.5) * Math.cos(y * 2.7) * Math.sin(z * 4.2);
const noise2 = Math.cos(x * 5.1) * Math.sin(y * 4.3) * Math.cos(z * 3.8);
const noise3 = Math.sin(x * 7.2) * Math.sin(y * 6.1) * Math.cos(z * 5.5);
const deformation = 1 + (noise1 * 0.25 + noise2 * 0.15 + noise3 * 0.1);
positions.setXYZ(i, x * deformation, y * deformation, z * deformation);
}
geo.computeVertexNormals();
return geo;
}, [size]);
// Slow rotation
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.05;
meshRef.current.rotation.z += delta * 0.02;
}
});
return (
<mesh ref={meshRef} geometry={geometry}>
{texture ? (
<meshStandardMaterial
map={texture}
roughness={0.9}
metalness={0.1}
color="#b8b8b8"
/>
) : (
<meshStandardMaterial
color="#6b6b6b"
roughness={0.95}
metalness={0.05}
/>
)}
</mesh>
);
}
// Enhanced Coma (gas/dust cloud) around comet nucleus
function CometComa({ radius }: { radius: number }) {
const positions = useMemo(() => {
const count = 200;
const p = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Random spherical distribution
const r = radius * (1.2 + Math.random() * 2.0); // Spread: 1.2x to 3.2x radius
// Denser near center, spreading outward
const r = radius * (0.8 + Math.pow(Math.random(), 1.5) * 4); // 0.8x to 4.8x radius
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
p[i * 3 + 2] = r * Math.cos(phi);
}
return p;
}, [radius, count]);
}, [radius]);
// Ref for animation
const pointsRef = useRef<THREE.Points>(null);
useFrame((_, delta) => {
// Animate coma
useFrame((state, delta) => {
if (pointsRef.current) {
// Subtle rotation
pointsRef.current.rotation.y += delta * 0.1;
pointsRef.current.rotation.z += delta * 0.05;
pointsRef.current.rotation.y += delta * 0.05;
// Pulsing effect
const scale = 1 + Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
pointsRef.current.scale.setScalar(scale);
}
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute attach="position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
size={radius * 0.4} // Particle size relative to comet size
color={color}
transparent
opacity={0.6}
sizeAttenuation={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
<>
{/* Inner bright coma */}
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute attach="position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
size={radius * 0.8}
color="#88ccff"
transparent
opacity={0.7}
sizeAttenuation={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
{/* Middle glow layer */}
<mesh>
<sphereGeometry args={[radius * 2.5, 32, 32]} />
<meshBasicMaterial
color="#66aadd"
transparent
opacity={0.15}
side={THREE.BackSide}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
{/* Outer diffuse glow */}
<mesh>
<sphereGeometry args={[radius * 4.5, 32, 32]} />
<meshBasicMaterial
color="#4488aa"
transparent
opacity={0.12}
side={THREE.BackSide}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</>
);
}
@ -214,42 +303,58 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
// Get offset description if this body has one
const offsetDesc = hasOffset ? getOffsetDescription(body, allBodies) : null;
// Label color
const labelColor = body.type === 'star' ? '#FDB813' : (body.type === 'comet' ? '#88ccff' : '#ffffff');
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(
body.name_zh || body.name,
offsetDesc,
`${distance.toFixed(2)} AU`,
labelColor
);
}, [body.name, body.name_zh, offsetDesc, distance, labelColor]);
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>
{/* Use irregular nucleus for comets, regular sphere for others */}
{body.type === 'comet' ? (
<>
<IrregularNucleus size={size} texture={texture} />
<CometComa radius={size} />
</>
) : (
<mesh ref={meshRef} renderOrder={0}>
<sphereGeometry args={[size, 32, 32]} />
{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.id === '699' && <SaturnRings />}
{/* Comet Particles */}
{body.type === 'comet' && (
<CometParticles radius={size} count={6} />
)}
{/* Sun glow effect */}
{body.type === 'star' && (
<>
@ -261,37 +366,27 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
</>
)}
{/* Name label */}
<Html
position={[0, size + 0.3, 0]}
center
distanceFactor={10}
style={{
color: body.type === 'star' ? '#FDB813' : (body.type === 'comet' ? '#88ccff' : '#ffffff'),
fontSize: '9px', // 从 11px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 4px rgba(0,0,0,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 9px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '7px', opacity: isSelected ? 0.7 : 0.3 }}> {/* 从 8px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
{/* Name label using CanvasTexture */}
{labelTexture && (
<Billboard
position={[0, size + 1.0, 0]} // Raised slightly
follow={true}
lockX={false}
lockY={false}
lockZ={false}
>
<mesh scale={[2.5, 1.25, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
opacity={isSelected ? 1 : 0.6}
depthWrite={false}
toneMapped={false} // Keep colors bright
/>
</mesh>
</Billboard>
)}
</group>
);
}

View File

@ -2,9 +2,10 @@
* Constellations component - renders major constellations with connecting lines
*/
import { useEffect, useState, useMemo } from 'react';
import { Line, Text, Billboard } from '@react-three/drei';
import { Line, Billboard } from '@react-three/drei';
import * as THREE from 'three';
import { fetchStaticData } from '../utils/api';
import { createLabelTexture } from '../utils/labelTexture';
interface ConstellationStar {
name: string;
@ -34,6 +35,61 @@ function raDecToCartesian(ra: number, dec: number, distance: number = 5000) {
return new THREE.Vector3(x, y, z);
}
interface ConstellationData {
name: string;
nameZh: string;
starPositions: THREE.Vector3[];
lineSegments: { start: THREE.Vector3; end: THREE.Vector3 }[];
center: THREE.Vector3;
}
// Sub-component for individual constellation
function ConstellationObject({ constellation, geometry }: { constellation: ConstellationData; geometry: THREE.SphereGeometry }) {
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(constellation.nameZh, null, "", "#6699FF");
}, [constellation.nameZh]);
return (
<group>
{/* Render constellation stars */}
{constellation.starPositions.map((pos, idx) => (
<mesh key={`${constellation.name}-star-${idx}`} position={pos} geometry={geometry}>
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.6} />
</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.3}
/>
))}
{/* Constellation name label */}
{labelTexture && (
<Billboard position={constellation.center}>
<mesh scale={[600, 300, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
opacity={0.6}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
</group>
);
}
export function Constellations() {
const [constellations, setConstellations] = useState<Constellation[]>([]);
@ -92,41 +148,11 @@ export function Constellations() {
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} geometry={sphereGeometry}>
<meshBasicMaterial color="#FFFFFF" transparent opacity={0.6} />
</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.3}
/>
))}
{/* Constellation name label */}
<Billboard position={constellation.center}>
<Text
fontSize={120}
color="#6699FF"
fillOpacity={0.6}
anchorX="center"
anchorY="middle"
outlineWidth={0}
outlineColor="#000000"
>
{constellation.nameZh}
</Text>
</Billboard>
</group>
<ConstellationObject
key={constellation.name}
constellation={constellation}
geometry={sphereGeometry}
/>
))}
</group>
);

View File

@ -1,4 +1,4 @@
import { X, Ruler, Activity, Radar } from 'lucide-react';
import { X, Ruler, Activity, Radar, Eye } from 'lucide-react';
import { useState } from 'react';
import { request } from '../utils/request';
import type { CelestialBody } from '../types';
@ -9,9 +9,10 @@ interface FocusInfoProps {
body: CelestialBody | null;
onClose: () => void;
toast: ToastContextValue; // Add toast prop
onViewDetails?: (body: CelestialBody) => void; // Add onViewDetails prop
}
export function FocusInfo({ body, onClose, toast }: FocusInfoProps) {
export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProps) {
const [showTerminal, setShowTerminal] = useState(false);
const [terminalData, setTerminalData] = useState('');
const [loading, setLoading] = useState(false);
@ -76,6 +77,18 @@ export function FocusInfo({ body, onClose, toast }: FocusInfoProps) {
<h2 className="text-xl font-bold text-white tracking-tight">
{body.name_zh || body.name}
</h2>
{onViewDetails && (
<button
onClick={(e) => {
e.stopPropagation();
onViewDetails(body);
}}
className="text-gray-400 hover:text-white transition-colors p-1 rounded-full hover:bg-white/10"
title="查看详细信息"
>
<Eye size={16} />
</button>
)}
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
isProbe
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'

View File

@ -2,9 +2,10 @@
* Galaxies component - renders distant galaxies as billboards
*/
import { useEffect, useState, useMemo } from 'react';
import { Billboard, Text, useTexture } from '@react-three/drei';
import { Billboard } from '@react-three/drei';
import * as THREE from 'three';
import { fetchStaticData } from '../utils/api';
import { createLabelTexture } from '../utils/labelTexture';
interface Galaxy {
name: string;
@ -106,6 +107,62 @@ function calculateAngularSize(diameterKly: number, distanceMly: number): number
return Math.max(20, angularDiameter * 8000);
}
interface GalaxyData {
name: string;
name_zh: string;
position: THREE.Vector3;
size: number;
texture: THREE.Texture;
}
// Sub-component for individual galaxy
function GalaxyObject({ galaxy }: { galaxy: GalaxyData }) {
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(galaxy.name_zh, null, "", "#DDAAFF");
}, [galaxy.name_zh]);
return (
<group>
<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 */}
{labelTexture && (
<Billboard position={galaxy.position.clone().multiplyScalar(1.03)}>
<mesh scale={[300, 150, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
opacity={0.8}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
</group>
);
}
export function Galaxies() {
const [galaxies, setGalaxies] = useState<Galaxy[]>([]);
@ -145,7 +202,8 @@ export function Galaxies() {
const texture = createGalaxyTexture(galaxy.color, galaxy.type);
return {
...galaxy,
name: galaxy.name,
name_zh: galaxy.name_zh,
position,
size,
texture,
@ -160,41 +218,7 @@ export function Galaxies() {
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={60} // Increased from 1.5
color="#DDAAFF"
anchorX="center"
anchorY="middle"
outlineWidth={2}
outlineColor="#000000"
>
{galaxy.name_zh}
</Text>
</Billboard>
</group>
<GalaxyObject key={galaxy.name} galaxy={galaxy} />
))}
</group>
);

View File

@ -2,9 +2,10 @@
* Nebulae component - renders nebulae as billboards with procedural textures
*/
import { useEffect, useState, useMemo } from 'react';
import { Billboard, Text } from '@react-three/drei';
import { Billboard } from '@react-three/drei';
import * as THREE from 'three';
import { fetchStaticData } from '../utils/api';
import { createLabelTexture } from '../utils/labelTexture';
interface Nebula {
name: string;
@ -159,6 +160,63 @@ function calculateAngularSize(diameterLy: number, distanceLy: number): number {
return Math.max(50, Math.min(300, angularDiameter * 100000));
}
interface NebulaData {
name: string;
name_zh: string;
type: string;
position: THREE.Vector3;
size: number;
texture: THREE.Texture;
}
// Sub-component for individual nebula
function NebulaObject({ nebula }: { nebula: NebulaData }) {
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(nebula.name_zh, null, "", "#FFAADD");
}, [nebula.name_zh]);
return (
<group>
<Billboard
position={nebula.position}
follow={true}
lockX={false}
lockY={false}
lockZ={false}
>
{/* Nebula texture */}
<mesh>
<planeGeometry args={[nebula.size * 2, nebula.size * 2]} />
<meshBasicMaterial
map={nebula.texture}
transparent
opacity={nebula.type === 'dark' ? 0.5 : 0.7}
blending={nebula.type === 'dark' ? THREE.NormalBlending : THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</Billboard>
{/* Nebula name label - positioned slightly outward */}
{labelTexture && (
<Billboard position={nebula.position.clone().multiplyScalar(1.02)}>
<mesh scale={[300, 150, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
opacity={0.8}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
</group>
);
}
export function Nebulae() {
const [nebulae, setNebulae] = useState<Nebula[]>([]);
@ -196,7 +254,9 @@ export function Nebulae() {
const texture = createNebulaTexture(nebula.color, nebula.type);
return {
...nebula,
name: nebula.name,
name_zh: nebula.name_zh,
type: nebula.type,
position,
size,
texture,
@ -211,41 +271,7 @@ export function Nebulae() {
return (
<group>
{nebulaData.map((nebula) => (
<group key={nebula.name}>
<Billboard
position={nebula.position}
follow={true}
lockX={false}
lockY={false}
lockZ={false}
>
{/* Nebula texture */}
<mesh>
<planeGeometry args={[nebula.size * 2, nebula.size * 2]} />
<meshBasicMaterial
map={nebula.texture}
transparent
opacity={nebula.type === 'dark' ? 0.5 : 0.7}
blending={nebula.type === 'dark' ? THREE.NormalBlending : THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</Billboard>
{/* Nebula name label - positioned slightly outward */}
<Billboard position={nebula.position.clone().multiplyScalar(1.02)}>
<Text
fontSize={60} // Increased font size from 1.2
color="#FFAADD"
anchorX="center"
anchorY="middle"
outlineWidth={2}
outlineColor="#000000"
>
{nebula.name_zh}
</Text>
</Billboard>
</group>
<NebulaObject key={nebula.name} nebula={nebula} />
))}
</group>
);

View File

@ -4,11 +4,12 @@
import { useRef, useMemo, useState, useEffect } from 'react';
import { Group } from 'three';
import * as THREE from 'three';
import { useGLTF, Html } from '@react-three/drei';
import { useGLTF, Billboard } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import type { CelestialBody } from '../types';
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
import { fetchBodyResources } from '../utils/api';
import { createLabelTexture } from '../utils/labelTexture';
interface ProbeProps {
body: CelestialBody;
@ -36,9 +37,6 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
// 2. Hook: Load GLTF
// We removed the try-catch block because calling hooks conditionally or inside try-catch is forbidden.
// If useGLTF fails, it will throw an error which should be caught by an ErrorBoundary or handled by Suspense.
// Since we preload in the parent, this should generally be safe.
const gltf = useGLTF(modelPath);
const scene = gltf.scene;
@ -60,7 +58,7 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
// Calculate scale factor
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.3;
// Clamp scale to reasonable range - tighter range for consistency
// Clamp scale to reasonable range
const finalScale = Math.max(0.2, Math.min(1.0, calculatedScale));
// Apply custom scale from resource metadata
@ -118,6 +116,17 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
// Generate label texture
// eslint-disable-next-line react-hooks/rules-of-hooks
const labelTexture = useMemo(() => {
return createLabelTexture(
body.name_zh || body.name,
offsetDesc,
`${distance.toFixed(2)} AU`,
'#00ffff'
);
}, [body.name, body.name_zh, offsetDesc, distance]);
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
<primitive
@ -125,37 +134,27 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
scale={optimalScale}
/>
{/* Name label */}
<Html
position={[0, optimalScale * 2, 0]}
center
distanceFactor={15}
style={{
color: '#00ffff',
fontSize: '9px', // 从 12px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 6px rgba(0,255,255,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 10px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '7px', opacity: isSelected ? 0.8 : 0.3 }}> {/* 从 10px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
{/* Name label with CanvasTexture */}
{labelTexture && (
<Billboard
position={[0, optimalScale * 2.5, 0]}
follow={true}
lockX={false}
lockY={false}
lockZ={false}
>
<mesh scale={[2.5, 1.25, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
opacity={isSelected ? 1 : 0.6}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
</group>
);
}
@ -177,6 +176,16 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(
body.name_zh || body.name,
offsetDesc,
`${distance.toFixed(2)} AU`,
'#ff6666'
);
}, [body.name, body.name_zh, offsetDesc, distance]);
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
<mesh>
@ -184,37 +193,27 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
</mesh>
{/* Name label */}
<Html
position={[0, 1, 0]}
center
distanceFactor={15}
style={{
color: '#ff6666',
fontSize: '9px', // 从 12px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 6px rgba(255,0,0,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 10px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '7px', opacity: isSelected ? 0.8 : 0.3 }}> {/* 从 10px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
{/* Name label with CanvasTexture */}
{labelTexture && (
<Billboard
position={[0, 1, 0]}
follow={true}
lockX={false}
lockY={false}
lockZ={false}
>
<mesh scale={[2.5, 1.25, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
opacity={isSelected ? 1 : 0.6}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
</group>
);
}

View File

@ -24,16 +24,24 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
// Calculate distance for sorting
const calculateDistance = (body: CelestialBody) => {
if (!body.positions || body.positions.length === 0) {
return Infinity; // Bodies without positions go to the end
}
const pos = body.positions[0];
return Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
};
const processBodies = (list: CelestialBody[]) => {
return list
.filter(b =>
(b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
b.type !== 'star' // Exclude Sun from list
)
.filter(b => {
// Filter out bodies without positions
if (!b.positions || b.positions.length === 0) {
return false;
}
// Filter by search term and type
return (b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
b.type !== 'star'; // Exclude Sun from list
})
.map(body => ({
body,
distance: calculateDistance(body)

View File

@ -28,9 +28,26 @@ interface SceneProps {
onBodySelect?: (body: CelestialBodyType | null) => void;
resetTrigger?: number;
toast: ToastContextValue; // Add toast prop
onViewDetails?: (body: CelestialBodyType) => void; // Add prop
}
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0, toast }: SceneProps) {
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0, toast, onViewDetails }: SceneProps) {
// Debug: log what Scene receives
useEffect(() => {
console.log('[Scene] Received bodies:', {
totalBodies: bodies.length,
bodiesWithPositions: bodies.filter(b => b.positions && b.positions.length > 0).length,
celestialBodies: bodies.filter(b => b.type !== 'probe').length,
probes: bodies.filter(b => b.type === 'probe').length,
sample: bodies.slice(0, 3).map(b => ({
id: b.id,
name: b.name,
type: b.type,
hasPositions: b.positions && b.positions.length > 0
}))
});
}, [bodies]);
// State to control info panel visibility (independent of selection)
const [showInfoPanel, setShowInfoPanel] = useState(true);
@ -172,7 +189,12 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
{/* Dynamic Focus Info Label */}
{selectedBody && showInfoPanel && (
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
<FocusInfo body={selectedBody} onClose={() => setShowInfoPanel(false)} toast={toast} />
<FocusInfo
body={selectedBody}
onClose={() => setShowInfoPanel(false)}
toast={toast}
onViewDetails={onViewDetails}
/>
</Html>
)}
</Canvas>

View File

@ -2,9 +2,10 @@
* Stars component - renders nearby stars in 3D space
*/
import { useEffect, useState, useMemo } from 'react';
import { Text, Billboard } from '@react-three/drei';
import { Billboard } from '@react-three/drei';
import * as THREE from 'three';
import { request } from '../utils/request';
import { createLabelTexture } from '../utils/labelTexture';
interface Star {
name: string;
@ -14,6 +15,8 @@ interface Star {
dec: number; // Declination in degrees
magnitude: number;
color: string;
position: THREE.Vector3;
size: number;
}
/**
@ -46,6 +49,53 @@ function magnitudeToSize(magnitude: number): number {
return Math.max(5, 20 - normalized * 1.2);
}
// Sub-component for individual star to handle label texture efficiently
function StarObject({ star, geometry }: { star: Star; geometry: THREE.SphereGeometry }) {
// Generate label texture
const labelTexture = useMemo(() => {
return createLabelTexture(star.name_zh, null, "", "#FFFFFF");
}, [star.name_zh]);
return (
<group>
{/* Star sphere */}
<mesh position={star.position} geometry={geometry} scale={[star.size, star.size, star.size]}>
<meshBasicMaterial
color={star.color}
transparent
opacity={0.9}
blending={THREE.AdditiveBlending}
/>
</mesh>
{/* Star glow */}
<mesh position={star.position} geometry={geometry} scale={[star.size * 2, star.size * 2, star.size * 2]}>
<meshBasicMaterial
color={star.color}
transparent
opacity={0.2}
blending={THREE.AdditiveBlending}
/>
</mesh>
{/* Star name label - positioned radially outward from star */}
{labelTexture && (
<Billboard position={star.position.clone().multiplyScalar(1.05)}>
<mesh scale={[200, 100, 1]}>
<planeGeometry />
<meshBasicMaterial
map={labelTexture}
transparent
depthWrite={false}
toneMapped={false}
/>
</mesh>
</Billboard>
)}
</group>
);
}
export function Stars() {
const [stars, setStars] = useState<Star[]>([]);
@ -55,82 +105,39 @@ export function Stars() {
.then((res) => {
const data = res.data;
// API returns { category, items: [{ id, name, name_zh, data: {...} }] }
const starData = data.items.map((item: any) => ({
name: item.name,
name_zh: item.name_zh,
distance_ly: item.data.distance_ly,
ra: item.data.ra,
dec: item.data.dec,
magnitude: item.data.magnitude,
color: item.data.color,
}));
const starData = data.items.map((item: any) => {
// Place all stars on a celestial sphere at fixed distance (5000 units)
const position = raDecToCartesian(item.data.ra, item.data.dec, 5000);
const size = magnitudeToSize(item.data.magnitude);
return {
name: item.name,
name_zh: item.name_zh,
distance_ly: item.data.distance_ly,
ra: item.data.ra,
dec: item.data.dec,
magnitude: item.data.magnitude,
color: item.data.color,
position,
size
};
});
setStars(starData);
})
.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 (5000 units)
// This way they appear as background objects, similar to constellations
const position = raDecToCartesian(star.ra, star.dec, 5000);
// Size based on brightness (magnitude)
const size = magnitudeToSize(star.magnitude);
return {
...star,
position,
size,
};
});
}, [stars]);
// Reuse geometry for all stars to improve performance
const sphereGeometry = useMemo(() => new THREE.SphereGeometry(1, 16, 16), []);
if (starData.length === 0) {
if (stars.length === 0) {
return null;
}
return (
<group>
{starData.map((star) => (
<group key={star.name}>
{/* Star sphere */}
<mesh position={star.position} geometry={sphereGeometry} scale={[star.size, star.size, star.size]}>
<meshBasicMaterial
color={star.color}
transparent
opacity={0.9}
blending={THREE.AdditiveBlending}
/>
</mesh>
{/* Star glow */}
<mesh position={star.position} geometry={sphereGeometry} scale={[star.size * 2, star.size * 2, star.size * 2]}>
<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={40} // Increased font size
color="#FFFFFF"
anchorX="center"
anchorY="middle"
outlineWidth={2}
outlineColor="#000000"
>
{star.name_zh}
</Text>
</Billboard>
</group>
{stars.map((star) => (
<StarObject key={star.name} star={star} geometry={sphereGeometry} />
))}
</group>
);

View File

@ -18,6 +18,9 @@ export function useHistoricalData(selectedDate: Date | null) {
return;
}
// Track if this effect instance is active
let isActive = true;
// 创建午夜时间戳
const targetDate = new Date(selectedDate);
targetDate.setUTCHours(0, 0, 0, 0);
@ -42,19 +45,30 @@ export function useHistoricalData(selectedDate: Date | null) {
'1d'
);
setBodies(data.bodies);
lastFetchedDateRef.current = dateKey; // 记录已请求的时间
console.log(`[useHistoricalData] Loaded ${data.bodies.length} bodies`);
// Only update state if this effect is still active
if (isActive) {
setBodies(data.bodies);
lastFetchedDateRef.current = dateKey; // 记录已请求的时间
console.log(`[useHistoricalData] Loaded ${data.bodies.length} bodies`);
setLoading(false);
} else {
console.log(`[useHistoricalData] Ignored stale data for ${dateKey}`);
}
} catch (err) {
console.error('Failed to fetch historical data:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
if (isActive) {
console.error('Failed to fetch historical data:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}
};
loadHistoricalData();
// Cleanup function
return () => {
isActive = false;
};
}, [selectedDate]);
return { bodies, loading, error };

View File

@ -27,16 +27,44 @@ export function useSpaceData() {
// Use cutoff date instead of current date
// Set to UTC midnight for consistency
const targetDate = new Date(cutoffDate);
const targetDate = new Date(cutoffDate!);
targetDate.setUTCHours(0, 0, 0, 0);
console.log('[useSpaceData] Loading data for date:', targetDate.toISOString());
const data = await fetchCelestialPositions(
targetDate.toISOString(),
targetDate.toISOString(), // Same as start - single point in time
'1d' // Use 1d step for consistency
);
console.log('[useSpaceData] API response:', {
totalBodies: data.bodies.length,
bodiesWithPositions: data.bodies.filter(b => b.positions && b.positions.length > 0).length,
sample: data.bodies.slice(0, 2).map(b => ({
id: b.id,
name: b.name,
type: b.type,
hasPositions: b.positions && b.positions.length > 0,
positionCount: b.positions?.length || 0,
firstPosition: b.positions?.[0]
}))
});
// Check if positions have the expected structure
const firstBody = data.bodies[0];
if (firstBody && firstBody.positions && firstBody.positions.length > 0) {
console.log('[useSpaceData] First body position structure:', {
body: firstBody.name,
position: firstBody.positions[0],
hasX: 'x' in firstBody.positions[0],
hasY: 'y' in firstBody.positions[0],
hasZ: 'z' in firstBody.positions[0]
});
}
setBodies(data.bodies);
console.log('[useSpaceData] State updated with', data.bodies.length, 'bodies');
} catch (err) {
console.error('Failed to fetch celestial data:', err);
setError(err instanceof Error ? err.message : 'Unknown error');

View File

@ -178,7 +178,7 @@ export function AdminLayout() {
color: '#fff',
}}
>
{collapsed ? '🌌' : '🌌 Cosmo'}
{collapsed ? '🌌' : '🌌 COSMO'}
</div>
<Menu
theme="dark"

View File

@ -1,18 +1,25 @@
import { useState, useEffect } from 'react';
import { Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col } from 'antd';
import { Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert, Upload, Popconfirm, Row, Col, Tabs } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import type { ColumnsType } from 'antd/es/table';
import MdEditor from 'react-markdown-editor-lite';
import MarkdownIt from 'markdown-it';
import 'react-markdown-editor-lite/lib/index.css';
import { DataTable } from '../../components/admin/DataTable';
import { request } from '../../utils/request';
import { useToast } from '../../contexts/ToastContext';
const MdEditorParser = new MarkdownIt();
interface CelestialBody {
id: string;
name: string;
name_zh: string;
type: string;
description: string;
details?: string; // Added details field
is_active: boolean;
resources?: {
[key: string]: Array<{
@ -36,6 +43,7 @@ export function CelestialBodies() {
const [searchQuery, setSearchQuery] = useState('');
const [uploading, setUploading] = useState(false);
const [refreshResources, setRefreshResources] = useState(0);
const [activeTabKey, setActiveTabKey] = useState('basic'); // State for active tab
const toast = useToast();
useEffect(() => {
@ -72,6 +80,7 @@ export function CelestialBodies() {
setEditingRecord(null);
form.resetFields();
setSearchQuery('');
setActiveTabKey('basic'); // Reset to basic tab
// Default values
form.setFieldsValue({ is_active: true, type: 'probe' });
setIsModalOpen(true);
@ -91,6 +100,28 @@ export function CelestialBodies() {
});
if (result.success) {
// Check if this body already exists in our database
const existingBody = data.find(b => b.id === result.data.id);
if (existingBody) {
Modal.warning({
title: '天体已存在',
content: (
<div>
<p>: <strong>{result.data.full_name}</strong></p>
<p>ID: <strong>{result.data.id}</strong></p>
<p style={{ color: '#faad14', marginTop: '10px' }}>
: <strong>{existingBody.name}</strong>
</p>
<p style={{ fontSize: '12px', color: '#888' }}>
</p>
</div>
),
});
return;
}
// Auto-fill form with search results
form.setFieldsValue({
id: result.data.id,
@ -134,6 +165,7 @@ export function CelestialBodies() {
const handleEdit = (record: CelestialBody) => {
setEditingRecord(record);
form.setFieldsValue(record);
setActiveTabKey('basic'); // Reset to basic tab
setIsModalOpen(true);
};
@ -181,9 +213,22 @@ export function CelestialBodies() {
setIsModalOpen(false);
loadData();
} catch (error) {
} catch (error: any) {
console.error(error);
// toast.error('操作失败'); // request interceptor might already handle this
// Check for specific error messages
if (error.response?.status === 400) {
const detail = error.response?.data?.detail;
if (detail && detail.includes('already exists')) {
toast.error(`天体已存在: ${values.id}`);
} else {
toast.error(detail || '请检查表单数据是否完整');
}
} else if (error.errorFields) {
// Validation error
toast.error('请填写所有必填字段');
} else {
toast.error(error.response?.data?.detail || '操作失败');
}
}
};
@ -320,121 +365,135 @@ export function CelestialBodies() {
open={isModalOpen}
onOk={handleModalOk}
onCancel={() => setIsModalOpen(false)}
width={800}
width={1000}
>
<Form
form={form}
layout="vertical"
>
{!editingRecord && (
<>
<Alert
title="智能搜索提示"
description={
<div>
<p>使 <strong>JPL Horizons ID</strong> </p>
<p style={{ marginTop: 4 }}>
Hubble ID <code>-48</code>Voyager 1 ID <code>-31</code>
</p>
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
ID ID
</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item label="从 NASA 数据库搜索">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleNASASearch}
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
<Tabs.TabPane tab="基础信息" key="basic">
{!editingRecord && (
<>
<Alert
title="智能搜索提示"
description={
<div>
<p>使 <strong>JPL Horizons ID</strong> </p>
<p style={{ marginTop: 4 }}>
Hubble ID <code>-48</code>Voyager 1 ID <code>-31</code>
</p>
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
ID ID
</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleNASASearch}
loading={searching}
<Form.Item label="从 NASA 数据库搜索">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleNASASearch}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleNASASearch}
loading={searching}
>
</Button>
</Space.Compact>
</Form.Item>
</>
)}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="id"
label="JPL Horizons ID"
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
>
</Button>
</Space.Compact>
</Form.Item>
</>
)}
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="type"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="planet"></Select.Option>
<Select.Option value="dwarf_planet"></Select.Option>
<Select.Option value="satellite"></Select.Option>
<Select.Option value="probe"></Select.Option>
<Select.Option value="star"></Select.Option>
<Select.Option value="comet"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="id"
label="JPL Horizons ID"
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
>
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="type"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="planet"></Select.Option>
<Select.Option value="dwarf_planet"></Select.Option>
<Select.Option value="satellite"></Select.Option>
<Select.Option value="probe"></Select.Option>
<Select.Option value="star"></Select.Option>
<Select.Option value="comet"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="英文名"
rules={[{ required: true, message: '请输入英文名' }]}
>
<Input placeholder="例如Voyager 1" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="name_zh"
label="中文名"
>
<Input placeholder="例如旅行者1号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="英文名"
rules={[{ required: true, message: '请输入英文名' }]}
name="description"
label="描述"
>
<Input placeholder="例如Voyager 1" />
<Input.TextArea rows={2} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="name_zh"
label="中文名"
>
<Input placeholder="例如旅行者1号" />
{editingRecord && (
<ResourceManager
bodyId={editingRecord.id}
bodyType={editingRecord.type}
resources={editingRecord.resources}
hasResources={editingRecord.has_resources}
onUpload={handleResourceUpload}
onDelete={handleResourceDelete}
uploading={uploading}
refreshTrigger={refreshResources}
toast={toast}
/>
)}
</Tabs.TabPane>
<Tabs.TabPane tab="详细信息 (Markdown)" key="details">
<Form.Item name="details" style={{ marginBottom: 0 }}>
<MdEditor
value={form.getFieldValue('details')}
style={{ height: '500px' }}
renderHTML={(text) => MdEditorParser.render(text)}
onChange={({ text }) => form.setFieldsValue({ details: text })}
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={2} />
</Form.Item>
{editingRecord && (
<ResourceManager
bodyId={editingRecord.id}
bodyType={editingRecord.type}
resources={editingRecord.resources}
hasResources={editingRecord.has_resources}
onUpload={handleResourceUpload}
onDelete={handleResourceDelete}
uploading={uploading}
refreshTrigger={refreshResources}
toast={toast}
/>
)}
</Tabs.TabPane>
</Tabs>
</Form>
</Modal>
</>

View File

@ -17,13 +17,17 @@ import {
Space,
Progress,
Calendar,
Alert
Alert,
Tag,
Modal,
Table
} from 'antd';
import {
DownloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined
LoadingOutlined,
DeleteOutlined
} from '@ant-design/icons';
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
import type { Dayjs } from 'dayjs';
@ -60,10 +64,14 @@ export function NASADownload() {
dayjs().startOf('month'),
dayjs().endOf('month')
]);
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
const [availableDates, setAvailableDates] = useState<Map<string, Set<string>>>(new Map());
const [loadingDates, setLoadingDates] = useState(false);
const [downloading, setDownloading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
const [activeBodyForCalendar, setActiveBodyForCalendar] = useState<string | null>(null);
const [viewingDateData, setViewingDateData] = useState<{date: string; bodies: any[]} | null>(null);
const [loadingDateData, setLoadingDateData] = useState(false);
const toast = useToast();
// Get data cutoff date
@ -85,8 +93,13 @@ export function NASADownload() {
useEffect(() => {
if (selectedBodies.length > 0) {
loadAvailableDates();
// Set first selected body as active for calendar display
if (!activeBodyForCalendar || !selectedBodies.includes(activeBodyForCalendar)) {
setActiveBodyForCalendar(selectedBodies[0]);
}
} else {
setAvailableDates(new Set());
setAvailableDates(new Map());
setActiveBodyForCalendar(null);
}
}, [selectedBodies, dateRange]);
@ -107,26 +120,29 @@ export function NASADownload() {
setLoadingDates(true);
try {
const allDates = new Set<string>();
const newAvailableDates = new Map<string, Set<string>>();
// Load available dates for the first selected body
const bodyId = selectedBodies[0];
const startDate = dateRange[0].format('YYYY-MM-DD');
const endDate = dateRange[1].format('YYYY-MM-DD');
// Load available dates for ALL selected bodies
for (const bodyId of selectedBodies) {
const startDate = dateRange[0].format('YYYY-MM-DD');
const endDate = dateRange[1].format('YYYY-MM-DD');
const { data } = await request.get('/celestial/positions/download/status', {
params: {
body_id: bodyId,
start_date: startDate,
end_date: endDate
}
});
const { data } = await request.get('/celestial/positions/download/status', {
params: {
body_id: bodyId,
start_date: startDate,
end_date: endDate
}
});
data.available_dates.forEach((date: string) => {
allDates.add(date);
});
const bodyDates = new Set<string>();
data.available_dates.forEach((date: string) => {
bodyDates.add(date);
});
newAvailableDates.set(bodyId, bodyDates);
}
setAvailableDates(allDates);
setAvailableDates(newAvailableDates);
} catch (error) {
toast.error('加载数据状态失败');
} finally {
@ -164,20 +180,33 @@ export function NASADownload() {
}
let datesToDownload: string[] = [];
const today = dayjs();
if (selectedDate) {
// Download single date
// Download single date - check if it's not in the future
if (selectedDate.isAfter(today, 'day')) {
toast.warning('不能下载未来日期的数据');
return;
}
datesToDownload = [selectedDate.format('YYYY-MM-DD')];
} else {
// Download all dates in range
// Download all dates in range - exclude future dates
const start = dateRange[0];
const end = dateRange[1];
let current = start;
while (current.isBefore(end) || current.isSame(end, 'day')) {
datesToDownload.push(current.format('YYYY-MM-DD'));
// Only include dates up to today
if (!current.isAfter(today, 'day')) {
datesToDownload.push(current.format('YYYY-MM-DD'));
}
current = current.add(1, 'day');
}
if (datesToDownload.length === 0) {
toast.warning('所选日期范围内没有可下载的数据(不能下载未来日期)');
return;
}
}
setDownloading(true);
@ -215,13 +244,61 @@ export function NASADownload() {
}
};
const handleDelete = async () => {
if (selectedBodies.length === 0) {
toast.warning('请先选择至少一个天体');
return;
}
const start = dateRange[0];
const end = dateRange[1];
const datesToDelete: string[] = [];
let current = start;
// Build list of all dates in range (including future dates)
while (current.isBefore(end) || current.isSame(end, 'day')) {
datesToDelete.push(current.format('YYYY-MM-DD'));
current = current.add(1, 'day');
}
if (datesToDelete.length === 0) {
return;
}
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${selectedBodies.length} 个天体在 ${datesToDelete.length} 天内的所有位置数据吗?此操作不可恢复。`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
setDeleting(true);
try {
const { data } = await request.post('/celestial/positions/delete', {
body_ids: selectedBodies,
dates: datesToDelete
});
toast.success(data.message || '数据已删除');
loadAvailableDates(); // Refresh status
} catch (error) {
toast.error('删除失败');
} finally {
setDeleting(false);
}
}
});
};
// Custom calendar cell renderer
const dateCellRender = (value: Dayjs) => {
const dateStr = value.format('YYYY-MM-DD');
const hasData = availableDates.has(dateStr);
const inRange = value.isBetween(dateRange[0], dateRange[1], 'day', '[]');
if (!inRange) return null;
if (!inRange || !activeBodyForCalendar) return null;
const bodyDates = availableDates.get(activeBodyForCalendar);
const hasData = bodyDates?.has(dateStr) || false;
return (
<div style={{ textAlign: 'center', padding: '4px 0' }}>
@ -239,9 +316,8 @@ export function NASADownload() {
return current && current.isAfter(dayjs(), 'day');
};
const handleCalendarDateClick = (date: Dayjs) => {
const handleCalendarDateClick = async (date: Dayjs) => {
const dateStr = date.format('YYYY-MM-DD');
const hasData = availableDates.has(dateStr);
const inRange = date.isBetween(dateRange[0], dateRange[1], 'day', '[]');
if (!inRange) {
@ -249,8 +325,22 @@ export function NASADownload() {
return;
}
if (date.isAfter(dayjs(), 'day')) {
toast.warning('不能下载未来日期的数据');
return;
}
if (!activeBodyForCalendar) {
toast.warning('请先选择天体');
return;
}
const bodyDates = availableDates.get(activeBodyForCalendar);
const hasData = bodyDates?.has(dateStr) || false;
if (hasData) {
toast.info('该日期已有数据');
// Load and display data for this date
await loadDateData(dateStr);
return;
}
@ -262,12 +352,74 @@ export function NASADownload() {
handleDownload(date);
};
const loadDateData = async (dateStr: string) => {
setLoadingDateData(true);
try {
// Query all selected bodies at once (comma-separated)
const bodyIdsToQuery = selectedBodies.filter(bodyId => {
const bodyDates = availableDates.get(bodyId);
return bodyDates?.has(dateStr);
});
if (bodyIdsToQuery.length === 0) {
setViewingDateData({ date: dateStr, bodies: [] });
setLoadingDateData(false);
return;
}
try {
// Query all bodies in one request
const { data } = await request.get('/celestial/positions', {
params: {
body_ids: bodyIdsToQuery.join(','),
start_time: `${dateStr}T00:00:00Z`,
end_time: `${dateStr}T00:00:00Z`
}
});
console.log(`Response for bodies ${bodyIdsToQuery.join(',')}:`, data);
const bodiesData = [];
const allBodies = Object.values(bodies).flat();
// Process each body in the response
if (data.bodies && data.bodies.length > 0) {
for (const bodyData of data.bodies) {
if (bodyData.positions && bodyData.positions.length > 0) {
const body = allBodies.find(b => b.id === bodyData.id);
const pos = bodyData.positions[0];
bodiesData.push({
id: bodyData.id,
name: body?.name_zh || body?.name || bodyData.name || bodyData.id,
x: pos.x,
y: pos.y,
z: pos.z,
vx: pos.vx,
vy: pos.vy,
vz: pos.vz,
time: pos.time
});
}
}
}
console.log('Processed bodies data:', bodiesData);
setViewingDateData({ date: dateStr, bodies: bodiesData });
} catch (error) {
console.error(`Failed to load data:`, error);
toast.error('加载数据失败');
}
} finally {
setLoadingDateData(false);
}
};
return (
<div>
{/* Data Cutoff Date Display */}
{cutoffDate && (
<Alert
message={`数据截止日期: ${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
title={`数据截止日期: ${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`}
description="选择左侧天体右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据00:00 UTC。"
type="success"
showIcon
@ -310,14 +462,14 @@ export function NASADownload() {
</div>
),
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Space orientation="vertical" style={{ width: '100%' }}>
{typeBodies.map((body) => (
<Checkbox
key={body.id}
checked={selectedBodies.includes(body.id)}
onChange={(e) => handleBodySelect(body.id, e.target.checked)}
>
{body.name_zh || body.name}
{body.name_zh || body.name} ({body.id})
{!body.is_active && <Badge status="default" text="(未激活)" style={{ marginLeft: 8 }} />}
</Checkbox>
))}
@ -331,7 +483,7 @@ export function NASADownload() {
{/* Right: Date Selection and Calendar */}
<Col span={16}>
<Card
title="选择下载日期"
title="选择日期"
extra={
<Space>
<RangePicker
@ -341,19 +493,67 @@ export function NASADownload() {
format="YYYY-MM-DD"
allowClear={false}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleDelete}
disabled={selectedBodies.length === 0 || downloading || deleting}
loading={deleting}
>
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => handleDownload()}
disabled={selectedBodies.length === 0}
disabled={selectedBodies.length === 0 || downloading || deleting}
loading={downloading}
>
()
</Button>
</Space>
}
>
<Spin spinning={loadingDates} indicator={<LoadingOutlined spin />}>
{/* Body selector for calendar view when multiple bodies selected */}
{selectedBodies.length > 1 && (
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Space wrap style={{ marginLeft: 8 }}>
{selectedBodies.map(bodyId => {
const allBodies = Object.values(bodies).flat();
const body = allBodies.find(b => b.id === bodyId);
return (
<Tag
key={bodyId}
color={bodyId === activeBodyForCalendar ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => setActiveBodyForCalendar(bodyId)}
>
{body?.name_zh || body?.name || bodyId} ({bodyId})
</Tag>
);
})}
</Space>
<div style={{ marginTop: 8, fontSize: '12px', color: '#888' }}>
</div>
</div>
)}
{selectedBodies.length === 1 && activeBodyForCalendar && (
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Tag color="blue" style={{ marginLeft: 8 }}>
{(() => {
const allBodies = Object.values(bodies).flat();
const body = allBodies.find(b => b.id === activeBodyForCalendar);
return `${body?.name_zh || body?.name || activeBodyForCalendar} (${activeBodyForCalendar})`;
})()}
</Tag>
</div>
)}
<div style={{ marginBottom: 16 }}>
<Space>
<Badge status="success" text="已有数据" />
@ -385,6 +585,77 @@ export function NASADownload() {
</Card>
</Col>
</Row>
{/* Data Viewer Modal */}
<Modal
title={`位置数据 - ${viewingDateData?.date || ''}`}
open={!!viewingDateData}
onCancel={() => setViewingDateData(null)}
footer={null}
width={900}
>
{viewingDateData && (
<Spin spinning={loadingDateData}>
<Table
dataSource={viewingDateData.bodies}
rowKey="id"
pagination={false}
size="small"
scroll={{ x: true }}
columns={[
{
title: '天体',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 120
},
{
title: 'X (AU)',
dataIndex: 'x',
key: 'x',
render: (val: number) => val?.toFixed(6) || '-'
},
{
title: 'Y (AU)',
dataIndex: 'y',
key: 'y',
render: (val: number) => val?.toFixed(6) || '-'
},
{
title: 'Z (AU)',
dataIndex: 'z',
key: 'z',
render: (val: number) => val?.toFixed(6) || '-'
},
{
title: 'VX (AU/day)',
dataIndex: 'vx',
key: 'vx',
render: (val: number) => val?.toFixed(8) || '-'
},
{
title: 'VY (AU/day)',
dataIndex: 'vy',
key: 'vy',
render: (val: number) => val?.toFixed(8) || '-'
},
{
title: 'VZ (AU/day)',
dataIndex: 'vz',
key: 'vz',
render: (val: number) => val?.toFixed(8) || '-'
}
]}
/>
{viewingDateData.bodies.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px', color: '#999' }}>
</div>
)}
</Spin>
)}
</Modal>
</div>
);
}

View File

@ -147,28 +147,32 @@ export function Tasks() {
width={800}
>
{currentTask && (
<Descriptions bordered column={1}>
<Descriptions.Item label="任务ID">{currentTask.id}</Descriptions.Item>
<Descriptions.Item label="类型">{currentTask.task_type}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={currentTask.status === 'completed' ? 'success' : currentTask.status === 'running' ? 'processing' : 'default'} text={currentTask.status} />
</Descriptions.Item>
<Descriptions.Item label="描述">{currentTask.description}</Descriptions.Item>
{currentTask.error_message && (
<Descriptions.Item label="错误信息">
<Text type="danger">{currentTask.error_message}</Text>
<div style={{ maxWidth: '100%', overflowX: 'auto' }}>
<Descriptions bordered column={1}>
<Descriptions.Item label="任务ID">{currentTask.id}</Descriptions.Item>
<Descriptions.Item label="类型">{currentTask.task_type}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={currentTask.status === 'completed' ? 'success' : currentTask.status === 'running' ? 'processing' : 'default'} text={currentTask.status} />
</Descriptions.Item>
)}
<Descriptions.Item label="结果">
<div className="bg-gray-100 p-2 rounded max-h-60 overflow-auto text-xs font-mono">
{currentTask.result ? (
<pre>{JSON.stringify(currentTask.result, null, 2)}</pre>
) : (
<span className="text-gray-400"></span>
)}
</div>
</Descriptions.Item>
</Descriptions>
<Descriptions.Item label="描述">{currentTask.description}</Descriptions.Item>
{currentTask.error_message && (
<Descriptions.Item label="错误信息">
<Text type="danger" style={{ wordBreak: 'break-word' }}>{currentTask.error_message}</Text>
</Descriptions.Item>
)}
<Descriptions.Item label="结果">
<div className="bg-gray-100 p-2 rounded max-h-60 overflow-auto text-xs font-mono" style={{ maxWidth: '100%' }}>
{currentTask.result ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(currentTask.result, null, 2)}
</pre>
) : (
<span className="text-gray-400"></span>
)}
</div>
</Descriptions.Item>
</Descriptions>
</div>
)}
</Modal>
</div>

View File

@ -0,0 +1,71 @@
import * as THREE from 'three';
/**
* Creates a texture containing text labels for celestial bodies.
* Uses Canvas 2D API to render system fonts, avoiding the need for external font files.
*/
export function createLabelTexture(name: string, subtext: string | null, distance: string, color: string): THREE.CanvasTexture | null {
const canvas = document.createElement('canvas');
// Power of 2 dimensions for better compatibility
canvas.width = 512;
canvas.height = 256;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Clear background
ctx.clearRect(0, 0, 512, 256);
// Common settings
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 6;
// Check if we only have a name (Simple Label Mode)
const isSimpleLabel = !subtext && !distance;
if (isSimpleLabel) {
// Center the single name vertically
ctx.textBaseline = 'middle';
// Use a slightly larger font for simple labels (like stars/constellations)
ctx.font = 'bold 72px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif';
ctx.fillStyle = color;
ctx.fillText(name, 256, 128);
} else {
// Complex Label Mode (Name + Subtext + Distance)
ctx.textBaseline = 'top';
// 1. Name (Large, Top)
ctx.font = 'bold 64px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif';
ctx.fillStyle = color;
ctx.fillText(name, 256, 10);
let nextY = 90;
// 2. Subtext/Offset (Medium, Middle)
if (subtext) {
ctx.font = 'bold 42px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif';
ctx.fillStyle = '#ffaa00'; // Orange for warning/info
ctx.fillText(subtext, 256, nextY);
nextY += 60;
} else {
// Add some spacing if no subtext
nextY += 20;
}
// 3. Distance (Small, Bottom)
if (distance) {
ctx.font = '36px "SF Mono", "Roboto Mono", Menlo, monospace';
ctx.fillStyle = color;
// Reduce opacity for distance to make it less distracting
ctx.globalAlpha = 0.7;
ctx.fillText(distance, 256, nextY);
}
}
const texture = new THREE.CanvasTexture(canvas);
// texture.minFilter = THREE.LinearMipMapLinearFilter; // Can cause artifacts if not careful
texture.minFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}

File diff suppressed because it is too large Load Diff