大幅调整的一个版本,待测试
parent
04fce88f44
commit
088529d6c4
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 缓存天数
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -112,7 +114,7 @@ async def get_celestial_nasa_data(
|
|||
try:
|
||||
# Fetch raw text from Horizons using the body_id
|
||||
# Note: body.id corresponds to JPL Horizons ID
|
||||
raw_text = await horizons_service.get_object_data_raw(body.id, db)
|
||||
raw_text = await horizons_service.get_object_data_raw(body.id)
|
||||
return {"id": body.id, "name": body.name, "raw_data": raw_text}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch raw data for {body_id}: {e}")
|
||||
|
|
@ -173,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"),
|
||||
)
|
||||
|
|
@ -212,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,
|
||||
|
|
|
|||
|
|
@ -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,7 +339,7 @@ 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
|
||||
|
|
@ -342,7 +347,7 @@ async def get_celestial_positions(
|
|||
|
||||
else:
|
||||
# Download from NASA Horizons
|
||||
pos_data = await horizons_service.get_body_positions(body.id, db, start_dt, end_dt, step)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -217,15 +217,16 @@ async def download_positions(
|
|||
continue
|
||||
|
||||
# Download from NASA Horizons
|
||||
logger.info(f"Downloading position for body {body_id} on {date_str}")
|
||||
positions = await horizons_service.get_body_positions(
|
||||
body_id=body_id,
|
||||
db=db,
|
||||
start_time=target_date,
|
||||
end_time=target_date,
|
||||
step="1d"
|
||||
)
|
||||
|
||||
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,
|
||||
|
|
@ -243,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,
|
||||
|
|
@ -283,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))
|
||||
|
|
|
|||
|
|
@ -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 AU(30,000,000 km;19,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",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -91,26 +91,23 @@ class HorizonsService:
|
|||
if end_time is None:
|
||||
end_time = start_time
|
||||
|
||||
# Format time for Horizons (YYYY-MM-DD HH:MM)
|
||||
# Horizons accepts ISO-like format without 'T'
|
||||
start_str = start_time.strftime('%Y-%m-%d %H:%M')
|
||||
end_str = end_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# Special case for single point query (start = end)
|
||||
# Horizons requires START != STOP for ranges, but we can handle single point
|
||||
# by making a very small range or just asking for 1 step.
|
||||
# Actually Horizons API is fine with start=end if we don't ask for range?
|
||||
# Let's keep using range parameters as standard.
|
||||
if start_time == end_time:
|
||||
# Just add 1 minute for range, but we only parse the first result
|
||||
end_dummy = end_time + timedelta(minutes=1)
|
||||
end_str = end_dummy.strftime('%Y-%m-%d %H:%M')
|
||||
# Override step to ensure we get the start point
|
||||
# But wait, '1d' step might skip.
|
||||
# If start==end, we want exactly one point.
|
||||
# We can't use '1' count in API easily via URL params without STEP_SIZE?
|
||||
# Let's just use the provided step.
|
||||
|
||||
# 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
|
||||
|
||||
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:
|
||||
# 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 (httpx) for body {body_id} from {start_str} to {end_str}")
|
||||
|
||||
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
|
||||
|
|
@ -126,223 +123,16 @@ class HorizonsService:
|
|||
"START_TIME": start_str,
|
||||
"STOP_TIME": end_str,
|
||||
"STEP_SIZE": step,
|
||||
"CSV_FORMAT": "YES"
|
||||
"CSV_FORMAT": "YES",
|
||||
"OUT_UNITS": "AU-D"
|
||||
}
|
||||
|
||||
# Configure proxy if available
|
||||
client_kwargs = {"timeout": settings.nasa_api_timeout}
|
||||
if settings.proxy_dict:
|
||||
client_kwargs["proxies"] = 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 _parse_vectors(self, text: str) -> list[Position]:
|
||||
"""
|
||||
Parse Horizons CSV output for vector data
|
||||
|
||||
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 = []
|
||||
|
||||
# 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 a snippet of text for debugging
|
||||
logger.debug(f"Response snippet: {text[:200]}...")
|
||||
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
|
||||
|
||||
"""
|
||||
NASA JPL Horizons data query service
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
# from astroquery.jplhorizons import Horizons # Removed astroquery dependency
|
||||
from astropy.time import Time # Kept astropy for Time object
|
||||
import logging
|
||||
import re
|
||||
import httpx
|
||||
import os
|
||||
|
||||
from app.models.celestial import Position, CelestialBody
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HorizonsService:
|
||||
"""Service for querying NASA JPL Horizons system"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the service"""
|
||||
self.location = "@sun" # Heliocentric coordinates
|
||||
# Proxy is handled via settings.proxy_dict in each request
|
||||
|
||||
async def get_object_data_raw(self, body_id: str) -> str:
|
||||
"""
|
||||
Get raw object data (terminal style text) from Horizons
|
||||
|
||||
Args:
|
||||
body_id: JPL Horizons ID
|
||||
|
||||
Returns:
|
||||
Raw text response from NASA
|
||||
"""
|
||||
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
|
||||
# Ensure ID is quoted for COMMAND
|
||||
cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id
|
||||
|
||||
params = {
|
||||
"format": "text",
|
||||
"COMMAND": cmd_val,
|
||||
"OBJ_DATA": "YES",
|
||||
"MAKE_EPHEM": "NO",
|
||||
"EPHEM_TYPE": "VECTORS",
|
||||
"CENTER": "@sun"
|
||||
}
|
||||
|
||||
try:
|
||||
# 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:
|
||||
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:
|
||||
raise Exception(f"NASA API returned status {response.status_code}")
|
||||
|
||||
return response.text
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
async def get_body_positions(
|
||||
self,
|
||||
body_id: str,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
step: str = "1d",
|
||||
) -> list[Position]:
|
||||
"""
|
||||
Get positions for a celestial body over a time range
|
||||
|
||||
Args:
|
||||
body_id: JPL Horizons ID (e.g., '-31' for Voyager 1)
|
||||
start_time: Start datetime (default: now)
|
||||
end_time: End datetime (default: now)
|
||||
step: Time step (e.g., '1d' for 1 day, '1h' for 1 hour)
|
||||
|
||||
Returns:
|
||||
List of Position objects
|
||||
"""
|
||||
try:
|
||||
# Set default times
|
||||
if start_time is None:
|
||||
start_time = datetime.utcnow()
|
||||
if end_time is None:
|
||||
end_time = start_time
|
||||
|
||||
# Format time for Horizons (YYYY-MM-DD HH:MM)
|
||||
# Horizons accepts ISO-like format without 'T'
|
||||
start_str = start_time.strftime('%Y-%m-%d %H:%M')
|
||||
end_str = end_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# Special case for single point query (start = end)
|
||||
# Horizons requires START != STOP for ranges, but we can handle single point
|
||||
# by making a very small range or just asking for 1 step.
|
||||
# Actually Horizons API is fine with start=end if we don't ask for range?
|
||||
# Let's keep using range parameters as standard.
|
||||
if start_time == end_time:
|
||||
# Just add 1 minute for range, but we only parse the first result
|
||||
end_dummy = end_time + timedelta(minutes=1)
|
||||
end_str = end_dummy.strftime('%Y-%m-%d %H:%M')
|
||||
# Override step to ensure we get the start point
|
||||
# But wait, '1d' step might skip.
|
||||
# If start==end, we want exactly one point.
|
||||
# We can't use '1' count in API easily via URL params without STEP_SIZE?
|
||||
# Let's just use the provided step.
|
||||
|
||||
logger.info(f"Querying Horizons (httpx) for body {body_id} from {start_str} to {end_str}")
|
||||
|
||||
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
|
||||
cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# Configure proxy if available
|
||||
client_kwargs = {"timeout": settings.nasa_api_timeout}
|
||||
if settings.proxy_dict:
|
||||
client_kwargs["proxies"] = settings.proxy_dict
|
||||
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
response = await client.get(url, params=params)
|
||||
|
||||
|
|
@ -358,20 +148,20 @@ class HorizonsService:
|
|||
def _parse_vectors(self, text: str) -> list[Position]:
|
||||
"""
|
||||
Parse Horizons CSV output for vector data
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
|
||||
# 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 a snippet of text for debugging
|
||||
logger.debug(f"Response snippet: {text[:200]}...")
|
||||
# Log full response for debugging
|
||||
logger.info(f"Full response for debugging:\n{text}")
|
||||
return []
|
||||
|
||||
data_block = match.group(1).strip()
|
||||
|
|
@ -413,7 +203,7 @@ class HorizonsService:
|
|||
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:
|
||||
|
|
@ -436,10 +226,11 @@ class HorizonsService:
|
|||
"CENTER": "@ssb" # Search from Solar System Barycenter for consistent object IDs
|
||||
}
|
||||
|
||||
timeout = await self._get_timeout(db)
|
||||
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)
|
||||
|
|
@ -449,6 +240,9 @@ class HorizonsService:
|
|||
|
||||
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}")
|
||||
|
|
@ -470,14 +264,53 @@ class HorizonsService:
|
|||
"error": "未找到匹配的天体,请检查名称或 ID"
|
||||
}
|
||||
|
||||
# Parse canonical name and ID from response (e.g., "Target body name: Jupiter Barycenter (599)")
|
||||
target_name_match = re.search(r"Target body name: (.+?)\s+\((\-?\d+)\)", response_text)
|
||||
# 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: {full_name} with ID: {numeric_id}")
|
||||
logger.info(f"Found target (pattern 1): {full_name} with ID: {numeric_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"id": numeric_id,
|
||||
|
|
@ -487,7 +320,7 @@ class HorizonsService:
|
|||
}
|
||||
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[:200]}")
|
||||
logger.warning(f"Could not parse target name/ID from response for: {name}. Response snippet: {response_text[:500]}")
|
||||
return {
|
||||
"success": False,
|
||||
"id": None,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List
|
|||
# Download
|
||||
positions = await horizons_service.get_body_positions(
|
||||
body_id=body_id,
|
||||
db=db,
|
||||
start_time=target_date,
|
||||
end_time=target_date,
|
||||
step="1d"
|
||||
|
|
|
|||
|
|
@ -152,7 +152,6 @@ class OrbitService:
|
|||
# Get positions from Horizons (synchronous call)
|
||||
positions = await horizons_service.get_body_positions(
|
||||
body_id=body_id,
|
||||
db=session,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
step=f"{step_days}d"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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 |
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function AdminLayout() {
|
|||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{collapsed ? '🌌' : '🌌 Cosmo'}
|
||||
{collapsed ? '🌌' : '🌌 COSMO'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue