Compare commits
9 Commits
9c79196bf3
...
088529d6c4
| Author | SHA1 | Date |
|---|---|---|
|
|
088529d6c4 | |
|
|
04fce88f44 | |
|
|
a5f18747b2 | |
|
|
5172ec5d66 | |
|
|
f228af4e7f | |
|
|
eb512918ae | |
|
|
185b03cbf9 | |
|
|
d38de785e6 | |
|
|
f0e6e3a4fe |
|
|
@ -1,5 +1,5 @@
|
||||||
# Application Settings
|
# Application Settings
|
||||||
APP_NAME=Cosmo - Deep Space Explorer
|
APP_NAME=COSMO - Deep Space Explorer
|
||||||
API_PREFIX=/api
|
API_PREFIX=/api
|
||||||
|
|
||||||
# CORS Settings (comma-separated list)
|
# CORS Settings (comma-separated list)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ REDIS_MAX_CONNECTIONS=50 # 最大连接数
|
||||||
### 3. 应用配置
|
### 3. 应用配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_NAME=Cosmo - Deep Space Explorer
|
APP_NAME=COSMO - Deep Space Explorer
|
||||||
API_PREFIX=/api
|
API_PREFIX=/api
|
||||||
CORS_ORIGINS=["*"] # 开发环境允许所有来源
|
CORS_ORIGINS=["*"] # 开发环境允许所有来源
|
||||||
CACHE_TTL_DAYS=3 # NASA API 缓存天数
|
CACHE_TTL_DAYS=3 # NASA API 缓存天数
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class CelestialBodyCreate(BaseModel):
|
||||||
name_zh: Optional[str] = None
|
name_zh: Optional[str] = None
|
||||||
type: str
|
type: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
details: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
extra_data: Optional[Dict[str, Any]] = None
|
extra_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ class CelestialBodyUpdate(BaseModel):
|
||||||
name_zh: Optional[str] = None
|
name_zh: Optional[str] = None
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
details: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
extra_data: Optional[Dict[str, Any]] = None
|
extra_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
@ -58,7 +60,8 @@ async def create_celestial_body(
|
||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def search_celestial_body(
|
async def search_celestial_body(
|
||||||
name: str = Query(..., description="Body name or ID to search in NASA Horizons")
|
name: str = Query(..., description="Body name or ID to search in NASA Horizons"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search for a celestial body in NASA Horizons database by name or ID
|
Search for a celestial body in NASA Horizons database by name or ID
|
||||||
|
|
@ -68,7 +71,7 @@ async def search_celestial_body(
|
||||||
logger.info(f"Searching for celestial body: {name}")
|
logger.info(f"Searching for celestial body: {name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = horizons_service.search_body_by_name(name)
|
result = await horizons_service.search_body_by_name(name, db)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
logger.info(f"Found body: {result['full_name']}")
|
logger.info(f"Found body: {result['full_name']}")
|
||||||
|
|
@ -172,6 +175,7 @@ async def get_body_info(body_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
name=body.name,
|
name=body.name,
|
||||||
type=body.type,
|
type=body.type,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
|
details=body.details,
|
||||||
launch_date=extra_data.get("launch_date"),
|
launch_date=extra_data.get("launch_date"),
|
||||||
status=extra_data.get("status"),
|
status=extra_data.get("status"),
|
||||||
)
|
)
|
||||||
|
|
@ -211,6 +215,7 @@ async def list_bodies(
|
||||||
"name_zh": body.name_zh,
|
"name_zh": body.name_zh,
|
||||||
"type": body.type,
|
"type": body.type,
|
||||||
"description": body.description,
|
"description": body.description,
|
||||||
|
"details": body.details,
|
||||||
"is_active": body.is_active,
|
"is_active": body.is_active,
|
||||||
"resources": resources_by_type,
|
"resources": resources_by_type,
|
||||||
"has_resources": len(resources) > 0,
|
"has_resources": len(resources) > 0,
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ async def get_celestial_positions(
|
||||||
# Check Redis cache first (persistent across restarts)
|
# Check Redis cache first (persistent across restarts)
|
||||||
start_str = "now"
|
start_str = "now"
|
||||||
end_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)
|
redis_cached = await redis_cache.get(redis_key)
|
||||||
if redis_cached is not None:
|
if redis_cached is not None:
|
||||||
logger.info("Cache hit (Redis) for recent positions")
|
logger.info("Cache hit (Redis) for recent positions")
|
||||||
|
|
@ -194,7 +195,8 @@ async def get_celestial_positions(
|
||||||
# Cache in Redis for persistence across restarts
|
# Cache in Redis for persistence across restarts
|
||||||
start_str = start_dt.isoformat() if start_dt else "now"
|
start_str = start_dt.isoformat() if start_dt else "now"
|
||||||
end_str = end_dt.isoformat() if end_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"))
|
await redis_cache.set(redis_key, bodies_data, get_ttl_seconds("current_positions"))
|
||||||
return CelestialDataResponse(bodies=bodies_data)
|
return CelestialDataResponse(bodies=bodies_data)
|
||||||
else:
|
else:
|
||||||
|
|
@ -204,7 +206,8 @@ async def get_celestial_positions(
|
||||||
# Check Redis cache first (persistent across restarts)
|
# Check Redis cache first (persistent across restarts)
|
||||||
start_str = start_dt.isoformat() if start_dt else "now"
|
start_str = start_dt.isoformat() if start_dt else "now"
|
||||||
end_str = end_dt.isoformat() if end_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)
|
redis_cached = await redis_cache.get(redis_key)
|
||||||
if redis_cached is not None:
|
if redis_cached is not None:
|
||||||
logger.info("Cache hit (Redis) for positions")
|
logger.info("Cache hit (Redis) for positions")
|
||||||
|
|
@ -222,7 +225,9 @@ async def get_celestial_positions(
|
||||||
|
|
||||||
# Filter bodies if body_ids specified
|
# Filter bodies if body_ids specified
|
||||||
if body_id_list:
|
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]
|
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
|
use_db_cache = True
|
||||||
db_cached_bodies = []
|
db_cached_bodies = []
|
||||||
|
|
@ -334,15 +339,15 @@ async def get_celestial_positions(
|
||||||
# Special handling for Cassini (mission ended 2017-09-15)
|
# Special handling for Cassini (mission ended 2017-09-15)
|
||||||
elif body.id == "-82":
|
elif body.id == "-82":
|
||||||
cassini_date = datetime(2017, 9, 15, 11, 58, 0)
|
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 = [
|
positions_list = [
|
||||||
{"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z}
|
{"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z}
|
||||||
for p in pos_data
|
for p in pos_data
|
||||||
]
|
]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Query NASA Horizons for other bodies
|
# Download from NASA Horizons
|
||||||
pos_data = horizons_service.get_body_positions(body.id, start_dt, end_dt, step)
|
pos_data = await horizons_service.get_body_positions(body.id, start_dt, end_dt, step)
|
||||||
positions_list = [
|
positions_list = [
|
||||||
{"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z}
|
{"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z}
|
||||||
for p in pos_data
|
for p in pos_data
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,8 @@ async def download_positions(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Download from NASA Horizons
|
# Download from NASA Horizons
|
||||||
positions = horizons_service.get_body_positions(
|
logger.info(f"Downloading position for body {body_id} on {date_str}")
|
||||||
|
positions = await horizons_service.get_body_positions(
|
||||||
body_id=body_id,
|
body_id=body_id,
|
||||||
start_time=target_date,
|
start_time=target_date,
|
||||||
end_time=target_date,
|
end_time=target_date,
|
||||||
|
|
@ -225,6 +226,7 @@ async def download_positions(
|
||||||
)
|
)
|
||||||
|
|
||||||
if positions and len(positions) > 0:
|
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
|
# Save to database
|
||||||
position_data = [{
|
position_data = [{
|
||||||
"time": target_date,
|
"time": target_date,
|
||||||
|
|
@ -242,6 +244,17 @@ async def download_positions(
|
||||||
source="nasa_horizons",
|
source="nasa_horizons",
|
||||||
session=db
|
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({
|
body_results["dates"].append({
|
||||||
"date": date_str,
|
"date": date_str,
|
||||||
|
|
@ -282,3 +295,89 @@ async def download_positions(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Download failed: {e}")
|
logger.error(f"Download failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class Settings(BaseSettings):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
app_name: str = "Cosmo - Deep Space Explorer"
|
app_name: str = "COSMO - Deep Space Explorer"
|
||||||
api_prefix: str = "/api"
|
api_prefix: str = "/api"
|
||||||
|
|
||||||
# CORS settings - stored as string in env, converted to list
|
# CORS settings - stored as string in env, converted to list
|
||||||
|
|
@ -67,6 +67,7 @@ class Settings(BaseSettings):
|
||||||
# Proxy settings (for accessing NASA JPL Horizons API in China)
|
# Proxy settings (for accessing NASA JPL Horizons API in China)
|
||||||
http_proxy: str = ""
|
http_proxy: str = ""
|
||||||
https_proxy: str = ""
|
https_proxy: str = ""
|
||||||
|
nasa_api_timeout: int = 30
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def proxy_dict(self) -> dict[str, str] | None:
|
def proxy_dict(self) -> dict[str, str] | None:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class BodyInfo(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
type: Literal["planet", "probe", "star", "dwarf_planet", "satellite", "comet"]
|
type: Literal["planet", "probe", "star", "dwarf_planet", "satellite", "comet"]
|
||||||
description: str
|
description: str
|
||||||
|
details: str | None = None
|
||||||
launch_date: str | None = None
|
launch_date: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
|
|
||||||
|
|
@ -200,4 +201,26 @@ CELESTIAL_BODIES = {
|
||||||
"type": "dwarf_planet",
|
"type": "dwarf_planet",
|
||||||
"description": "鸟神星,柯伊伯带中第二亮的天体",
|
"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")
|
name_zh = Column(String(200), nullable=True, comment="Chinese name")
|
||||||
type = Column(String(50), nullable=False, comment="Body type")
|
type = Column(String(50), nullable=False, comment="Body type")
|
||||||
description = Column(Text, nullable=True, comment="Description")
|
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)")
|
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)")
|
extra_data = Column(JSONB, nullable=True, comment="Extended metadata (JSON)")
|
||||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
NASA JPL Horizons data query service
|
NASA JPL Horizons data query service
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from astroquery.jplhorizons import Horizons
|
|
||||||
from astropy.time import Time
|
from astropy.time import Time
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession # Added this import
|
||||||
|
|
||||||
from app.models.celestial import Position, CelestialBody
|
from app.models.celestial import Position, CelestialBody
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
@ -21,15 +21,7 @@ class HorizonsService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the service"""
|
"""Initialize the service"""
|
||||||
self.location = "@sun" # Heliocentric coordinates
|
self.location = "@sun" # Heliocentric coordinates
|
||||||
|
# Proxy is handled via settings.proxy_dict in each request
|
||||||
# Set proxy for astroquery if configured
|
|
||||||
# astroquery uses standard HTTP_PROXY and HTTPS_PROXY environment variables
|
|
||||||
if settings.http_proxy:
|
|
||||||
os.environ['HTTP_PROXY'] = settings.http_proxy
|
|
||||||
logger.info(f"Set HTTP_PROXY for astroquery: {settings.http_proxy}")
|
|
||||||
if settings.https_proxy:
|
|
||||||
os.environ['HTTPS_PROXY'] = settings.https_proxy
|
|
||||||
logger.info(f"Set HTTPS_PROXY for astroquery: {settings.https_proxy}")
|
|
||||||
|
|
||||||
async def get_object_data_raw(self, body_id: str) -> str:
|
async def get_object_data_raw(self, body_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -56,13 +48,13 @@ class HorizonsService:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Configure proxy if available
|
# Configure proxy if available
|
||||||
client_kwargs = {"timeout": 5.0}
|
client_kwargs = {"timeout": settings.nasa_api_timeout}
|
||||||
if settings.proxy_dict:
|
if settings.proxy_dict:
|
||||||
client_kwargs["proxies"] = settings.proxy_dict
|
client_kwargs["proxies"] = settings.proxy_dict
|
||||||
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
|
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||||
logger.info(f"Fetching raw data for body {body_id}")
|
logger.info(f"Fetching raw data for body {body_id} with timeout {settings.nasa_api_timeout}s")
|
||||||
response = await client.get(url, params=params)
|
response = await client.get(url, params=params)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
|
@ -73,7 +65,7 @@ class HorizonsService:
|
||||||
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
|
logger.error(f"Error fetching raw data for {body_id}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_body_positions(
|
async def get_body_positions(
|
||||||
self,
|
self,
|
||||||
body_id: str,
|
body_id: str,
|
||||||
start_time: datetime | None = None,
|
start_time: datetime | None = None,
|
||||||
|
|
@ -99,157 +91,254 @@ class HorizonsService:
|
||||||
if end_time is None:
|
if end_time is None:
|
||||||
end_time = start_time
|
end_time = start_time
|
||||||
|
|
||||||
# Convert to astropy Time objects for single point queries
|
# Format time for Horizons
|
||||||
# For ranges, use ISO format strings which Horizons prefers
|
# NASA Horizons accepts: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'
|
||||||
|
# When querying a single point (same start/end date), we need STOP > START
|
||||||
|
# So we add 1 second and use precise time format
|
||||||
|
|
||||||
# Create time range
|
if start_time.date() == end_time.date():
|
||||||
if start_time == end_time:
|
# Single day query - use the date at 00:00 and next second
|
||||||
# Single time point - use JD format
|
start_str = start_time.strftime('%Y-%m-%d')
|
||||||
epochs = Time(start_time).jd
|
# 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:
|
else:
|
||||||
# Time range - use ISO format (YYYY-MM-DD HH:MM)
|
# Multi-day range query
|
||||||
# Horizons expects this format for ranges
|
start_str = start_time.strftime('%Y-%m-%d')
|
||||||
start_str = start_time.strftime('%Y-%m-%d %H:%M')
|
end_str = end_time.strftime('%Y-%m-%d')
|
||||||
end_str = end_time.strftime('%Y-%m-%d %H:%M')
|
|
||||||
epochs = {"start": start_str, "stop": end_str, "step": step}
|
|
||||||
|
|
||||||
logger.info(f"Querying Horizons for body {body_id} from {start_time} to {end_time}")
|
logger.info(f"Querying Horizons (httpx) for body {body_id} from {start_str} to {end_str}")
|
||||||
|
|
||||||
# Query JPL Horizons
|
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
|
||||||
obj = Horizons(id=body_id, location=self.location, epochs=epochs)
|
cmd_val = f"'{body_id}'" if not body_id.startswith("'") else body_id
|
||||||
vectors = obj.vectors()
|
|
||||||
|
|
||||||
# Extract positions
|
params = {
|
||||||
positions = []
|
"format": "text",
|
||||||
if isinstance(epochs, dict):
|
"COMMAND": cmd_val,
|
||||||
# Multiple time points
|
"OBJ_DATA": "NO",
|
||||||
for i in range(len(vectors)):
|
"MAKE_EPHEM": "YES",
|
||||||
pos = Position(
|
"EPHEM_TYPE": "VECTORS",
|
||||||
time=Time(vectors["datetime_jd"][i], format="jd").datetime,
|
"CENTER": self.location,
|
||||||
x=float(vectors["x"][i]),
|
"START_TIME": start_str,
|
||||||
y=float(vectors["y"][i]),
|
"STOP_TIME": end_str,
|
||||||
z=float(vectors["z"][i]),
|
"STEP_SIZE": step,
|
||||||
)
|
"CSV_FORMAT": "YES",
|
||||||
positions.append(pos)
|
"OUT_UNITS": "AU-D"
|
||||||
else:
|
}
|
||||||
# Single time point
|
|
||||||
pos = Position(
|
|
||||||
time=start_time,
|
|
||||||
x=float(vectors["x"][0]),
|
|
||||||
y=float(vectors["y"][0]),
|
|
||||||
z=float(vectors["z"][0]),
|
|
||||||
)
|
|
||||||
positions.append(pos)
|
|
||||||
|
|
||||||
logger.info(f"Successfully retrieved {len(positions)} positions for body {body_id}")
|
# Configure proxy if available
|
||||||
return positions
|
client_kwargs = {"timeout": settings.nasa_api_timeout}
|
||||||
|
if settings.proxy_dict:
|
||||||
|
client_kwargs["proxies"] = settings.proxy_dict
|
||||||
|
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"NASA API returned status {response.status_code}")
|
||||||
|
|
||||||
|
return self._parse_vectors(response.text)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error querying Horizons for body {body_id}: {str(e)}")
|
logger.error(f"Error querying Horizons for body {body_id}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def search_body_by_name(self, name: str) -> dict:
|
def _parse_vectors(self, text: str) -> list[Position]:
|
||||||
"""
|
"""
|
||||||
Search for a celestial body by name in NASA Horizons database
|
Parse Horizons CSV output for vector data
|
||||||
|
|
||||||
Args:
|
Format looks like:
|
||||||
name: Body name or ID to search for
|
$$SOE
|
||||||
|
2460676.500000000, A.D. 2025-Jan-01 00:00:00.0000, 9.776737278236609E-01, -1.726677228793678E-01, -1.636678733289160E-05, ...
|
||||||
|
$$EOE
|
||||||
|
"""
|
||||||
|
positions = []
|
||||||
|
|
||||||
Returns:
|
# Extract data block between $$SOE and $$EOE
|
||||||
Dictionary with search results:
|
match = re.search(r'\$\$SOE(.*?)\$\$EOE', text, re.DOTALL)
|
||||||
{
|
if not match:
|
||||||
"success": bool,
|
logger.warning("No data block ($$SOE...$$EOE) found in Horizons response")
|
||||||
"id": str (extracted or input),
|
# Log full response for debugging
|
||||||
"name": str (short name),
|
logger.info(f"Full response for debugging:\n{text}")
|
||||||
"full_name": str (complete name from NASA),
|
return []
|
||||||
"error": str (if failed)
|
|
||||||
}
|
data_block = match.group(1).strip()
|
||||||
|
lines = data_block.split('\n')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Index 0: JD, 1: Date, 2: X, 3: Y, 4: Z, 5: VX, 6: VY, 7: VZ
|
||||||
|
# Time parsing: 2460676.500000000 is JD.
|
||||||
|
# A.D. 2025-Jan-01 00:00:00.0000 is Calendar.
|
||||||
|
# We can use JD or parse the string. Using JD via astropy is accurate.
|
||||||
|
|
||||||
|
jd_str = parts[0]
|
||||||
|
time_obj = Time(float(jd_str), format="jd").datetime
|
||||||
|
|
||||||
|
x = float(parts[2])
|
||||||
|
y = float(parts[3])
|
||||||
|
z = float(parts[4])
|
||||||
|
|
||||||
|
# Velocity if available (indices 5, 6, 7)
|
||||||
|
vx = float(parts[5]) if len(parts) > 5 else None
|
||||||
|
vy = float(parts[6]) if len(parts) > 6 else None
|
||||||
|
vz = float(parts[7]) if len(parts) > 7 else None
|
||||||
|
|
||||||
|
pos = Position(
|
||||||
|
time=time_obj,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
z=z,
|
||||||
|
vx=vx,
|
||||||
|
vy=vy,
|
||||||
|
vz=vz
|
||||||
|
)
|
||||||
|
positions.append(pos)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Failed to parse line: {line}. Error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return positions
|
||||||
|
|
||||||
|
async def search_body_by_name(self, name: str, db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Search for a celestial body by name in NASA Horizons database using httpx.
|
||||||
|
This method replaces the astroquery-based search to unify proxy and timeout control.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Searching Horizons for: {name}")
|
logger.info(f"Searching Horizons (httpx) for: {name}")
|
||||||
|
|
||||||
# Try to query with the name
|
url = "https://ssd.jpl.nasa.gov/api/horizons.api"
|
||||||
obj = Horizons(id=name, location=self.location)
|
cmd_val = f"'{name}'" # Name can be ID or actual name
|
||||||
vec = obj.vectors()
|
|
||||||
|
|
||||||
# Get the full target name from response
|
params = {
|
||||||
targetname = vec['targetname'][0]
|
"format": "text",
|
||||||
logger.info(f"Found target: {targetname}")
|
"COMMAND": cmd_val,
|
||||||
|
"OBJ_DATA": "YES", # Request object data to get canonical name/ID
|
||||||
# Extract ID and name from targetname
|
"MAKE_EPHEM": "NO", # Don't need ephemeris
|
||||||
# Possible formats:
|
"EPHEM_TYPE": "OBSERVER", # Arbitrary, won't be used since MAKE_EPHEM=NO
|
||||||
# 1. "136472 Makemake (2005 FY9)" - ID at start
|
"CENTER": "@ssb" # Search from Solar System Barycenter for consistent object IDs
|
||||||
# 2. "Voyager 1 (spacecraft) (-31)" - ID in parentheses
|
|
||||||
# 3. "Mars (499)" - ID in parentheses
|
|
||||||
# 4. "Parker Solar Probe (spacecraft)" - no ID
|
|
||||||
# 5. "Hubble Space Telescope (spacecra" - truncated
|
|
||||||
|
|
||||||
numeric_id = None
|
|
||||||
short_name = None
|
|
||||||
|
|
||||||
# Check if input is already a numeric ID
|
|
||||||
input_is_numeric = re.match(r'^-?\d+$', name.strip())
|
|
||||||
if input_is_numeric:
|
|
||||||
numeric_id = name.strip()
|
|
||||||
# Extract name from targetname
|
|
||||||
# Remove leading ID if present
|
|
||||||
name_part = re.sub(r'^\d+\s+', '', targetname)
|
|
||||||
short_name = name_part.split('(')[0].strip()
|
|
||||||
else:
|
|
||||||
# Try to extract ID from start of targetname (format: "136472 Makemake")
|
|
||||||
start_match = re.match(r'^(\d+)\s+(.+)', targetname)
|
|
||||||
if start_match:
|
|
||||||
numeric_id = start_match.group(1)
|
|
||||||
short_name = start_match.group(2).split('(')[0].strip()
|
|
||||||
else:
|
|
||||||
# Try to extract ID from parentheses (format: "Name (-31)" or "Name (499)")
|
|
||||||
id_match = re.search(r'\((-?\d+)\)', targetname)
|
|
||||||
if id_match:
|
|
||||||
numeric_id = id_match.group(1)
|
|
||||||
short_name = targetname.split('(')[0].strip()
|
|
||||||
else:
|
|
||||||
# No numeric ID found, use input name as ID
|
|
||||||
numeric_id = name
|
|
||||||
short_name = targetname.split('(')[0].strip()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"id": numeric_id,
|
|
||||||
"name": short_name,
|
|
||||||
"full_name": targetname,
|
|
||||||
"error": None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
timeout = settings.nasa_api_timeout
|
||||||
|
client_kwargs = {"timeout": timeout}
|
||||||
|
if settings.proxy_dict:
|
||||||
|
client_kwargs["proxies"] = settings.proxy_dict
|
||||||
|
logger.info(f"Using proxy for NASA API: {settings.proxy_dict}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"NASA API returned status {response.status_code}")
|
||||||
|
|
||||||
|
response_text = response.text
|
||||||
|
|
||||||
|
# Log full response for debugging (temporarily)
|
||||||
|
logger.info(f"Full NASA API response for '{name}':\n{response_text}")
|
||||||
|
|
||||||
|
# Check for "Ambiguous target name"
|
||||||
|
if "Ambiguous target name" in response_text:
|
||||||
|
logger.warning(f"Ambiguous target name for: {name}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"id": None,
|
||||||
|
"name": None,
|
||||||
|
"full_name": None,
|
||||||
|
"error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID"
|
||||||
|
}
|
||||||
|
# Check for "No matches found" or "Unknown target"
|
||||||
|
if "No matches found" in response_text or "Unknown target" in response_text:
|
||||||
|
logger.warning(f"No matches found for: {name}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"id": None,
|
||||||
|
"name": None,
|
||||||
|
"full_name": None,
|
||||||
|
"error": "未找到匹配的天体,请检查名称或 ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try multiple parsing patterns for different response formats
|
||||||
|
# Pattern 1: "Target body name: Jupiter Barycenter (599)"
|
||||||
|
target_name_match = re.search(r"Target body name:\s*(.+?)\s+\((\-?\d+)\)", response_text)
|
||||||
|
|
||||||
|
if not target_name_match:
|
||||||
|
# Pattern 2: " Revised: Mar 12, 2021 Ganymede / (Jupiter) 503"
|
||||||
|
# This pattern appears in the header section of many bodies
|
||||||
|
revised_match = re.search(r"Revised:.*?\s{2,}(.+?)\s{2,}(\-?\d+)\s*$", response_text, re.MULTILINE)
|
||||||
|
if revised_match:
|
||||||
|
full_name = revised_match.group(1).strip()
|
||||||
|
numeric_id = revised_match.group(2).strip()
|
||||||
|
short_name = full_name.split('/')[0].strip() # Remove parent body info like "/ (Jupiter)"
|
||||||
|
|
||||||
|
logger.info(f"Found target (pattern 2): {full_name} with ID: {numeric_id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"id": numeric_id,
|
||||||
|
"name": short_name,
|
||||||
|
"full_name": full_name,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
if not target_name_match:
|
||||||
|
# Pattern 3: Look for body name in title section (works for comets and other objects)
|
||||||
|
# Example: "JPL/HORIZONS ATLAS (C/2025 N1) 2025-Dec-"
|
||||||
|
title_match = re.search(r"JPL/HORIZONS\s+(.+?)\s{2,}", response_text)
|
||||||
|
if title_match:
|
||||||
|
full_name = title_match.group(1).strip()
|
||||||
|
# For this pattern, the ID was in the original COMMAND, use it
|
||||||
|
numeric_id = name.strip("'\"")
|
||||||
|
short_name = full_name.split('(')[0].strip()
|
||||||
|
|
||||||
|
logger.info(f"Found target (pattern 3): {full_name} with ID: {numeric_id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"id": numeric_id,
|
||||||
|
"name": short_name,
|
||||||
|
"full_name": full_name,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_name_match:
|
||||||
|
full_name = target_name_match.group(1).strip()
|
||||||
|
numeric_id = target_name_match.group(2).strip()
|
||||||
|
short_name = full_name.split('(')[0].strip() # Remove any part after '('
|
||||||
|
|
||||||
|
logger.info(f"Found target (pattern 1): {full_name} with ID: {numeric_id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"id": numeric_id,
|
||||||
|
"name": short_name,
|
||||||
|
"full_name": full_name,
|
||||||
|
"error": None
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback if specific pattern not found, might be a valid but weird response
|
||||||
|
logger.warning(f"Could not parse target name/ID from response for: {name}. Response snippet: {response_text[:500]}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"id": None,
|
||||||
|
"name": None,
|
||||||
|
"full_name": None,
|
||||||
|
"error": f"未能解析 JPL Horizons 响应,请尝试精确 ID: {name}"
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.error(f"Error searching for {name}: {error_msg}")
|
logger.error(f"Error searching for {name}: {error_msg}")
|
||||||
|
return {
|
||||||
# Check for specific error types
|
"success": False,
|
||||||
if 'Ambiguous target name' in error_msg:
|
"id": None,
|
||||||
return {
|
"name": None,
|
||||||
"success": False,
|
"full_name": None,
|
||||||
"id": None,
|
"error": f"查询失败: {error_msg}"
|
||||||
"name": None,
|
}
|
||||||
"full_name": None,
|
|
||||||
"error": "名称不唯一,请提供更具体的名称或 JPL Horizons ID"
|
|
||||||
}
|
|
||||||
elif 'No matches found' in error_msg or 'Unknown target' in error_msg:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"id": None,
|
|
||||||
"name": None,
|
|
||||||
"full_name": None,
|
|
||||||
"error": "未找到匹配的天体,请检查名称或 ID"
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"id": None,
|
|
||||||
"name": None,
|
|
||||||
"full_name": None,
|
|
||||||
"error": f"查询失败: {error_msg}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
horizons_service = HorizonsService()
|
horizons_service = HorizonsService()
|
||||||
|
|
@ -60,7 +60,7 @@ async def download_positions_task(task_id: int, body_ids: List[str], dates: List
|
||||||
success_count += 1
|
success_count += 1
|
||||||
else:
|
else:
|
||||||
# Download
|
# Download
|
||||||
positions = horizons_service.get_body_positions(
|
positions = await horizons_service.get_body_positions(
|
||||||
body_id=body_id,
|
body_id=body_id,
|
||||||
start_time=target_date,
|
start_time=target_date,
|
||||||
end_time=target_date,
|
end_time=target_date,
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ class OrbitService:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get positions from Horizons (synchronous call)
|
# Get positions from Horizons (synchronous call)
|
||||||
positions = horizons_service.get_body_positions(
|
positions = await horizons_service.get_body_positions(
|
||||||
body_id=body_id,
|
body_id=body_id,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"axios": "^1.13.2",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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",
|
"react-router-dom": "^7.9.6",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "^0.181.2"
|
"three": "^0.181.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { InterstellarTicker } from './components/InterstellarTicker';
|
||||||
import { ControlPanel } from './components/ControlPanel';
|
import { ControlPanel } from './components/ControlPanel';
|
||||||
import { AuthModal } from './components/AuthModal';
|
import { AuthModal } from './components/AuthModal';
|
||||||
import { MessageBoard } from './components/MessageBoard';
|
import { MessageBoard } from './components/MessageBoard';
|
||||||
|
import { BodyDetailOverlay } from './components/BodyDetailOverlay'; // Import the new overlay component
|
||||||
import { auth } from './utils/auth';
|
import { auth } from './utils/auth';
|
||||||
import type { CelestialBody } from './types';
|
import type { CelestialBody } from './types';
|
||||||
import { useToast } from './contexts/ToastContext';
|
import { useToast } from './contexts/ToastContext';
|
||||||
|
|
@ -32,6 +33,7 @@ function App() {
|
||||||
const [showOrbits, setShowOrbits] = useState(true);
|
const [showOrbits, setShowOrbits] = useState(true);
|
||||||
const [isSoundOn, setIsSoundOn] = useState(false);
|
const [isSoundOn, setIsSoundOn] = useState(false);
|
||||||
const [showMessageBoard, setShowMessageBoard] = useState(false);
|
const [showMessageBoard, setShowMessageBoard] = useState(false);
|
||||||
|
const [showDetailOverlayId, setShowDetailOverlayId] = useState<string | null>(null); // State for detail overlay
|
||||||
|
|
||||||
// Initialize state from localStorage
|
// Initialize state from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -75,6 +77,19 @@ function App() {
|
||||||
const loading = isTimelineMode ? historicalLoading : realTimeLoading;
|
const loading = isTimelineMode ? historicalLoading : realTimeLoading;
|
||||||
const error = isTimelineMode ? historicalError : realTimeError;
|
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 [selectedBody, setSelectedBody] = useState<CelestialBody | null>(null);
|
||||||
const { trajectoryPositions } = useTrajectory(selectedBody);
|
const { trajectoryPositions } = useTrajectory(selectedBody);
|
||||||
|
|
||||||
|
|
@ -94,6 +109,11 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [isTimelineMode, cutoffDate]);
|
}, [isTimelineMode, cutoffDate]);
|
||||||
|
|
||||||
|
// Handle viewing body details
|
||||||
|
const handleViewDetails = useCallback((body: CelestialBody) => {
|
||||||
|
setShowDetailOverlayId(body.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Filter probes and planets from all bodies
|
// Filter probes and planets from all bodies
|
||||||
const probes = bodies.filter((b) => b.type === 'probe');
|
const probes = bodies.filter((b) => b.type === 'probe');
|
||||||
const planets = bodies.filter((b) =>
|
const planets = bodies.filter((b) =>
|
||||||
|
|
@ -213,6 +233,7 @@ function App() {
|
||||||
onBodySelect={handleBodySelect}
|
onBodySelect={handleBodySelect}
|
||||||
resetTrigger={resetTrigger}
|
resetTrigger={resetTrigger}
|
||||||
toast={toast}
|
toast={toast}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Timeline Controller */}
|
{/* Timeline Controller */}
|
||||||
|
|
@ -241,6 +262,12 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Body Detail Overlay */}
|
||||||
|
<BodyDetailOverlay
|
||||||
|
bodyId={showDetailOverlayId}
|
||||||
|
onClose={() => setShowDetailOverlayId(null)}
|
||||||
|
/>
|
||||||
</div>
|
</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 { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
|
||||||
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
|
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
|
||||||
import { useFrame } from '@react-three/fiber';
|
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 type { CelestialBody as CelestialBodyType } from '../types';
|
||||||
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
||||||
import { fetchBodyResources } from '../utils/api';
|
import { fetchBodyResources } from '../utils/api';
|
||||||
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
|
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
|
||||||
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
|
||||||
interface CelestialBodyProps {
|
interface CelestialBodyProps {
|
||||||
body: CelestialBodyType;
|
body: CelestialBodyType;
|
||||||
|
|
@ -23,7 +24,7 @@ function SaturnRings() {
|
||||||
<group rotation={[Math.PI / 2, 0, 0]}>
|
<group rotation={[Math.PI / 2, 0, 0]}>
|
||||||
{/* Inner bright ring */}
|
{/* Inner bright ring */}
|
||||||
<mesh>
|
<mesh>
|
||||||
<ringGeometry args={[1.4, 1.6, 64]} />
|
<ringGeometry args={[1.4, 1.6, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#D4B896"
|
color="#D4B896"
|
||||||
transparent
|
transparent
|
||||||
|
|
@ -33,7 +34,7 @@ function SaturnRings() {
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Middle darker band */}
|
{/* Middle darker band */}
|
||||||
<mesh>
|
<mesh>
|
||||||
<ringGeometry args={[1.6, 1.75, 64]} />
|
<ringGeometry args={[1.6, 1.75, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#8B7355"
|
color="#8B7355"
|
||||||
transparent
|
transparent
|
||||||
|
|
@ -43,7 +44,7 @@ function SaturnRings() {
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Outer bright ring */}
|
{/* Outer bright ring */}
|
||||||
<mesh>
|
<mesh>
|
||||||
<ringGeometry args={[1.75, 2.0, 64]} />
|
<ringGeometry args={[1.75, 2.0, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#C4A582"
|
color="#C4A582"
|
||||||
transparent
|
transparent
|
||||||
|
|
@ -53,7 +54,7 @@ function SaturnRings() {
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Cassini Division (gap) */}
|
{/* Cassini Division (gap) */}
|
||||||
<mesh>
|
<mesh>
|
||||||
<ringGeometry args={[2.0, 2.05, 64]} />
|
<ringGeometry args={[2.0, 2.05, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#000000"
|
color="#000000"
|
||||||
transparent
|
transparent
|
||||||
|
|
@ -63,7 +64,7 @@ function SaturnRings() {
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* A Ring (outer) */}
|
{/* A Ring (outer) */}
|
||||||
<mesh>
|
<mesh>
|
||||||
<ringGeometry args={[2.05, 2.2, 64]} />
|
<ringGeometry args={[2.05, 2.2, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#B89968"
|
color="#B89968"
|
||||||
transparent
|
transparent
|
||||||
|
|
@ -139,49 +140,137 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comet Particles Component
|
// Irregular Comet Nucleus - potato-like shape
|
||||||
function CometParticles({ radius, count = 6, color = '#88ccff' }: { radius: number; count?: number; color?: string }) {
|
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 positions = useMemo(() => {
|
||||||
|
const count = 200;
|
||||||
const p = new Float32Array(count * 3);
|
const p = new Float32Array(count * 3);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// Random spherical distribution
|
// Denser near center, spreading outward
|
||||||
const r = radius * (1.2 + Math.random() * 2.0); // Spread: 1.2x to 3.2x radius
|
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 theta = Math.random() * Math.PI * 2;
|
||||||
const phi = Math.acos(2 * Math.random() - 1);
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
|
||||||
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||||
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||||
p[i * 3 + 2] = r * Math.cos(phi);
|
p[i * 3 + 2] = r * Math.cos(phi);
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}, [radius, count]);
|
}, [radius]);
|
||||||
|
|
||||||
// Ref for animation
|
|
||||||
const pointsRef = useRef<THREE.Points>(null);
|
const pointsRef = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
// Animate coma
|
||||||
|
useFrame((state, delta) => {
|
||||||
if (pointsRef.current) {
|
if (pointsRef.current) {
|
||||||
// Subtle rotation
|
pointsRef.current.rotation.y += delta * 0.05;
|
||||||
pointsRef.current.rotation.y += delta * 0.1;
|
// Pulsing effect
|
||||||
pointsRef.current.rotation.z += delta * 0.05;
|
const scale = 1 + Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
|
||||||
|
pointsRef.current.scale.setScalar(scale);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<points ref={pointsRef}>
|
<>
|
||||||
<bufferGeometry>
|
{/* Inner bright coma */}
|
||||||
<bufferAttribute attach="position" args={[positions, 3]} />
|
<points ref={pointsRef}>
|
||||||
</bufferGeometry>
|
<bufferGeometry>
|
||||||
<pointsMaterial
|
<bufferAttribute attach="position" args={[positions, 3]} />
|
||||||
size={radius * 0.4} // Particle size relative to comet size
|
</bufferGeometry>
|
||||||
color={color}
|
<pointsMaterial
|
||||||
transparent
|
size={radius * 0.8}
|
||||||
opacity={0.6}
|
color="#88ccff"
|
||||||
sizeAttenuation={true}
|
transparent
|
||||||
blending={THREE.AdditiveBlending}
|
opacity={0.7}
|
||||||
depthWrite={false}
|
sizeAttenuation={true}
|
||||||
/>
|
blending={THREE.AdditiveBlending}
|
||||||
</points>
|
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
|
// Get offset description if this body has one
|
||||||
const offsetDesc = hasOffset ? getOffsetDescription(body, allBodies) : null;
|
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 (
|
return (
|
||||||
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
||||||
<mesh ref={meshRef} renderOrder={0}>
|
{/* Use irregular nucleus for comets, regular sphere for others */}
|
||||||
<sphereGeometry args={[size, 64, 64]} />
|
{body.type === 'comet' ? (
|
||||||
{texture ? (
|
<>
|
||||||
<meshStandardMaterial
|
<IrregularNucleus size={size} texture={texture} />
|
||||||
map={texture}
|
<CometComa radius={size} />
|
||||||
emissive={emissive}
|
</>
|
||||||
emissiveIntensity={emissiveIntensity}
|
) : (
|
||||||
roughness={body.type === 'star' ? 0 : 0.7}
|
<mesh ref={meshRef} renderOrder={0}>
|
||||||
metalness={0.1}
|
<sphereGeometry args={[size, 32, 32]} />
|
||||||
depthTest={true}
|
{texture ? (
|
||||||
depthWrite={true}
|
<meshStandardMaterial
|
||||||
/>
|
map={texture}
|
||||||
) : (
|
emissive={emissive}
|
||||||
<meshStandardMaterial
|
emissiveIntensity={emissiveIntensity}
|
||||||
color="#888888"
|
roughness={body.type === 'star' ? 0 : 0.7}
|
||||||
emissive={emissive}
|
metalness={0.1}
|
||||||
emissiveIntensity={emissiveIntensity}
|
depthTest={true}
|
||||||
roughness={0.7}
|
depthWrite={true}
|
||||||
metalness={0.1}
|
/>
|
||||||
depthTest={true}
|
) : (
|
||||||
depthWrite={true}
|
<meshStandardMaterial
|
||||||
/>
|
color="#888888"
|
||||||
)}
|
emissive={emissive}
|
||||||
</mesh>
|
emissiveIntensity={emissiveIntensity}
|
||||||
|
roughness={0.7}
|
||||||
|
metalness={0.1}
|
||||||
|
depthTest={true}
|
||||||
|
depthWrite={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Saturn Rings */}
|
{/* Saturn Rings */}
|
||||||
{body.id === '699' && <SaturnRings />}
|
{body.id === '699' && <SaturnRings />}
|
||||||
|
|
||||||
{/* Comet Particles */}
|
|
||||||
{body.type === 'comet' && (
|
|
||||||
<CometParticles radius={size} count={6} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sun glow effect */}
|
{/* Sun glow effect */}
|
||||||
{body.type === 'star' && (
|
{body.type === 'star' && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -261,37 +366,27 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name label */}
|
{/* Name label using CanvasTexture */}
|
||||||
<Html
|
{labelTexture && (
|
||||||
position={[0, size + 0.3, 0]}
|
<Billboard
|
||||||
center
|
position={[0, size + 1.0, 0]} // Raised slightly
|
||||||
distanceFactor={10}
|
follow={true}
|
||||||
style={{
|
lockX={false}
|
||||||
color: body.type === 'star' ? '#FDB813' : (body.type === 'comet' ? '#88ccff' : '#ffffff'),
|
lockY={false}
|
||||||
fontSize: '9px', // 从 11px 减小到 9px
|
lockZ={false}
|
||||||
fontWeight: 'bold',
|
>
|
||||||
textShadow: '0 0 4px rgba(0,0,0,0.8)',
|
<mesh scale={[2.5, 1.25, 1]}>
|
||||||
pointerEvents: 'none',
|
<planeGeometry />
|
||||||
userSelect: 'none',
|
<meshBasicMaterial
|
||||||
whiteSpace: 'nowrap',
|
map={labelTexture}
|
||||||
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
transparent
|
||||||
transition: 'opacity 0.3s ease',
|
opacity={isSelected ? 1 : 0.6}
|
||||||
}}
|
depthWrite={false}
|
||||||
>
|
toneMapped={false} // Keep colors bright
|
||||||
{body.name_zh || body.name}
|
/>
|
||||||
{offsetDesc && (
|
</mesh>
|
||||||
<>
|
</Billboard>
|
||||||
<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>
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
* Constellations component - renders major constellations with connecting lines
|
* Constellations component - renders major constellations with connecting lines
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
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 * as THREE from 'three';
|
||||||
import { fetchStaticData } from '../utils/api';
|
import { fetchStaticData } from '../utils/api';
|
||||||
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
|
||||||
interface ConstellationStar {
|
interface ConstellationStar {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -34,6 +35,61 @@ function raDecToCartesian(ra: number, dec: number, distance: number = 5000) {
|
||||||
return new THREE.Vector3(x, y, z);
|
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() {
|
export function Constellations() {
|
||||||
const [constellations, setConstellations] = useState<Constellation[]>([]);
|
const [constellations, setConstellations] = useState<Constellation[]>([]);
|
||||||
|
|
||||||
|
|
@ -92,41 +148,11 @@ export function Constellations() {
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{constellationLines.map((constellation) => (
|
{constellationLines.map((constellation) => (
|
||||||
<group key={constellation.name}>
|
<ConstellationObject
|
||||||
{/* Render constellation stars */}
|
key={constellation.name}
|
||||||
{constellation.starPositions.map((pos, idx) => (
|
constellation={constellation}
|
||||||
<mesh key={`${constellation.name}-star-${idx}`} position={pos} geometry={sphereGeometry}>
|
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>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</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 { useState } from 'react';
|
||||||
import { request } from '../utils/request';
|
import { request } from '../utils/request';
|
||||||
import type { CelestialBody } from '../types';
|
import type { CelestialBody } from '../types';
|
||||||
|
|
@ -9,9 +9,10 @@ interface FocusInfoProps {
|
||||||
body: CelestialBody | null;
|
body: CelestialBody | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
toast: ToastContextValue; // Add toast prop
|
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 [showTerminal, setShowTerminal] = useState(false);
|
||||||
const [terminalData, setTerminalData] = useState('');
|
const [terminalData, setTerminalData] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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">
|
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||||
{body.name_zh || body.name}
|
{body.name_zh || body.name}
|
||||||
</h2>
|
</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 ${
|
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border ${
|
||||||
isProbe
|
isProbe
|
||||||
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
* Galaxies component - renders distant galaxies as billboards
|
* Galaxies component - renders distant galaxies as billboards
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
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 * as THREE from 'three';
|
||||||
import { fetchStaticData } from '../utils/api';
|
import { fetchStaticData } from '../utils/api';
|
||||||
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
|
||||||
interface Galaxy {
|
interface Galaxy {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -106,6 +107,62 @@ function calculateAngularSize(diameterKly: number, distanceMly: number): number
|
||||||
return Math.max(20, angularDiameter * 8000);
|
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() {
|
export function Galaxies() {
|
||||||
const [galaxies, setGalaxies] = useState<Galaxy[]>([]);
|
const [galaxies, setGalaxies] = useState<Galaxy[]>([]);
|
||||||
|
|
||||||
|
|
@ -145,7 +202,8 @@ export function Galaxies() {
|
||||||
const texture = createGalaxyTexture(galaxy.color, galaxy.type);
|
const texture = createGalaxyTexture(galaxy.color, galaxy.type);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...galaxy,
|
name: galaxy.name,
|
||||||
|
name_zh: galaxy.name_zh,
|
||||||
position,
|
position,
|
||||||
size,
|
size,
|
||||||
texture,
|
texture,
|
||||||
|
|
@ -160,41 +218,7 @@ export function Galaxies() {
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{galaxyData.map((galaxy) => (
|
{galaxyData.map((galaxy) => (
|
||||||
<group key={galaxy.name}>
|
<GalaxyObject key={galaxy.name} galaxy={galaxy} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
* Nebulae component - renders nebulae as billboards with procedural textures
|
* Nebulae component - renders nebulae as billboards with procedural textures
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
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 * as THREE from 'three';
|
||||||
import { fetchStaticData } from '../utils/api';
|
import { fetchStaticData } from '../utils/api';
|
||||||
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
|
||||||
interface Nebula {
|
interface Nebula {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -159,6 +160,63 @@ function calculateAngularSize(diameterLy: number, distanceLy: number): number {
|
||||||
return Math.max(50, Math.min(300, angularDiameter * 100000));
|
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() {
|
export function Nebulae() {
|
||||||
const [nebulae, setNebulae] = useState<Nebula[]>([]);
|
const [nebulae, setNebulae] = useState<Nebula[]>([]);
|
||||||
|
|
||||||
|
|
@ -196,7 +254,9 @@ export function Nebulae() {
|
||||||
const texture = createNebulaTexture(nebula.color, nebula.type);
|
const texture = createNebulaTexture(nebula.color, nebula.type);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nebula,
|
name: nebula.name,
|
||||||
|
name_zh: nebula.name_zh,
|
||||||
|
type: nebula.type,
|
||||||
position,
|
position,
|
||||||
size,
|
size,
|
||||||
texture,
|
texture,
|
||||||
|
|
@ -211,41 +271,7 @@ export function Nebulae() {
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{nebulaData.map((nebula) => (
|
{nebulaData.map((nebula) => (
|
||||||
<group key={nebula.name}>
|
<NebulaObject key={nebula.name} nebula={nebula} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@
|
||||||
import { useRef, useMemo, useState, useEffect } from 'react';
|
import { useRef, useMemo, useState, useEffect } from 'react';
|
||||||
import { Group } from 'three';
|
import { Group } from 'three';
|
||||||
import * as THREE 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 { useFrame } from '@react-three/fiber';
|
||||||
import type { CelestialBody } from '../types';
|
import type { CelestialBody } from '../types';
|
||||||
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
||||||
import { fetchBodyResources } from '../utils/api';
|
import { fetchBodyResources } from '../utils/api';
|
||||||
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
|
||||||
interface ProbeProps {
|
interface ProbeProps {
|
||||||
body: CelestialBody;
|
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 };
|
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
|
||||||
|
|
||||||
// 2. Hook: Load GLTF
|
// 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 gltf = useGLTF(modelPath);
|
||||||
const scene = gltf.scene;
|
const scene = gltf.scene;
|
||||||
|
|
||||||
|
|
@ -60,7 +58,7 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
||||||
// Calculate scale factor
|
// Calculate scale factor
|
||||||
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.3;
|
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));
|
const finalScale = Math.max(0.2, Math.min(1.0, calculatedScale));
|
||||||
|
|
||||||
// Apply custom scale from resource metadata
|
// 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
|
// Get offset description if this probe has one
|
||||||
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
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 (
|
return (
|
||||||
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
|
||||||
<primitive
|
<primitive
|
||||||
|
|
@ -125,37 +134,27 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
|
||||||
scale={optimalScale}
|
scale={optimalScale}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name label */}
|
{/* Name label with CanvasTexture */}
|
||||||
<Html
|
{labelTexture && (
|
||||||
position={[0, optimalScale * 2, 0]}
|
<Billboard
|
||||||
center
|
position={[0, optimalScale * 2.5, 0]}
|
||||||
distanceFactor={15}
|
follow={true}
|
||||||
style={{
|
lockX={false}
|
||||||
color: '#00ffff',
|
lockY={false}
|
||||||
fontSize: '9px', // 从 12px 减小到 9px
|
lockZ={false}
|
||||||
fontWeight: 'bold',
|
>
|
||||||
textShadow: '0 0 6px rgba(0,255,255,0.8)',
|
<mesh scale={[2.5, 1.25, 1]}>
|
||||||
pointerEvents: 'none',
|
<planeGeometry />
|
||||||
userSelect: 'none',
|
<meshBasicMaterial
|
||||||
whiteSpace: 'nowrap',
|
map={labelTexture}
|
||||||
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
transparent
|
||||||
transition: 'opacity 0.3s ease',
|
opacity={isSelected ? 1 : 0.6}
|
||||||
}}
|
depthWrite={false}
|
||||||
>
|
toneMapped={false}
|
||||||
{body.name_zh || body.name}
|
/>
|
||||||
{offsetDesc && (
|
</mesh>
|
||||||
<>
|
</Billboard>
|
||||||
<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>
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -177,6 +176,16 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
|
||||||
// Get offset description if this probe has one
|
// Get offset description if this probe has one
|
||||||
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
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 (
|
return (
|
||||||
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
||||||
<mesh>
|
<mesh>
|
||||||
|
|
@ -184,37 +193,27 @@ function ProbeFallback({ body, allBodies, isSelected = false }: { body: Celestia
|
||||||
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
|
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Name label */}
|
{/* Name label with CanvasTexture */}
|
||||||
<Html
|
{labelTexture && (
|
||||||
position={[0, 1, 0]}
|
<Billboard
|
||||||
center
|
position={[0, 1, 0]}
|
||||||
distanceFactor={15}
|
follow={true}
|
||||||
style={{
|
lockX={false}
|
||||||
color: '#ff6666',
|
lockY={false}
|
||||||
fontSize: '9px', // 从 12px 减小到 9px
|
lockZ={false}
|
||||||
fontWeight: 'bold',
|
>
|
||||||
textShadow: '0 0 6px rgba(255,0,0,0.8)',
|
<mesh scale={[2.5, 1.25, 1]}>
|
||||||
pointerEvents: 'none',
|
<planeGeometry />
|
||||||
userSelect: 'none',
|
<meshBasicMaterial
|
||||||
whiteSpace: 'nowrap',
|
map={labelTexture}
|
||||||
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
transparent
|
||||||
transition: 'opacity 0.3s ease',
|
opacity={isSelected ? 1 : 0.6}
|
||||||
}}
|
depthWrite={false}
|
||||||
>
|
toneMapped={false}
|
||||||
{body.name_zh || body.name}
|
/>
|
||||||
{offsetDesc && (
|
</mesh>
|
||||||
<>
|
</Billboard>
|
||||||
<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>
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,24 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
|
|
||||||
// Calculate distance for sorting
|
// Calculate distance for sorting
|
||||||
const calculateDistance = (body: CelestialBody) => {
|
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];
|
const pos = body.positions[0];
|
||||||
return Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
return Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const processBodies = (list: CelestialBody[]) => {
|
const processBodies = (list: CelestialBody[]) => {
|
||||||
return list
|
return list
|
||||||
.filter(b =>
|
.filter(b => {
|
||||||
(b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
|
// Filter out bodies without positions
|
||||||
b.type !== 'star' // Exclude Sun from list
|
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 => ({
|
.map(body => ({
|
||||||
body,
|
body,
|
||||||
distance: calculateDistance(body)
|
distance: calculateDistance(body)
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,26 @@ interface SceneProps {
|
||||||
onBodySelect?: (body: CelestialBodyType | null) => void;
|
onBodySelect?: (body: CelestialBodyType | null) => void;
|
||||||
resetTrigger?: number;
|
resetTrigger?: number;
|
||||||
toast: ToastContextValue; // Add toast prop
|
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)
|
// State to control info panel visibility (independent of selection)
|
||||||
const [showInfoPanel, setShowInfoPanel] = useState(true);
|
const [showInfoPanel, setShowInfoPanel] = useState(true);
|
||||||
|
|
||||||
|
|
@ -172,7 +189,12 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
|
||||||
{/* Dynamic Focus Info Label */}
|
{/* Dynamic Focus Info Label */}
|
||||||
{selectedBody && showInfoPanel && (
|
{selectedBody && showInfoPanel && (
|
||||||
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
|
<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>
|
</Html>
|
||||||
)}
|
)}
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
* Stars component - renders nearby stars in 3D space
|
* Stars component - renders nearby stars in 3D space
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
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 * as THREE from 'three';
|
||||||
import { request } from '../utils/request';
|
import { request } from '../utils/request';
|
||||||
|
import { createLabelTexture } from '../utils/labelTexture';
|
||||||
|
|
||||||
interface Star {
|
interface Star {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -14,6 +15,8 @@ interface Star {
|
||||||
dec: number; // Declination in degrees
|
dec: number; // Declination in degrees
|
||||||
magnitude: number;
|
magnitude: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
position: THREE.Vector3;
|
||||||
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,6 +49,53 @@ function magnitudeToSize(magnitude: number): number {
|
||||||
return Math.max(5, 20 - normalized * 1.2);
|
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() {
|
export function Stars() {
|
||||||
const [stars, setStars] = useState<Star[]>([]);
|
const [stars, setStars] = useState<Star[]>([]);
|
||||||
|
|
||||||
|
|
@ -55,82 +105,39 @@ export function Stars() {
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
// API returns { category, items: [{ id, name, name_zh, data: {...} }] }
|
// API returns { category, items: [{ id, name, name_zh, data: {...} }] }
|
||||||
const starData = data.items.map((item: any) => ({
|
const starData = data.items.map((item: any) => {
|
||||||
name: item.name,
|
// Place all stars on a celestial sphere at fixed distance (5000 units)
|
||||||
name_zh: item.name_zh,
|
const position = raDecToCartesian(item.data.ra, item.data.dec, 5000);
|
||||||
distance_ly: item.data.distance_ly,
|
const size = magnitudeToSize(item.data.magnitude);
|
||||||
ra: item.data.ra,
|
|
||||||
dec: item.data.dec,
|
return {
|
||||||
magnitude: item.data.magnitude,
|
name: item.name,
|
||||||
color: item.data.color,
|
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);
|
setStars(starData);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('Failed to load stars:', err));
|
.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
|
// Reuse geometry for all stars to improve performance
|
||||||
const sphereGeometry = useMemo(() => new THREE.SphereGeometry(1, 16, 16), []);
|
const sphereGeometry = useMemo(() => new THREE.SphereGeometry(1, 16, 16), []);
|
||||||
|
|
||||||
if (starData.length === 0) {
|
if (stars.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{starData.map((star) => (
|
{stars.map((star) => (
|
||||||
<group key={star.name}>
|
<StarObject key={star.name} star={star} geometry={sphereGeometry} />
|
||||||
{/* 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>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ export function useHistoricalData(selectedDate: Date | null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track if this effect instance is active
|
||||||
|
let isActive = true;
|
||||||
|
|
||||||
// 创建午夜时间戳
|
// 创建午夜时间戳
|
||||||
const targetDate = new Date(selectedDate);
|
const targetDate = new Date(selectedDate);
|
||||||
targetDate.setUTCHours(0, 0, 0, 0);
|
targetDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
@ -42,19 +45,30 @@ export function useHistoricalData(selectedDate: Date | null) {
|
||||||
'1d'
|
'1d'
|
||||||
);
|
);
|
||||||
|
|
||||||
setBodies(data.bodies);
|
// Only update state if this effect is still active
|
||||||
lastFetchedDateRef.current = dateKey; // 记录已请求的时间
|
if (isActive) {
|
||||||
|
setBodies(data.bodies);
|
||||||
console.log(`[useHistoricalData] Loaded ${data.bodies.length} 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) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch historical data:', err);
|
if (isActive) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
console.error('Failed to fetch historical data:', err);
|
||||||
} finally {
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadHistoricalData();
|
loadHistoricalData();
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
return { bodies, loading, error };
|
return { bodies, loading, error };
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,44 @@ export function useSpaceData() {
|
||||||
|
|
||||||
// Use cutoff date instead of current date
|
// Use cutoff date instead of current date
|
||||||
// Set to UTC midnight for consistency
|
// Set to UTC midnight for consistency
|
||||||
const targetDate = new Date(cutoffDate);
|
const targetDate = new Date(cutoffDate!);
|
||||||
targetDate.setUTCHours(0, 0, 0, 0);
|
targetDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
console.log('[useSpaceData] Loading data for date:', targetDate.toISOString());
|
||||||
|
|
||||||
const data = await fetchCelestialPositions(
|
const data = await fetchCelestialPositions(
|
||||||
targetDate.toISOString(),
|
targetDate.toISOString(),
|
||||||
targetDate.toISOString(), // Same as start - single point in time
|
targetDate.toISOString(), // Same as start - single point in time
|
||||||
'1d' // Use 1d step for consistency
|
'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);
|
setBodies(data.bodies);
|
||||||
|
console.log('[useSpaceData] State updated with', data.bodies.length, 'bodies');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch celestial data:', err);
|
console.error('Failed to fetch celestial data:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function AdminLayout() {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? '🌌' : '🌌 Cosmo'}
|
{collapsed ? '🌌' : '🌌 COSMO'}
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined, UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { DataTable } from '../../components/admin/DataTable';
|
||||||
import { request } from '../../utils/request';
|
import { request } from '../../utils/request';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
|
||||||
|
const MdEditorParser = new MarkdownIt();
|
||||||
|
|
||||||
interface CelestialBody {
|
interface CelestialBody {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
name_zh: string;
|
name_zh: string;
|
||||||
type: string;
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
details?: string; // Added details field
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
resources?: {
|
resources?: {
|
||||||
[key: string]: Array<{
|
[key: string]: Array<{
|
||||||
|
|
@ -36,6 +43,7 @@ export function CelestialBodies() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [refreshResources, setRefreshResources] = useState(0);
|
const [refreshResources, setRefreshResources] = useState(0);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState('basic'); // State for active tab
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -72,6 +80,7 @@ export function CelestialBodies() {
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
setActiveTabKey('basic'); // Reset to basic tab
|
||||||
// Default values
|
// Default values
|
||||||
form.setFieldsValue({ is_active: true, type: 'probe' });
|
form.setFieldsValue({ is_active: true, type: 'probe' });
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
|
@ -91,6 +100,28 @@ export function CelestialBodies() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
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
|
// Auto-fill form with search results
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
id: result.data.id,
|
id: result.data.id,
|
||||||
|
|
@ -134,6 +165,7 @@ export function CelestialBodies() {
|
||||||
const handleEdit = (record: CelestialBody) => {
|
const handleEdit = (record: CelestialBody) => {
|
||||||
setEditingRecord(record);
|
setEditingRecord(record);
|
||||||
form.setFieldsValue(record);
|
form.setFieldsValue(record);
|
||||||
|
setActiveTabKey('basic'); // Reset to basic tab
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -181,9 +213,22 @@ export function CelestialBodies() {
|
||||||
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
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}
|
open={isModalOpen}
|
||||||
onOk={handleModalOk}
|
onOk={handleModalOk}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
width={800}
|
width={1000}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
>
|
>
|
||||||
{!editingRecord && (
|
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
|
||||||
<>
|
<Tabs.TabPane tab="基础信息" key="basic">
|
||||||
<Alert
|
{!editingRecord && (
|
||||||
title="智能搜索提示"
|
<>
|
||||||
description={
|
<Alert
|
||||||
<div>
|
title="智能搜索提示"
|
||||||
<p>推荐使用 <strong>JPL Horizons 数字 ID</strong> 进行搜索,可获得最准确的结果。</p>
|
description={
|
||||||
<p style={{ marginTop: 4 }}>
|
<div>
|
||||||
示例:Hubble 的 ID 是 <code>-48</code>,Voyager 1 的 ID 是 <code>-31</code>
|
<p>推荐使用 <strong>JPL Horizons 数字 ID</strong> 进行搜索,可获得最准确的结果。</p>
|
||||||
</p>
|
<p style={{ marginTop: 4 }}>
|
||||||
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
|
示例:Hubble 的 ID 是 <code>-48</code>,Voyager 1 的 ID 是 <code>-31</code>
|
||||||
不知道 ID?可以先用名称搜索,系统会尽量提取 ID,或提示您手动确认。
|
</p>
|
||||||
</p>
|
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
|
||||||
</div>
|
不知道 ID?可以先用名称搜索,系统会尽量提取 ID,或提示您手动确认。
|
||||||
}
|
</p>
|
||||||
type="info"
|
</div>
|
||||||
showIcon
|
}
|
||||||
style={{ marginBottom: 16 }}
|
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}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
type="primary"
|
<Form.Item label="从 NASA 数据库搜索">
|
||||||
icon={<SearchOutlined />}
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
onClick={handleNASASearch}
|
<Input
|
||||||
loading={searching}
|
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' }]}
|
||||||
>
|
>
|
||||||
搜索
|
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||||
</Button>
|
</Form.Item>
|
||||||
</Space.Compact>
|
</Col>
|
||||||
</Form.Item>
|
<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}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="id"
|
name="name"
|
||||||
label="JPL Horizons ID"
|
label="英文名"
|
||||||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
rules={[{ required: true, message: '请输入英文名' }]}
|
||||||
>
|
>
|
||||||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
<Input placeholder="例如:Voyager 1" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="type"
|
name="name_zh"
|
||||||
label="类型"
|
label="中文名"
|
||||||
rules={[{ required: true, message: '请选择类型' }]}
|
>
|
||||||
>
|
<Input placeholder="例如:旅行者1号" />
|
||||||
<Select>
|
</Form.Item>
|
||||||
<Select.Option value="planet">行星</Select.Option>
|
</Col>
|
||||||
<Select.Option value="dwarf_planet">矮行星</Select.Option>
|
</Row>
|
||||||
<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
|
<Form.Item
|
||||||
name="name"
|
name="description"
|
||||||
label="英文名"
|
label="描述"
|
||||||
rules={[{ required: true, message: '请输入英文名' }]}
|
|
||||||
>
|
>
|
||||||
<Input placeholder="例如:Voyager 1" />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
{editingRecord && (
|
||||||
<Form.Item
|
<ResourceManager
|
||||||
name="name_zh"
|
bodyId={editingRecord.id}
|
||||||
label="中文名"
|
bodyType={editingRecord.type}
|
||||||
>
|
resources={editingRecord.resources}
|
||||||
<Input placeholder="例如:旅行者1号" />
|
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>
|
</Form.Item>
|
||||||
</Col>
|
</Tabs.TabPane>
|
||||||
</Row>
|
</Tabs>
|
||||||
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,17 @@ import {
|
||||||
Space,
|
Space,
|
||||||
Progress,
|
Progress,
|
||||||
Calendar,
|
Calendar,
|
||||||
Alert
|
Alert,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
Table
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
LoadingOutlined
|
LoadingOutlined,
|
||||||
|
DeleteOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
@ -60,10 +64,14 @@ export function NASADownload() {
|
||||||
dayjs().startOf('month'),
|
dayjs().startOf('month'),
|
||||||
dayjs().endOf('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 [loadingDates, setLoadingDates] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
|
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();
|
const toast = useToast();
|
||||||
|
|
||||||
// Get data cutoff date
|
// Get data cutoff date
|
||||||
|
|
@ -85,8 +93,13 @@ export function NASADownload() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedBodies.length > 0) {
|
if (selectedBodies.length > 0) {
|
||||||
loadAvailableDates();
|
loadAvailableDates();
|
||||||
|
// Set first selected body as active for calendar display
|
||||||
|
if (!activeBodyForCalendar || !selectedBodies.includes(activeBodyForCalendar)) {
|
||||||
|
setActiveBodyForCalendar(selectedBodies[0]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setAvailableDates(new Set());
|
setAvailableDates(new Map());
|
||||||
|
setActiveBodyForCalendar(null);
|
||||||
}
|
}
|
||||||
}, [selectedBodies, dateRange]);
|
}, [selectedBodies, dateRange]);
|
||||||
|
|
||||||
|
|
@ -107,26 +120,29 @@ export function NASADownload() {
|
||||||
|
|
||||||
setLoadingDates(true);
|
setLoadingDates(true);
|
||||||
try {
|
try {
|
||||||
const allDates = new Set<string>();
|
const newAvailableDates = new Map<string, Set<string>>();
|
||||||
|
|
||||||
// Load available dates for the first selected body
|
// Load available dates for ALL selected bodies
|
||||||
const bodyId = selectedBodies[0];
|
for (const bodyId of selectedBodies) {
|
||||||
const startDate = dateRange[0].format('YYYY-MM-DD');
|
const startDate = dateRange[0].format('YYYY-MM-DD');
|
||||||
const endDate = dateRange[1].format('YYYY-MM-DD');
|
const endDate = dateRange[1].format('YYYY-MM-DD');
|
||||||
|
|
||||||
const { data } = await request.get('/celestial/positions/download/status', {
|
const { data } = await request.get('/celestial/positions/download/status', {
|
||||||
params: {
|
params: {
|
||||||
body_id: bodyId,
|
body_id: bodyId,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
end_date: endDate
|
end_date: endDate
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
data.available_dates.forEach((date: string) => {
|
const bodyDates = new Set<string>();
|
||||||
allDates.add(date);
|
data.available_dates.forEach((date: string) => {
|
||||||
});
|
bodyDates.add(date);
|
||||||
|
});
|
||||||
|
newAvailableDates.set(bodyId, bodyDates);
|
||||||
|
}
|
||||||
|
|
||||||
setAvailableDates(allDates);
|
setAvailableDates(newAvailableDates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('加载数据状态失败');
|
toast.error('加载数据状态失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -164,20 +180,33 @@ export function NASADownload() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let datesToDownload: string[] = [];
|
let datesToDownload: string[] = [];
|
||||||
|
const today = dayjs();
|
||||||
|
|
||||||
if (selectedDate) {
|
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')];
|
datesToDownload = [selectedDate.format('YYYY-MM-DD')];
|
||||||
} else {
|
} else {
|
||||||
// Download all dates in range
|
// Download all dates in range - exclude future dates
|
||||||
const start = dateRange[0];
|
const start = dateRange[0];
|
||||||
const end = dateRange[1];
|
const end = dateRange[1];
|
||||||
let current = start;
|
let current = start;
|
||||||
|
|
||||||
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
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');
|
current = current.add(1, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (datesToDownload.length === 0) {
|
||||||
|
toast.warning('所选日期范围内没有可下载的数据(不能下载未来日期)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloading(true);
|
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
|
// Custom calendar cell renderer
|
||||||
const dateCellRender = (value: Dayjs) => {
|
const dateCellRender = (value: Dayjs) => {
|
||||||
const dateStr = value.format('YYYY-MM-DD');
|
const dateStr = value.format('YYYY-MM-DD');
|
||||||
const hasData = availableDates.has(dateStr);
|
|
||||||
const inRange = value.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
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 (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '4px 0' }}>
|
<div style={{ textAlign: 'center', padding: '4px 0' }}>
|
||||||
|
|
@ -239,9 +316,8 @@ export function NASADownload() {
|
||||||
return current && current.isAfter(dayjs(), 'day');
|
return current && current.isAfter(dayjs(), 'day');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCalendarDateClick = (date: Dayjs) => {
|
const handleCalendarDateClick = async (date: Dayjs) => {
|
||||||
const dateStr = date.format('YYYY-MM-DD');
|
const dateStr = date.format('YYYY-MM-DD');
|
||||||
const hasData = availableDates.has(dateStr);
|
|
||||||
const inRange = date.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
const inRange = date.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
||||||
|
|
||||||
if (!inRange) {
|
if (!inRange) {
|
||||||
|
|
@ -249,8 +325,22 @@ export function NASADownload() {
|
||||||
return;
|
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) {
|
if (hasData) {
|
||||||
toast.info('该日期已有数据');
|
// Load and display data for this date
|
||||||
|
await loadDateData(dateStr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,12 +352,74 @@ export function NASADownload() {
|
||||||
handleDownload(date);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Data Cutoff Date Display */}
|
{/* Data Cutoff Date Display */}
|
||||||
{cutoffDate && (
|
{cutoffDate && (
|
||||||
<Alert
|
<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)。"
|
description="选择左侧天体,右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据(00:00 UTC)。"
|
||||||
type="success"
|
type="success"
|
||||||
showIcon
|
showIcon
|
||||||
|
|
@ -310,14 +462,14 @@ export function NASADownload() {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
children: (
|
children: (
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
{typeBodies.map((body) => (
|
{typeBodies.map((body) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={body.id}
|
key={body.id}
|
||||||
checked={selectedBodies.includes(body.id)}
|
checked={selectedBodies.includes(body.id)}
|
||||||
onChange={(e) => handleBodySelect(body.id, e.target.checked)}
|
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 }} />}
|
{!body.is_active && <Badge status="default" text="(未激活)" style={{ marginLeft: 8 }} />}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
|
|
@ -331,7 +483,7 @@ export function NASADownload() {
|
||||||
{/* Right: Date Selection and Calendar */}
|
{/* Right: Date Selection and Calendar */}
|
||||||
<Col span={16}>
|
<Col span={16}>
|
||||||
<Card
|
<Card
|
||||||
title="选择下载日期"
|
title="选择日期"
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<RangePicker
|
<RangePicker
|
||||||
|
|
@ -341,19 +493,67 @@ export function NASADownload() {
|
||||||
format="YYYY-MM-DD"
|
format="YYYY-MM-DD"
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={selectedBodies.length === 0 || downloading || deleting}
|
||||||
|
loading={deleting}
|
||||||
|
>
|
||||||
|
清空区段数据
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
onClick={() => handleDownload()}
|
onClick={() => handleDownload()}
|
||||||
disabled={selectedBodies.length === 0}
|
disabled={selectedBodies.length === 0 || downloading || deleting}
|
||||||
loading={downloading}
|
loading={downloading}
|
||||||
>
|
>
|
||||||
下载范围内数据 (后台任务)
|
下载区段数据
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Spin spinning={loadingDates} indicator={<LoadingOutlined spin />}>
|
<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 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<Badge status="success" text="已有数据" />
|
<Badge status="success" text="已有数据" />
|
||||||
|
|
@ -385,6 +585,77 @@ export function NASADownload() {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,28 +147,32 @@ export function Tasks() {
|
||||||
width={800}
|
width={800}
|
||||||
>
|
>
|
||||||
{currentTask && (
|
{currentTask && (
|
||||||
<Descriptions bordered column={1}>
|
<div style={{ maxWidth: '100%', overflowX: 'auto' }}>
|
||||||
<Descriptions.Item label="任务ID">{currentTask.id}</Descriptions.Item>
|
<Descriptions bordered column={1}>
|
||||||
<Descriptions.Item label="类型">{currentTask.task_type}</Descriptions.Item>
|
<Descriptions.Item label="任务ID">{currentTask.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="类型">{currentTask.task_type}</Descriptions.Item>
|
||||||
<Badge status={currentTask.status === 'completed' ? 'success' : currentTask.status === 'running' ? 'processing' : 'default'} text={currentTask.status} />
|
<Descriptions.Item label="状态">
|
||||||
</Descriptions.Item>
|
<Badge status={currentTask.status === 'completed' ? 'success' : currentTask.status === 'running' ? 'processing' : 'default'} text={currentTask.status} />
|
||||||
<Descriptions.Item label="描述">{currentTask.description}</Descriptions.Item>
|
|
||||||
{currentTask.error_message && (
|
|
||||||
<Descriptions.Item label="错误信息">
|
|
||||||
<Text type="danger">{currentTask.error_message}</Text>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
<Descriptions.Item label="描述">{currentTask.description}</Descriptions.Item>
|
||||||
<Descriptions.Item label="结果">
|
{currentTask.error_message && (
|
||||||
<div className="bg-gray-100 p-2 rounded max-h-60 overflow-auto text-xs font-mono">
|
<Descriptions.Item label="错误信息">
|
||||||
{currentTask.result ? (
|
<Text type="danger" style={{ wordBreak: 'break-word' }}>{currentTask.error_message}</Text>
|
||||||
<pre>{JSON.stringify(currentTask.result, null, 2)}</pre>
|
</Descriptions.Item>
|
||||||
) : (
|
)}
|
||||||
<span className="text-gray-400">暂无结果</span>
|
<Descriptions.Item label="结果">
|
||||||
)}
|
<div className="bg-gray-100 p-2 rounded max-h-60 overflow-auto text-xs font-mono" style={{ maxWidth: '100%' }}>
|
||||||
</div>
|
{currentTask.result ? (
|
||||||
</Descriptions.Item>
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
</Descriptions>
|
{JSON.stringify(currentTask.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">暂无结果</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</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