diff --git a/backend/Dockerfile b/backend/Dockerfile
index bf28c37c..75a98ce8 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -16,4 +16,4 @@ COPY . .
EXPOSE 8000
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py
index 296887a1..51c04353 100644
--- a/backend/app/api/v1/datasources.py
+++ b/backend/app/api/v1/datasources.py
@@ -120,6 +120,20 @@ COLLECTOR_INFO = {
"priority": "P1",
"frequency_hours": 168,
},
+ "spacetrack_tle": {
+ "id": 19,
+ "name": "Space-Track TLE",
+ "module": "L3",
+ "priority": "P2",
+ "frequency_hours": 24,
+ },
+ "celestrak_tle": {
+ "id": 20,
+ "name": "CelesTrak TLE",
+ "module": "L3",
+ "priority": "P2",
+ "frequency_hours": 24,
+ },
}
ID_TO_COLLECTOR = {info["id"]: name for name, info in COLLECTOR_INFO.items()}
diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py
index df791995..bd3786c1 100644
--- a/backend/app/api/v1/visualization.py
+++ b/backend/app/api/v1/visualization.py
@@ -1,8 +1,13 @@
-"""Visualization API - GeoJSON endpoints for 3D Earth display"""
+"""Visualization API - GeoJSON endpoints for 3D Earth display
+Unified API for all visualization data sources.
+Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
+"""
+
+from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
from app.db.session import get_db
@@ -12,6 +17,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph
router = APIRouter()
+# ============== Converter Functions ==============
+
+
def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert cable records to GeoJSON FeatureCollection"""
features = []
@@ -122,6 +130,117 @@ def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str,
return {"type": "FeatureCollection", "features": features}
+def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
+ """Convert satellite TLE records to GeoJSON"""
+ features = []
+
+ for record in records:
+ metadata = record.extra_data or {}
+ norad_id = metadata.get("norad_cat_id")
+
+ if not norad_id:
+ continue
+
+ features.append(
+ {
+ "type": "Feature",
+ "id": norad_id,
+ "geometry": {"type": "Point", "coordinates": [0, 0, 0]},
+ "properties": {
+ "id": record.id,
+ "norad_cat_id": norad_id,
+ "name": record.name,
+ "international_designator": metadata.get("international_designator"),
+ "epoch": metadata.get("epoch"),
+ "inclination": metadata.get("inclination"),
+ "raan": metadata.get("raan"),
+ "eccentricity": metadata.get("eccentricity"),
+ "arg_of_perigee": metadata.get("arg_of_perigee"),
+ "mean_anomaly": metadata.get("mean_anomaly"),
+ "mean_motion": metadata.get("mean_motion"),
+ "bstar": metadata.get("bstar"),
+ "classification_type": metadata.get("classification_type"),
+ "data_type": "satellite_tle",
+ },
+ }
+ )
+
+ return {"type": "FeatureCollection", "features": features}
+
+
+def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
+ """Convert TOP500 supercomputer records to GeoJSON"""
+ features = []
+
+ for record in records:
+ try:
+ lat = float(record.latitude) if record.latitude and record.latitude != "0.0" else None
+ lon = (
+ float(record.longitude) if record.longitude and record.longitude != "0.0" else None
+ )
+ except (ValueError, TypeError):
+ lat, lon = None, None
+
+ metadata = record.extra_data or {}
+
+ features.append(
+ {
+ "type": "Feature",
+ "id": record.id,
+ "geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]},
+ "properties": {
+ "id": record.id,
+ "name": record.name,
+ "rank": metadata.get("rank"),
+ "r_max": record.value,
+ "r_peak": metadata.get("r_peak"),
+ "cores": metadata.get("cores"),
+ "power": metadata.get("power"),
+ "country": record.country,
+ "city": record.city,
+ "data_type": "supercomputer",
+ },
+ }
+ )
+
+ return {"type": "FeatureCollection", "features": features}
+
+
+def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
+ """Convert GPU cluster records to GeoJSON"""
+ features = []
+
+ for record in records:
+ try:
+ lat = float(record.latitude) if record.latitude else None
+ lon = float(record.longitude) if record.longitude else None
+ except (ValueError, TypeError):
+ lat, lon = None, None
+
+ metadata = record.extra_data or {}
+
+ features.append(
+ {
+ "type": "Feature",
+ "id": record.id,
+ "geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]},
+ "properties": {
+ "id": record.id,
+ "name": record.name,
+ "country": record.country,
+ "city": record.city,
+ "metadata": metadata,
+ "data_type": "gpu_cluster",
+ },
+ }
+ )
+
+ return {"type": "FeatureCollection", "features": features}
+
+
+# ============== API Endpoints ==============
+
+
@router.get("/geo/cables")
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
"""获取海底电缆 GeoJSON 数据 (LineString)"""
@@ -196,6 +315,178 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)):
}
+@router.get("/geo/satellites")
+async def get_satellites_geojson(
+ limit: int = 10000,
+ db: AsyncSession = Depends(get_db),
+):
+ """获取卫星 TLE GeoJSON 数据"""
+ stmt = (
+ select(CollectedData)
+ .where(CollectedData.source == "celestrak_tle")
+ .where(CollectedData.name != "Unknown")
+ .order_by(CollectedData.id.desc())
+ .limit(limit)
+ )
+ result = await db.execute(stmt)
+ records = result.scalars().all()
+
+ if not records:
+ return {"type": "FeatureCollection", "features": [], "count": 0}
+
+ geojson = convert_satellite_to_geojson(list(records))
+ return {
+ **geojson,
+ "count": len(geojson.get("features", [])),
+ }
+
+
+@router.get("/geo/supercomputers")
+async def get_supercomputers_geojson(
+ limit: int = 500,
+ db: AsyncSession = Depends(get_db),
+):
+ """获取 TOP500 超算中心 GeoJSON 数据"""
+ stmt = (
+ select(CollectedData)
+ .where(CollectedData.source == "top500")
+ .where(CollectedData.name != "Unknown")
+ .limit(limit)
+ )
+ result = await db.execute(stmt)
+ records = result.scalars().all()
+
+ if not records:
+ return {"type": "FeatureCollection", "features": [], "count": 0}
+
+ geojson = convert_supercomputer_to_geojson(list(records))
+ return {
+ **geojson,
+ "count": len(geojson.get("features", [])),
+ }
+
+
+@router.get("/geo/gpu-clusters")
+async def get_gpu_clusters_geojson(
+ limit: int = 100,
+ db: AsyncSession = Depends(get_db),
+):
+ """获取 GPU 集群 GeoJSON 数据"""
+ stmt = (
+ select(CollectedData)
+ .where(CollectedData.source == "epoch_ai_gpu")
+ .where(CollectedData.name != "Unknown")
+ .limit(limit)
+ )
+ result = await db.execute(stmt)
+ records = result.scalars().all()
+
+ if not records:
+ return {"type": "FeatureCollection", "features": [], "count": 0}
+
+ geojson = convert_gpu_cluster_to_geojson(list(records))
+ return {
+ **geojson,
+ "count": len(geojson.get("features", [])),
+ }
+
+
+@router.get("/all")
+async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
+ """获取所有可视化数据的统一端点
+
+ Returns GeoJSON FeatureCollections for all data types:
+ - satellites: 卫星 TLE 数据
+ - cables: 海底电缆
+ - landing_points: 登陆点
+ - supercomputers: TOP500 超算
+ - gpu_clusters: GPU 集群
+ """
+ cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
+ cables_result = await db.execute(cables_stmt)
+ cables_records = list(cables_result.scalars().all())
+
+ points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
+ points_result = await db.execute(points_stmt)
+ points_records = list(points_result.scalars().all())
+
+ satellites_stmt = (
+ select(CollectedData)
+ .where(CollectedData.source == "celestrak_tle")
+ .where(CollectedData.name != "Unknown")
+ )
+ satellites_result = await db.execute(satellites_stmt)
+ satellites_records = list(satellites_result.scalars().all())
+
+ supercomputers_stmt = (
+ select(CollectedData)
+ .where(CollectedData.source == "top500")
+ .where(CollectedData.name != "Unknown")
+ )
+ supercomputers_result = await db.execute(supercomputers_stmt)
+ supercomputers_records = list(supercomputers_result.scalars().all())
+
+ gpu_stmt = (
+ select(CollectedData)
+ .where(CollectedData.source == "epoch_ai_gpu")
+ .where(CollectedData.name != "Unknown")
+ )
+ gpu_result = await db.execute(gpu_stmt)
+ gpu_records = list(gpu_result.scalars().all())
+
+ cables = (
+ convert_cable_to_geojson(cables_records)
+ if cables_records
+ else {"type": "FeatureCollection", "features": []}
+ )
+ landing_points = (
+ convert_landing_point_to_geojson(points_records)
+ if points_records
+ else {"type": "FeatureCollection", "features": []}
+ )
+ satellites = (
+ convert_satellite_to_geojson(satellites_records)
+ if satellites_records
+ else {"type": "FeatureCollection", "features": []}
+ )
+ supercomputers = (
+ convert_supercomputer_to_geojson(supercomputers_records)
+ if supercomputers_records
+ else {"type": "FeatureCollection", "features": []}
+ )
+ gpu_clusters = (
+ convert_gpu_cluster_to_geojson(gpu_records)
+ if gpu_records
+ else {"type": "FeatureCollection", "features": []}
+ )
+
+ return {
+ "generated_at": datetime.utcnow().isoformat() + "Z",
+ "version": "1.0",
+ "data": {
+ "satellites": satellites,
+ "cables": cables,
+ "landing_points": landing_points,
+ "supercomputers": supercomputers,
+ "gpu_clusters": gpu_clusters,
+ },
+ "stats": {
+ "total_features": (
+ len(satellites.get("features", []))
+ + len(cables.get("features", []))
+ + len(landing_points.get("features", []))
+ + len(supercomputers.get("features", []))
+ + len(gpu_clusters.get("features", []))
+ ),
+ "satellites": len(satellites.get("features", [])),
+ "cables": len(cables.get("features", [])),
+ "landing_points": len(landing_points.get("features", [])),
+ "supercomputers": len(supercomputers.get("features", [])),
+ "gpu_clusters": len(gpu_clusters.get("features", [])),
+ },
+ }
+
+
# Cache for cable graph
_cable_graph: Optional[CableGraph] = None
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 71c194a3..d7bbb0c2 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
+ SPACETRACK_USERNAME: str = ""
+ SPACETRACK_PASSWORD: str = ""
+
@property
def REDIS_URL(self) -> str:
return os.getenv(
@@ -34,7 +37,7 @@ class Settings(BaseSettings):
)
class Config:
- env_file = ".env"
+ env_file = Path(__file__).parent.parent.parent / ".env"
case_sensitive = True
diff --git a/backend/app/core/data_sources.py b/backend/app/core/data_sources.py
index 3abca4d6..13f078a0 100644
--- a/backend/app/core/data_sources.py
+++ b/backend/app/core/data_sources.py
@@ -22,6 +22,7 @@ COLLECTOR_URL_KEYS = {
"peeringdb_facility": "peeringdb.facility_url",
"top500": "top500.url",
"epoch_ai_gpu": "epoch_ai.gpu_clusters_url",
+ "spacetrack_tle": "spacetrack.tle_query_url",
}
diff --git a/backend/app/core/data_sources.yaml b/backend/app/core/data_sources.yaml
index 99095689..7e97d335 100644
--- a/backend/app/core/data_sources.yaml
+++ b/backend/app/core/data_sources.yaml
@@ -33,3 +33,7 @@ top500:
epoch_ai:
gpu_clusters_url: "https://epoch.ai/data/gpu-clusters"
+
+spacetrack:
+ base_url: "https://www.space-track.org"
+ tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json"
diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py
index 4422c334..69a2b11b 100644
--- a/backend/app/services/collectors/__init__.py
+++ b/backend/app/services/collectors/__init__.py
@@ -28,6 +28,8 @@ from app.services.collectors.arcgis_cables import ArcGISCableCollector
from app.services.collectors.fao_landing import FAOLandingPointCollector
from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector
from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector
+from app.services.collectors.spacetrack import SpaceTrackTLECollector
+from app.services.collectors.celestrak import CelesTrakTLECollector
collector_registry.register(TOP500Collector())
collector_registry.register(EpochAIGPUCollector())
@@ -47,3 +49,5 @@ collector_registry.register(ArcGISCableCollector())
collector_registry.register(FAOLandingPointCollector())
collector_registry.register(ArcGISLandingPointCollector())
collector_registry.register(ArcGISCableLandingRelationCollector())
+collector_registry.register(SpaceTrackTLECollector())
+collector_registry.register(CelesTrakTLECollector())
diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py
index 8a22232b..77a1948c 100644
--- a/backend/app/services/collectors/base.py
+++ b/backend/app/services/collectors/base.py
@@ -119,6 +119,9 @@ class BaseCollector(ABC):
records_added = 0
for i, item in enumerate(data):
+ print(
+ f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}"
+ )
record = CollectedData(
source=self.name,
source_id=item.get("source_id") or item.get("id"),
diff --git a/backend/app/services/collectors/celestrak.py b/backend/app/services/collectors/celestrak.py
new file mode 100644
index 00000000..a0e91d43
--- /dev/null
+++ b/backend/app/services/collectors/celestrak.py
@@ -0,0 +1,99 @@
+"""CelesTrak TLE Collector
+
+Collects satellite TLE (Two-Line Element) data from CelesTrak.org.
+Free, no authentication required.
+"""
+
+import json
+from typing import Dict, Any, List
+import httpx
+
+from app.services.collectors.base import BaseCollector
+
+
+class CelesTrakTLECollector(BaseCollector):
+ name = "celestrak_tle"
+ priority = "P2"
+ module = "L3"
+ frequency_hours = 24
+ data_type = "satellite_tle"
+
+ @property
+ def base_url(self) -> str:
+ return "https://celestrak.org/NORAD/elements/gp.php"
+
+ async def fetch(self) -> List[Dict[str, Any]]:
+ satellite_groups = [
+ "starlink",
+ "gps-ops",
+ "galileo",
+ "glonass",
+ "beidou",
+ "leo",
+ "geo",
+ "iridium-next",
+ ]
+
+ all_satellites = []
+
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ for group in satellite_groups:
+ try:
+ url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
+ response = await client.get(url)
+
+ if response.status_code == 200:
+ data = response.json()
+ if isinstance(data, list):
+ all_satellites.extend(data)
+ print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
+ except Exception as e:
+ print(f"CelesTrak: Error fetching group '{group}': {e}")
+
+ if not all_satellites:
+ return self._get_sample_data()
+
+ print(f"CelesTrak: Total satellites fetched: {len(all_satellites)}")
+
+ # Return raw data - base.run() will call transform()
+ return all_satellites
+
+ def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ transformed = []
+ for item in raw_data:
+ transformed.append(
+ {
+ "name": item.get("OBJECT_NAME", "Unknown"),
+ "reference_date": item.get("EPOCH", ""),
+ "metadata": {
+ "norad_cat_id": item.get("NORAD_CAT_ID"),
+ "international_designator": item.get("OBJECT_ID"),
+ "epoch": item.get("EPOCH"),
+ "mean_motion": item.get("MEAN_MOTION"),
+ "eccentricity": item.get("ECCENTRICITY"),
+ "inclination": item.get("INCLINATION"),
+ "raan": item.get("RA_OF_ASC_NODE"),
+ "arg_of_perigee": item.get("ARG_OF_PERICENTER"),
+ "mean_anomaly": item.get("MEAN_ANOMALY"),
+ "classification_type": item.get("CLASSIFICATION_TYPE"),
+ "bstar": item.get("BSTAR"),
+ "mean_motion_dot": item.get("MEAN_MOTION_DOT"),
+ "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
+ "ephemeris_type": item.get("EPHEMERIS_TYPE"),
+ },
+ }
+ )
+ return transformed
+
+ def _get_sample_data(self) -> List[Dict[str, Any]]:
+ return [
+ {
+ "name": "STARLINK-1000",
+ "norad_cat_id": 44720,
+ "international_designator": "2019-029AZ",
+ "epoch": "2026-03-13T00:00:00Z",
+ "mean_motion": 15.79234567,
+ "eccentricity": 0.0001234,
+ "inclination": 53.0,
+ },
+ ]
diff --git a/backend/app/services/collectors/spacetrack.py b/backend/app/services/collectors/spacetrack.py
new file mode 100644
index 00000000..4f66c97d
--- /dev/null
+++ b/backend/app/services/collectors/spacetrack.py
@@ -0,0 +1,222 @@
+"""Space-Track TLE Collector
+
+Collects satellite TLE (Two-Line Element) data from Space-Track.org.
+API documentation: https://www.space-track.org/documentation
+"""
+
+import json
+from typing import Dict, Any, List
+import httpx
+
+from app.services.collectors.base import BaseCollector
+from app.core.data_sources import get_data_sources_config
+
+
+class SpaceTrackTLECollector(BaseCollector):
+ name = "spacetrack_tle"
+ priority = "P2"
+ module = "L3"
+ frequency_hours = 24
+ data_type = "satellite_tle"
+
+ @property
+ def base_url(self) -> str:
+ config = get_data_sources_config()
+ if self._resolved_url:
+ return self._resolved_url
+ return config.get_yaml_url("spacetrack_tle")
+
+ async def fetch(self) -> List[Dict[str, Any]]:
+ from app.core.config import settings
+
+ username = settings.SPACETRACK_USERNAME
+ password = settings.SPACETRACK_PASSWORD
+
+ if not username or not password:
+ print("SPACETRACK: No credentials configured, using sample data")
+ return self._get_sample_data()
+
+ print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=120.0,
+ follow_redirects=True,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Accept": "application/json, text/html, */*",
+ "Accept-Language": "en-US,en;q=0.9",
+ "Referer": "https://www.space-track.org/",
+ },
+ ) as client:
+ await client.get("https://www.space-track.org/")
+
+ login_response = await client.post(
+ "https://www.space-track.org/ajaxauth/login",
+ data={
+ "identity": username,
+ "password": password,
+ },
+ )
+ print(f"SPACETRACK: Login response status: {login_response.status_code}")
+ print(f"SPACETRACK: Login response URL: {login_response.url}")
+
+ if login_response.status_code == 403:
+ print("SPACETRACK: Trying alternate login method...")
+
+ async with httpx.AsyncClient(
+ timeout=120.0,
+ follow_redirects=True,
+ ) as alt_client:
+ await alt_client.get("https://www.space-track.org/")
+
+ form_data = {
+ "username": username,
+ "password": password,
+ "query": "class/gp/NORAD_CAT_ID/25544/format/json",
+ }
+ alt_login = await alt_client.post(
+ "https://www.space-track.org/ajaxauth/login",
+ data={
+ "identity": username,
+ "password": password,
+ },
+ )
+ print(f"SPACETRACK: Alt login status: {alt_login.status_code}")
+
+ if alt_login.status_code == 200:
+ tle_response = await alt_client.get(
+ "https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json"
+ )
+ if tle_response.status_code == 200:
+ data = tle_response.json()
+ print(f"SPACETRACK: Received {len(data)} records via alt method")
+ return data
+
+ if login_response.status_code != 200:
+ print(f"SPACETRACK: Login failed, using sample data")
+ return self._get_sample_data()
+
+ tle_response = await client.get(
+ "https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json"
+ )
+ print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
+
+ if tle_response.status_code != 200:
+ print(f"SPACETRACK: Query failed, using sample data")
+ return self._get_sample_data()
+
+ data = tle_response.json()
+ print(f"SPACETRACK: Received {len(data)} records")
+ return data
+ except Exception as e:
+ print(f"SPACETRACK: Error - {e}, using sample data")
+ return self._get_sample_data()
+
+ print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=120.0,
+ follow_redirects=True,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+ "Accept": "application/json, text/html, */*",
+ "Accept-Language": "en-US,en;q=0.9",
+ },
+ ) as client:
+ # First, visit the main page to get any cookies
+ await client.get("https://www.space-track.org/")
+
+ # Login to get session cookie
+ login_response = await client.post(
+ "https://www.space-track.org/ajaxauth/login",
+ data={
+ "identity": username,
+ "password": password,
+ },
+ )
+ print(f"SPACETRACK: Login response status: {login_response.status_code}")
+ print(f"SPACETRACK: Login response URL: {login_response.url}")
+ print(f"SPACETRACK: Login response body: {login_response.text[:500]}")
+
+ if login_response.status_code != 200:
+ print(f"SPACETRACK: Login failed, using sample data")
+ return self._get_sample_data()
+
+ # Query for TLE data (get first 1000 satellites)
+ tle_response = await client.get(
+ "https://www.space-track.org/basicspacedata/query"
+ "/class/gp"
+ "/orderby/EPOCH%20desc"
+ "/limit/1000"
+ "/format/json"
+ )
+ print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
+
+ if tle_response.status_code != 200:
+ print(f"SPACETRACK: Query failed, using sample data")
+ return self._get_sample_data()
+
+ data = tle_response.json()
+ print(f"SPACETRACK: Received {len(data)} records")
+ return data
+ except Exception as e:
+ print(f"SPACETRACK: Error - {e}, using sample data")
+ return self._get_sample_data()
+
+ def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Transform TLE data to internal format"""
+ transformed = []
+ for item in raw_data:
+ transformed.append(
+ {
+ "name": item.get("OBJECT_NAME", "Unknown"),
+ "norad_cat_id": item.get("NORAD_CAT_ID"),
+ "international_designator": item.get("INTL_DESIGNATOR"),
+ "epoch": item.get("EPOCH"),
+ "mean_motion": item.get("MEAN_MOTION"),
+ "eccentricity": item.get("ECCENTRICITY"),
+ "inclination": item.get("INCLINATION"),
+ "raan": item.get("RAAN"),
+ "arg_of_perigee": item.get("ARG_OF_PERIGEE"),
+ "mean_anomaly": item.get("MEAN_ANOMALY"),
+ "ephemeris_type": item.get("EPHEMERIS_TYPE"),
+ "classification_type": item.get("CLASSIFICATION_TYPE"),
+ "element_set_no": item.get("ELEMENT_SET_NO"),
+ "rev_at_epoch": item.get("REV_AT_EPOCH"),
+ "bstar": item.get("BSTAR"),
+ "mean_motion_dot": item.get("MEAN_MOTION_DOT"),
+ "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
+ }
+ )
+ return transformed
+
+ def _get_sample_data(self) -> List[Dict[str, Any]]:
+ """Return sample TLE data for testing"""
+ return [
+ {
+ "name": "ISS (ZARYA)",
+ "norad_cat_id": 25544,
+ "international_designator": "1998-067A",
+ "epoch": "2026-03-13T00:00:00Z",
+ "mean_motion": 15.49872723,
+ "eccentricity": 0.0006292,
+ "inclination": 51.6400,
+ "raan": 315.0000,
+ "arg_of_perigee": 100.0000,
+ "mean_anomaly": 260.0000,
+ },
+ {
+ "name": "STARLINK-1000",
+ "norad_cat_id": 44720,
+ "international_designator": "2019-029AZ",
+ "epoch": "2026-03-13T00:00:00Z",
+ "mean_motion": 15.79234567,
+ "eccentricity": 0.0001234,
+ "inclination": 53.0000,
+ "raan": 120.0000,
+ "arg_of_perigee": 90.0000,
+ "mean_anomaly": 270.0000,
+ },
+ ]
diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py
index 8990cfda..58762e11 100644
--- a/backend/app/services/scheduler.py
+++ b/backend/app/services/scheduler.py
@@ -33,6 +33,8 @@ COLLECTOR_TO_ID = {
"arcgis_landing_points": 16,
"arcgis_cable_landing_relation": 17,
"fao_landing_points": 18,
+ "spacetrack_tle": 19,
+ "celestrak_tle": 20,
}
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 72d77848..00d9865f 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -16,3 +16,4 @@ email-validator
apscheduler>=3.10.4
pytest>=7.4.0
pytest-asyncio>=0.23.0
+networkx>=3.0
diff --git a/docker-compose.yml b/docker-compose.yml
index a4c3d297..0df7ca19 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,6 +38,8 @@ services:
container_name: planet_backend
ports:
- "8000:8000"
+ env_file:
+ - .env
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/planet_db
- REDIS_URL=redis://redis:6379/0
@@ -48,6 +50,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
+ volumes:
+ - ./backend:/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
@@ -64,6 +68,8 @@ services:
environment:
- VITE_API_URL=http://backend:8000/api/v1
- VITE_WS_URL=ws://backend:8000/ws
+ volumes:
+ - ./frontend/public:/app/public
depends_on:
backend:
condition: service_healthy
diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css
index d08ac3da..589c1feb 100644
--- a/frontend/public/earth/css/base.css
+++ b/frontend/public/earth/css/base.css
@@ -17,14 +17,76 @@ body {
position: relative;
width: 100vw;
height: 100vh;
- /* user-select: none;
- -webkit-user-select: none; */
}
#container.dragging {
cursor: grabbing;
}
+/* Zoom Toolbar - Top Center */
+#zoom-toolbar {
+ position: absolute;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ background: rgba(10, 10, 30, 0.9);
+ padding: 10px 20px;
+ border-radius: 30px;
+ border: 1px solid rgba(77, 184, 255, 0.3);
+ box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
+ z-index: 200;
+}
+
+#zoom-toolbar .zoom-percent {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: #4db8ff;
+ min-width: 60px;
+ text-align: center;
+ cursor: pointer;
+ padding: 5px 10px;
+ border-radius: 5px;
+ transition: all 0.2s ease;
+}
+
+#zoom-toolbar .zoom-percent:hover {
+ background: rgba(77, 184, 255, 0.2);
+ box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
+}
+
+#zoom-toolbar .zoom-btn {
+ width: 36px;
+ height: 36px;
+ min-width: 36px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(77, 184, 255, 0.2);
+ color: #4db8ff;
+ font-size: 22px;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ padding: 0;
+ flex: 0 0 auto;
+}
+
+#zoom-toolbar .zoom-btn:hover {
+ background: rgba(77, 184, 255, 0.4);
+ transform: scale(1.1);
+ box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
+}
+
+#zoom-toolbar #zoom-slider {
+ width: 100px;
+ margin-left: 10px;
+}
+
#loading {
position: absolute;
top: 50%;
@@ -147,3 +209,128 @@ input[type="range"]::-webkit-slider-thumb {
display: none;
user-select: none;
}
+
+/* Control Toolbar - Stellarium/Star Walk style */
+#control-toolbar {
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ background: rgba(10, 10, 30, 0.9);
+ border-radius: 30px;
+ padding: 8px 12px;
+ border: 1px solid rgba(77, 184, 255, 0.3);
+ box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
+ z-index: 200;
+ transition: all 0.3s ease;
+}
+
+#control-toolbar.collapsed {
+ padding: 8px 8px 8px 12px;
+}
+
+#control-toolbar.collapsed .toolbar-items {
+ width: 0;
+ padding: 0;
+ overflow: hidden;
+ opacity: 0;
+}
+
+#toolbar-toggle {
+ font-size: 0;
+ min-width: 32px;
+ line-height: 1;
+ transition: all 0.3s ease;
+}
+
+.toggle-circle {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: rgba(77, 184, 255, 0.8);
+ box-shadow: 0 0 8px rgba(77, 184, 255, 0.6);
+}
+
+#control-toolbar:not(.collapsed) #toolbar-toggle {
+ background: rgba(77, 184, 255, 0.3);
+}
+
+.toolbar-items {
+ display: flex;
+ gap: 4px;
+ width: auto;
+ padding: 0 8px 0 4px;
+ overflow: visible;
+ opacity: 1;
+ transition: all 0.3s ease;
+ border-right: 1px solid rgba(77, 184, 255, 0.3);
+ margin-right: 4px;
+}
+
+.toolbar-btn {
+ position: relative;
+ width: 40px;
+ height: 40px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(77, 184, 255, 0.15);
+ color: #4db8ff;
+ font-size: 18px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.toolbar-btn:hover {
+ background: rgba(77, 184, 255, 0.35);
+ transform: scale(1.1);
+ box-shadow: 0 0 15px rgba(77, 184, 255, 0.5);
+}
+
+.toolbar-btn:active {
+ transform: scale(0.95);
+}
+
+.toolbar-btn.active {
+ background: rgba(77, 184, 255, 0.4);
+ box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
+}
+
+.toolbar-btn .tooltip {
+ position: absolute;
+ bottom: 50px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(10, 10, 30, 0.95);
+ color: #fff;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 12px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.2s ease;
+ border: 1px solid rgba(77, 184, 255, 0.4);
+ pointer-events: none;
+ z-index: 100;
+}
+
+.toolbar-btn:hover .tooltip {
+ opacity: 1;
+ visibility: visible;
+ bottom: 52px;
+}
+
+.toolbar-btn .tooltip::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 6px solid transparent;
+ border-top-color: rgba(77, 184, 255, 0.4);
+}
diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css
index 67bd0f7b..d7a1458a 100644
--- a/frontend/public/earth/css/earth-stats.css
+++ b/frontend/public/earth/css/earth-stats.css
@@ -29,3 +29,33 @@
color: #4db8ff;
font-weight: 500;
}
+
+#satellite-info {
+ position: absolute;
+ bottom: 20px;
+ right: 290px;
+ background-color: rgba(10, 10, 30, 0.9);
+ border-radius: 10px;
+ padding: 15px;
+ width: 220px;
+ z-index: 10;
+ box-shadow: 0 0 20px rgba(0, 229, 255, 0.3);
+ border: 1px solid rgba(0, 229, 255, 0.3);
+ font-size: 0.85rem;
+ backdrop-filter: blur(5px);
+}
+
+#satellite-info .stats-item {
+ margin-bottom: 6px;
+ display: flex;
+ justify-content: space-between;
+}
+
+#satellite-info .stats-label {
+ color: #aaa;
+}
+
+#satellite-info .stats-value {
+ color: #00e5ff;
+ font-weight: 500;
+}
diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css
index 183d0f97..a358db03 100644
--- a/frontend/public/earth/css/info-panel.css
+++ b/frontend/public/earth/css/info-panel.css
@@ -95,11 +95,153 @@
#info-panel .zoom-buttons {
display: flex;
- gap: 10px;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
margin-top: 10px;
+ width: 100%;
+}
+
+#info-panel .zoom-percent-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
+}
+
+#info-panel .zoom-percent {
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: #4db8ff;
+ min-width: 70px;
+ text-align: center;
+ cursor: pointer;
+ padding: 5px 10px;
+ border-radius: 5px;
+ transition: all 0.2s ease;
+}
+
+#info-panel .zoom-percent:hover {
+ background: rgba(77, 184, 255, 0.2);
+ box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
+}
+
+#info-panel .zoom-buttons .zoom-btn {
+ width: 36px;
+ height: 36px;
+ min-width: 36px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(77, 184, 255, 0.2);
+ color: #4db8ff;
+ font-size: 22px;
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ padding: 0;
+ flex: 0 0 auto;
+}
+
+#info-panel .zoom-buttons .zoom-btn:hover {
+ background: rgba(77, 184, 255, 0.4);
+ transform: scale(1.1);
+ box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#info-panel .zoom-buttons button {
flex: 1;
min-width: 60px;
}
+
+/* Info Card - Unified details panel (inside info-panel) */
+.info-card {
+ margin-top: 15px;
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ padding: 0;
+ overflow: hidden;
+}
+
+.info-card.no-border {
+ background: transparent;
+ border: none;
+}
+
+.info-card-header {
+ display: flex;
+ align-items: center;
+ padding: 10px 12px;
+ background: rgba(77, 184, 255, 0.1);
+ gap: 8px;
+}
+
+.info-card-icon {
+ font-size: 18px;
+}
+
+.info-card-header h3 {
+ flex: 1;
+ margin: 0;
+ font-size: 1rem;
+ color: #4db8ff;
+}
+
+.info-card-content {
+ padding: 10px 12px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.info-card-property {
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.info-card-property:last-child {
+ border-bottom: none;
+}
+
+.info-card-label {
+ color: #aaa;
+ font-size: 0.85rem;
+}
+
+.info-card-value {
+ color: #4db8ff;
+ font-weight: 500;
+ font-size: 0.9rem;
+ text-align: right;
+ max-width: 180px;
+ word-break: break-word;
+}
+
+/* Cable type */
+.info-card.cable {
+ border-color: rgba(255, 200, 0, 0.4);
+}
+
+.info-card.cable .info-card-header {
+ background: rgba(255, 200, 0, 0.15);
+}
+
+.info-card.cable .info-card-header h3 {
+ color: #ffc800;
+}
+
+/* Satellite type */
+.info-card.satellite {
+ border-color: rgba(0, 229, 255, 0.4);
+}
+
+.info-card.satellite .info-card-header {
+ background: rgba(0, 229, 255, 0.15);
+}
+
+.info-card.satellite .info-card-header h3 {
+ color: #00e5ff;
+}
diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html
index d116193a..3645c7b4 100644
--- a/frontend/public/earth/index.html
+++ b/frontend/public/earth/index.html
@@ -3,12 +3,13 @@
- 3D球形地图 - 海底电缆系统
+ 智能星球计划 - 现实层宇宙全息感知
@@ -20,58 +21,40 @@
+
+
+ 100%
+
+
+
+
-
全球海底电缆系统
-
3D地形球形地图可视化 | 高分辨率卫星图
-
-
-
缩放控制
-
-
-
-
-
-
+
智能星球计划
+
现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施
+
+
-
-
点击电缆查看详情
-
- 所有者:
- -
-
-
- 状态:
- -
-
-
- 长度:
- -
-
-
- 经纬度:
- -
-
-
- 投入使用时间:
- -
-
-
-
-
-
-
-
-
+
+
+
坐标信息
@@ -124,6 +107,10 @@
地形:
开启
+
+ 卫星:
+ 0 颗
+
视角距离:
300 km
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
index ea49b3eb..f6450d2a 100644
--- a/frontend/public/earth/js/cables.js
+++ b/frontend/public/earth/js/cables.js
@@ -4,7 +4,8 @@ import * as THREE from 'three';
import { CONFIG, CABLE_COLORS, PATHS } from './constants.js';
import { latLonToVector3 } from './utils.js';
-import { updateCableDetails, updateEarthStats, showStatusMessage } from './ui.js';
+import { updateEarthStats, showStatusMessage } from './ui.js';
+import { showInfoCard } from './info-card.js';
export let cableLines = [];
export let landingPoints = [];
@@ -312,8 +313,7 @@ export function handleCableClick(cable) {
lockedCable = cable;
const data = cable.userData;
- // console.log(data)
- updateCableDetails({
+ showInfoCard('cable', {
name: data.name,
owner: data.owner,
status: data.status,
@@ -327,14 +327,6 @@ export function handleCableClick(cable) {
export function clearCableSelection() {
lockedCable = null;
- updateCableDetails({
- name: '点击电缆查看详情',
- owner: '-',
- status: '-',
- length: '-',
- coords: '-',
- rfs: '-'
- });
}
export function getCableLines() {
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index 97cf206d..77e5b2d7 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -3,6 +3,8 @@
import { CONFIG } from './constants.js';
import { updateZoomDisplay, showStatusMessage } from './ui.js';
import { toggleTerrain } from './earth.js';
+import { reloadData } from './main.js';
+import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js';
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -20,20 +22,86 @@ export function setupControls(camera, renderer, scene, earth) {
}
function setupZoomControls(camera) {
- document.getElementById('zoom-in').addEventListener('click', () => {
- zoomLevel = Math.min(zoomLevel + 0.5, CONFIG.maxZoom);
- applyZoom(camera);
- });
+ let zoomInterval = null;
+ let lastDirection = 0;
+ let isSnapped = false;
- document.getElementById('zoom-out').addEventListener('click', () => {
- zoomLevel = Math.max(zoomLevel - 0.5, CONFIG.minZoom);
- applyZoom(camera);
- });
+ const MIN_PERCENT = CONFIG.minZoom * 100;
+ const MAX_PERCENT = CONFIG.maxZoom * 100;
- document.getElementById('zoom-reset').addEventListener('click', () => {
- zoomLevel = 1.0;
+ function adjustZoom(direction) {
+ const currentPercent = Math.round(zoomLevel * 100);
+ let newPercent;
+
+ if (direction > 0) {
+ if (!isSnapped || currentPercent % 10 !== 0) {
+ newPercent = Math.ceil(currentPercent / 10) * 10;
+ if (newPercent <= currentPercent) newPercent = currentPercent + 10;
+ isSnapped = true;
+ } else {
+ newPercent = currentPercent + 10;
+ }
+ } else {
+ if (!isSnapped || currentPercent % 10 !== 0) {
+ newPercent = Math.floor(currentPercent / 10) * 10;
+ if (newPercent >= currentPercent) newPercent = currentPercent - 10;
+ isSnapped = true;
+ } else {
+ newPercent = currentPercent - 10;
+ }
+ }
+
+ if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
+ if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
+
+ zoomLevel = newPercent / 100;
applyZoom(camera);
- showStatusMessage('缩放已重置', 'info');
+ }
+
+ function startZoom(direction) {
+ isSnapped = false;
+ lastDirection = direction;
+ adjustZoom(direction);
+ zoomInterval = setInterval(() => {
+ adjustZoom(direction);
+ }, 150);
+ }
+
+ function stopZoom() {
+ if (zoomInterval) {
+ clearInterval(zoomInterval);
+ zoomInterval = null;
+ }
+ }
+
+ document.getElementById('zoom-in').addEventListener('mousedown', () => startZoom(1));
+ document.getElementById('zoom-in').addEventListener('mouseup', stopZoom);
+ document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom);
+ document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); startZoom(1); });
+ document.getElementById('zoom-in').addEventListener('touchend', stopZoom);
+
+ document.getElementById('zoom-out').addEventListener('mousedown', () => startZoom(-1));
+ document.getElementById('zoom-out').addEventListener('mouseup', stopZoom);
+ document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom);
+ document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); startZoom(-1); });
+ document.getElementById('zoom-out').addEventListener('touchend', stopZoom);
+
+ document.getElementById('zoom-value').addEventListener('click', function() {
+ const startZoom = zoomLevel;
+ const targetZoom = 1.0;
+ const startDistance = CONFIG.defaultCameraZ / startZoom;
+ const targetDistance = CONFIG.defaultCameraZ / targetZoom;
+
+ animateValue(0, 1, 600, (progress) => {
+ const ease = 1 - Math.pow(1 - progress, 3);
+ zoomLevel = startZoom + (targetZoom - startZoom) * ease;
+ camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
+ const distance = startDistance + (targetDistance - startDistance) * ease;
+ updateZoomDisplay(zoomLevel, distance.toFixed(0));
+ }, () => {
+ zoomLevel = 1.0;
+ showStatusMessage('缩放已重置到100%', 'info');
+ });
});
const slider = document.getElementById('zoom-slider');
@@ -111,13 +179,12 @@ export function resetView(camera) {
}
function setupRotateControls(camera, earth) {
- document.getElementById('rotate-toggle').addEventListener('click', () => {
+ document.getElementById('rotate-toggle').addEventListener('click', function() {
toggleAutoRotate();
- const isOn = autoRotate;
- showStatusMessage(isOn ? '自动旋转已开启' : '自动旋转已暂停', 'info');
+ showStatusMessage(autoRotate ? '自动旋转已开启' : '自动旋转已暂停', 'info');
});
- document.getElementById('reset-view').addEventListener('click', () => {
+ document.getElementById('reset-view').addEventListener('click', function() {
if (!earthObj) return;
const startRotX = earthObj.rotation.x;
@@ -143,18 +210,45 @@ function setupRotateControls(camera, earth) {
}
function setupTerrainControls() {
- document.getElementById('toggle-terrain').addEventListener('click', () => {
+ document.getElementById('toggle-terrain').addEventListener('click', function() {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
- const btn = document.getElementById('toggle-terrain');
- btn.textContent = showTerrain ? '隐藏地形' : '显示地形';
+ this.classList.toggle('active', showTerrain);
+ this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
+ document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
});
- document.getElementById('reload-data').addEventListener('click', () => {
- showStatusMessage('重新加载数据...', 'info');
- window.location.reload();
+ document.getElementById('toggle-satellites').addEventListener('click', function() {
+ const showSats = !getShowSatellites();
+ toggleSatellites(showSats);
+ this.classList.toggle('active', showSats);
+ this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星';
+ document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗';
+ showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info');
});
+
+ document.getElementById('toggle-trails').addEventListener('click', function() {
+ const isActive = this.classList.contains('active');
+ const showTrails = !isActive;
+ toggleTrails(showTrails);
+ this.classList.toggle('active', showTrails);
+ this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹';
+ showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info');
+ });
+
+ document.getElementById('reload-data').addEventListener('click', async () => {
+ await reloadData();
+ showStatusMessage('数据已重新加载', 'success');
+ });
+
+ const toolbarToggle = document.getElementById('toolbar-toggle');
+ const toolbar = document.getElementById('control-toolbar');
+ if (toolbarToggle && toolbar) {
+ toolbarToggle.addEventListener('click', () => {
+ toolbar.classList.toggle('collapsed');
+ });
+ }
}
function setupMouseControls(camera, renderer) {
@@ -192,7 +286,9 @@ export function setAutoRotate(value) {
autoRotate = value;
const btn = document.getElementById('rotate-toggle');
if (btn) {
- btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
+ btn.classList.toggle('active', value);
+ const tooltip = btn.querySelector('.tooltip');
+ if (tooltip) tooltip.textContent = value ? '暂停旋转' : '自动旋转';
}
}
@@ -200,7 +296,9 @@ export function toggleAutoRotate() {
autoRotate = !autoRotate;
const btn = document.getElementById('rotate-toggle');
if (btn) {
- btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
+ btn.classList.toggle('active', autoRotate);
+ const tooltip = btn.querySelector('.tooltip');
+ if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '自动旋转';
}
if (window.clearLockedCable) {
window.clearLockedCable();
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
new file mode 100644
index 00000000..e7281862
--- /dev/null
+++ b/frontend/public/earth/js/info-card.js
@@ -0,0 +1,121 @@
+// info-card.js - Unified info card module
+
+let currentType = null;
+
+const CARD_CONFIG = {
+ cable: {
+ icon: '🛥️',
+ title: '电缆详情',
+ className: 'cable',
+ fields: [
+ { key: 'name', label: '名称' },
+ { key: 'owner', label: '所有者' },
+ { key: 'status', label: '状态' },
+ { key: 'length', label: '长度' },
+ { key: 'coords', label: '经纬度' },
+ { key: 'rfs', label: '投入使用' }
+ ]
+ },
+ satellite: {
+ icon: '🛰️',
+ title: '卫星详情',
+ className: 'satellite',
+ fields: [
+ { key: 'name', label: '名称' },
+ { key: 'norad_id', label: 'NORAD ID' },
+ { key: 'inclination', label: '倾角', unit: '°' },
+ { key: 'period', label: '周期', unit: '分钟' },
+ { key: 'perigee', label: '近地点', unit: 'km' },
+ { key: 'apogee', label: '远地点', unit: 'km' }
+ ]
+ },
+ supercomputer: {
+ icon: '🖥️',
+ title: '超算详情',
+ className: 'supercomputer',
+ fields: [
+ { key: 'name', label: '名称' },
+ { key: 'rank', label: '排名' },
+ { key: 'r_max', label: 'Rmax', unit: 'GFlops' },
+ { key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
+ { key: 'country', label: '国家' },
+ { key: 'city', label: '城市' }
+ ]
+ },
+ gpu_cluster: {
+ icon: '🎮',
+ title: 'GPU集群详情',
+ className: 'gpu_cluster',
+ fields: [
+ { key: 'name', label: '名称' },
+ { key: 'country', label: '国家' },
+ { key: 'city', label: '城市' }
+ ]
+ }
+};
+
+export function initInfoCard() {
+ // Close button removed - now uses external clear button
+}
+
+export function setInfoCardNoBorder(noBorder = true) {
+ const card = document.getElementById('info-card');
+ if (card) {
+ card.classList.toggle('no-border', noBorder);
+ }
+}
+
+export function showInfoCard(type, data) {
+ const config = CARD_CONFIG[type];
+ if (!config) {
+ console.warn('Unknown info card type:', type);
+ return;
+ }
+
+ currentType = type;
+ const card = document.getElementById('info-card');
+ const icon = document.getElementById('info-card-icon');
+ const title = document.getElementById('info-card-title');
+ const content = document.getElementById('info-card-content');
+
+ card.className = 'info-card ' + config.className;
+ icon.textContent = config.icon;
+ title.textContent = config.title;
+
+ let html = '';
+ for (const field of config.fields) {
+ let value = data[field.key];
+
+ if (value === undefined || value === null || value === '') {
+ value = '-';
+ } else if (typeof value === 'number') {
+ value = value.toLocaleString();
+ }
+
+ if (field.unit && value !== '-') {
+ value = value + ' ' + field.unit;
+ }
+
+ html += `
+
+ ${field.label}
+ ${value}
+
+ `;
+ }
+
+ content.innerHTML = html;
+ card.style.display = 'block';
+}
+
+export function hideInfoCard() {
+ const card = document.getElementById('info-card');
+ if (card) {
+ card.style.display = 'none';
+ }
+ currentType = null;
+}
+
+export function getCurrentType() {
+ return currentType;
+}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 4bf389f1..75c7a156 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -8,14 +8,15 @@ import {
updateCoordinatesDisplay,
updateZoomDisplay,
updateEarthStats,
- updateCableDetails,
setLoading,
showTooltip,
hideTooltip
} from './ui.js';
import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById } from './cables.js';
+import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, selectSatellite, getSatelliteData, getSatellitePoints } from './satellites.js';
import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate } from './controls.js';
+import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js';
export let scene, camera, renderer;
let simplex;
@@ -49,11 +50,13 @@ export function init() {
document.getElementById('container').appendChild(renderer.domElement);
addLights();
+ initInfoCard();
const earthObj = createEarth(scene);
createClouds(scene, earthObj);
createTerrain(scene, earthObj, simplex);
createStars(scene);
createGridLines(scene, earthObj);
+ createSatellites(scene, earthObj);
setupControls(camera, renderer, scene, earthObj);
setupEventListeners(camera, renderer);
@@ -80,7 +83,19 @@ function addLights() {
scene.add(pointLight);
}
-async function loadData() {
+let earthTexture = null;
+
+async function loadData(showWhiteSphere = false) {
+ if (showWhiteSphere) {
+ const earth = getEarth();
+ if (earth && earth.material) {
+ earthTexture = earth.material.map;
+ earth.material.map = null;
+ earth.material.color.setHex(0xffffff);
+ earth.material.needsUpdate = true;
+ }
+ }
+
setLoading(true);
try {
console.log('开始加载电缆数据...');
@@ -88,11 +103,29 @@ async function loadData() {
console.log('电缆数据加载完成');
await loadLandingPoints(scene, getEarth());
console.log('登陆点数据加载完成');
+
+ const satCount = await loadSatellites();
+ console.log(`卫星数据加载完成: ${satCount} 颗`);
+ updateSatellitePositions();
+ console.log('卫星位置已更新');
} catch (error) {
console.error('加载数据失败:', error);
showStatusMessage('加载数据失败: ' + error.message, 'error');
}
setLoading(false);
+
+ if (showWhiteSphere) {
+ const earth = getEarth();
+ if (earth && earth.material) {
+ earth.material.map = earthTexture;
+ earth.material.color.setHex(0xffffff);
+ earth.material.needsUpdate = true;
+ }
+ }
+}
+
+export async function reloadData() {
+ await loadData(true);
}
function setupEventListeners(camera, renderer) {
@@ -159,7 +192,7 @@ function onMouseMove(event, camera) {
});
hoveredCable = null;
}
-
+
if (intersects.length > 0) {
const cable = intersects[0].object;
const cableId = cable.userData.cableId;
@@ -171,34 +204,25 @@ function onMouseMove(event, camera) {
c.material.opacity = 1;
});
hoveredCable = cable;
+
+ showInfoCard('cable', {
+ name: cable.userData.name,
+ owner: cable.userData.owner,
+ status: cable.userData.status,
+ length: cable.userData.length,
+ coords: cable.userData.coords,
+ rfs: cable.userData.rfs
+ });
+ setInfoCardNoBorder(true);
}
const userData = cable.userData;
- document.getElementById('cable-name').textContent =
- userData.name || userData.shortname || '未命名电缆';
- document.getElementById('cable-owner').textContent = userData.owner || '-';
- document.getElementById('cable-status').textContent = userData.status || '-';
- document.getElementById('cable-length').textContent = userData.length || '-';
- document.getElementById('cable-coords').textContent = '-';
- document.getElementById('cable-rfs').textContent = userData.rfs || '-';
-
hideTooltip();
} else {
- if (lockedCable && lockedCableData) {
- document.getElementById('cable-name').textContent =
- lockedCableData.name || lockedCableData.shortname || '未命名电缆';
- document.getElementById('cable-owner').textContent = lockedCableData.owner || '-';
- document.getElementById('cable-status').textContent = lockedCableData.status || '-';
- document.getElementById('cable-length').textContent = lockedCableData.length || '-';
- document.getElementById('cable-coords').textContent = '-';
- document.getElementById('cable-rfs').textContent = lockedCableData.rfs || '-';
+ if (lockedCable) {
+ handleCableClick(lockedCable);
} else {
- document.getElementById('cable-name').textContent = '点击电缆查看详情';
- document.getElementById('cable-owner').textContent = '-';
- document.getElementById('cable-status').textContent = '-';
- document.getElementById('cable-length').textContent = '-';
- document.getElementById('cable-coords').textContent = '-';
- document.getElementById('cable-rfs').textContent = '-';
+ hideInfoCard();
}
const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
@@ -278,6 +302,45 @@ function onClick(event, camera, renderer) {
setAutoRotate(false);
handleCableClick(clickedCable);
+
+ showInfoCard('cable', {
+ name: clickedCable.userData.name,
+ owner: clickedCable.userData.owner,
+ status: clickedCable.userData.status,
+ length: clickedCable.userData.length,
+ coords: clickedCable.userData.coords,
+ rfs: clickedCable.userData.rfs
+ });
+ } else if (getShowSatellites()) {
+ const satIntersects = raycaster.intersectObject(getSatellitePoints());
+
+ if (satIntersects.length > 0) {
+ const index = satIntersects[0].index;
+ const sat = selectSatellite(index);
+
+ if (sat && sat.properties) {
+ const props = sat.properties;
+
+ const meanMotion = props.mean_motion || 0;
+ const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-';
+
+ const ecc = props.eccentricity || 0;
+ const earthRadius = 6371;
+ const perigee = (earthRadius * (1 - ecc)).toFixed(0);
+ const apogee = (earthRadius * (1 + ecc)).toFixed(0);
+
+ showInfoCard('satellite', {
+ name: props.name,
+ norad_id: props.norad_cat_id,
+ inclination: props.inclination ? props.inclination.toFixed(2) : '-',
+ period: period,
+ perigee: perigee,
+ apogee: apogee
+ });
+
+ showStatusMessage('已选择: ' + props.name, 'info');
+ }
+ }
} else {
if (lockedCable) {
const prevCableId = lockedCable.userData.cableId;
@@ -315,6 +378,8 @@ function animate() {
});
}
+ updateSatellitePositions(16);
+
renderer.render(scene, camera);
}
@@ -334,4 +399,9 @@ window.clearLockedCable = function() {
clearCableSelection();
};
+window.clearSelection = function() {
+ hideInfoCard();
+ window.clearLockedCable();
+};
+
document.addEventListener('DOMContentLoaded', init);
diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js
new file mode 100644
index 00000000..df2c68cb
--- /dev/null
+++ b/frontend/public/earth/js/satellites.js
@@ -0,0 +1,321 @@
+// satellites.js - Satellite visualization module with real SGP4 positions and animations
+
+import * as THREE from 'three';
+import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js';
+import { CONFIG } from './constants.js';
+
+let satellitePoints = null;
+let satelliteTrails = null;
+let satelliteData = [];
+let showSatellites = false;
+let showTrails = true;
+let animationTime = 0;
+let selectedSatellite = null;
+let satellitePositions = [];
+
+const SATELLITE_API = '/api/v1/visualization/geo/satellites?limit=2000';
+const MAX_SATELLITES = 500;
+const TRAIL_LENGTH = 30;
+
+export function createSatellites(scene, earthObj) {
+ const positions = new Float32Array(MAX_SATELLITES * 3);
+ const colors = new Float32Array(MAX_SATELLITES * 3);
+
+ const pointsGeometry = new THREE.BufferGeometry();
+ pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+
+ const pointsMaterial = new THREE.PointsMaterial({
+ size: 3,
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.9,
+ sizeAttenuation: true
+ });
+
+ satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
+ satellitePoints.visible = false;
+ satellitePoints.userData = { type: 'satellitePoints' };
+ earthObj.add(satellitePoints);
+
+ const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3);
+ const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3);
+
+ const trailGeometry = new THREE.BufferGeometry();
+ trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
+ trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3));
+
+ const trailMaterial = new THREE.LineBasicMaterial({
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.3,
+ blending: THREE.AdditiveBlending
+ });
+
+ satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
+ satelliteTrails.visible = false;
+ satelliteTrails.userData = { type: 'satelliteTrails' };
+ earthObj.add(satelliteTrails);
+
+ satellitePositions = [];
+ for (let i = 0; i < MAX_SATELLITES; i++) {
+ satellitePositions.push({
+ current: new THREE.Vector3(),
+ trail: []
+ });
+ }
+
+ return satellitePoints;
+}
+
+function computeSatellitePosition(satellite, time) {
+ try {
+ const props = satellite.properties;
+ if (!props || !props.norad_cat_id) {
+ return null;
+ }
+
+ const noradId = props.norad_cat_id;
+ const inclination = props.inclination || 53;
+ const raan = props.raan || 0;
+ const eccentricity = props.eccentricity || 0.0001;
+ const argOfPerigee = props.arg_of_perigee || 0;
+ const meanAnomaly = props.mean_anomaly || 0;
+ const meanMotion = props.mean_motion || 15;
+ const epoch = props.epoch || '';
+
+ const year = epoch && epoch.length >= 4 ? parseInt(epoch.substring(0, 4)) : time.getUTCFullYear();
+ const month = epoch && epoch.length >= 7 ? parseInt(epoch.substring(5, 7)) : time.getUTCMonth() + 1;
+ const day = epoch && epoch.length >= 10 ? parseInt(epoch.substring(8, 10)) : time.getUTCDate();
+
+ const tleLine1 = `1 ${String(noradId).padStart(5, '0')}U 00001A ${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}.00000000 .00000000 00000-0 00000-0 0 9999`;
+ const tleLine2 = `2 ${String(noradId).padStart(5, '0')} ${String(raan.toFixed(4)).padStart(8, ' ')} ${String(inclination.toFixed(4)).padStart(8, ' ')} ${String(eccentricity.toFixed(7)).replace('0.', '.')} ${String(argOfPerigee.toFixed(4)).padStart(8, ' ')} ${String(meanAnomaly.toFixed(4)).padStart(8, ' ')} ${String(meanMotion.toFixed(8)).padStart(11, ' ')} 0 9999`;
+
+ const satrec = twoline2satrec(tleLine1, tleLine2);
+ if (!satrec || satrec.error) {
+ return null;
+ }
+
+ const positionAndVelocity = propagate(satrec, time);
+ if (!positionAndVelocity || !positionAndVelocity.position) {
+ return null;
+ }
+
+ const x = positionAndVelocity.position.x;
+ const y = positionAndVelocity.position.y;
+ const z = positionAndVelocity.position.z;
+
+ if (!x || !y || !z) {
+ return null;
+ }
+
+ const r = Math.sqrt(x * x + y * y + z * z);
+ const earthRadius = 6371;
+ const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05;
+
+ const scale = displayRadius / r;
+
+ return new THREE.Vector3(x * scale, y * scale, z * scale);
+ } catch (e) {
+ return null;
+ }
+}
+
+function generateFallbackPosition(satellite, index, total) {
+ const radius = CONFIG.earthRadius + 5;
+
+ const noradId = satellite.properties?.norad_cat_id || index;
+ const inclination = satellite.properties?.inclination || 53;
+ const raan = satellite.properties?.raan || 0;
+ const meanAnomaly = satellite.properties?.mean_anomaly || 0;
+
+ const hash = String(noradId).split('').reduce((a, b) => a + b.charCodeAt(0), 0);
+ const randomOffset = (hash % 1000) / 1000;
+
+ const normalizedIndex = index / total;
+ const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180);
+ const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1);
+
+ const adjustedPhi = Math.abs(phi % Math.PI);
+ const adjustedTheta = theta + randomOffset * Math.PI * 2;
+
+ const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
+ const y = radius * Math.cos(adjustedPhi);
+ const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
+
+ return new THREE.Vector3(x, y, z);
+}
+
+export async function loadSatellites() {
+ try {
+ const response = await fetch(SATELLITE_API);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ satelliteData = data.features || [];
+
+ console.log(`Loaded ${satelliteData.length} satellites`);
+ return satelliteData;
+ } catch (error) {
+ console.error('Failed to load satellites:', error);
+ return [];
+ }
+}
+
+export function updateSatellitePositions(deltaTime = 0) {
+ if (!satellitePoints || satelliteData.length === 0) return;
+
+ animationTime += deltaTime * 0.001;
+
+ const positions = satellitePoints.geometry.attributes.position.array;
+ const colors = satellitePoints.geometry.attributes.color.array;
+
+ const trailPositions = satelliteTrails.geometry.attributes.position.array;
+ const trailColors = satelliteTrails.geometry.attributes.color.array;
+
+ const baseTime = new Date();
+ const count = Math.min(satelliteData.length, 500);
+
+ for (let i = 0; i < count; i++) {
+ const satellite = satelliteData[i];
+ const props = satellite.properties;
+
+ const timeOffset = (i / count) * 2 * Math.PI * 0.1;
+ const adjustedTime = new Date(baseTime.getTime() + timeOffset * 1000 * 60 * 10);
+
+ let pos = computeSatellitePosition(satellite, adjustedTime);
+
+ if (!pos) {
+ pos = generateFallbackPosition(satellite, i, count);
+ }
+
+ satellitePositions[i].current.copy(pos);
+
+ satellitePositions[i].trail.push(pos.clone());
+ if (satellitePositions[i].trail.length > TRAIL_LENGTH) {
+ satellitePositions[i].trail.shift();
+ }
+
+ positions[i * 3] = pos.x;
+ positions[i * 3 + 1] = pos.y;
+ positions[i * 3 + 2] = pos.z;
+
+ const inclination = props?.inclination || 53;
+ const name = props?.name || '';
+ const isStarlink = name.includes('STARLINK');
+ const isGeo = inclination > 20 && inclination < 30;
+ const isIridium = name.includes('IRIDIUM');
+
+ let r, g, b;
+ if (isStarlink) {
+ r = 0.0; g = 0.9; b = 1.0;
+ } else if (isGeo) {
+ r = 1.0; g = 0.8; b = 0.0;
+ } else if (isIridium) {
+ r = 1.0; g = 0.5; b = 0.0;
+ } else if (inclination > 50 && inclination < 70) {
+ r = 0.0; g = 1.0; b = 0.3;
+ } else {
+ r = 1.0; g = 1.0; b = 1.0;
+ }
+
+ colors[i * 3] = r;
+ colors[i * 3 + 1] = g;
+ colors[i * 3 + 2] = b;
+
+ const trail = satellitePositions[i].trail;
+ for (let j = 0; j < TRAIL_LENGTH; j++) {
+ const trailIdx = (i * TRAIL_LENGTH + j) * 3;
+
+ if (j < trail.length) {
+ const t = trail[j];
+ trailPositions[trailIdx] = t.x;
+ trailPositions[trailIdx + 1] = t.y;
+ trailPositions[trailIdx + 2] = t.z;
+
+ const alpha = j / trail.length;
+ trailColors[trailIdx] = r * alpha;
+ trailColors[trailIdx + 1] = g * alpha;
+ trailColors[trailIdx + 2] = b * alpha;
+ } else {
+ trailPositions[trailIdx] = 0;
+ trailPositions[trailIdx + 1] = 0;
+ trailPositions[trailIdx + 2] = 0;
+ trailColors[trailIdx] = 0;
+ trailColors[trailIdx + 1] = 0;
+ trailColors[trailIdx + 2] = 0;
+ }
+ }
+ }
+
+ for (let i = count; i < 2000; i++) {
+ positions[i * 3] = 0;
+ positions[i * 3 + 1] = 0;
+ positions[i * 3 + 2] = 0;
+
+ for (let j = 0; j < TRAIL_LENGTH; j++) {
+ const trailIdx = (i * TRAIL_LENGTH + j) * 3;
+ trailPositions[trailIdx] = 0;
+ trailPositions[trailIdx + 1] = 0;
+ trailPositions[trailIdx + 2] = 0;
+ }
+ }
+
+ satellitePoints.geometry.attributes.position.needsUpdate = true;
+ satellitePoints.geometry.attributes.color.needsUpdate = true;
+ satellitePoints.geometry.setDrawRange(0, count);
+
+ satelliteTrails.geometry.attributes.position.needsUpdate = true;
+ satelliteTrails.geometry.attributes.color.needsUpdate = true;
+}
+
+export function toggleSatellites(visible) {
+ showSatellites = visible;
+ if (satellitePoints) {
+ satellitePoints.visible = visible;
+ }
+ if (satelliteTrails) {
+ satelliteTrails.visible = visible && showTrails;
+ }
+}
+
+export function toggleTrails(visible) {
+ showTrails = visible;
+ if (satelliteTrails) {
+ satelliteTrails.visible = visible && showSatellites;
+ }
+}
+
+export function getShowSatellites() {
+ return showSatellites;
+}
+
+export function getSatelliteCount() {
+ return satelliteData.length;
+}
+
+export function getSatelliteAt(index) {
+ if (index >= 0 && index < satelliteData.length) {
+ return satelliteData[index];
+ }
+ return null;
+}
+
+export function getSatelliteData() {
+ return satelliteData;
+}
+
+export function selectSatellite(index) {
+ selectedSatellite = index;
+ return getSatelliteAt(index);
+}
+
+export function getSelectedSatellite() {
+ return selectedSatellite;
+}
+
+export function getSatellitePoints() {
+ return satellitePoints;
+}
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index 2723a272..1e9c1b7c 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -22,8 +22,9 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) {
// Update zoom display
export function updateZoomDisplay(zoomLevel, distance) {
- document.getElementById('zoom-value').textContent = zoomLevel.toFixed(1) + 'x';
- document.getElementById('zoom-level').textContent = '缩放: ' + zoomLevel.toFixed(1) + 'x';
+ const percent = Math.round(zoomLevel * 100);
+ document.getElementById('zoom-value').textContent = percent + '%';
+ document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
document.getElementById('zoom-slider').value = zoomLevel;
document.getElementById('camera-distance').textContent = distance + ' km';
}
diff --git a/frontend/public/earth/js/utils.js b/frontend/public/earth/js/utils.js
index 84f0bda2..dcc70ebe 100644
--- a/frontend/public/earth/js/utils.js
+++ b/frontend/public/earth/js/utils.js
@@ -20,7 +20,11 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
export function vector3ToLatLon(vector) {
const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI);
- const lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
+
+ let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
+
+ while (lon <= -180) lon += 360;
+ while (lon > 180) lon -= 360;
return {
lat: parseFloat(lat.toFixed(4)),
@@ -30,26 +34,43 @@ export function vector3ToLatLon(vector) {
}
// Convert screen coordinates to Earth surface 3D coordinates
-export function screenToEarthCoords(x, y, camera, earth) {
+export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) {
const raycaster = new THREE.Raycaster();
- const mouse = new THREE.Vector2(
- (x / window.innerWidth) * 2 - 1,
- -(y / window.innerHeight) * 2 + 1
- );
+ const mouse = new THREE.Vector2();
+
+ if (domElement === document.body) {
+ mouse.x = (clientX / window.innerWidth) * 2 - 1;
+ mouse.y = -(clientY / window.innerHeight) * 2 + 1;
+ } else {
+ const rect = domElement.getBoundingClientRect();
+ mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
+ mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
+ }
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(earth);
if (intersects.length > 0) {
- return intersects[0].point;
+ const localPoint = intersects[0].point.clone();
+ earth.worldToLocal(localPoint);
+ return localPoint;
}
return null;
}
-// Calculate simplified distance between two points
-export function calculateDistance(lat1, lon1, lat2, lon2) {
- const dx = lon2 - lon1;
- const dy = lat2 - lat1;
- return Math.sqrt(dx * dx + dy * dy);
+// Calculate accurate spherical distance between two points (Haversine formula)
+export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) {
+ const toRad = (angle) => (angle * Math.PI) / 180;
+
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
+ Math.sin(dLon / 2) * Math.sin(dLon / 2);
+
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+ return radius * c;
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index faf51997..5114b67b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -8,12 +8,12 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
- target: 'http://localhost:8000',
+ target: 'http://backend:8000',
changeOrigin: true,
secure: false,
},
'/ws': {
- target: 'ws://localhost:8000',
+ target: 'ws://backend:8000',
ws: true,
changeOrigin: true,
secure: false,
@@ -29,4 +29,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
+ optimizeDeps: {
+ exclude: ['satellite.js'],
+ },
})
diff --git a/restart.sh b/restart.sh
index a307ca10..e96be2a9 100755
--- a/restart.sh
+++ b/restart.sh
@@ -1,134 +1,33 @@
#!/bin/bash
-# Planet 重启脚本 - 停止并重启所有服务
+# Planet 重启脚本 - 使用 volume 映射,代码改动自动同步
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
-# 颜色定义
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m'
-
-echo -e "${BLUE}🔄 重启智能星球计划...${NC}"
+echo "🔄 重启智能星球计划..."
echo ""
-# 停止服务
-echo -e "${YELLOW}🛑 停止服务...${NC}"
+# 停止并重建容器(volume 映射会自动同步代码)
+echo "🛑 停止容器..."
+docker-compose down
-# 停止后端
-if pgrep -f "uvicorn.*app.main:app" > /dev/null 2>&1; then
- pkill -f "uvicorn.*app.main:app" 2>/dev/null || true
- echo " ✅ 后端已停止"
-else
- echo " ℹ️ 后端未运行"
-fi
+echo "🚀 启动服务..."
+docker-compose up -d
-# 停止前端
-if pgrep -f "vite" > /dev/null 2>&1; then
- pkill -f "vite" 2>/dev/null || true
- echo " ✅ 前端已停止"
-else
- echo " ℹ️ 前端未运行"
-fi
+# 等待服务就绪
+echo "⏳ 等待服务就绪..."
+sleep 10
-# 停止 Docker 服务
-if command -v docker &> /dev/null && docker ps &> /dev/null 2>&1; then
- if command -v docker-compose &> /dev/null; then
- docker-compose down > /dev/null 2>&1 || true
- else
- docker compose down > /dev/null 2>&1 || true
- fi
- echo " ✅ Docker 容器已停止"
-fi
-
-# 等待服务完全停止
+# 验证服务
echo ""
-echo -e "${YELLOW}⏳ 等待进程退出...${NC}"
-sleep 2
-
-# 检查端口是否已释放
-if lsof -i :8000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":8000" || ss -tlnp 2>/dev/null | grep -q ":8000"; then
- echo -e "${YELLOW}⚠️ 端口 8000 仍被占用,强制杀死...${NC}"
- fuser -k 8000/tcp 2>/dev/null || true
- sleep 1
-fi
-
-if lsof -i :3000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":3000" || ss -tlnp 2>/dev/null | grep -q ":3000"; then
- echo -e "${YELLOW}⚠️ 端口 3000 仍被占用,强制杀死...${NC}"
- fuser -k 3000/tcp 2>/dev/null || true
- sleep 1
-fi
+echo "📊 服务状态:"
+docker ps --format "table {{.Names}}\t{{.Status}}"
echo ""
-echo -e "${GREEN}✅ 服务已停止${NC}"
+echo "✅ 完成!"
+echo " 前端: http://localhost:3000"
+echo " 后端: http://localhost:8000"
echo ""
-
-# 启动服务
-echo -e "${BLUE}🚀 启动服务...${NC}"
-
-# 启动 Docker 服务
-if command -v docker &> /dev/null && docker ps &> /dev/null 2>&1; then
- echo -e "${BLUE}🗄️ 启动数据库...${NC}"
- if command -v docker-compose &> /dev/null; then
- docker-compose up -d postgres redis > /dev/null 2>&1 || true
- else
- docker compose up -d postgres redis > /dev/null 2>&1 || true
- fi
- echo -e "${YELLOW}⏳ 等待数据库就绪...${NC}"
- sleep 5
-fi
-
-# 同步 Python 依赖
-echo -e "${BLUE}🐍 同步 Python 依赖...${NC}"
-cd "$SCRIPT_DIR/backend"
-uv sync > /dev/null 2>&1 || true
-cd "$SCRIPT_DIR"
-
-# 初始化数据库
-echo -e "${BLUE}🗃️ 初始化数据库...${NC}"
-PYTHONPATH="$SCRIPT_DIR/backend" python "$SCRIPT_DIR/backend/scripts/init_admin.py" > /dev/null 2>&1 || true
-
-# 启动后端
-echo -e "${BLUE}🔧 启动后端服务...${NC}"
-export PYTHONPATH="$SCRIPT_DIR/backend"
-cd "$SCRIPT_DIR/backend"
-nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/planet_backend.log 2>&1 &
-BACKEND_PID=$!
-cd "$SCRIPT_DIR"
-
-echo -e "${YELLOW}⏳ 等待后端启动...${NC}"
-sleep 3
-
-if curl -s http://localhost:8000/health > /dev/null 2>&1; then
- echo -e "${GREEN}✅ 后端已启动${NC}"
-else
- echo -e "${RED}❌ 后端启动失败${NC}"
- tail -10 /tmp/planet_backend.log
-fi
-
-# 启动前端
-echo -e "${BLUE}🌐 启动前端服务...${NC}"
-cd "$SCRIPT_DIR/frontend"
-npm run dev > /tmp/planet_frontend.log 2>&1 &
-FRONTEND_PID=$!
-cd "$SCRIPT_DIR"
-
-sleep 2
-
-echo ""
-echo -e "${GREEN}========================================${NC}"
-echo -e "${GREEN}✅ 重启完成!${NC}"
-echo -e "${GREEN}========================================${NC}"
-echo ""
-echo -e "${BLUE}🌐 访问地址:${NC}"
-echo " 后端: http://localhost:8000"
-echo " API文档: http://localhost:8000/docs"
-echo " 前端: http://localhost:3000"
-echo ""
-echo -e "${BLUE}📝 日志:${NC}"
-echo " tail -f /tmp/planet_backend.log"
-echo " tail -f /tmp/planet_frontend.log"
+echo "💡 代码改动会自动同步,无需重启"