1.1.0
parent
6a519126c1
commit
d93b4f787a
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "yarn",
|
||||
"runtimeArgs": ["--cwd", "frontend", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,15 @@
|
|||
# Cosmo 文档
|
||||
|
||||
项目入口文档保留在仓库根目录:
|
||||
|
||||
- [README](../README.md)
|
||||
- [项目说明](../PROJECT.md)
|
||||
- [快速开始](../QUICKSTART.md)
|
||||
- [部署指南](../DEPLOYMENT.md)
|
||||
|
||||
其余文档按用途归档:
|
||||
|
||||
- `architecture/`:架构、数据策略、算法和数据库设计
|
||||
- `guides/`:配置、运维和功能使用指南
|
||||
- `plans/`:路线图及阶段计划
|
||||
- `reports/`:阶段完成报告、问题分析和修复记录
|
||||
|
|
@ -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 小时 (可配) |
|
||||
|
|
@ -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/)
|
||||
|
||||
---
|
||||
|
|
@ -87,4 +87,4 @@
|
|||
3. **验证**:
|
||||
* 测试关注/取关。
|
||||
* 测试频道消息发送与接收(验证 Redis 存储)。
|
||||
* 测试 Horizons 缓存生效。
|
||||
* 测试 Horizons 缓存生效。
|
||||
|
|
@ -328,4 +328,4 @@ function getPlanetTexture(radius, temp) {
|
|||
// 4. 宜居带/岩石行星 (默认)
|
||||
return textureLoader.load(PATH + 'rocky_atmosphere.jpg');
|
||||
}
|
||||
```
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
|
|
@ -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`
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "",
|
||||
"description": "",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,13 +15,6 @@ interface BeltProps {
|
|||
rotationSpeed: number;
|
||||
}
|
||||
|
||||
interface BeltData {
|
||||
category: string;
|
||||
name: string;
|
||||
name_zh: string;
|
||||
data: BeltProps;
|
||||
}
|
||||
|
||||
function Belt({
|
||||
innerRadiusAU,
|
||||
outerRadiusAU,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue