main
mula.liu 2026-07-21 20:09:51 +08:00
parent 6a519126c1
commit d93b4f787a
135 changed files with 12227 additions and 3463 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "yarn",
"runtimeArgs": ["--cwd", "frontend", "dev"],
"port": 5173,
"autoPort": true
}
]
}

View File

@ -71,7 +71,12 @@
"Bash(recreate_resources_table.py )",
"Bash(reset_positions.py )",
"Bash(test_pluto.py )",
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend backend/venv/bin/python:*)"
"Bash(PYTHONPATH=/Users/jiliu/WorkSpace/cosmo/backend backend/venv/bin/python:*)",
"Bash(cat vite.config.ts)",
"Bash(ls -la .env*)",
"Bash(cat .env*)",
"Bash(node -e \"console.log\\('fiber', require\\('@react-three/fiber/package.json'\\).version\\); console.log\\('three', require\\('three/package.json'\\).version\\); console.log\\('drei', require\\('@react-three/drei/package.json'\\).version\\)\")",
"Bash(npx tsc *)"
],
"deny": [],
"ask": []

1
AGENTS.md 100644
View File

@ -0,0 +1 @@
- tools

View File

@ -11,7 +11,8 @@ from typing import Optional, Dict, Any
from app.database import get_db
from app.models.celestial import BodyInfo
from app.services.horizons import horizons_service
from app.services.db_service import celestial_body_service, resource_service
from app.services.celestial_body_service import celestial_body_service
from app.services.resource_service import resource_service
logger = logging.getLogger(__name__)

View File

@ -9,7 +9,7 @@ from typing import Optional
from app.database import get_db
from app.services.horizons import horizons_service
from app.services.db_service import celestial_body_service
from app.services.celestial_body_service import celestial_body_service
from app.services.orbit_service import orbit_service
from app.services.task_service import task_service
from app.services.nasa_worker import generate_orbits_task

View File

@ -14,11 +14,9 @@ from app.services.horizons import horizons_service
from app.services.cache import cache_service
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
from app.services.system_settings_service import system_settings_service
from app.services.db_service import (
celestial_body_service,
position_service,
nasa_cache_service,
)
from app.services.celestial_body_service import celestial_body_service
from app.services.nasa_cache_service import nasa_cache_service
from app.services.position_service import position_service
logger = logging.getLogger(__name__)

View File

@ -15,7 +15,8 @@ from typing import Optional, Dict, Any
from app.database import get_db
from app.models.db import Resource
from app.services.db_service import celestial_body_service, resource_service
from app.services.celestial_body_service import celestial_body_service
from app.services.resource_service import resource_service
logger = logging.getLogger(__name__)

View File

@ -8,7 +8,7 @@ from pydantic import BaseModel
from typing import Optional, Dict, Any
from app.database import get_db
from app.services.db_service import static_data_service
from app.services.static_data_service import static_data_service
router = APIRouter(prefix="/celestial/static", tags=["celestial-static"])

View File

@ -10,7 +10,8 @@ from pydantic import BaseModel
from app.database import get_db
from app.services.horizons import horizons_service
from app.services.db_service import celestial_body_service, position_service
from app.services.celestial_body_service import celestial_body_service
from app.services.position_service import position_service
from app.services.task_service import task_service
from app.services.nasa_worker import download_positions_task

View File

@ -0,0 +1,222 @@
"""Rocket simulator configuration APIs."""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.db import RocketConfig, User
from app.services.auth_deps import require_admin
router = APIRouter(prefix="/rockets", tags=["rockets"])
RocketCode = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9-]*$")]
class RocketStage(BaseModel):
name: str = Field(min_length=1, max_length=100)
dry_mass_kg: float = Field(gt=0)
fuel_mass_kg: float = Field(gt=0)
max_thrust_n: float = Field(gt=0)
specific_impulse_s: float = Field(gt=0)
engine_count: int = Field(gt=0)
length_ratio: float = Field(gt=0, le=1)
class RocketBase(BaseModel):
code: RocketCode
name: str = Field(min_length=1, max_length=100)
name_zh: str | None = Field(default=None, max_length=100)
manufacturer: str | None = Field(default=None, max_length=100)
country: str | None = Field(default=None, max_length=100)
launch_site_name: str = Field(default="Equatorial Launch Site", min_length=1, max_length=120)
launch_latitude_deg: float = Field(default=0, ge=-90, le=90)
launch_longitude_deg: float = Field(default=0, ge=-180, le=180)
description: str | None = None
color: str = Field(default="#f8fafc", pattern=r"^#[0-9a-fA-F]{6}$")
height_m: float = Field(gt=0)
diameter_m: float = Field(gt=0)
payload_mass_kg: float = Field(ge=0, default=0)
drag_coefficient: float = Field(gt=0, le=2, default=0.4)
reference_area_m2: float = Field(gt=0)
target_orbit_km: float = Field(gt=0, default=200)
target_velocity_mps: float = Field(gt=0, default=7800)
separation_delay_seconds: float = Field(ge=0, le=30, default=2)
second_stage_ignition_delay_seconds: float = Field(ge=0, le=30, default=1)
stage_1: RocketStage
stage_2: RocketStage
is_active: bool = True
sort_order: int = 0
@field_validator("code", mode="before")
@classmethod
def normalize_code(cls, value: str) -> str:
return value.strip().lower()
class RocketCreate(RocketBase):
pass
class RocketUpdate(BaseModel):
code: RocketCode | None = None
name: str | None = Field(default=None, min_length=1, max_length=100)
name_zh: str | None = Field(default=None, max_length=100)
manufacturer: str | None = Field(default=None, max_length=100)
country: str | None = Field(default=None, max_length=100)
launch_site_name: str | None = Field(default=None, min_length=1, max_length=120)
launch_latitude_deg: float | None = Field(default=None, ge=-90, le=90)
launch_longitude_deg: float | None = Field(default=None, ge=-180, le=180)
description: str | None = None
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
height_m: float | None = Field(default=None, gt=0)
diameter_m: float | None = Field(default=None, gt=0)
payload_mass_kg: float | None = Field(default=None, ge=0)
drag_coefficient: float | None = Field(default=None, gt=0, le=2)
reference_area_m2: float | None = Field(default=None, gt=0)
target_orbit_km: float | None = Field(default=None, gt=0)
target_velocity_mps: float | None = Field(default=None, gt=0)
separation_delay_seconds: float | None = Field(default=None, ge=0, le=30)
second_stage_ignition_delay_seconds: float | None = Field(default=None, ge=0, le=30)
stage_1: RocketStage | None = None
stage_2: RocketStage | None = None
is_active: bool | None = None
sort_order: int | None = None
@field_validator("code", mode="before")
@classmethod
def normalize_code(cls, value: str | None) -> str | None:
return value.strip().lower() if value else value
class RocketResponse(RocketBase):
id: int
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
def rocket_values(data: RocketCreate | RocketUpdate, *, exclude_unset: bool = False) -> dict:
values = data.model_dump(exclude_unset=exclude_unset)
for key in ("stage_1", "stage_2"):
if key in values and values[key] is not None:
values[key] = dict(values[key])
return values
async def ensure_unique_code(db: AsyncSession, code: str, exclude_id: int | None = None) -> None:
query = select(RocketConfig.id).where(RocketConfig.code == code)
if exclude_id is not None:
query = query.where(RocketConfig.id != exclude_id)
if (await db.execute(query)).scalar_one_or_none() is not None:
raise HTTPException(status_code=400, detail="Rocket code already exists")
@router.get("", response_model=list[RocketResponse])
async def list_active_rockets(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(RocketConfig)
.where(RocketConfig.is_active.is_(True))
.order_by(RocketConfig.sort_order, RocketConfig.id)
)
return result.scalars().all()
@router.get("/admin")
async def list_rockets_for_admin(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
search: str | None = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
filters = []
if search:
keyword = f"%{search.strip()}%"
filters.append(or_(
RocketConfig.code.ilike(keyword),
RocketConfig.name.ilike(keyword),
RocketConfig.name_zh.ilike(keyword),
))
total = await db.scalar(select(func.count(RocketConfig.id)).where(*filters))
result = await db.execute(
select(RocketConfig)
.where(*filters)
.order_by(RocketConfig.sort_order, RocketConfig.id)
.offset(skip)
.limit(limit)
)
return {
"rockets": [RocketResponse.model_validate(item) for item in result.scalars().all()],
"total": total or 0,
}
@router.get("/{code}", response_model=RocketResponse)
async def get_active_rocket(code: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(RocketConfig).where(
RocketConfig.code == code.lower(),
RocketConfig.is_active.is_(True),
)
)
rocket = result.scalar_one_or_none()
if not rocket:
raise HTTPException(status_code=404, detail="Rocket not found")
return rocket
@router.post("/admin", response_model=RocketResponse, status_code=status.HTTP_201_CREATED)
async def create_rocket(
data: RocketCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
await ensure_unique_code(db, data.code)
rocket = RocketConfig(**rocket_values(data))
db.add(rocket)
await db.commit()
await db.refresh(rocket)
return rocket
@router.put("/admin/{rocket_id}", response_model=RocketResponse)
async def update_rocket(
rocket_id: int,
data: RocketUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
rocket = await db.get(RocketConfig, rocket_id)
if not rocket:
raise HTTPException(status_code=404, detail="Rocket not found")
values = rocket_values(data, exclude_unset=True)
if not values:
raise HTTPException(status_code=400, detail="No fields to update")
if "code" in values:
await ensure_unique_code(db, values["code"], rocket_id)
for key, value in values.items():
setattr(rocket, key, value)
await db.commit()
await db.refresh(rocket)
return rocket
@router.delete("/admin/{rocket_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_rocket(
rocket_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_admin),
):
rocket = await db.get(RocketConfig, rocket_id)
if not rocket:
raise HTTPException(status_code=404, detail="Rocket not found")
await db.delete(rocket)
await db.commit()

View File

@ -34,6 +34,7 @@ from app.api.star_system import router as star_system_router
from app.api.scheduled_job import router as scheduled_job_router
from app.api.social import router as social_router # Import social_router
from app.api.event import router as event_router # Import event_router
from app.api.rocket import router as rocket_router
from app.services.redis_cache import redis_cache
from app.services.cache_preheat import preheat_all_caches
from app.services.scheduler_service import scheduler_service
@ -187,6 +188,7 @@ app.include_router(task_router, prefix=settings.api_prefix)
app.include_router(scheduled_job_router, prefix=settings.api_prefix)
app.include_router(social_router, prefix=settings.api_prefix)
app.include_router(event_router, prefix=settings.api_prefix) # Added event_router
app.include_router(rocket_router, prefix=settings.api_prefix)
# Mount static files for uploaded resources
upload_dir = Path(__file__).parent.parent / "upload"
@ -235,4 +237,4 @@ if __name__ == "__main__":
port=8000,
reload=True,
log_level="info",
)
)

View File

@ -15,6 +15,7 @@ from .system_settings import SystemSettings
from .task import Task
from .user_follow import UserFollow
from .celestial_event import CelestialEvent
from .rocket import RocketConfig
__all__ = [
"CelestialBody",
@ -33,4 +34,5 @@ __all__ = [
"Task",
"UserFollow",
"CelestialEvent",
"RocketConfig",
]

View File

@ -0,0 +1,38 @@
"""Rocket configuration ORM model."""
from sqlalchemy import Boolean, Column, Double, Integer, String, Text, TIMESTAMP, func
from sqlalchemy.dialects.postgresql import JSONB
from app.database import Base
class RocketConfig(Base):
"""Two-stage launch vehicle configuration used by the simulator."""
__tablename__ = "rocket_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
name_zh = Column(String(100), nullable=True)
manufacturer = Column(String(100), nullable=True)
country = Column(String(100), nullable=True)
launch_site_name = Column(String(120), nullable=False, default="Equatorial Launch Site")
launch_latitude_deg = Column(Double, nullable=False, default=0)
launch_longitude_deg = Column(Double, nullable=False, default=0)
description = Column(Text, nullable=True)
color = Column(String(20), nullable=False, default="#f8fafc")
height_m = Column(Double, nullable=False)
diameter_m = Column(Double, nullable=False)
payload_mass_kg = Column(Double, nullable=False, default=0)
drag_coefficient = Column(Double, nullable=False, default=0.4)
reference_area_m2 = Column(Double, nullable=False)
target_orbit_km = Column(Double, nullable=False, default=200)
target_velocity_mps = Column(Double, nullable=False, default=7800)
separation_delay_seconds = Column(Double, nullable=False, default=2)
second_stage_ignition_delay_seconds = Column(Double, nullable=False, default=1)
stage_1 = Column(JSONB, nullable=False)
stage_2 = Column(JSONB, nullable=False)
is_active = Column(Boolean, nullable=False, default=True, index=True)
sort_order = Column(Integer, nullable=False, default=0)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())

View File

@ -8,7 +8,8 @@ from typing import List, Dict, Any
from app.database import get_db
from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds
from app.services.db_service import celestial_body_service, position_service
from app.services.celestial_body_service import celestial_body_service
from app.services.position_service import position_service
logger = logging.getLogger(__name__)

View File

@ -0,0 +1,118 @@
"""Persistence operations for celestial bodies."""
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.db import CelestialBody
class CelestialBodyService:
"""Create, read, update, and delete celestial bodies."""
@staticmethod
async def get_all_bodies(
session: Optional[AsyncSession] = None,
body_type: Optional[str] = None,
system_id: Optional[int] = None,
) -> List[CelestialBody]:
async def query(active_session: AsyncSession) -> List[CelestialBody]:
statement = select(CelestialBody)
if body_type:
statement = statement.where(CelestialBody.type == body_type)
if system_id is not None:
statement = statement.where(CelestialBody.system_id == system_id)
result = await active_session.execute(statement.order_by(CelestialBody.name))
return list(result.scalars().all())
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def get_body_by_id(
body_id: str,
session: Optional[AsyncSession] = None,
) -> Optional[CelestialBody]:
async def query(active_session: AsyncSession) -> Optional[CelestialBody]:
result = await active_session.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
return result.scalar_one_or_none()
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def create_body(
body_data: Dict[str, Any],
session: Optional[AsyncSession] = None,
) -> CelestialBody:
async def create(active_session: AsyncSession) -> CelestialBody:
body = CelestialBody(**body_data)
active_session.add(body)
await active_session.commit()
await active_session.refresh(body)
return body
if session is not None:
return await create(session)
async with AsyncSessionLocal() as active_session:
return await create(active_session)
@staticmethod
async def update_body(
body_id: str,
update_data: Dict[str, Any],
session: Optional[AsyncSession] = None,
) -> Optional[CelestialBody]:
async def update(active_session: AsyncSession) -> Optional[CelestialBody]:
result = await active_session.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
body = result.scalar_one_or_none()
if body is None:
return None
for key, value in update_data.items():
if hasattr(body, key):
setattr(body, key, value)
await active_session.commit()
await active_session.refresh(body)
return body
if session is not None:
return await update(session)
async with AsyncSessionLocal() as active_session:
return await update(active_session)
@staticmethod
async def delete_body(
body_id: str,
session: Optional[AsyncSession] = None,
) -> bool:
async def delete(active_session: AsyncSession) -> bool:
result = await active_session.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
body = result.scalar_one_or_none()
if body is None:
return False
await active_session.delete(body)
await active_session.commit()
return True
if session is not None:
return await delete(session)
async with AsyncSessionLocal() as active_session:
return await delete(active_session)
celestial_body_service = CelestialBodyService()

View File

@ -1,689 +1,27 @@
"""Backward-compatible exports for domain-specific persistence services.
New code should import from the individual service modules. This facade keeps
existing API routes, jobs, and maintenance scripts working during migration.
"""
Database service layer for celestial data operations
"""
from typing import List, Optional, Dict, Any
from datetime import datetime
from sqlalchemy import select, and_, delete
from sqlalchemy.ext.asyncio import AsyncSession
import logging
from app.models.db import CelestialBody, Position, StaticData, NasaCache, Resource
from app.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
class CelestialBodyService:
"""Service for celestial body operations"""
@staticmethod
async def get_all_bodies(
session: Optional[AsyncSession] = None,
body_type: Optional[str] = None,
system_id: Optional[int] = None
) -> List[CelestialBody]:
"""
Get all celestial bodies, optionally filtered by type and star system
Args:
session: Database session
body_type: Filter by body type (star, planet, dwarf_planet, etc.)
system_id: Filter by star system ID (1=Solar System, 2+=Exoplanets)
"""
async def _query(s: AsyncSession):
query = select(CelestialBody)
if body_type:
query = query.where(CelestialBody.type == body_type)
if system_id is not None:
query = query.where(CelestialBody.system_id == system_id)
result = await s.execute(query.order_by(CelestialBody.name))
return result.scalars().all()
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def get_body_by_id(
body_id: str,
session: Optional[AsyncSession] = None
) -> Optional[CelestialBody]:
"""Get a celestial body by ID"""
async def _query(s: AsyncSession):
result = await s.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
return result.scalar_one_or_none()
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def create_body(
body_data: Dict[str, Any],
session: Optional[AsyncSession] = None
) -> CelestialBody:
"""Create a new celestial body"""
async def _create(s: AsyncSession):
body = CelestialBody(**body_data)
s.add(body)
await s.commit()
await s.refresh(body)
return body
if session:
return await _create(session)
else:
async with AsyncSessionLocal() as s:
return await _create(s)
@staticmethod
async def update_body(
body_id: str,
update_data: Dict[str, Any],
session: Optional[AsyncSession] = None
) -> Optional[CelestialBody]:
"""Update a celestial body"""
async def _update(s: AsyncSession):
# Query the body
result = await s.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
body = result.scalar_one_or_none()
if not body:
return None
# Update fields
for key, value in update_data.items():
if hasattr(body, key):
setattr(body, key, value)
await s.commit()
await s.refresh(body)
return body
if session:
return await _update(session)
else:
async with AsyncSessionLocal() as s:
return await _update(s)
@staticmethod
async def delete_body(
body_id: str,
session: Optional[AsyncSession] = None
) -> bool:
"""Delete a celestial body"""
async def _delete(s: AsyncSession):
result = await s.execute(
select(CelestialBody).where(CelestialBody.id == body_id)
)
body = result.scalar_one_or_none()
if not body:
return False
await s.delete(body)
await s.commit()
return True
if session:
return await _delete(session)
else:
async with AsyncSessionLocal() as s:
return await _delete(s)
class PositionService:
"""Service for position data operations"""
@staticmethod
async def save_positions(
body_id: str,
positions: List[Dict[str, Any]],
source: str = "nasa_horizons",
session: Optional[AsyncSession] = None
) -> int:
"""Save multiple position records for a celestial body (upsert: insert or update if exists)"""
async def _save(s: AsyncSession):
from sqlalchemy.dialects.postgresql import insert
count = 0
for pos_data in positions:
# Use PostgreSQL's INSERT ... ON CONFLICT to handle duplicates
stmt = insert(Position).values(
body_id=body_id,
time=pos_data["time"],
x=pos_data["x"],
y=pos_data["y"],
z=pos_data["z"],
vx=pos_data.get("vx"),
vy=pos_data.get("vy"),
vz=pos_data.get("vz"),
source=source
)
# On conflict (body_id, time), update the existing record
stmt = stmt.on_conflict_do_update(
index_elements=['body_id', 'time'],
set_={
'x': pos_data["x"],
'y': pos_data["y"],
'z': pos_data["z"],
'vx': pos_data.get("vx"),
'vy': pos_data.get("vy"),
'vz': pos_data.get("vz"),
'source': source
}
)
await s.execute(stmt)
count += 1
await s.commit()
return count
if session:
return await _save(session)
else:
async with AsyncSessionLocal() as s:
return await _save(s)
@staticmethod
async def get_positions(
body_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
session: Optional[AsyncSession] = None
) -> List[Position]:
"""Get positions for a celestial body within a time range"""
async def _query(s: AsyncSession):
query = select(Position).where(Position.body_id == body_id)
if start_time and end_time:
query = query.where(
and_(
Position.time >= start_time,
Position.time <= end_time
)
)
elif start_time:
query = query.where(Position.time >= start_time)
elif end_time:
query = query.where(Position.time <= end_time)
query = query.order_by(Position.time)
result = await s.execute(query)
return result.scalars().all()
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def get_positions_in_range(
body_id: str,
start_time: datetime,
end_time: datetime,
session: Optional[AsyncSession] = None
) -> List[Position]:
"""Alias for get_positions with required time range"""
return await PositionService.get_positions(body_id, start_time, end_time, session)
@staticmethod
async def save_position(
body_id: str,
time: datetime,
x: float,
y: float,
z: float,
source: str = "nasa_horizons",
vx: Optional[float] = None,
vy: Optional[float] = None,
vz: Optional[float] = None,
session: Optional[AsyncSession] = None
) -> Position:
"""Save a single position record"""
async def _save(s: AsyncSession):
# Check if position already exists
existing = await s.execute(
select(Position).where(
and_(
Position.body_id == body_id,
Position.time == time
)
)
)
existing_pos = existing.scalar_one_or_none()
if existing_pos:
# Update existing position
existing_pos.x = x
existing_pos.y = y
existing_pos.z = z
existing_pos.vx = vx
existing_pos.vy = vy
existing_pos.vz = vz
existing_pos.source = source
await s.commit()
await s.refresh(existing_pos)
return existing_pos
else:
# Create new position
position = Position(
body_id=body_id,
time=time,
x=x,
y=y,
z=z,
vx=vx,
vy=vy,
vz=vz,
source=source
)
s.add(position)
await s.commit()
await s.refresh(position)
return position
if session:
return await _save(session)
else:
async with AsyncSessionLocal() as s:
return await _save(s)
@staticmethod
async def delete_old_positions(
before_time: datetime,
session: Optional[AsyncSession] = None
) -> int:
"""Delete position records older than specified time"""
async def _delete(s: AsyncSession):
result = await s.execute(
delete(Position).where(Position.time < before_time)
)
await s.commit()
return result.rowcount
if session:
return await _delete(session)
else:
async with AsyncSessionLocal() as s:
return await _delete(s)
@staticmethod
async def get_available_dates(
body_id: str,
start_time: datetime,
end_time: datetime,
session: Optional[AsyncSession] = None
) -> List[datetime]:
"""Get all dates that have position data for a specific body within a time range"""
async def _query(s: AsyncSession):
from sqlalchemy import func, Date
# Query distinct dates (truncate to date)
query = select(func.date(Position.time)).where(
and_(
Position.body_id == body_id,
Position.time >= start_time,
Position.time <= end_time
)
).distinct().order_by(func.date(Position.time))
result = await s.execute(query)
dates = [row[0] for row in result]
return dates
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
class NasaCacheService:
"""Service for NASA API response caching"""
@staticmethod
async def get_cached_response(
body_id: str,
start_time: Optional[datetime],
end_time: Optional[datetime],
step: str,
session: Optional[AsyncSession] = None
) -> Optional[Dict[str, Any]]:
"""Get cached NASA API response"""
async def _query(s: AsyncSession):
# Remove timezone info for comparison with database TIMESTAMP WITHOUT TIME ZONE
start_naive = start_time.replace(tzinfo=None) if start_time else None
end_naive = end_time.replace(tzinfo=None) if end_time else None
now_naive = datetime.utcnow()
result = await s.execute(
select(NasaCache).where(
and_(
NasaCache.body_id == body_id,
NasaCache.start_time == start_naive,
NasaCache.end_time == end_naive,
NasaCache.step == step,
NasaCache.expires_at > now_naive
)
)
)
cache = result.scalar_one_or_none()
return cache.data if cache else None
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def save_response(
body_id: str,
start_time: Optional[datetime],
end_time: Optional[datetime],
step: str,
response_data: Dict[str, Any],
ttl_days: int = 7,
session: Optional[AsyncSession] = None
) -> NasaCache:
"""Save NASA API response to cache (upsert: insert or update if exists)"""
async def _save(s: AsyncSession):
from datetime import timedelta
from sqlalchemy.dialects.postgresql import insert
# Remove timezone info for database storage (TIMESTAMP WITHOUT TIME ZONE)
start_naive = start_time.replace(tzinfo=None) if start_time else None
end_naive = end_time.replace(tzinfo=None) if end_time else None
now_naive = datetime.utcnow()
# Generate cache key
start_str = start_time.isoformat() if start_time else "null"
end_str = end_time.isoformat() if end_time else "null"
cache_key = f"{body_id}:{start_str}:{end_str}:{step}"
# Use PostgreSQL's INSERT ... ON CONFLICT to handle duplicates atomically
stmt = insert(NasaCache).values(
cache_key=cache_key,
body_id=body_id,
start_time=start_naive,
end_time=end_naive,
step=step,
data=response_data,
expires_at=now_naive + timedelta(days=ttl_days)
)
# On conflict, update the existing record
stmt = stmt.on_conflict_do_update(
index_elements=['cache_key'],
set_={
'data': response_data,
'created_at': now_naive,
'expires_at': now_naive + timedelta(days=ttl_days)
}
).returning(NasaCache)
result = await s.execute(stmt)
cache = result.scalar_one()
await s.commit()
await s.refresh(cache)
return cache
if session:
return await _save(session)
else:
async with AsyncSessionLocal() as s:
return await _save(s)
class StaticDataService:
"""Service for static data operations"""
@staticmethod
async def get_all_items(
session: Optional[AsyncSession] = None
) -> List[StaticData]:
"""Get all static data items"""
async def _query(s: AsyncSession):
result = await s.execute(
select(StaticData).order_by(StaticData.category, StaticData.name)
)
return result.scalars().all()
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def create_static(
data: Dict[str, Any],
session: Optional[AsyncSession] = None
) -> StaticData:
"""Create new static data"""
async def _create(s: AsyncSession):
item = StaticData(**data)
s.add(item)
await s.commit()
await s.refresh(item)
return item
if session:
return await _create(session)
else:
async with AsyncSessionLocal() as s:
return await _create(s)
@staticmethod
async def update_static(
item_id: int,
update_data: Dict[str, Any],
session: Optional[AsyncSession] = None
) -> Optional[StaticData]:
"""Update static data"""
async def _update(s: AsyncSession):
result = await s.execute(
select(StaticData).where(StaticData.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
return None
for key, value in update_data.items():
if hasattr(item, key):
setattr(item, key, value)
await s.commit()
await s.refresh(item)
return item
if session:
return await _update(session)
else:
async with AsyncSessionLocal() as s:
return await _update(s)
@staticmethod
async def delete_static(
item_id: int,
session: Optional[AsyncSession] = None
) -> bool:
"""Delete static data"""
async def _delete(s: AsyncSession):
result = await s.execute(
select(StaticData).where(StaticData.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
return False
await s.delete(item)
await s.commit()
return True
if session:
return await _delete(session)
else:
async with AsyncSessionLocal() as s:
return await _delete(s)
@staticmethod
async def get_by_category(
category: str,
session: Optional[AsyncSession] = None
) -> List[StaticData]:
"""Get all static data items for a category"""
async def _query(s: AsyncSession):
result = await s.execute(
select(StaticData)
.where(StaticData.category == category)
.order_by(StaticData.name)
)
return result.scalars().all()
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def get_all_categories(
session: Optional[AsyncSession] = None
) -> List[str]:
"""Get all available categories"""
async def _query(s: AsyncSession):
result = await s.execute(
select(StaticData.category).distinct()
)
return [row[0] for row in result]
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
class ResourceService:
"""Service for resource file management"""
@staticmethod
async def create_resource(
resource_data: Dict[str, Any],
session: Optional[AsyncSession] = None
) -> Resource:
"""Create a new resource record"""
async def _create(s: AsyncSession):
resource = Resource(**resource_data)
s.add(resource)
await s.commit()
await s.refresh(resource)
return resource
if session:
return await _create(session)
else:
async with AsyncSessionLocal() as s:
return await _create(s)
@staticmethod
async def get_resources_by_body(
body_id: str,
resource_type: Optional[str] = None,
session: Optional[AsyncSession] = None
) -> List[Resource]:
"""Get all resources for a celestial body"""
async def _query(s: AsyncSession):
query = select(Resource).where(Resource.body_id == body_id)
if resource_type:
query = query.where(Resource.resource_type == resource_type)
result = await s.execute(query.order_by(Resource.created_at))
return result.scalars().all()
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def get_all_resources_grouped_by_body(
body_ids: Optional[List[str]] = None,
session: Optional[AsyncSession] = None
) -> Dict[str, List[Resource]]:
"""
Get all resources grouped by body_id (optimized for bulk loading)
Args:
body_ids: Optional list of body IDs to filter by
session: Database session
Returns:
Dictionary mapping body_id to list of resources
"""
async def _query(s: AsyncSession):
query = select(Resource).order_by(Resource.body_id, Resource.created_at)
if body_ids:
query = query.where(Resource.body_id.in_(body_ids))
result = await s.execute(query)
resources = result.scalars().all()
# Group by body_id
grouped = {}
for resource in resources:
if resource.body_id not in grouped:
grouped[resource.body_id] = []
grouped[resource.body_id].append(resource)
return grouped
if session:
return await _query(session)
else:
async with AsyncSessionLocal() as s:
return await _query(s)
@staticmethod
async def delete_resource(
resource_id: int,
session: Optional[AsyncSession] = None
) -> bool:
"""Delete a resource record"""
async def _delete(s: AsyncSession):
result = await s.execute(
select(Resource).where(Resource.id == resource_id)
)
resource = result.scalar_one_or_none()
if resource:
await s.delete(resource)
await s.commit()
return True
return False
if session:
return await _delete(session)
else:
async with AsyncSessionLocal() as s:
return await _delete(s)
# Export service instances
celestial_body_service = CelestialBodyService()
position_service = PositionService()
nasa_cache_service = NasaCacheService()
static_data_service = StaticDataService()
resource_service = ResourceService()
from app.services.celestial_body_service import (
CelestialBodyService,
celestial_body_service,
)
from app.services.nasa_cache_service import NasaCacheService, nasa_cache_service
from app.services.position_service import PositionService, position_service
from app.services.resource_service import ResourceService, resource_service
from app.services.static_data_service import StaticDataService, static_data_service
__all__ = [
"CelestialBodyService",
"NasaCacheService",
"PositionService",
"ResourceService",
"StaticDataService",
"celestial_body_service",
"nasa_cache_service",
"position_service",
"resource_service",
"static_data_service",
]

View File

@ -0,0 +1,95 @@
"""Persistence operations for NASA API response caching."""
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from sqlalchemy import and_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.db import NasaCache
class NasaCacheService:
"""Read and write cached NASA API responses."""
@staticmethod
async def get_cached_response(
body_id: str,
start_time: Optional[datetime],
end_time: Optional[datetime],
step: str,
session: Optional[AsyncSession] = None,
) -> Optional[Dict[str, Any]]:
async def query(active_session: AsyncSession) -> Optional[Dict[str, Any]]:
start_naive = start_time.replace(tzinfo=None) if start_time else None
end_naive = end_time.replace(tzinfo=None) if end_time else None
result = await active_session.execute(
select(NasaCache).where(
and_(
NasaCache.body_id == body_id,
NasaCache.start_time == start_naive,
NasaCache.end_time == end_naive,
NasaCache.step == step,
NasaCache.expires_at > datetime.utcnow(),
)
)
)
cache = result.scalar_one_or_none()
return cache.data if cache else None
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def save_response(
body_id: str,
start_time: Optional[datetime],
end_time: Optional[datetime],
step: str,
response_data: Dict[str, Any],
ttl_days: int = 7,
session: Optional[AsyncSession] = None,
) -> NasaCache:
async def save(active_session: AsyncSession) -> NasaCache:
start_naive = start_time.replace(tzinfo=None) if start_time else None
end_naive = end_time.replace(tzinfo=None) if end_time else None
now = datetime.utcnow()
start_key = start_time.isoformat() if start_time else "null"
end_key = end_time.isoformat() if end_time else "null"
cache_key = f"{body_id}:{start_key}:{end_key}:{step}"
statement = insert(NasaCache).values(
cache_key=cache_key,
body_id=body_id,
start_time=start_naive,
end_time=end_naive,
step=step,
data=response_data,
expires_at=now + timedelta(days=ttl_days),
)
statement = statement.on_conflict_do_update(
index_elements=["cache_key"],
set_={
"data": response_data,
"created_at": now,
"expires_at": now + timedelta(days=ttl_days),
},
).returning(NasaCache)
result = await active_session.execute(statement)
cache = result.scalar_one()
await active_session.commit()
await active_session.refresh(cache)
return cache
if session is not None:
return await save(session)
async with AsyncSessionLocal() as active_session:
return await save(active_session)
nasa_cache_service = NasaCacheService()

View File

@ -10,7 +10,8 @@ from typing import List, Optional
from app.database import AsyncSessionLocal
from app.services.task_service import task_service
from app.services.db_service import celestial_body_service, position_service
from app.services.celestial_body_service import celestial_body_service
from app.services.position_service import position_service
from app.services.horizons import horizons_service
from app.services.orbit_service import orbit_service
from app.services.event_service import event_service

View File

@ -0,0 +1,183 @@
"""Persistence operations for celestial position data."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.db import Position
class PositionService:
"""Store and query time-series position data."""
@staticmethod
async def save_positions(
body_id: str,
positions: List[Dict[str, Any]],
source: str = "nasa_horizons",
session: Optional[AsyncSession] = None,
) -> int:
async def save(active_session: AsyncSession) -> int:
for position_data in positions:
statement = insert(Position).values(
body_id=body_id,
time=position_data["time"],
x=position_data["x"],
y=position_data["y"],
z=position_data["z"],
vx=position_data.get("vx"),
vy=position_data.get("vy"),
vz=position_data.get("vz"),
source=source,
)
statement = statement.on_conflict_do_update(
index_elements=["body_id", "time"],
set_={
"x": position_data["x"],
"y": position_data["y"],
"z": position_data["z"],
"vx": position_data.get("vx"),
"vy": position_data.get("vy"),
"vz": position_data.get("vz"),
"source": source,
},
)
await active_session.execute(statement)
await active_session.commit()
return len(positions)
if session is not None:
return await save(session)
async with AsyncSessionLocal() as active_session:
return await save(active_session)
@staticmethod
async def get_positions(
body_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
session: Optional[AsyncSession] = None,
) -> List[Position]:
async def query(active_session: AsyncSession) -> List[Position]:
statement = select(Position).where(Position.body_id == body_id)
if start_time and end_time:
statement = statement.where(
and_(Position.time >= start_time, Position.time <= end_time)
)
elif start_time:
statement = statement.where(Position.time >= start_time)
elif end_time:
statement = statement.where(Position.time <= end_time)
result = await active_session.execute(statement.order_by(Position.time))
return list(result.scalars().all())
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def get_positions_in_range(
body_id: str,
start_time: datetime,
end_time: datetime,
session: Optional[AsyncSession] = None,
) -> List[Position]:
return await PositionService.get_positions(
body_id, start_time, end_time, session
)
@staticmethod
async def save_position(
body_id: str,
time: datetime,
x: float,
y: float,
z: float,
source: str = "nasa_horizons",
vx: Optional[float] = None,
vy: Optional[float] = None,
vz: Optional[float] = None,
session: Optional[AsyncSession] = None,
) -> Position:
async def save(active_session: AsyncSession) -> Position:
result = await active_session.execute(
select(Position).where(
and_(Position.body_id == body_id, Position.time == time)
)
)
position = result.scalar_one_or_none()
if position is None:
position = Position(body_id=body_id, time=time, x=x, y=y, z=z)
active_session.add(position)
position.x = x
position.y = y
position.z = z
position.vx = vx
position.vy = vy
position.vz = vz
position.source = source
await active_session.commit()
await active_session.refresh(position)
return position
if session is not None:
return await save(session)
async with AsyncSessionLocal() as active_session:
return await save(active_session)
@staticmethod
async def delete_old_positions(
before_time: datetime,
session: Optional[AsyncSession] = None,
) -> int:
async def remove(active_session: AsyncSession) -> int:
result = await active_session.execute(
delete(Position).where(Position.time < before_time)
)
await active_session.commit()
return result.rowcount
if session is not None:
return await remove(session)
async with AsyncSessionLocal() as active_session:
return await remove(active_session)
@staticmethod
async def get_available_dates(
body_id: str,
start_time: datetime,
end_time: datetime,
session: Optional[AsyncSession] = None,
) -> List[datetime]:
async def query(active_session: AsyncSession) -> List[datetime]:
date_expression = func.date(Position.time)
statement = (
select(date_expression)
.where(
and_(
Position.body_id == body_id,
Position.time >= start_time,
Position.time <= end_time,
)
)
.distinct()
.order_by(date_expression)
)
result = await active_session.execute(statement)
return [row[0] for row in result]
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
position_service = PositionService()

View File

@ -0,0 +1,99 @@
"""Persistence operations for celestial resource metadata."""
from collections import defaultdict
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.db import Resource
class ResourceService:
"""Create, query, and delete celestial resource records."""
@staticmethod
async def create_resource(
resource_data: Dict[str, Any],
session: Optional[AsyncSession] = None,
) -> Resource:
async def create(active_session: AsyncSession) -> Resource:
resource = Resource(**resource_data)
active_session.add(resource)
await active_session.commit()
await active_session.refresh(resource)
return resource
if session is not None:
return await create(session)
async with AsyncSessionLocal() as active_session:
return await create(active_session)
@staticmethod
async def get_resources_by_body(
body_id: str,
resource_type: Optional[str] = None,
session: Optional[AsyncSession] = None,
) -> List[Resource]:
async def query(active_session: AsyncSession) -> List[Resource]:
statement = select(Resource).where(Resource.body_id == body_id)
if resource_type:
statement = statement.where(Resource.resource_type == resource_type)
result = await active_session.execute(
statement.order_by(Resource.created_at)
)
return list(result.scalars().all())
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def get_all_resources_grouped_by_body(
body_ids: Optional[List[str]] = None,
session: Optional[AsyncSession] = None,
) -> Dict[str, List[Resource]]:
async def query(active_session: AsyncSession) -> Dict[str, List[Resource]]:
statement = select(Resource).order_by(
Resource.body_id, Resource.created_at
)
if body_ids:
statement = statement.where(Resource.body_id.in_(body_ids))
result = await active_session.execute(statement)
grouped: Dict[str, List[Resource]] = defaultdict(list)
for resource in result.scalars().all():
grouped[resource.body_id].append(resource)
return dict(grouped)
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def delete_resource(
resource_id: int,
session: Optional[AsyncSession] = None,
) -> bool:
async def delete(active_session: AsyncSession) -> bool:
result = await active_session.execute(
select(Resource).where(Resource.id == resource_id)
)
resource = result.scalar_one_or_none()
if resource is None:
return False
await active_session.delete(resource)
await active_session.commit()
return True
if session is not None:
return await delete(session)
async with AsyncSessionLocal() as active_session:
return await delete(active_session)
resource_service = ResourceService()

View File

@ -0,0 +1,130 @@
"""Persistence operations for system static data."""
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.db import StaticData
class StaticDataService:
"""Create, read, update, and delete static data entries."""
@staticmethod
async def get_all_items(
session: Optional[AsyncSession] = None,
) -> List[StaticData]:
async def query(active_session: AsyncSession) -> List[StaticData]:
result = await active_session.execute(
select(StaticData).order_by(StaticData.category, StaticData.name)
)
return list(result.scalars().all())
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def create_static(
data: Dict[str, Any],
session: Optional[AsyncSession] = None,
) -> StaticData:
async def create(active_session: AsyncSession) -> StaticData:
item = StaticData(**data)
active_session.add(item)
await active_session.commit()
await active_session.refresh(item)
return item
if session is not None:
return await create(session)
async with AsyncSessionLocal() as active_session:
return await create(active_session)
@staticmethod
async def update_static(
item_id: int,
update_data: Dict[str, Any],
session: Optional[AsyncSession] = None,
) -> Optional[StaticData]:
async def update(active_session: AsyncSession) -> Optional[StaticData]:
result = await active_session.execute(
select(StaticData).where(StaticData.id == item_id)
)
item = result.scalar_one_or_none()
if item is None:
return None
for key, value in update_data.items():
if hasattr(item, key):
setattr(item, key, value)
await active_session.commit()
await active_session.refresh(item)
return item
if session is not None:
return await update(session)
async with AsyncSessionLocal() as active_session:
return await update(active_session)
@staticmethod
async def delete_static(
item_id: int,
session: Optional[AsyncSession] = None,
) -> bool:
async def delete(active_session: AsyncSession) -> bool:
result = await active_session.execute(
select(StaticData).where(StaticData.id == item_id)
)
item = result.scalar_one_or_none()
if item is None:
return False
await active_session.delete(item)
await active_session.commit()
return True
if session is not None:
return await delete(session)
async with AsyncSessionLocal() as active_session:
return await delete(active_session)
@staticmethod
async def get_by_category(
category: str,
session: Optional[AsyncSession] = None,
) -> List[StaticData]:
async def query(active_session: AsyncSession) -> List[StaticData]:
result = await active_session.execute(
select(StaticData)
.where(StaticData.category == category)
.order_by(StaticData.name)
)
return list(result.scalars().all())
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
@staticmethod
async def get_all_categories(
session: Optional[AsyncSession] = None,
) -> List[str]:
async def query(active_session: AsyncSession) -> List[str]:
result = await active_session.execute(
select(StaticData.category).distinct()
)
return [row[0] for row in result]
if session is not None:
return await query(session)
async with AsyncSessionLocal() as active_session:
return await query(active_session)
static_data_service = StaticDataService()

View File

@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS rocket_configs (
id serial PRIMARY KEY,
code varchar(50) NOT NULL UNIQUE,
name varchar(100) NOT NULL,
name_zh varchar(100),
manufacturer varchar(100),
country varchar(100),
launch_site_name varchar(120) NOT NULL DEFAULT 'Equatorial Launch Site',
launch_latitude_deg double precision NOT NULL DEFAULT 0 CHECK (launch_latitude_deg BETWEEN -90 AND 90),
launch_longitude_deg double precision NOT NULL DEFAULT 0 CHECK (launch_longitude_deg BETWEEN -180 AND 180),
description text,
color varchar(20) NOT NULL DEFAULT '#f8fafc',
height_m double precision NOT NULL CHECK (height_m > 0),
diameter_m double precision NOT NULL CHECK (diameter_m > 0),
payload_mass_kg double precision NOT NULL DEFAULT 0 CHECK (payload_mass_kg >= 0),
drag_coefficient double precision NOT NULL DEFAULT 0.4 CHECK (drag_coefficient > 0),
reference_area_m2 double precision NOT NULL CHECK (reference_area_m2 > 0),
target_orbit_km double precision NOT NULL DEFAULT 200 CHECK (target_orbit_km > 0),
target_velocity_mps double precision NOT NULL DEFAULT 7800 CHECK (target_velocity_mps > 0),
separation_delay_seconds double precision NOT NULL DEFAULT 2 CHECK (separation_delay_seconds >= 0),
second_stage_ignition_delay_seconds double precision NOT NULL DEFAULT 1 CHECK (second_stage_ignition_delay_seconds >= 0),
stage_1 jsonb NOT NULL,
stage_2 jsonb NOT NULL,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamp DEFAULT now(),
updated_at timestamp DEFAULT now()
);
ALTER TABLE rocket_configs ADD COLUMN IF NOT EXISTS launch_site_name varchar(120) NOT NULL DEFAULT 'Equatorial Launch Site';
ALTER TABLE rocket_configs ADD COLUMN IF NOT EXISTS launch_latitude_deg double precision NOT NULL DEFAULT 0;
ALTER TABLE rocket_configs ADD COLUMN IF NOT EXISTS launch_longitude_deg double precision NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS ix_rocket_configs_code ON rocket_configs (code);
CREATE INDEX IF NOT EXISTS ix_rocket_configs_is_active ON rocket_configs (is_active);
INSERT INTO rocket_configs (
code, name, name_zh, manufacturer, country,
launch_site_name, launch_latitude_deg, launch_longitude_deg, description, color,
height_m, diameter_m, payload_mass_kg, drag_coefficient, reference_area_m2,
target_orbit_km, target_velocity_mps, separation_delay_seconds,
second_stage_ignition_delay_seconds, stage_1, stage_2, is_active, sort_order
) VALUES
(
'falcon-9', 'Falcon 9', '猎鹰9号', 'SpaceX', '美国',
'Cape Canaveral', 28.5623, -80.5774,
'两级可重复使用运载火箭,用于近地轨道发射任务模拟。', '#f8fafc',
70, 3.7, 15000, 0.4, 10.75, 200, 7800, 2.2, 1.0,
jsonb_build_object('name', '一级推进器', 'dry_mass_kg', 25600, 'fuel_mass_kg', 411000, 'max_thrust_n', 7607000, 'specific_impulse_s', 282, 'engine_count', 9, 'length_ratio', 0.65),
jsonb_build_object('name', '二级推进器', 'dry_mass_kg', 4000, 'fuel_mass_kg', 107500, 'max_thrust_n', 981000, 'specific_impulse_s', 348, 'engine_count', 1, 'length_ratio', 0.35),
true, 1
),
(
'long-march-5', 'Long March 5', '长征五号', '中国运载火箭技术研究院', '中国',
'文昌航天发射场', 19.6145, 110.951,
'中国大型两级液体运载火箭,用于重型载荷与深空任务模拟。', '#e8edf2',
56.97, 5, 18000, 0.42, 19.63, 200, 7800, 2.5, 1.2,
jsonb_build_object('name', '芯一级', 'dry_mass_kg', 38000, 'fuel_mass_kg', 175000, 'max_thrust_n', 10600000, 'specific_impulse_s', 310, 'engine_count', 10, 'length_ratio', 0.62),
jsonb_build_object('name', '芯二级', 'dry_mass_kg', 12000, 'fuel_mass_kg', 26000, 'max_thrust_n', 768000, 'specific_impulse_s', 442, 'engine_count', 2, 'length_ratio', 0.38),
true, 2
)
ON CONFLICT (code) DO NOTHING;
UPDATE rocket_configs
SET launch_site_name = 'Cape Canaveral', launch_latitude_deg = 28.5623, launch_longitude_deg = -80.5774
WHERE code = 'falcon-9' AND launch_site_name = 'Equatorial Launch Site';
UPDATE rocket_configs
SET launch_site_name = '文昌航天发射场', launch_latitude_deg = 19.6145, launch_longitude_deg = 110.951
WHERE code = 'long-march-5' AND launch_site_name = 'Equatorial Launch Site';
INSERT INTO menus (
parent_id, name, title, icon, path, component, sort_order,
is_active, description
)
SELECT
parent.id, 'rocket_configs', '火箭数据管理', 'rocket',
'/admin/rockets', 'admin/Rockets', 5, true,
'管理火箭发射模拟使用的两级运载火箭数据'
FROM menus AS parent
WHERE parent.name = 'data_management'
AND NOT EXISTS (SELECT 1 FROM menus WHERE name = 'rocket_configs')
LIMIT 1;
INSERT INTO role_menus (role_id, menu_id)
SELECT roles.id, menus.id
FROM roles
JOIN menus ON menus.name = 'rocket_configs'
WHERE roles.name = 'admin'
AND NOT EXISTS (
SELECT 1 FROM role_menus
WHERE role_menus.role_id = roles.id
AND role_menus.menu_id = menus.id
);

View File

@ -23,11 +23,9 @@ import logging
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.services.horizons import horizons_service
from app.services.db_service import (
celestial_body_service,
position_service,
nasa_cache_service
)
from app.services.celestial_body_service import celestial_body_service
from app.services.nasa_cache_service import nasa_cache_service
from app.services.position_service import position_service
from app.services.redis_cache import redis_cache, cache_nasa_response
from app.config import settings

View File

@ -5420,7 +5420,8 @@ CREATE TABLE "public"."users" (
"is_active" bool NOT NULL,
"created_at" timestamp(6) DEFAULT now(),
"updated_at" timestamp(6) DEFAULT now(),
"last_login_at" timestamp(6)
"last_login_at" timestamp(6),
"avatar_url" varchar(255) COLLATE "pg_catalog"."default"
)
;
ALTER TABLE "public"."users" OWNER TO "postgres";
@ -5430,6 +5431,7 @@ COMMENT ON COLUMN "public"."users"."email" IS 'Email address';
COMMENT ON COLUMN "public"."users"."full_name" IS 'Full name';
COMMENT ON COLUMN "public"."users"."is_active" IS 'Account active status';
COMMENT ON COLUMN "public"."users"."last_login_at" IS 'Last login time';
COMMENT ON COLUMN "public"."users"."avatar_url" IS 'User avatar file path';
-- ----------------------------
-- Records of users

View File

@ -28,7 +28,8 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
from app.database import get_db
from app.services.horizons import horizons_service
from app.services.db_service import position_service, celestial_body_service
from app.services.celestial_body_service import celestial_body_service
from app.services.position_service import position_service
async def prefetch_month(year: int, month: int, session):

View File

@ -19,7 +19,8 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')
from app.database import get_db
from app.services.horizons import horizons_service
from app.services.db_service import celestial_body_service, position_service
from app.services.celestial_body_service import celestial_body_service
from app.services.position_service import position_service
from app.models.celestial import CELESTIAL_BODIES

View File

@ -10,7 +10,7 @@ import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_db
from app.services.db_service import static_data_service
from app.services.static_data_service import static_data_service
from app.models.db import StaticData
from sqlalchemy import select, update, insert
from sqlalchemy.dialects.postgresql import insert as pg_insert

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
data/.DS_Store vendored

Binary file not shown.

BIN
data/backups/.DS_Store vendored

Binary file not shown.

15
docs/README.md 100644
View File

@ -0,0 +1,15 @@
# Cosmo 文档
项目入口文档保留在仓库根目录:
- [README](../README.md)
- [项目说明](../PROJECT.md)
- [快速开始](../QUICKSTART.md)
- [部署指南](../DEPLOYMENT.md)
其余文档按用途归档:
- `architecture/`:架构、数据策略、算法和数据库设计
- `guides/`:配置、运维和功能使用指南
- `plans/`:路线图及阶段计划
- `reports/`:阶段完成报告、问题分析和修复记录

View File

@ -37,4 +37,4 @@
|------|--------------|-------------|------|
| user:tokens:{uid} | Set | 存该用户所有活动的 JWT Token用于多端登录和注销。 | 随 Token 过期 |
| positions:{args} | String (JSON) | API 响应缓存。存前端需要的天体坐标数组。 | 1小时 (实时) / 7天 (历史) |
| cosmo:danmaku:stream | ZSet | 留言板消息流。按时间排序,自动过期。 | 24 小时 (可配) |
| cosmo:danmaku:stream | ZSet | 留言板消息流。按时间排序,自动过期。 | 24 小时 (可配) |

View File

@ -307,7 +307,7 @@ GET /celestial/orbits?body_type=planet
## 相关文档
- [天体轨道生成系统技术文档](./ORBIT_GENERATION_SYSTEM.md)
- [天体轨道生成系统技术文档](../architecture/ORBIT_GENERATION_SYSTEM.md)
- [NASA JPL Horizons API文档](https://ssd.jpl.nasa.gov/horizons/)
---

View File

@ -87,4 +87,4 @@
3. **验证**:
* 测试关注/取关。
* 测试频道消息发送与接收(验证 Redis 存储)。
* 测试 Horizons 缓存生效。
* 测试 Horizons 缓存生效。

View File

@ -328,4 +328,4 @@ function getPlanetTexture(radius, temp) {
// 4. 宜居带/岩石行星 (默认)
return textureLoader.load(PATH + 'rocky_atmosphere.jpg');
}
```
```

View File

@ -364,7 +364,7 @@ POSITIVE:
- **Model Source**: `/Users/jiliu/WorkSpace/cosmo/backend/upload/model/`
- **Texture Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/textures/`
- **Model Build Output**: `/Users/jiliu/WorkSpace/cosmo/frontend/dist/models/`
- **Documentation**: `/Users/jiliu/WorkSpace/cosmo/frontend/PERFORMANCE_OPTIMIZATION.md`
- **Documentation**: `../guides/PERFORMANCE_OPTIMIZATION.md`
---
@ -387,4 +387,3 @@ POSITIVE:
2. Add KTX2/Basis texture compression
3. Implement virtual scrolling for star catalog
4. Add service worker for persistent caching

View File

@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

View File

@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/60f28484-41b5-453e-beeb-3d312fb598db
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,6 @@
{
"name": "",
"description": "",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

View File

@ -0,0 +1,118 @@
import { CONSTANTS, PRESETS } from "./src/types";
let h = 0, x = 0, vx = 0, vy = 0, v = 0, pitch = 90, t = 0;
let currentStage = 0;
let fuel = PRESETS[0].stages[0].fuelMass;
const currentEvents = [];
let maxQ = 0;
let isRunning = true;
for (let i = 0; i < 50000; i++) {
const dt = 0.1;
const rocket = PRESETS[0];
const { area, cd, stages } = rocket;
const stage = stages[currentStage];
const { dryMass, maxThrust, isp } = stage;
let upperStagesMass = 0;
for (let j = currentStage + 1; j < stages.length; j++) {
upperStagesMass += stages[j].dryMass + stages[j].fuelMass;
}
const m = dryMass + fuel + upperStagesMass;
const mDotMax = maxThrust / (isp * CONSTANTS.g0);
let actualThrottle = 1;
if (fuel <= 0 && currentStage < stages.length - 1) {
currentStage++;
fuel = stages[currentStage].fuelMass;
if (!currentEvents.find((e) => e.name === `stageSep${currentStage}`)) {
currentEvents.push({ name: `stageSep${currentStage}`, time: t });
}
} else if (fuel <= 0) {
actualThrottle = 0;
fuel = 0;
}
const currentThrust = maxThrust * actualThrottle;
const mDot = mDotMax * actualThrottle;
if (actualThrottle > 0) {
fuel -= mDot * dt;
if (fuel < 0) fuel = 0;
}
v = Math.sqrt(vx * vx + vy * vy);
const pitching = h > 500;
if (pitching) {
pitch = 90 * Math.exp(-h / 30000);
}
if (h <= 500) {
pitch = 90;
}
const pitchRad = (pitch * Math.PI) / 180;
const fg = (CONSTANTS.G * (CONSTANTS.M_EARTH * m)) / Math.pow(CONSTANTS.R_EARTH + h, 2);
const centrifugalY = (m * vx * vx) / (CONSTANTS.R_EARTH + h);
const rho = h > 100000 ? 0 : CONSTANTS.RHO_0 * Math.exp(-h / CONSTANTS.H);
const currentQ = 0.5 * rho * v * v;
const fd = currentQ * cd * area;
const thrustX = currentThrust * Math.cos(pitchRad);
const thrustY = currentThrust * Math.sin(pitchRad);
const vDirX = v > 0 ? vx / v : 0;
const vDirY = v > 0 ? vy / v : 1;
const dragX = fd * vDirX;
const dragY = fd * vDirY;
const netForceX = thrustX - dragX;
const netForceY = thrustY - dragY - fg + centrifugalY;
let ax = netForceX / m;
let ay = netForceY / m;
if (h <= 0 && vy <= 0 && netForceY <= 0) {
ay = 0; ax = 0; vx = 0; vy = 0; h = 0; pitch = 90;
}
vx += ax * dt;
vy += ay * dt;
x += vx * dt;
h += vy * dt;
if (h < 0) h = 0;
t += dt;
v = Math.sqrt(vx * vx + vy * vy);
if (h > 10 && v > 1 && !currentEvents.find((e) => e.name === "liftoff")) {
currentEvents.push({ name: "liftoff", time: t });
}
if (currentQ > maxQ) maxQ = currentQ;
if (maxQ > 20000 && currentQ < maxQ * 0.95 && !currentEvents.find((e) => e.name === "maxq")) {
currentEvents.push({ name: "maxq", time: t });
}
if (currentStage === stages.length - 1 && fuel <= 0 && t > 10 && !currentEvents.find((e) => e.name === "meco")) {
currentEvents.push({ name: "meco", time: t });
}
if (h >= 100000 && !currentEvents.find((e) => e.name === "karman")) {
currentEvents.push({ name: "karman", time: t });
}
if (vx >= 7800 && !currentEvents.find((e) => e.name === "orbit")) {
currentEvents.push({ name: "orbit", time: t });
}
if (i % 500 === 0) {
console.log(`t: ${t.toFixed(1)}, stage: ${currentStage}, h: ${(h/1000).toFixed(1)}km, v: ${v.toFixed(1)}m/s, vx: ${vx.toFixed(1)}, pitch: ${pitch.toFixed(1)}, fuel: ${fuel.toFixed(0)}, FnetY: ${netForceY.toFixed(0)}`);
}
if (currentEvents.find((e) => e.name === "meco") && t > currentEvents.find((e) => e.name === "meco").time + 3) {
console.log("SIM ENDED");
break;
}
}
console.log(currentEvents);

View File

@ -0,0 +1,466 @@
import { useState, useRef, useEffect } from "react";
import {
Rocket,
Play,
Pause,
RotateCcw,
Activity,
Globe,
Camera,
Volume2,
VolumeX,
Music,
} from "lucide-react";
import { useSimulation } from "./useSimulation";
import { PRESETS } from "./types";
import { DashboardChart } from "./components/DashboardChart";
import { FlightVisualizer } from "./components/FlightVisualizer";
import { TelemetryGauge } from "./components/TelemetryGauge";
import { Navball } from "./components/Navball";
import { i18n, Lang } from "./i18n";
export default function App() {
const [lang, setLang] = useState<Lang>("en");
const [cameraMode, setCameraMode] = useState<"dynamic" | "follow">("dynamic");
const [audioOn, setAudioOn] = useState(false);
const [bgmUrl, setBgmUrl] = useState<string>("");
const { rocket, state, setThrottle, changePreset, toggleSimulation, reset } =
useSimulation(PRESETS[0]);
const t = i18n[lang];
const formatNumber = (num: number, decimals: number = 1) =>
num.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
let totalDryMass = 0;
let totalFuelMass = 0;
let maxPossibleThrust = 0;
rocket.stages.forEach((s) => {
totalDryMass += s.dryMass;
totalFuelMass += s.fuelMass;
if (s.maxThrust > maxPossibleThrust) maxPossibleThrust = s.maxThrust;
});
const totalMass = totalDryMass + state.fuel; // Simplified for TWR calc
const currentThrust =
rocket.stages[state.currentStage].maxThrust * state.throttle;
const tWR = currentThrust / (totalMass * 9.80665);
const drag = state.q * rocket.cd * rocket.area;
let tempC = 15 - 0.0065 * Math.min(state.altitude, 11000);
if (state.altitude > 11000) tempC = -56.5;
const speedOfSound = Math.sqrt(1.4 * 287 * (tempC + 273.15));
const mach = state.velocity / speedOfSound;
const gForce =
state.altitude <= 0 && currentThrust < totalMass * 9.80665
? 1
: Math.max(0, currentThrust - drag) / (totalMass * 9.80665);
const formatTime = (totalSeconds: number) => {
const mins = Math.floor(totalSeconds / 60);
const secs = Math.floor(totalSeconds % 60);
return `T+ ${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
const isFinished = state.events.some((e) => e.name === "simEnded");
return (
<div className="relative w-screen h-screen bg-black overflow-hidden text-slate-100 selection:bg-white/20">
{bgmUrl && <audio autoPlay loop muted={!audioOn} src={bgmUrl} />}
{/* Absolute Background Visualizer */}
<FlightVisualizer rocket={rocket} state={state} cameraMode={cameraMode} />
{/* Top Left: Logo / Mission Info Overlay */}
<div className="absolute top-6 left-8 z-50 pointer-events-none drop-shadow-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/10 backdrop-blur-md rounded-xl border border-white/20">
<Rocket className="w-6 h-6 text-white" />
</div>
<div className="flex flex-col">
<h1 className="text-2xl font-sans font-black tracking-tighter text-white uppercase leading-none">
{t.appTitle}
</h1>
<p className="text-[10px] text-white/70 font-mono tracking-[0.2em] uppercase mt-1">
{t.appSub}
</p>
</div>
</div>
</div>
{/* Top Right: Preset Selector & Lang Toggle */}
<div className="absolute top-6 right-8 z-50 flex gap-4">
<div className="flex bg-black/40 backdrop-blur-md border border-white/10 rounded-lg p-1.5 items-center">
<select
value={rocket.id}
onChange={(e) => {
const p = PRESETS.find((x) => x.id === e.target.value);
if (p) changePreset(p);
}}
className="bg-transparent text-white/90 text-xs font-bold uppercase tracking-widest outline-none px-3 py-1 cursor-pointer appearance-none"
>
{PRESETS.map((p) => (
<option
key={p.id}
value={p.id}
className="bg-slate-900 text-white"
>
{p.name}
</option>
))}
</select>
</div>
<button
onClick={() => {
setAudioOn((v) => !v);
}}
className="p-2 bg-black/30 backdrop-blur-md border border-white/10 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all shadow-lg flex items-center justify-center"
title="Toggle Global Audio (Mutes/Unmutes BGM)"
>
{audioOn ? (
<Volume2 className="w-5 h-5" />
) : (
<VolumeX className="w-5 h-5" />
)}
</button>
<button
onClick={() => setLang((l) => (l === "en" ? "zh" : "en"))}
className="p-2 bg-black/30 backdrop-blur-md border border-white/10 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all shadow-lg flex items-center justify-center"
title="Toggle Language"
>
<Globe className="w-5 h-5" />
</button>
</div>
{/* Control Panel (Floating Glass Panel Left) */}
<div className="absolute left-8 top-1/2 -translate-y-1/2 w-80 bg-black/40 backdrop-blur-xl border border-white/10 rounded-[2rem] p-6 z-50 shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-2 text-white/80">
<Activity className="w-4 h-4" />
<h2 className="text-xs font-bold tracking-widest uppercase">
{t.directives}
</h2>
</div>
<button
onClick={() =>
setCameraMode((m) => (m === "dynamic" ? "follow" : "dynamic"))
}
className="px-2 py-1 bg-white/5 border border-white/10 rounded-md text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center justify-center gap-1.5"
title="Toggle Camera Mode"
>
<Camera className="w-3 h-3" />
<span className="text-[8px] font-bold tracking-widest uppercase">
{cameraMode === "dynamic" ? t.cameraDynamic : t.cameraFollow}
</span>
</button>
</div>
{/* Rocket Info */}
<div className="mb-6 p-4 rounded-xl bg-white/5 border border-white/10">
<h3 className="text-white/60 font-bold text-[10px] uppercase tracking-widest mb-3 border-b border-white/10 pb-2">
{t.vehicleParams}
</h3>
<div className="grid grid-cols-2 gap-y-2 text-[10px] font-mono tracking-widest">
<div className="text-white/40">{t.dryMass}</div>
<div className="text-white/90 text-right">
{formatNumber(totalDryMass / 1000, 1)} t
</div>
<div className="text-white/40">{t.fuelMass}</div>
<div className="text-white/90 text-right">
{formatNumber(totalFuelMass / 1000, 1)} t
</div>
<div className="text-white/40">{t.maxThrust}</div>
<div className="text-white/90 text-right">
{formatNumber(maxPossibleThrust / 1000000, 1)} MN
</div>
<div className="text-white/40">{t.isp}</div>
<div className="text-white/90 text-right">
{rocket.stages[0].isp} s
</div>
<div className="text-white/40">{t.engines}</div>
<div className="text-white/90 text-right">
{rocket.stages[0].engines}
</div>
</div>
</div>
<div className="flex justify-between gap-3 mb-8">
<button
onClick={() => {
toggleSimulation();
}}
disabled={isFinished}
className={`flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl font-bold tracking-widest text-xs transition-all border ${
isFinished
? "bg-red-500/20 text-red-500 border-red-500/30 opacity-70 cursor-not-allowed"
: state.isRunning
? "bg-amber-500/20 text-amber-400 border-amber-500/30 hover:bg-amber-500/30"
: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/30"
}`}
>
{isFinished ? (
<span className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
{t.ended}
</span>
) : state.isRunning ? (
<>
<Pause className="w-4 h-4" />
{t.pause}
</>
) : (
<>
<Play className="w-4 h-4" />
{t.launch}
</>
)}
</button>
<button
onClick={reset}
className="px-4 py-3.5 rounded-xl font-bold text-white/70 bg-white/5 border border-white/10 hover:bg-white/10 hover:text-white transition-all flex items-center justify-center"
title="Reset Simulation"
>
<RotateCcw className="w-4 h-4" />
</button>
<input
type="file"
accept="audio/*"
className="hidden"
id="bgm-upload"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
setBgmUrl(url);
setAudioOn(true); // Automatically turn on global audio so they can hear it
}
}}
/>
<label
htmlFor="bgm-upload"
className="px-4 py-3.5 rounded-xl font-bold text-white/70 bg-white/5 border border-white/10 hover:bg-white/10 hover:text-white transition-all flex items-center justify-center cursor-pointer group relative"
>
<Music className="w-4 h-4" />
{/* Tooltip explaining BGM relation to the global speaker control */}
<div className="absolute top-full mt-2 left-1/2 -translate-x-1/2 bg-black/90 border border-white/20 text-[10px] text-white px-3 py-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
Load BGM (Controlled by global audio button)
</div>
</label>
</div>
<div className="space-y-5">
<div className="flex justify-between items-end">
<label className="text-xs font-bold tracking-widest text-white/70 uppercase">
{t.throttle}
</label>
<div className="text-lg font-mono font-bold text-white tabular-nums drop-shadow-md">
{Math.round(state.throttle * 100)}%
</div>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={state.throttle}
disabled={isFinished}
onChange={(e) => setThrottle(parseFloat(e.target.value))}
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer accent-white"
/>
</div>
<div className="mt-8 grid grid-cols-4 gap-2 text-[11px] text-white/50 font-mono font-bold uppercase tracking-widest p-4 bg-white/5 rounded-xl border border-white/5">
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.twr}>
{t.twr}
</span>
<span
className={
tWR > 1 ? "text-emerald-400 drop-shadow-md" : "text-white/80"
}
>
{formatNumber(tWR, 2)}
</span>
</div>
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.mach}>
{t.mach}
</span>
<span
className={
mach > 1 ? "text-sky-400 drop-shadow-md" : "text-white/80"
}
>
{formatNumber(mach, 1)}
</span>
</div>
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.gforce}>
{t.gforce}
</span>
<span
className={
gForce > 3 ? "text-rose-400 drop-shadow-md" : "text-white/80"
}
>
{formatNumber(gForce, 1)}
</span>
</div>
<div className="flex flex-col text-center">
<span className="mb-1 text-white/30 truncate" title={t.maxT}>
{t.maxT}
</span>
<span className="text-white/80">
{formatNumber(
rocket.stages[state.currentStage].maxThrust / 1000000,
1,
)}
MN
</span>
</div>
</div>
</div>
{/* Dashboards (Floating Glass Panel Right) */}
<div className="absolute right-6 top-[100px] bottom-36 w-[280px] flex flex-col gap-3 z-50 overflow-y-auto custom-scrollbar pr-2 pb-6">
<div className="h-[160px] shrink-0">
<DashboardChart
data={state.history}
dataKey="altitude"
color="#38bdf8"
unit="m"
title={t.altProfile}
/>
</div>
<div className="h-[160px] shrink-0">
<DashboardChart
data={state.history}
dataKey="velocity"
color="#fbbf24"
unit="m/s"
title={t.velProfile}
/>
</div>
<div className="h-[160px] shrink-0">
<DashboardChart
data={state.history}
dataKey="q"
color="#f43f5e"
unit="Pa"
title={t.qProfile}
/>
</div>
</div>
{/* Timeline (Top Center) */}
<div className="absolute top-6 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 bg-black/60 backdrop-blur-xl border border-white/10 rounded-2xl px-5 py-4 shadow-2xl scale-[0.85] origin-top">
{["liftoff", "maxq", "stageSep1", "karman", "orbit"].map(
(evName, idx, arr) => {
const ev = state.events.find((e) => e.name === evName);
const isReached = !!ev;
return (
<div key={evName} className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2 w-16">
{/* Light */}
<div
className={`w-3 h-3 rounded-full ${isReached ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]" : "bg-red-500/50 shadow-[0_0_8px_rgba(239,68,68,0.5)]"}`}
/>
{/* Label */}
<div className="text-[9px] font-bold tracking-widest text-white/70 uppercase text-center leading-tight">
{t[evName as keyof typeof t]}
</div>
{/* Time */}
<div className="text-[10px] font-mono text-white/50">
{isReached ? formatTime(ev.time) : "--:--"}
</div>
</div>
{idx < arr.length - 1 && (
<div className="w-[1px] h-10 bg-white/10 mx-2" />
)}
</div>
);
},
)}
</div>
{/* Bottom Center: Broadcast Telemetry Style HUD */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center justify-center gap-8 bg-black/60 backdrop-blur-xl border border-white/10 rounded-[1.5rem] px-6 py-4 shadow-2xl">
{/* Speed */}
<TelemetryGauge
label={t.speed}
value={state.velocity * 3.6} // m/s to km/h
unit="km/h"
maxValue={40000}
/>
{/* Center Stage Info */}
<div className="flex flex-col items-center justify-center w-40">
<div className="text-2xl leading-none font-mono font-black text-white tracking-widest tabular-nums tabular-nums drop-shadow-xl pb-2">
{formatTime(state.time)}
</div>
<div className="w-full flex flex-col gap-2">
{/* Throttle Bar */}
<div className="w-full">
<div className="flex justify-between text-[8px] text-white/50 font-bold tracking-widest mb-0.5">
<span>{t.hudThrottle}</span>
<span className="text-white drop-shadow-md">
{Math.round(state.throttle * 100)}%
</span>
</div>
<div className="h-0.5 w-full bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-white transition-all duration-75 relative shadow-[0_0_8px_rgba(255,255,255,0.8)]"
style={{ width: `${state.throttle * 100}%` }}
/>
</div>
</div>
{/* Fuel Bar */}
<div className="w-full">
<div className="flex justify-between text-[8px] text-white/50 font-bold tracking-widest mb-0.5">
<span>
{t.hudProp} (S{state.currentStage + 1})
</span>
<span className="text-white drop-shadow-md">
{Math.round(
(state.fuel / rocket.stages[state.currentStage].fuelMass) *
100,
)}
%
</span>
</div>
<div className="h-0.5 w-full bg-white/10 rounded-full overflow-hidden relative">
<div
className="h-full bg-sky-500 transition-all duration-75 absolute top-0 bottom-0 left-0 shadow-[0_0_8px_rgba(14,165,233,0.8)]"
style={{
width: `${(state.fuel / rocket.stages[state.currentStage].fuelMass) * 100}%`,
}}
/>
</div>
</div>
</div>
</div>
{/* Altitude */}
<TelemetryGauge
label={t.altitude}
value={state.altitude / 1000} // m to km
unit="km"
maxValue={1000}
/>
{/* Navball */}
<Navball pitch={state.pitch} />
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { DataPoint } from "../types";
interface ChartProps {
data: DataPoint[];
dataKey: keyof DataPoint;
color: string;
unit: string;
title: string;
}
export function DashboardChart({
data,
dataKey,
color,
unit,
title,
}: ChartProps) {
// Simple formatter for Y Axis
const formatYAxis = (tickItem: number) => {
if (tickItem > 1000) {
return `${(tickItem / 1000).toFixed(1)}k`;
}
return tickItem.toFixed(0);
};
return (
<div className="flex flex-col bg-black/40 backdrop-blur-md border border-white/10 rounded-2xl p-5 w-full h-full shadow-2xl">
<h3 className="text-white/70 text-[11px] font-bold tracking-widest uppercase mb-4 drop-shadow-sm">
{title}
</h3>
<div className="flex-grow min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 5, left: -20, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(255,255,255,0.1)"
vertical={false}
/>
<XAxis
dataKey="time"
type="number"
domain={["dataMin", "dataMax"]}
tickFormatter={(t) => `${t.toFixed(0)}s`}
stroke="rgba(255,255,255,0.4)"
fontSize={10}
tickMargin={8}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatYAxis}
stroke="rgba(255,255,255,0.4)"
fontSize={10}
width={45}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgba(0,0,0,0.8)",
borderColor: "rgba(255,255,255,0.15)",
borderRadius: "12px",
backdropFilter: "blur(8px)",
}}
itemStyle={{ color: color, fontSize: "12px", fontWeight: "bold" }}
labelStyle={{
color: "rgba(255,255,255,0.6)",
marginBottom: "4px",
fontSize: "10px",
textTransform: "uppercase",
}}
formatter={(value: number) => [
`${value.toFixed(1)} ${unit}`,
title,
]}
labelFormatter={(label: number) => `T+ ${label.toFixed(1)}s`}
/>
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -0,0 +1,428 @@
import { ReactNode } from "react";
import { RocketPreset, SimulationState } from "../types";
interface FlightVisualizerProps {
rocket: RocketPreset;
state: SimulationState;
cameraMode: "dynamic" | "follow";
}
export function FlightVisualizer({
rocket,
state,
cameraMode,
}: FlightVisualizerProps) {
// Atmosphere gradient based on altitude: fades to black at 80km
const getAtmosphereOpacity = () => {
return Math.max(0, 1 - state.altitude / 80000);
};
// Map physical coords to SVG viewbox
const aspect =
typeof window !== "undefined" ? window.innerWidth / window.innerHeight : 2;
const minVHeight = cameraMode === "dynamic" ? 2500 : 500;
const viewHeight =
cameraMode === "dynamic"
? Math.max(minVHeight, state.altitude * 0.8 + 500)
: minVHeight;
const viewWidth = viewHeight * aspect;
const physX = state.downrange || 0;
const physY = state.altitude || 0;
// Center camera on rocket
const minY =
cameraMode === "dynamic"
? -physY - viewHeight * 0.35
: -physY - viewHeight * 0.6;
const minX = physX - viewWidth * 0.5;
const viewBox = `${minX} ${minY} ${viewWidth} ${viewHeight}`;
const trailPoints = (state.history || [])
.map((p) => `${p.downrange},${-p.altitude}`)
.join(" ");
// visualScale controls the visual size of the rocket.
const visualScale =
cameraMode === "dynamic"
? Math.max(1, Math.pow(viewHeight / minVHeight, 0.95)) * 1.5
: 3.5;
const getStageOffset = (stageIndex: number) => {
if (state.currentStage <= stageIndex) return 0;
const sepEventName = `stageSep${stageIndex + 1}`;
const sepEvent = state.events.find((e) => e.name === sepEventName);
if (!sepEvent) return 0;
const dt = state.time - sepEvent.time;
if (dt < 0) return 0;
// Quadratic falling back effect
return (dt * 15 + 0.5 * 10 * Math.pow(Math.min(dt, 20), 2)) * visualScale;
};
// Custom render for each rocket type
const renderRocketSvg = (id: string, scale: number) => {
let activeEngineY = 0;
let nodes: ReactNode = null;
if (id === "saturnv") {
const w = 10 * scale;
const l = 111 * scale;
const s3Bottom = l * 0.3;
const s2Bottom = l * 0.7;
const s1Bottom = l;
if (state.currentStage === 0) activeEngineY = s1Bottom;
else if (state.currentStage === 1) activeEngineY = s2Bottom;
else activeEngineY = s3Bottom;
const offset1 = getStageOffset(0);
const offset2 = getStageOffset(1);
nodes = (
<>
{/* Stage 3 + Apollo */}
<g>
<path
d={`M ${-w / 2} ${s3Bottom} L ${-w / 2} ${l * 0.15} L ${-w / 4} ${l * 0.1} L 0 0 L ${w / 4} ${l * 0.1} L ${w / 2} ${l * 0.15} L ${w / 2} ${s3Bottom} Z`}
fill="#ffffff"
/>
{/* Decal */}
<rect
x={-w * 0.3}
y={l * 0.2}
width={w * 0.6}
height={l * 0.05}
fill="#111"
/>
</g>
{/* Stage 2 */}
<g
transform={`translate(0, ${offset2})`}
style={{ opacity: Math.max(0, 1 - offset2 / (500 * scale)) }}
>
<rect
x={-w / 2}
y={s3Bottom}
width={w}
height={s2Bottom - s3Bottom}
fill="#ffffff"
/>
<rect
x={-w / 2}
y={l * 0.6}
width={w}
height={l * 0.05}
fill="#111"
/>
</g>
{/* Stage 1 */}
<g
transform={`translate(0, ${offset1})`}
style={{ opacity: Math.max(0, 1 - offset1 / (500 * scale)) }}
>
<path
d={`M ${-w / 2} ${s2Bottom} L ${w / 2} ${s2Bottom} L ${w / 2} ${s1Bottom} L ${-w / 2} ${s1Bottom} Z`}
fill="#ffffff"
/>
<rect
x={-w / 2}
y={l * 0.8}
width={w}
height={l * 0.05}
fill="#111"
/>
</g>
</>
);
} else if (id === "starship") {
const w = 9 * scale;
const l = 120 * scale;
const s2Bottom = l * 0.4;
const s1Bottom = l;
if (state.currentStage === 0) activeEngineY = s1Bottom;
else activeEngineY = s2Bottom;
const offset1 = getStageOffset(0);
nodes = (
<>
{/* Stage 2 (Starship) */}
<g>
<path
d={`M ${-w / 2} ${s2Bottom} L ${-w / 2} ${l * 0.15} Q 0 0 ${w / 2} ${l * 0.15} L ${w / 2} ${s2Bottom} Z`}
fill="#94a3b8"
/>
<path
d={`M ${-w / 2} ${s2Bottom * 0.8} L ${-w * 0.8} ${s2Bottom * 0.9} L ${-w / 2} ${s2Bottom * 0.95} Z`}
fill="#64748b"
/>
<path
d={`M ${w / 2} ${s2Bottom * 0.8} L ${w * 0.8} ${s2Bottom * 0.9} L ${w / 2} ${s2Bottom * 0.95} Z`}
fill="#64748b"
/>
<path
d={`M ${-w / 4} ${l * 0.15} L ${-w * 0.6} ${l * 0.2} L ${-w / 3} ${l * 0.25} Z`}
fill="#64748b"
/>
<path
d={`M ${w / 4} ${l * 0.15} L ${w * 0.6} ${l * 0.2} L ${w / 3} ${l * 0.25} Z`}
fill="#64748b"
/>
</g>
{/* Stage 1 (Super Heavy) */}
<g
transform={`translate(0, ${offset1})`}
style={{ opacity: Math.max(0, 1 - offset1 / (500 * scale)) }}
>
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={s1Bottom - s2Bottom}
fill="#94a3b8"
/>
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={l * 0.02}
fill="#111"
/>
</g>
</>
);
} else {
// Falcon 9 default
const w = 3.7 * scale;
const l = 70 * scale;
const s2Bottom = l * 0.35;
const s1Bottom = l;
if (state.currentStage === 0) activeEngineY = s1Bottom;
else activeEngineY = s2Bottom;
const offset1 = getStageOffset(0);
nodes = (
<>
{/* Stage 2 + Fairing */}
<g>
<path
d={`M ${-w / 2} ${s2Bottom} L ${-w / 2} ${l * 0.15} Q 0 0 ${w / 2} ${l * 0.15} L ${w / 2} ${s2Bottom} Z`}
fill="#ffffff"
/>
</g>
{/* Stage 1 */}
<g
transform={`translate(0, ${offset1})`}
style={{ opacity: Math.max(0, 1 - offset1 / (500 * scale)) }}
>
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={s1Bottom - s2Bottom}
fill="#ffffff"
/>
{/* Interstage (top of Stage 1) */}
<rect
x={-w / 2}
y={s2Bottom}
width={w}
height={l * 0.1}
fill="#111111"
/>
{/* Landing Legs */}
<path
d={`M ${-w / 2} ${l} L ${-w} ${l * 1.05} L ${-w / 2} ${l * 0.95} Z`}
fill="#222"
/>
<path
d={`M ${w / 2} ${l} L ${w} ${l * 1.05} L ${w / 2} ${l * 0.95} Z`}
fill="#222"
/>
</g>
</>
);
}
return { activeEngineY, nodes };
};
const getFlameScale = () => {
if (rocket.id === "saturnv") return 11 * visualScale;
if (rocket.id === "starship") return 10 * visualScale;
return 4 * visualScale; // falcon 9
};
const flameWidth = getFlameScale();
const { activeEngineY, nodes } = renderRocketSvg(rocket.id, visualScale);
return (
<div className="absolute inset-0 w-full h-full bg-[#030712] overflow-hidden z-0">
{/* Atmosphere background */}
<div
className="absolute inset-0 transition-colors duration-500 ease-out"
style={{
backgroundColor: `rgba(2, 132, 199, ${getAtmosphereOpacity()})`, // sky-600 fading to black
}}
/>
{/* Stars - Deep layer */}
<div
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(1px 1px at 15% 25%, white, transparent), radial-gradient(1px 1px at 75% 15%, rgba(255,255,255,0.8), transparent), radial-gradient(1px 1px at 45% 85%, rgba(255,255,255,0.6), transparent)",
backgroundSize: "150px 150px",
opacity: 1 - getAtmosphereOpacity(),
transform: `translateY(${(physY * 0.02) % 150}px) translateX(${-(physX * 0.02) % 150}px)`,
}}
/>
{/* Stars - Mid layer */}
<div
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(2px 2px at 30% 60%, rgba(255,255,255,0.9), transparent), radial-gradient(1.5px 1.5px at 80% 40%, rgba(255,255,255,0.7), transparent)",
backgroundSize: "200px 200px",
opacity: 1 - getAtmosphereOpacity(),
transform: `translateY(${(physY * 0.04) % 200}px) translateX(${-(physX * 0.04) % 200}px)`,
}}
/>
{/* Stars - Close layer */}
<div
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(2.5px 2.5px at 10% 80%, white, transparent), radial-gradient(3px 3px at 90% 10%, rgba(255,255,255,0.9), transparent)",
backgroundSize: "350px 350px",
opacity: (1 - getAtmosphereOpacity()) * 0.8,
transform: `translateY(${(physY * 0.08) % 350}px) translateX(${-(physX * 0.08) % 350}px)`,
}}
/>
{/* Screen Effects (Cinematic Vignette) */}
<div className="absolute inset-0 pointer-events-none z-30 bg-[radial-gradient(circle_at_center,transparent_40%,rgba(0,0,0,0.4)_100%)]" />
<svg
className="absolute inset-0 w-full h-full z-10"
viewBox={viewBox}
preserveAspectRatio="xMidYMid slice"
>
{/* Ground */}
<rect
x={minX - viewWidth}
y={0}
width={viewWidth * 3}
height={Math.max(viewHeight, 1000)}
fill="#020617"
/>
<line
x1={minX - viewWidth}
y1={0}
x2={minX + viewWidth * 2}
y2={0}
stroke="#334155"
strokeWidth={2 * visualScale}
/>
{/* Launch Pad Base */}
<rect x={-20} y={-10} width={40} height={10} fill="#475569" />
<rect x={30} y={-100} width={8} height={100} fill="#334155" />
{/* Trajectory Trail */}
{trailPoints.length > 0 && (
<polyline
points={trailPoints}
fill="none"
stroke="rgba(255, 255, 255, 0.3)"
strokeWidth={4 * visualScale}
strokeDasharray={`${12 * visualScale} ${12 * visualScale}`}
/>
)}
{/* The Rocket Container */}
<g
transform={`translate(${physX}, ${-physY}) rotate(${90 - (state.pitch || 90)})`}
>
{/* Engine Flame */}
{state.throttle > 0 && state.fuel > 0 && (
<g transform={`translate(0, 0)`}>
{/* Outer Glow */}
<ellipse
cx={0}
cy={flameWidth * 4 * state.throttle}
rx={flameWidth * 1.5}
ry={flameWidth * 4.5 * state.throttle}
fill="url(#flameOuter)"
style={{ transformOrigin: "top" }}
className="animate-pulse"
/>
{/* Inner Core */}
<ellipse
cx={0}
cy={flameWidth * 3 * state.throttle}
rx={flameWidth * 0.8}
ry={flameWidth * 3 * state.throttle}
fill="url(#flameInner)"
style={{ transformOrigin: "top" }}
/>
{/* Mach Diamonds (visible at higher speeds/altitudes) */}
{state.throttle > 0.5 &&
state.altitude > 8000 &&
state.altitude < 60000 && (
<g fill="rgba(255,255,255,0.7)">
<ellipse
cx={0}
cy={flameWidth * 1.5}
rx={flameWidth * 0.4}
ry={flameWidth * 0.2}
/>
<ellipse
cx={0}
cy={flameWidth * 3.0}
rx={flameWidth * 0.3}
ry={flameWidth * 0.15}
/>
<ellipse
cx={0}
cy={flameWidth * 4.5}
rx={flameWidth * 0.2}
ry={flameWidth * 0.1}
/>
</g>
)}
</g>
)}
{/* Render Detailed Rocket Specific to Model with Active Engine aligned to (0,0) */}
<g transform={`translate(0, ${-activeEngineY})`}>{nodes}</g>
</g>
<defs>
<radialGradient id="flameOuter" cx="50%" cy="0%" r="100%">
<stop offset="0%" stopColor="rgba(255, 255, 255, 1)" />
<stop offset="20%" stopColor="rgba(253, 224, 71, 0.9)" />
<stop offset="60%" stopColor="rgba(249, 115, 22, 0.4)" />
<stop offset="100%" stopColor="transparent" />
</radialGradient>
<radialGradient id="flameInner" cx="50%" cy="0%" r="100%">
<stop offset="0%" stopColor="white" />
<stop offset="50%" stopColor="#fef08a" />
<stop offset="100%" stopColor="transparent" />
</radialGradient>
</defs>
</svg>
</div>
);
}

View File

@ -0,0 +1,46 @@
import React from "react";
interface NavballProps {
pitch: number; // 90 is up, 0 is horizontal, -90 is down
}
export function Navball({ pitch }: NavballProps) {
// For SpaceX broadcast style, we show a rocket silhouette that rotates based on pitch.
// pitch 90 = pointing straight up = 0 deg rotation
// pitch 0 = pointing horizontal = 90 deg rotation
const rotation = 90 - pitch;
return (
<div className="flex flex-col items-center justify-center w-24">
<div className="relative w-16 h-16 rounded-full border border-white/20 flex items-center justify-center bg-black/40 shadow-inner overflow-hidden">
{/* Horizontal reference line */}
<div className="absolute w-full h-[1px] bg-white/20" />
{/* Vertical reference line */}
<div className="absolute h-full w-[1px] bg-white/20" />
{/* Rocket outline rotating */}
<div
className="absolute transition-transform duration-100 ease-linear flex items-center justify-center text-white"
style={{ transform: `rotate(${rotation}deg)` }}
>
{/* Simple rocket silhouette */}
<svg width="24" height="40" viewBox="0 0 24 40" fill="currentColor">
{/* Rocket body */}
<path
d="M12 2 C12 2, 8 10, 8 25 L8 35 L16 35 L16 25 C16 10, 12 2, 12 2z"
fill="#f8fafc"
/>
{/* Fins */}
<path d="M8 28 L4 35 L8 35z" fill="#f8fafc" />
<path d="M16 28 L20 35 L16 35z" fill="#f8fafc" />
{/* Engine bell */}
<path d="M10 35 L14 35 L15 38 L9 38z" fill="#9ca3af" />
</svg>
</div>
</div>
<div className="text-white/80 text-[10px] font-bold font-mono tracking-widest mt-3">
PITCH {Math.round(pitch)}°
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import React from "react";
interface TelemetryGaugeProps {
label: string;
value: number;
unit: string;
maxValue: number;
}
export function TelemetryGauge({
label,
value,
unit,
maxValue,
}: TelemetryGaugeProps) {
const radius = 32;
const stroke = 3;
const normalizedRadius = radius - stroke * 2;
const circumference = normalizedRadius * 2 * Math.PI;
const fillRatio = Math.min(Math.max(value / maxValue, 0), 1);
const strokeDashoffset = circumference - fillRatio * circumference;
return (
<div className="flex flex-col items-center justify-center relative w-20">
<div className="relative w-16 h-16">
<svg
height="100%"
width="100%"
className="absolute inset-0 transform -rotate-90"
>
<circle
stroke="rgba(255, 255, 255, 0.15)"
fill="transparent"
strokeWidth={stroke}
r={normalizedRadius}
cx="50%"
cy="50%"
/>
<circle
stroke="white"
fill="transparent"
strokeWidth={stroke}
strokeDasharray={circumference + " " + circumference}
style={{
strokeDashoffset,
transition: "stroke-dashoffset 0.1s linear",
}}
strokeLinecap="round"
r={normalizedRadius}
cx="50%"
cy="50%"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[1.1rem] font-mono font-bold text-white tracking-tighter drop-shadow-md tabular-nums leading-none">
{Math.floor(value).toLocaleString()}
</span>
<span className="text-[8px] uppercase tracking-widest text-white/50 font-semibold mt-0.5">
{unit}
</span>
</div>
</div>
<div className="text-white/80 text-[9px] font-bold font-sans tracking-[0.2em] whitespace-nowrap mt-2">
{label}
</div>
</div>
);
}

View File

@ -0,0 +1,78 @@
export const i18n = {
en: {
appTitle: "Orbital Ascent",
appSub: "Orbital Launch Telemetry",
directives: "Flight Directives",
launch: "LAUNCH",
pause: "PAUSE",
throttle: "Engine Throttle",
twr: "TWR",
mach: "MACH",
gforce: "G-FORCE",
maxT: "MAX T",
altProfile: "Altitude Profile",
velProfile: "Velocity Profile",
qProfile: "Dynamic Pressure (Q)",
speed: "SPEED",
altitude: "ALTITUDE",
hudThrottle: "THROTTLE",
hudProp: "PROPELLANT",
liftoff: "Liftoff",
maxq: "Max-Q",
meco: "MECO",
karman: "Karman Line",
stageSep1: "Stage 1 Sep",
stageSep2: "Stage 2 Sep",
stageSep3: "Stage 3 Sep",
orbit: "Orbital Insertion",
simEnded: "Simulation Ended",
vehicleParams: "Vehicle Parameters",
dryMass: "Dry Mass",
fuelMass: "Propellant",
maxThrust: "Max Thrust",
isp: "Vacuum ISP",
engines: "Engines",
cameraDynamic: "Dynamic Cam",
cameraFollow: "Follow Cam",
ended: "ENDED",
},
zh: {
appTitle: "火箭发射模拟器",
appSub: "轨道发射实时遥测",
directives: "飞行控制",
launch: "发射",
pause: "暂停",
throttle: "主引擎推力调节",
twr: "推重比 (TWR)",
mach: "马赫数 (MACH)",
gforce: "G力 (G-FORCE)",
maxT: "最大推力",
altProfile: "高度-时间曲线",
velProfile: "速度-时间曲线",
qProfile: "空气动压 (Q)",
speed: "速度",
altitude: "高度",
hudThrottle: "当前推力",
hudProp: "推进剂",
liftoff: "起飞",
maxq: "最大动压",
meco: "引擎熄火",
karman: "卡门线",
stageSep1: "一级分离",
stageSep2: "二级分离",
stageSep3: "三级分离",
orbit: "入轨",
simEnded: "结束",
vehicleParams: "载具参数",
dryMass: "干质量",
fuelMass: "燃料质量",
maxThrust: "最大推力",
isp: "真空比冲",
engines: "发动机数量",
cameraDynamic: "动态视角",
cameraFollow: "跟随视角",
ended: "已结束",
},
};
export type Lang = "en" | "zh";

View File

@ -0,0 +1,20 @@
@import "tailwindcss";
@layer utilities {
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #475569;
}
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,140 @@
export interface RocketStage {
name: string;
dryMass: number; // kg
fuelMass: number; // kg
maxThrust: number; // N
isp: number; // seconds
engines: number;
}
export interface RocketPreset {
id: string;
name: string;
stages: RocketStage[];
area: number; // m^2
cd: number; // drag coefficient
color: string;
}
export interface SimulationState {
time: number; // s
altitude: number; // m
downrange: number; // m
velocity: number; // m/s
vx: number;
vy: number;
pitch: number; // degrees, 90 is straight up
acceleration: number; // m/s^2
q: number; // dynamic pressure (Pa)
currentStage: number; // index of active stage
fuel: number; // kg of current active stage
throttle: number; // 0 to 1
isRunning: boolean;
history: DataPoint[]; // For charts
events: { name: string; time: number }[];
}
export interface DataPoint {
time: number;
altitude: number; // m
downrange: number; // m
velocity: number; // m/s
acceleration: number; // m/s^2
thrust: number; // N
q: number; // Pa
}
export const CONSTANTS = {
G: 6.6743e-11,
M_EARTH: 5.972e24,
R_EARTH: 6371000,
RHO_0: 1.225, // Sea level density kg/m^3
H: 8500, // Scale height m
g0: 9.80665, // Standard gravity
};
export const PRESETS: RocketPreset[] = [
{
id: "falcon9",
name: "Falcon 9",
stages: [
{
name: "Stage 1",
dryMass: 25600,
fuelMass: 411000,
maxThrust: 7607000,
isp: 282,
engines: 9,
},
{
name: "Stage 2",
dryMass: 4000,
fuelMass: 107500,
maxThrust: 981000,
isp: 348,
engines: 1,
},
],
area: 10.75,
cd: 0.4,
color: "#ffffff", // falcon 9 white
},
{
id: "saturnv",
name: "Saturn V",
stages: [
{
name: "S-IC (Stage 1)",
dryMass: 131000,
fuelMass: 2149000,
maxThrust: 35100000,
isp: 263,
engines: 5,
},
{
name: "S-II (Stage 2)",
dryMass: 36000,
fuelMass: 444000,
maxThrust: 5100000,
isp: 421,
engines: 5,
},
{
name: "S-IVB (Stage 3)",
dryMass: 10000,
fuelMass: 109000,
maxThrust: 1000000,
isp: 421,
engines: 1,
},
],
area: 80,
cd: 0.5,
color: "#f59e0b", // amber-500
},
{
id: "starship",
name: "Starship",
stages: [
{
name: "Super Heavy",
dryMass: 200000,
fuelMass: 3400000,
maxThrust: 74000000,
isp: 330,
engines: 33,
},
{
name: "Starship",
dryMass: 100000,
fuelMass: 1200000,
maxThrust: 15000000,
isp: 380,
engines: 6,
},
],
area: 63.6,
cd: 0.4,
color: "#a8a29e", // stone-400
},
];

View File

@ -0,0 +1,332 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { RocketPreset, SimulationState, DataPoint, CONSTANTS } from "./types";
export function useSimulation(initialPreset: RocketPreset) {
const [rocket, setRocket] = useState<RocketPreset>(initialPreset);
const [state, setState] = useState<SimulationState>({
time: 0,
altitude: 0,
downrange: 0,
velocity: 0,
vx: 0,
vy: 0,
pitch: 90,
acceleration: 0,
q: 0,
fuel: initialPreset.stages[0].fuelMass,
currentStage: 0,
throttle: 0,
isRunning: false,
history: [],
events: [],
});
const stateRef = useRef(state);
const rocketRef = useRef(rocket);
const lastTimeRef = useRef<number>(0);
const frameRef = useRef<number>(0);
const maxQRef = useRef<number>(0);
const setThrottle = useCallback((val: number) => {
setState((prev) => {
const newState = { ...prev, throttle: Math.max(0, Math.min(1, val)) };
stateRef.current = newState;
return newState;
});
}, []);
const changePreset = useCallback((preset: RocketPreset) => {
maxQRef.current = 0;
setRocket(preset);
rocketRef.current = preset;
const newState = {
time: 0,
altitude: 0,
downrange: 0,
velocity: 0,
vx: 0,
vy: 0,
pitch: 90,
acceleration: 0,
q: 0,
currentStage: 0,
fuel: preset.stages[0].fuelMass,
throttle: 0,
isRunning: false,
history: [],
events: [],
};
stateRef.current = newState;
setState(newState);
}, []);
const reset = useCallback(() => {
maxQRef.current = 0;
const newState = {
time: 0,
altitude: 0,
downrange: 0,
velocity: 0,
vx: 0,
vy: 0,
pitch: 90,
acceleration: 0,
q: 0,
currentStage: 0,
fuel: rocketRef.current.stages[0].fuelMass,
throttle: 0,
isRunning: false,
history: [],
events: [],
};
stateRef.current = newState;
setState(newState);
}, []);
const toggleSimulation = useCallback(() => {
setState((prev) => {
const isFinished = prev.events.some((e) => e.name === "simEnded");
if (isFinished) return prev;
const isRunning = !prev.isRunning;
if (isRunning) {
lastTimeRef.current = performance.now();
}
const newState = {
...prev,
isRunning,
throttle: isRunning && prev.throttle === 0 ? 1 : prev.throttle,
};
stateRef.current = newState;
return newState;
});
}, []);
useEffect(() => {
const loop = (time: number) => {
if (!stateRef.current.isRunning) {
frameRef.current = requestAnimationFrame(loop);
return;
}
const { G, M_EARTH, R_EARTH, RHO_0, H, g0 } = CONSTANTS;
const dtSystem = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
// Physics Sub-stepping to prevent Euler integration explosion
const PHYSICS_STEPS = 5;
const dt = Math.min(dtSystem, 0.1) / PHYSICS_STEPS;
let {
time: t,
altitude: h,
downrange: x,
vx,
vy,
pitch,
currentStage,
fuel,
throttle,
history,
} = stateRef.current;
const { area, cd, stages, payloadMass } = rocketRef.current;
const stage = stages[currentStage];
const { dryMass, maxThrust, isp } = stage;
let upperStagesMass = 0;
for (let i = currentStage + 1; i < stages.length; i++) {
upperStagesMass += stages[i].dryMass + stages[i].fuelMass;
}
const mDotMax = maxThrust / (isp * g0);
let actualThrottle = throttle;
let currentEvents = [...stateRef.current.events];
let currentQ = stateRef.current.q;
let a = stateRef.current.acceleration;
let finalThrust = 0;
for (let step = 0; step < PHYSICS_STEPS; step++) {
const m = dryMass + fuel + upperStagesMass + (payloadMass || 0);
if (fuel <= 0 && currentStage < stages.length - 1) {
currentStage++;
fuel = stages[currentStage].fuelMass;
if (!currentEvents.find((e) => e.name === `stageSep${currentStage}`)) {
currentEvents.push({ name: `stageSep${currentStage}`, time: t });
}
} else if (fuel <= 0) {
actualThrottle = 0;
fuel = 0;
}
const currentThrust = maxThrust * actualThrottle;
finalThrust = currentThrust;
const mDot = mDotMax * actualThrottle;
if (actualThrottle > 0) {
fuel -= mDot * dt;
if (fuel < 0) fuel = 0;
}
let v = Math.sqrt(vx * vx + vy * vy);
// Improved pitch program to prevent deep atmospheric dips and orbit bounce
const pitching = h > 500 && v > 50;
if (pitching) {
// Smooth horizontal transition aiming for 0 pitch at ~120km
pitch = Math.max(0, 90 * (1 - Math.min(1, (h - 500) / 120000)));
}
if (h <= 500) {
pitch = 90;
}
const pitchRad = (pitch * Math.PI) / 180;
const fg = (G * (M_EARTH * m)) / Math.pow(R_EARTH + h, 2);
const centrifugalY = (m * vx * vx) / (R_EARTH + h);
const rho = h > 100000 ? 0 : RHO_0 * Math.exp(-h / H);
// Dynamic pressure (q) in Pa
currentQ = 0.5 * rho * v * v;
let fd = currentQ * cd * area;
// Prevent drag from reversing velocity in a single step (numerical stability)
const maxDragForce = (m * v) / dt;
if (fd > maxDragForce * 0.9) fd = maxDragForce * 0.9;
const thrustX = currentThrust * Math.cos(pitchRad);
const thrustY = currentThrust * Math.sin(pitchRad);
const vDirX = v > 0 ? vx / v : 0;
const vDirY = v > 0 ? vy / v : 1;
const dragX = fd * vDirX;
const dragY = fd * vDirY;
const netForceX = thrustX - dragX;
const netForceY = thrustY - dragY - fg + centrifugalY;
let ax = netForceX / m;
let ay = netForceY / m;
// Ground constraint
if (h <= 0 && vy <= 0 && netForceY <= 0) {
ay = 0;
ax = 0;
vx = 0;
vy = 0;
h = 0;
pitch = 90;
}
vx += ax * dt;
vy += ay * dt;
x += vx * dt;
h += vy * dt;
if (h < 0) h = 0;
t += dt;
a = Math.sqrt(ax * ax + ay * ay);
}
let v = Math.sqrt(vx * vx + vy * vy);
// Event Tracking
if (h > 10 && v > 1 && !currentEvents.find((e) => e.name === "liftoff")) {
currentEvents.push({ name: "liftoff", time: t });
}
if (currentQ > maxQRef.current) {
maxQRef.current = currentQ;
}
// Trigger Max-Q if it drops to 95% of peak after reaching at least 20kPa
if (
maxQRef.current > 20000 &&
currentQ < maxQRef.current * 0.95 &&
!currentEvents.find((e) => e.name === "maxq")
) {
currentEvents.push({ name: "maxq", time: t });
}
if (
currentStage === stages.length - 1 &&
fuel <= 0 &&
t > 10 &&
!currentEvents.find((e) => e.name === "meco")
) {
currentEvents.push({ name: "meco", time: t });
}
if (h >= 100000 && !currentEvents.find((e) => e.name === "karman")) {
currentEvents.push({ name: "karman", time: t });
}
if (vx >= 7800 && !currentEvents.find((e) => e.name === "orbit")) {
currentEvents.push({ name: "orbit", time: t });
}
let shouldEnd = false;
const mecoEvent = currentEvents.find((e) => e.name === "meco");
if (
mecoEvent &&
t > mecoEvent.time + 3 &&
!currentEvents.find((e) => e.name === "simEnded")
) {
currentEvents.push({ name: "simEnded", time: t });
shouldEnd = true;
}
const lastHistoryTime =
history.length > 0 ? history[history.length - 1].time : -1;
let newHistory = history;
if (t - lastHistoryTime >= 0.5) {
newHistory = [
...history,
{
time: t,
altitude: h,
downrange: x,
velocity: v,
acceleration: a,
thrust: finalThrust,
q: currentQ,
},
];
}
const newState = {
...stateRef.current,
time: t,
altitude: h,
downrange: x,
velocity: v,
vx,
vy,
pitch,
acceleration: a,
q: currentQ,
currentStage,
fuel,
throttle:
fuel <= 0 && currentStage === stages.length - 1 ? 0 : throttle,
isRunning: shouldEnd ? false : stateRef.current.isRunning,
history: newHistory,
events: currentEvents,
};
stateRef.current = newState;
setState(newState);
frameRef.current = requestAnimationFrame(loop);
};
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, []);
return {
rocket,
state,
setThrottle,
changePreset,
toggleSimulation,
reset,
};
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});

View File

@ -34,6 +34,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.0",
"baseline-browser-mapping": "^2.10.43",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

View File

@ -14,6 +14,7 @@ import { TimelineController } from './components/TimelineController';
import { Loading } from './components/Loading';
import { ControlPanel } from './components/ControlPanel';
import { AuthModal } from './components/AuthModal';
import { BodyDetailOverlay } from './components/BodyDetailOverlay';
import { auth } from './utils/auth';
import type { CelestialBody } from './types';
import { useToast } from './contexts/ToastContext';
@ -21,7 +22,6 @@ import { useToast } from './contexts/ToastContext';
// Lazy load non-critical components for better performance
const InterstellarTicker = lazy(() => import('./components/InterstellarTicker').then(m => ({ default: m.InterstellarTicker })));
const MessageBoard = lazy(() => import('./components/MessageBoard').then(m => ({ default: m.MessageBoard })));
const BodyDetailOverlay = lazy(() => import('./components/BodyDetailOverlay').then(m => ({ default: m.BodyDetailOverlay })));
// Timeline configuration - will be fetched from backend later
const TIMELINE_DAYS = 30; // Total days in timeline range
@ -223,6 +223,7 @@ function App() {
setViewMode(prev => prev === 'solar' ? 'galaxy' : 'solar');
setSelectedBody(null); // Reset selection when switching
}}
onOpenRocketSimulator={() => navigate('/rocket-simulator')}
/>
{/* Auth Modal */}

View File

@ -1,25 +1,37 @@
/**
* Router configuration
*/
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Login } from './pages/Login';
import { AdminLayout } from './pages/admin/AdminLayout';
import { Dashboard } from './pages/admin/Dashboard';
import { CelestialBodies } from './pages/admin/CelestialBodies';
import { CelestialEvents } from './pages/admin/CelestialEvents';
import { StarSystems } from './pages/admin/StarSystems';
import { StaticData } from './pages/admin/StaticData';
import { Users } from './pages/admin/Users';
import { NASADownload } from './pages/admin/NASADownload';
import { SystemSettings } from './pages/admin/SystemSettings';
import { Tasks } from './pages/admin/Tasks';
import { ScheduledJobs } from './pages/admin/ScheduledJobs';
import { UserProfile } from './pages/admin/UserProfile';
import { ChangePassword } from './pages/admin/ChangePassword';
import { MyCelestialBodies } from './pages/admin/MyCelestialBodies';
import { auth } from './utils/auth';
import { ToastProvider } from './contexts/ToastContext';
import App from './App';
const App = lazy(() => import('./App'));
const Login = lazy(() => import('./pages/Login').then((module) => ({ default: module.Login })));
const RocketSimulator = lazy(() => import('./pages/RocketSimulator').then((module) => ({ default: module.RocketSimulator })));
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout').then((module) => ({ default: module.AdminLayout })));
const Dashboard = lazy(() => import('./pages/admin/Dashboard').then((module) => ({ default: module.Dashboard })));
const CelestialBodies = lazy(() => import('./pages/admin/CelestialBodies').then((module) => ({ default: module.CelestialBodies })));
const CelestialEvents = lazy(() => import('./pages/admin/CelestialEvents').then((module) => ({ default: module.CelestialEvents })));
const StarSystems = lazy(() => import('./pages/admin/StarSystems').then((module) => ({ default: module.StarSystems })));
const StaticData = lazy(() => import('./pages/admin/StaticData').then((module) => ({ default: module.StaticData })));
const Users = lazy(() => import('./pages/admin/Users').then((module) => ({ default: module.Users })));
const NASADownload = lazy(() => import('./pages/admin/NASADownload').then((module) => ({ default: module.NASADownload })));
const SystemSettings = lazy(() => import('./pages/admin/SystemSettings').then((module) => ({ default: module.SystemSettings })));
const Tasks = lazy(() => import('./pages/admin/Tasks').then((module) => ({ default: module.Tasks })));
const ScheduledJobs = lazy(() => import('./pages/admin/ScheduledJobs').then((module) => ({ default: module.ScheduledJobs })));
const UserProfile = lazy(() => import('./pages/admin/UserProfile').then((module) => ({ default: module.UserProfile })));
const ChangePassword = lazy(() => import('./pages/admin/ChangePassword').then((module) => ({ default: module.ChangePassword })));
const MyCelestialBodies = lazy(() => import('./pages/admin/MyCelestialBodies').then((module) => ({ default: module.MyCelestialBodies })));
const Rockets = lazy(() => import('./pages/admin/Rockets').then((module) => ({ default: module.Rockets })));
function RouteFallback() {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black" role="status" aria-label="页面加载中">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-green-500" />
</div>
);
}
// Protected Route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@ -33,12 +45,14 @@ export function Router() {
return (
<ToastProvider>
<BrowserRouter>
<Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />
{/* Main app (3D visualization) */}
<Route path="/" element={<App />} />
<Route path="/rocket-simulator" element={<RocketSimulator />} />
{/* User routes (protected) */}
<Route
@ -73,11 +87,13 @@ export function Router() {
<Route path="tasks" element={<Tasks />} />
<Route path="scheduled-jobs" element={<ScheduledJobs />} />
<Route path="settings" element={<SystemSettings />} />
<Route path="rockets" element={<Rockets />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Routes>
</Suspense>
</BrowserRouter>
</ToastProvider>
);

View File

@ -15,13 +15,6 @@ interface BeltProps {
rotationSpeed: number;
}
interface BeltData {
category: string;
name: string;
name_zh: string;
data: BeltProps;
}
function Belt({
innerRadiusAU,
outerRadiusAU,

View File

@ -39,20 +39,30 @@ export function BodyDetailOverlay({ bodyId, preloadedData, onClose }: BodyDetail
return;
}
// Guard against out-of-order responses and setState-after-unmount.
let active = true;
setLoading(true);
request.get(`/celestial/info/${bodyId}`)
.then(response => {
setBodyData(response.data);
if (active) setBodyData(response.data);
})
.catch(error => {
if (!active) return;
console.error("Failed to fetch body details:", error);
toast.error("加载天体详情失败");
onClose(); // Close overlay on error
})
.finally(() => {
setLoading(false);
if (active) setLoading(false);
});
}, [bodyId, preloadedData, onClose, toast]);
return () => {
active = false;
};
// Only refetch when the target body changes. `onClose`/`toast` are stable in
// behavior but not identity, so excluding them avoids refetch on every parent render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bodyId, preloadedData]);
if ((!bodyId && !preloadedData) || !bodyData) {
return null;

View File

@ -1,12 +1,13 @@
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
import { Mesh, Group, DoubleSide } from 'three';
import * as THREE from 'three';
import { useGLTF, useTexture } from '@react-three/drei';
import { useGLTF } 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';
import { useSystemSetting } from '../hooks/useSystemSetting';
import { useOptionalTexture } from '../hooks/useOptionalTexture';
interface BodyViewerProps {
body: CelestialBodyType;
@ -15,7 +16,7 @@ interface BodyViewerProps {
// Planetary Rings component - handles texture-based rings
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
const texture = texturePath ? useTexture(texturePath) : null;
const texture = useOptionalTexture(texturePath);
const meshRef = useRef<Mesh>(null);
// Dynamic ring dimensions based on planet size
@ -273,6 +274,142 @@ function ProbeModelViewer({ modelPath, modelScale }: { modelPath: string; modelS
);
}
// Irregular Comet Nucleus - potato-like shape (module scope to avoid remount每次渲染)
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
key="textured"
map={texture}
roughness={0.9}
metalness={0.1}
color="#b8b8b8"
/>
) : (
<meshStandardMaterial
key="plain"
color="#6b6b6b"
roughness={0.95}
metalness={0.05}
/>
)}
</mesh>
);
}
// Enhanced Coma (gas/dust cloud) around comet nucleus (module scope)
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>
</>
);
}
// Sub-component for Planet models
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, ringTexturePath, meshRef, disableGlow = false }: {
body: CelestialBodyType;
@ -281,10 +418,10 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
emissiveIntensity: number;
texturePath: string | null;
ringTexturePath?: string | null;
meshRef: React.RefObject<Mesh>;
meshRef: React.RefObject<Mesh | null>;
disableGlow?: boolean;
}) {
const texture = texturePath ? useTexture(texturePath) : null;
const texture = useOptionalTexture(texturePath);
// Rotation animation
useFrame((_, delta) => {
@ -293,140 +430,6 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
}
});
// 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>
</>
);
}
return (
<group>
{/* Use irregular nucleus for comets, regular sphere for others */}
@ -440,6 +443,7 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
<sphereGeometry args={[size, 64, 64]} /> {/* Higher resolution for detail view */}
{texture ? (
<meshStandardMaterial
key="textured"
map={texture}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
@ -448,6 +452,7 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
/>
) : (
<meshStandardMaterial
key="plain"
color="#888888"
emissive={emissive}
emissiveIntensity={emissiveIntensity}

View File

@ -1,11 +1,19 @@
/**
* CameraController - handles camera movement and focus
*/
import { useEffect, useRef } from 'react';
import { useEffect, useRef, type RefObject } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { Vector3 } from 'three';
import type { CelestialBody } from '../types';
import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition';
import { getCelestialSize } from '../config/celestialSizes';
interface OrbitControlsLike {
target: Vector3;
update: () => void;
}
type CelestialTypeConfigs = Record<string, { ratio?: number; default?: number }> | null;
interface CameraControllerProps {
focusTarget: CelestialBody | null;
@ -13,6 +21,8 @@ interface CameraControllerProps {
onAnimationComplete?: () => void;
resetTrigger?: number;
defaultPosition?: [number, number, number];
typeConfigs?: CelestialTypeConfigs;
controlsRef: RefObject<OrbitControlsLike | null>;
}
export function CameraController({
@ -20,83 +30,78 @@ export function CameraController({
allBodies,
onAnimationComplete,
resetTrigger = 0,
defaultPosition = [10, 8, 10] // Default closer to inner solar system
defaultPosition = [10, 8, 10],
typeConfigs,
controlsRef,
}: CameraControllerProps) {
const { camera } = useThree();
const targetPosition = useRef(new Vector3());
const targetLookPosition = useRef(new Vector3());
const isAnimating = useRef(false);
const isResetting = useRef(false);
const animationProgress = useRef(0);
const startPosition = useRef(new Vector3());
const startLookPosition = useRef(new Vector3());
const trackedLookPosition = useRef(new Vector3());
const lastResetTrigger = useRef(0);
// Keep the latest bodies list without making it a trigger for the focus effect.
// `allBodies` gets a new array reference on every timeline/historical frame, and we
// don't want that to re-fire the fly-to animation while the user is watching playback.
const allBodiesRef = useRef(allBodies);
allBodiesRef.current = allBodies;
// Handle manual reset trigger
useEffect(() => {
if (resetTrigger !== lastResetTrigger.current) {
lastResetTrigger.current = resetTrigger;
// Force reset
targetPosition.current.set(...defaultPosition);
targetLookPosition.current.set(0, 0, 0);
startPosition.current.copy(camera.position);
startLookPosition.current.copy(controlsRef.current?.target ?? new Vector3());
isAnimating.current = true;
isResetting.current = true;
animationProgress.current = 0;
}
}, [resetTrigger, camera, defaultPosition]); // Only run when resetTrigger changes
}, [resetTrigger, camera, defaultPosition, controlsRef]); // Only run when resetTrigger changes
// Handle focus target changes
// Trigger only on the SELECTED body changing (by id), not on every new bodies array.
useEffect(() => {
const bodies = allBodiesRef.current;
if (focusTarget) {
isResetting.current = false; // Cancel reset if target selected
// Focus on target - use smart rendered position
const renderPos = calculateRenderPosition(focusTarget, allBodies);
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y);
const bodySize = getCelestialSize(latestBody, undefined, typeConfigs);
const parentInfo = findParentPlanet(latestBody, bodies);
const focusDistance = latestBody.type === 'star'
? Math.max(5, bodySize * 9)
: latestBody.type === 'planet'
? Math.max(4, bodySize * 7)
: latestBody.type === 'dwarf_planet'
? Math.max(2.4, bodySize * 8)
: parentInfo
? Math.max(1.4, bodySize * 8)
: Math.max(1.6, bodySize * 9);
const previousLook = controlsRef.current?.target ?? new Vector3();
const viewDirection = camera.position.clone().sub(previousLook);
if (viewDirection.lengthSq() < 0.0001) viewDirection.set(1, 0.65, 1);
viewDirection.normalize();
// Calculate ideal camera position based on target
const pos = focusTarget.positions[0];
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
const parentInfo = findParentPlanet(focusTarget, allBodies);
let offset: number;
let heightMultiplier = 1;
let sideMultiplier = 1;
if (focusTarget.type === 'planet') {
offset = 4;
heightMultiplier = 1.5;
sideMultiplier = 1;
} else if (focusTarget.type === 'probe') {
if (parentInfo) {
offset = 3;
heightMultiplier = 0.8;
sideMultiplier = 1.2;
} else if (distance < 10) {
offset = 5;
heightMultiplier = 0.6;
sideMultiplier = 1.5;
} else if (distance > 50) {
offset = 4;
heightMultiplier = 0.8;
sideMultiplier = 1;
} else {
offset = 6;
heightMultiplier = 0.8;
sideMultiplier = 1.2;
}
} else {
offset = 10;
heightMultiplier = 1;
sideMultiplier = 1;
}
targetPosition.current.set(
currentTargetPos.x + (offset * sideMultiplier),
currentTargetPos.y + (offset * heightMultiplier),
currentTargetPos.z + offset
);
targetLookPosition.current.copy(currentTargetPos);
targetPosition.current.copy(currentTargetPos)
.addScaledVector(viewDirection, focusDistance)
.add(new Vector3(0, focusDistance * 0.12, 0));
// Start animation to target
startPosition.current.copy(camera.position);
startLookPosition.current.copy(previousLook);
trackedLookPosition.current.copy(currentTargetPos);
isAnimating.current = true;
animationProgress.current = 0;
@ -108,9 +113,11 @@ export function CameraController({
animationProgress.current = 0;
}
}
}, [focusTarget, allBodies, camera]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusTarget?.id, camera, controlsRef, typeConfigs]);
useFrame((_, delta) => {
const orbitControls = controlsRef.current;
if (isAnimating.current) {
// Smooth animation using easing
animationProgress.current += delta * 0.8; // Animation speed
@ -129,15 +136,24 @@ export function CameraController({
// Interpolate camera position
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
// Look at target - use smart rendered position (only during animation)
if (focusTarget) {
const renderPos = calculateRenderPosition(focusTarget, allBodies);
camera.lookAt(renderPos.x, renderPos.z, renderPos.y);
} else {
camera.lookAt(0, 0, 0);
if (orbitControls) {
orbitControls.target.lerpVectors(startLookPosition.current, targetLookPosition.current, eased);
orbitControls.update();
}
camera.lookAt(targetLookPosition.current);
} else if (focusTarget && orbitControls) {
const bodies = allBodiesRef.current;
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
const nextLook = new Vector3(renderPos.x, renderPos.z, renderPos.y);
const movement = nextLook.sub(trackedLookPosition.current);
if (movement.lengthSq() > 0.00000001) {
camera.position.add(movement);
orbitControls.target.add(movement);
trackedLookPosition.current.add(movement);
orbitControls.update();
}
}
// After animation completes, OrbitControls will take over
});
return null;

View File

@ -5,24 +5,26 @@ import { useRef, useMemo, useState, useEffect } from 'react';
import { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
import { useFrame } from '@react-three/fiber';
import { useTexture, Billboard } from '@react-three/drei';
import { Billboard } from '@react-three/drei';
import type { CelestialBody as CelestialBodyType } from '../types';
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
import { fetchBodyResources } from '../utils/api';
import { getCelestialSize } from '../config/celestialSizes';
import { createLabelTexture } from '../utils/labelTexture';
import { useOptionalTexture } from '../hooks/useOptionalTexture';
interface CelestialBodyProps {
body: CelestialBodyType;
allBodies: CelestialBodyType[];
isSelected?: boolean;
showLabel?: boolean;
onBodySelect?: (body: CelestialBodyType) => void;
typeConfigs?: any;
}
// Planetary Rings component - handles texture-based rings
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
const texture = texturePath ? useTexture(texturePath) : null;
const texture = useOptionalTexture(texturePath);
const meshRef = useRef<Mesh>(null);
// Dynamic ring dimensions based on planet size
@ -87,13 +89,14 @@ function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string |
}
// Planet component with texture
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, onBodySelect, typeConfigs }: {
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
allBodies: CelestialBodyType[];
isSelected?: boolean;
showLabel?: boolean;
onBodySelect?: (body: CelestialBodyType) => void;
typeConfigs?: any;
}) {
@ -164,6 +167,7 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
hasOffset={renderPosition.hasOffset}
allBodies={allBodies}
isSelected={isSelected}
showLabel={showLabel}
onBodySelect={onBodySelect}
/>;
}
@ -215,6 +219,7 @@ function IrregularNucleus({ size, texture, onClick }: { size: number; texture: T
>
{texture ? (
<meshStandardMaterial
key="textured"
map={texture}
roughness={0.9}
metalness={0.1}
@ -222,6 +227,7 @@ function IrregularNucleus({ size, texture, onClick }: { size: number; texture: T
/>
) : (
<meshStandardMaterial
key="plain"
color="#6b6b6b"
roughness={0.95}
metalness={0.05}
@ -309,7 +315,7 @@ function CometComa({ radius }: { radius: number }) {
}
// Separate component to handle texture loading
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, ringTexturePath, position, meshRef, hasOffset, allBodies, isSelected = false, onBodySelect }: {
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, ringTexturePath, position, meshRef, hasOffset, allBodies, isSelected = false, showLabel = true, onBodySelect }: {
body: CelestialBodyType;
size: number;
emissive: string;
@ -318,14 +324,14 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
texturePath: string | null;
ringTexturePath?: string | null;
position: { x: number; y: number; z: number };
meshRef: React.RefObject<Mesh>;
meshRef: React.RefObject<Mesh | null>;
hasOffset: boolean;
allBodies: CelestialBodyType[];
isSelected?: boolean;
showLabel?: boolean;
onBodySelect?: (body: CelestialBodyType) => void;
}) {
// Load texture if path is provided
const texture = texturePath ? useTexture(texturePath) : null;
const texture = useOptionalTexture(texturePath);
// Slow rotation for visual effect
useFrame((_, delta) => {
@ -380,6 +386,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
<sphereGeometry args={[size, 32, 32]} />
{texture ? (
<meshStandardMaterial
key="textured"
map={texture}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
@ -390,6 +397,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
/>
) : (
<meshStandardMaterial
key="plain"
color="#888888"
emissive={emissive}
emissiveIntensity={emissiveIntensity}
@ -463,7 +471,7 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
)}
{/* Name label using CanvasTexture */}
{labelTexture && (
{showLabel && labelTexture && (
<Billboard
position={[0, size + 0.8, 0]} // Slightly closer to body
follow={true}
@ -487,18 +495,13 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
);
}
export function CelestialBody({ body, allBodies, isSelected = false, onBodySelect, typeConfigs }: CelestialBodyProps) {
export function CelestialBody({ body, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: CelestialBodyProps) {
// Get the current position (use the first position for now)
const position = body.positions[0];
if (!position) return null;
// Skip probes - they will use 3D models
if (body.type === 'probe') {
return null;
}
// Determine size based on body type
// NOTE: this hook must run before any early return to keep hook order stable
// across re-renders (e.g. when positions transiently empty during a refetch).
const appearance = useMemo(() => {
if (body.type === 'star') {
return {
@ -534,6 +537,13 @@ export function CelestialBody({ body, allBodies, isSelected = false, onBodySelec
};
}, [body, typeConfigs]);
if (!position) return null;
// Skip probes - they will use 3D models
if (body.type === 'probe') {
return null;
}
return (
<Planet
body={body}
@ -542,6 +552,7 @@ export function CelestialBody({ body, allBodies, isSelected = false, onBodySelec
emissiveIntensity={appearance.emissiveIntensity}
allBodies={allBodies}
isSelected={isSelected}
showLabel={showLabel}
onBodySelect={onBodySelect}
typeConfigs={typeConfigs}
/>

View File

@ -1,6 +1,5 @@
import {
Calendar,
Orbit,
Volume2,
VolumeX,
MessageSquare,
@ -9,6 +8,7 @@ import {
Camera,
Globe,
Sparkles
,Rocket
} from 'lucide-react';
interface ControlPanelProps {
@ -23,6 +23,7 @@ interface ControlPanelProps {
onScreenshot: () => void;
viewMode: 'solar' | 'galaxy';
onToggleViewMode: () => void;
onOpenRocketSimulator: () => void;
}
export function ControlPanel({
@ -37,6 +38,7 @@ export function ControlPanel({
onScreenshot,
viewMode,
onToggleViewMode,
onOpenRocketSimulator,
}: ControlPanelProps) {
const buttonClass = (isActive: boolean) => `
p-2 rounded-lg transition-all duration-200 relative group
@ -50,6 +52,15 @@ export function ControlPanel({
return (
<div className="absolute top-24 right-6 z-40 flex flex-col gap-3 items-end">
<button
onClick={onOpenRocketSimulator}
className="p-2 rounded-lg bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 border border-amber-400/30 transition-all duration-200 relative group"
aria-label="打开火箭发射模拟"
>
<Rocket size={20} />
<div className={tooltipClass}></div>
</button>
{/* View Mode Toggle */}
<button
onClick={onToggleViewMode}

View File

@ -53,7 +53,6 @@ export function FocusInfo({ body, onClose, toast, onViewDetails }: FocusInfoProp
`;
return (
// Remove fixed positioning, now handled by parent container (Html component in 3D)
<div className="flex flex-col items-center -translate-y-24 pointer-events-none">
<style>{styles}</style>
{/* Main Info Card */}

View File

@ -1,10 +1,11 @@
import { UserAuth } from './UserAuth';
import type { AuthUser } from '../utils/auth';
interface HeaderProps {
selectedBodyName?: string;
bodyCount: number;
cutoffDate?: Date | null;
user?: any;
user?: AuthUser | null;
onOpenAuth?: () => void;
onLogout?: () => void;
onNavigateToAdmin?: () => void;
@ -72,4 +73,4 @@ export function Header({
</div>
</header>
);
}
}

View File

@ -15,17 +15,18 @@ interface ProbeProps {
body: CelestialBody;
allBodies: CelestialBody[];
isSelected?: boolean;
showLabel?: boolean;
onBodySelect?: (body: CelestialBody) => void;
typeConfigs?: any;
}
// Separate component for each probe type to properly use hooks
function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, resourceScale = 1.0, onBodySelect, typeConfigs }: {
function ProbeModel({ body, modelPath, allBodies, isSelected = false, showLabel = true, resourceScale = 1.0, onBodySelect, typeConfigs }: {
body: CelestialBody;
modelPath: string;
allBodies: CelestialBody[];
isSelected?: boolean;
onError: () => void;
showLabel?: boolean;
resourceScale?: number;
onBodySelect?: (body: CelestialBody) => void;
typeConfigs?: any;
@ -111,25 +112,13 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
}
});
// Render Logic
if (!scene || !configuredScene) return null;
// Calculate ACTUAL distance from Sun (not scaled)
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
// Handle click event
const handleClick = (e: any) => {
e.stopPropagation();
if (onBodySelect) {
onBodySelect(body);
}
};
// Generate label texture
// eslint-disable-next-line react-hooks/rules-of-hooks
// 6. Hook: Label texture (must be called before any early return to keep hook order stable)
const labelTexture = useMemo(() => {
return createLabelTexture(
body.name_zh || body.name,
@ -139,6 +128,17 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
);
}, [body.name, body.name_zh, offsetDesc, distance]);
// Render Logic
if (!scene || !configuredScene) return null;
// Handle click event
const handleClick = (e: any) => {
e.stopPropagation();
if (onBodySelect) {
onBodySelect(body);
}
};
return (
<group
position={[scaledPos.x, scaledPos.z, scaledPos.y]}
@ -153,7 +153,7 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
/>
{/* Name label with CanvasTexture */}
{labelTexture && (
{showLabel && labelTexture && (
<Billboard
position={[0, optimalScale * 2.5, 0]}
follow={true}
@ -178,10 +178,11 @@ function ProbeModel({ body, modelPath, allBodies, isSelected = false, onError, r
}
// Fallback component when model is not available
function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, typeConfigs }: {
function ProbeFallback({ body, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: {
body: CelestialBody;
allBodies: CelestialBody[];
isSelected?: boolean;
showLabel?: boolean;
onBodySelect?: (body: CelestialBody) => void;
typeConfigs?: any;
}) {
@ -231,7 +232,7 @@ function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, type
</mesh>
{/* Name label with CanvasTexture */}
{labelTexture && (
{showLabel && labelTexture && (
<Billboard
position={[0, 1, 0]}
follow={true}
@ -255,7 +256,7 @@ function ProbeFallback({ body, allBodies, isSelected = false, onBodySelect, type
);
}
export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeConfigs }: ProbeProps) {
export function Probe({ body, allBodies, isSelected = false, showLabel = true, onBodySelect, typeConfigs }: ProbeProps) {
const position = body.positions[0];
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [loadError, setLoadError] = useState<boolean>(false);
@ -312,11 +313,9 @@ export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeC
modelPath={modelPath}
allBodies={allBodies}
isSelected={isSelected}
showLabel={showLabel}
resourceScale={resourceScale}
onBodySelect={onBodySelect}
onError={() => {
setLoadError(true);
}}
typeConfigs={typeConfigs}
/>;
}
@ -325,6 +324,7 @@ export function Probe({ body, allBodies, isSelected = false, onBodySelect, typeC
body={body}
allBodies={allBodies}
isSelected={isSelected}
showLabel={showLabel}
onBodySelect={onBodySelect}
typeConfigs={typeConfigs}
/>;

View File

@ -18,10 +18,6 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
// Auto-expand the group when a body is selected from the 3D scene
useEffect(() => {
if (selectedBody) {
// Don't auto-collapse panel if it's already open
// Only auto-collapse if panel was already collapsed
// This allows users to keep the panel open while browsing bodies
// Auto-expand the group that contains the selected body
setExpandedGroup(selectedBody.type);
}
@ -79,6 +75,10 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
setExpandedGroup(prev => prev === groupName ? null : groupName);
};
const handleBodySelect = (body: CelestialBody) => {
onBodySelect(body);
};
return (
<div
className={`
@ -161,7 +161,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
isExpanded={expandedGroup === 'star'}
onToggle={() => toggleGroup('star')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
onBodySelect={handleBodySelect}
/>
)}
@ -175,7 +175,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
isExpanded={expandedGroup === 'planet'}
onToggle={() => toggleGroup('planet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
onBodySelect={handleBodySelect}
/>
)}
@ -189,7 +189,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
isExpanded={expandedGroup === 'dwarf_planet'}
onToggle={() => toggleGroup('dwarf_planet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
onBodySelect={handleBodySelect}
/>
)}
@ -203,7 +203,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
isExpanded={expandedGroup === 'satellite'}
onToggle={() => toggleGroup('satellite')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
onBodySelect={handleBodySelect}
/>
)}
@ -217,7 +217,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
isExpanded={expandedGroup === 'comet'}
onToggle={() => toggleGroup('comet')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
onBodySelect={handleBodySelect}
/>
)}
@ -231,7 +231,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
isExpanded={expandedGroup === 'probe'}
onToggle={() => toggleGroup('probe')}
selectedBody={selectedBody}
onBodySelect={onBodySelect}
onBodySelect={handleBodySelect}
/>
)}

View File

@ -3,7 +3,9 @@
*/
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stars as BackgroundStars, Html } from '@react-three/drei';
import { useMemo, useState, useEffect } from 'react';
import * as THREE from 'three';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
import { CelestialBody } from './CelestialBody';
import { Probe } from './Probe';
import { CameraController } from './CameraController';
@ -15,7 +17,6 @@ import { Galaxies } from './Galaxies';
import { Nebulae } from './Nebulae';
import { FocusInfo } from './FocusInfo';
import { AsteroidBelts } from './AsteroidBelts';
import { scalePosition } from '../utils/scaleDistance';
import { calculateRenderPosition } from '../utils/renderPosition';
import type { CelestialBody as CelestialBodyType, Position } from '../types';
import type { ToastContextValue } from '../contexts/ToastContext'; // Import ToastContextValue
@ -35,6 +36,29 @@ interface SceneProps {
export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbits = true, onBodySelect, resetTrigger = 0, toast, onViewDetails }: SceneProps) {
const [typeConfigs] = useSystemSetting('celestial_type_configs', null);
const [rawCameraPos] = useSystemSetting('default_camera_position', [10, 8, 10]);
const [rendererGeneration, setRendererGeneration] = useState(0);
const recoveryAttemptedRef = useRef(false);
const contextListenerCleanupRef = useRef<(() => void) | null>(null);
const orbitControlsRef = useRef<OrbitControlsImpl>(null);
useEffect(() => () => contextListenerCleanupRef.current?.(), []);
const handleCanvasCreated = useCallback(({ gl, camera }: { gl: THREE.WebGLRenderer; camera: THREE.Camera }) => {
gl.sortObjects = true;
gl.setClearColor(0x000000, 1);
camera.lookAt(0, 0, 0);
contextListenerCleanupRef.current?.();
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
if (recoveryAttemptedRef.current) return;
recoveryAttemptedRef.current = true;
setRendererGeneration((generation) => generation + 1);
};
canvas.addEventListener('webglcontextlost', handleContextLost, { once: true });
contextListenerCleanupRef.current = () => canvas.removeEventListener('webglcontextlost', handleContextLost);
}, []);
// Parse camera position if it's a string (from system settings)
const defaultCameraPos = useMemo(() => {
@ -85,31 +109,21 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
// Always show all probes (changed from previous behavior)
const visibleProbes = probes;
// Calculate target position for OrbitControls
const controlsTarget = useMemo(() => {
if (selectedBody) {
const pos = selectedBody.positions[0];
const scaledPos = scalePosition(pos.x, pos.y, pos.z);
return [scaledPos.x, scaledPos.z, scaledPos.y] as [number, number, number];
}
return [0, 0, 0] as [number, number, number];
}, [selectedBody]);
// Calculate position for FocusInfo (needs to match rendered position of body)
const currentSelectedBody = useMemo(
() => selectedBody ? bodies.find((body) => body.id === selectedBody.id) ?? selectedBody : null,
[bodies, selectedBody],
);
const focusInfoPosition = useMemo(() => {
if (!selectedBody) return [0, 0, 0] as [number, number, number];
// We need to use the EXACT same logic as CelestialBody/Probe components
// to ensure the label sticks to the object
const renderPos = calculateRenderPosition(selectedBody, bodies, typeConfigs);
// Convert to Three.js coordinates (x, z, y)
return [renderPos.x, renderPos.z, renderPos.y] as [number, number, number];
}, [selectedBody, bodies, typeConfigs]);
if (!currentSelectedBody) return [0, 0, 0] as [number, number, number];
const position = calculateRenderPosition(currentSelectedBody, bodies, typeConfigs);
return [position.x, position.z, position.y] as [number, number, number];
}, [bodies, currentSelectedBody, typeConfigs]);
return (
<div id="cosmo-scene-container" className="w-full h-full bg-black">
<Canvas
key={rendererGeneration}
style={{ background: '#000000' }}
camera={{
position: defaultCameraPos, // Dynamic default position
fov: 60, // Slightly narrower FOV for less distortion
@ -120,19 +134,8 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
antialias: true,
preserveDrawingBuffer: true, // Required for screenshots
}}
onCreated={({ gl, camera }) => {
gl.sortObjects = true; // Enable object sorting by renderOrder
camera.lookAt(0, 0, 0); // Look at the Sun (center)
}}
onCreated={handleCanvasCreated}
>
{/* Camera controller for smooth transitions */}
<CameraController
focusTarget={selectedBody}
allBodies={bodies}
resetTrigger={resetTrigger}
defaultPosition={defaultCameraPos}
/>
{/* Increase ambient light to see textures better */}
<ambientLight intensity={0.5} />
@ -171,6 +174,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
body={body}
allBodies={bodies}
isSelected={selectedBody?.id === body.id}
showLabel={!selectedBody || selectedBody.id === body.id}
onBodySelect={onBodySelect}
typeConfigs={typeConfigs}
/>
@ -186,6 +190,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
body={body}
allBodies={bodies}
isSelected={selectedBody?.id === body.id}
showLabel={!selectedBody || selectedBody.id === body.id}
onBodySelect={onBodySelect}
typeConfigs={typeConfigs}
/>
@ -202,23 +207,36 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi
{/* Camera controls */}
<OrbitControls
ref={orbitControlsRef}
makeDefault
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={2}
enableDamping={true}
dampingFactor={0.08}
minDistance={0.5}
maxDistance={500}
target={controlsTarget}
enabled={true} // Always enabled
/>
{/* Run after OrbitControls so focus interpolation owns the final camera transform. */}
<CameraController
focusTarget={selectedBody}
allBodies={bodies}
resetTrigger={resetTrigger}
defaultPosition={defaultCameraPos}
typeConfigs={typeConfigs}
controlsRef={orbitControlsRef}
/>
{/* Dynamic Focus Info Label */}
{selectedBody && showInfoPanel && (
<Html position={focusInfoPosition} center zIndexRange={[100, 0]}>
<FocusInfo
body={selectedBody}
onClose={() => setShowInfoPanel(false)}
toast={toast}
onViewDetails={onViewDetails}
{currentSelectedBody && showInfoPanel && (
<Html position={focusInfoPosition} center zIndexRange={[30, 0]} style={{ pointerEvents: 'none' }}>
<FocusInfo
body={currentSelectedBody}
onClose={() => setShowInfoPanel(false)}
toast={toast}
onViewDetails={onViewDetails}
/>
</Html>
)}

View File

@ -1,7 +1,7 @@
/**
* TimelineController - controls time for viewing historical positions
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Play, Pause, RotateCcw, FastForward, CalendarClock } from 'lucide-react';
export interface TimelineState {
@ -19,14 +19,19 @@ interface TimelineControllerProps {
}
export function TimelineController({ onTimeChange, minDate, maxDate }: TimelineControllerProps) {
// Swap: startDate is now (maxDate), endDate is past (minDate)
const startDate = maxDate || new Date(); // Start from now
const endDate = minDate || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); // End at past
// Swap: startDate is now (maxDate), endDate is past (minDate).
// Reduce to primitive timestamps so identity is stable across parent re-renders
// (minDate/maxDate are frequently passed as fresh `new Date(...)` objects).
const startTime = maxDate ? maxDate.getTime() : Date.now();
const endTime = minDate ? minDate.getTime() : Date.now() - 365 * 24 * 60 * 60 * 1000;
const [currentDate, setCurrentDate] = useState<Date>(startDate); // Start from now
const startDate = useMemo(() => new Date(startTime), [startTime]);
const endDate = useMemo(() => new Date(endTime), [endTime]);
const [currentDate, setCurrentDate] = useState<Date>(() => new Date(startTime)); // Start from now
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(1); // 1 day per second
const animationFrameRef = useRef<number>();
const animationFrameRef = useRef<number | undefined>(undefined);
const lastUpdateRef = useRef<number>(Date.now());
// Animation loop

View File

@ -1,9 +1,9 @@
import { useState } from 'react';
import { User, LogOut, LayoutDashboard, LogIn } from 'lucide-react';
import { API_BASE_URL } from '../utils/request';
import type { AuthUser } from '../utils/auth';
interface UserAuthProps {
user: any;
user: AuthUser | null | undefined;
onOpenAuth: () => void;
onLogout: () => void;
onNavigateToAdmin: () => void;
@ -26,7 +26,7 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
// Helper to get full avatar URL
const getAvatarUrl = () => {
if (!user?.avatar_url) return null;
if (!user?.avatar_url) return undefined;
return `/upload/${user.avatar_url}`;
};
@ -77,8 +77,7 @@ export function UserAuth({ user, onOpenAuth, onLogout, onNavigateToAdmin }: User
<div className="p-1">
<button
onClick={() => {
// Open admin in new tab
window.open('/admin', '_blank');
onNavigateToAdmin();
setShowUserMenu(false);
}}
className="w-full text-left px-3 py-2.5 text-sm text-gray-200 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors rounded-lg"

Some files were not shown because too many files have changed in this diff Show More