fix: 修复3D地球坐标映射多个严重bug
## Bug修复详情 ### 1. 致命错误:球面距离计算 (calculateDistance) - 问题:使用勾股定理计算经纬度距离,在球体表面完全错误 - 修复:改用Haversine公式计算球面大圆距离 - 影响:赤道1度=111km,极地1度=19km,原计算误差巨大 ### 2. 经度范围规范化 (vector3ToLatLon) - 问题:Math.atan2返回[-180°,180°],转换后可能超出标准范围 - 修复:添加while循环规范化到[-180, 180]区间 - 影响:避免本初子午线附近返回360°的异常值 ### 3. 屏幕坐标转换支持非全屏 (screenToEarthCoords) - 问题:假设Canvas永远全屏,非全屏时点击偏移严重 - 修复:新增domElement参数,使用getBoundingClientRect()计算相对坐标 - 影响:嵌入式3D地球组件也能精准拾取 ### 4. 地球旋转时经纬度映射错误 - 问题:Raycaster返回世界坐标,未考虑地球自转 - 修复:使用earth.worldToLocal()转换到本地坐标空间 - 影响:地球旋转时经纬度显示正确跟随 ## 新增功能 - CelesTrak卫星数据采集器 - Space-Track卫星数据采集器 - 卫星可视化模块(500颗,实时SGP4轨道计算) - 海底光缆悬停显示info-card - 统一info-card组件 - 工具栏按钮(Stellarium风格) - 缩放控制(百分比显示) - Docker volume映射(代码热更新) ## 文件变更 - utils.js: 坐标转换核心逻辑修复 - satellites.js: 新增卫星可视化 - cables.js: 悬停交互支持 - main.js: 悬停/锁定逻辑 - controls.js: 工具栏UI - info-card.js: 统一卡片组件 - docker-compose.yml: volume映射 - restart.sh: 简化重启脚本
This commit is contained in:
@@ -16,4 +16,4 @@ COPY . .
|
|||||||
|
|
||||||
EXPOSE 8000
|
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"]
|
||||||
|
|||||||
@@ -120,6 +120,20 @@ COLLECTOR_INFO = {
|
|||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"frequency_hours": 168,
|
"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()}
|
ID_TO_COLLECTOR = {info["id"]: name for name, info in COLLECTOR_INFO.items()}
|
||||||
|
|||||||
@@ -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 fastapi import APIRouter, HTTPException, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -12,6 +17,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Converter Functions ==============
|
||||||
|
|
||||||
|
|
||||||
def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||||
"""Convert cable records to GeoJSON FeatureCollection"""
|
"""Convert cable records to GeoJSON FeatureCollection"""
|
||||||
features = []
|
features = []
|
||||||
@@ -122,6 +130,117 @@ def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str,
|
|||||||
return {"type": "FeatureCollection", "features": features}
|
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")
|
@router.get("/geo/cables")
|
||||||
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||||
"""获取海底电缆 GeoJSON 数据 (LineString)"""
|
"""获取海底电缆 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
|
# Cache for cable graph
|
||||||
_cable_graph: Optional[CableGraph] = None
|
_cable_graph: Optional[CableGraph] = None
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
|
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||||
|
|
||||||
|
SPACETRACK_USERNAME: str = ""
|
||||||
|
SPACETRACK_PASSWORD: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def REDIS_URL(self) -> str:
|
def REDIS_URL(self) -> str:
|
||||||
return os.getenv(
|
return os.getenv(
|
||||||
@@ -34,7 +37,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ COLLECTOR_URL_KEYS = {
|
|||||||
"peeringdb_facility": "peeringdb.facility_url",
|
"peeringdb_facility": "peeringdb.facility_url",
|
||||||
"top500": "top500.url",
|
"top500": "top500.url",
|
||||||
"epoch_ai_gpu": "epoch_ai.gpu_clusters_url",
|
"epoch_ai_gpu": "epoch_ai.gpu_clusters_url",
|
||||||
|
"spacetrack_tle": "spacetrack.tle_query_url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,7 @@ top500:
|
|||||||
|
|
||||||
epoch_ai:
|
epoch_ai:
|
||||||
gpu_clusters_url: "https://epoch.ai/data/gpu-clusters"
|
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"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ from app.services.collectors.arcgis_cables import ArcGISCableCollector
|
|||||||
from app.services.collectors.fao_landing import FAOLandingPointCollector
|
from app.services.collectors.fao_landing import FAOLandingPointCollector
|
||||||
from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector
|
from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector
|
||||||
from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector
|
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(TOP500Collector())
|
||||||
collector_registry.register(EpochAIGPUCollector())
|
collector_registry.register(EpochAIGPUCollector())
|
||||||
@@ -47,3 +49,5 @@ collector_registry.register(ArcGISCableCollector())
|
|||||||
collector_registry.register(FAOLandingPointCollector())
|
collector_registry.register(FAOLandingPointCollector())
|
||||||
collector_registry.register(ArcGISLandingPointCollector())
|
collector_registry.register(ArcGISLandingPointCollector())
|
||||||
collector_registry.register(ArcGISCableLandingRelationCollector())
|
collector_registry.register(ArcGISCableLandingRelationCollector())
|
||||||
|
collector_registry.register(SpaceTrackTLECollector())
|
||||||
|
collector_registry.register(CelesTrakTLECollector())
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ class BaseCollector(ABC):
|
|||||||
records_added = 0
|
records_added = 0
|
||||||
|
|
||||||
for i, item in enumerate(data):
|
for i, item in enumerate(data):
|
||||||
|
print(
|
||||||
|
f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}"
|
||||||
|
)
|
||||||
record = CollectedData(
|
record = CollectedData(
|
||||||
source=self.name,
|
source=self.name,
|
||||||
source_id=item.get("source_id") or item.get("id"),
|
source_id=item.get("source_id") or item.get("id"),
|
||||||
|
|||||||
99
backend/app/services/collectors/celestrak.py
Normal file
99
backend/app/services/collectors/celestrak.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
222
backend/app/services/collectors/spacetrack.py
Normal file
222
backend/app/services/collectors/spacetrack.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -33,6 +33,8 @@ COLLECTOR_TO_ID = {
|
|||||||
"arcgis_landing_points": 16,
|
"arcgis_landing_points": 16,
|
||||||
"arcgis_cable_landing_relation": 17,
|
"arcgis_cable_landing_relation": 17,
|
||||||
"fao_landing_points": 18,
|
"fao_landing_points": 18,
|
||||||
|
"spacetrack_tle": 19,
|
||||||
|
"celestrak_tle": 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ email-validator
|
|||||||
apscheduler>=3.10.4
|
apscheduler>=3.10.4
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
pytest-asyncio>=0.23.0
|
pytest-asyncio>=0.23.0
|
||||||
|
networkx>=3.0
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ services:
|
|||||||
container_name: planet_backend
|
container_name: planet_backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/planet_db
|
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/planet_db
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
@@ -48,6 +50,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -64,6 +68,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://backend:8000/api/v1
|
- VITE_API_URL=http://backend:8000/api/v1
|
||||||
- VITE_WS_URL=ws://backend:8000/ws
|
- VITE_WS_URL=ws://backend:8000/ws
|
||||||
|
volumes:
|
||||||
|
- ./frontend/public:/app/public
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -17,14 +17,76 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
/* user-select: none;
|
|
||||||
-webkit-user-select: none; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#container.dragging {
|
#container.dragging {
|
||||||
cursor: grabbing;
|
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 {
|
#loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -147,3 +209,128 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
display: none;
|
display: none;
|
||||||
user-select: 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,3 +29,33 @@
|
|||||||
color: #4db8ff;
|
color: #4db8ff;
|
||||||
font-weight: 500;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,11 +95,153 @@
|
|||||||
|
|
||||||
#info-panel .zoom-buttons {
|
#info-panel .zoom-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
margin-top: 10px;
|
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 {
|
#info-panel .zoom-buttons button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 60px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>3D球形地图 - 海底电缆系统</title>
|
<title>智能星球计划 - 现实层宇宙全息感知</title>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"three": "https://esm.sh/three@0.128.0",
|
"three": "https://esm.sh/three@0.128.0",
|
||||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1"
|
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||||
|
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -20,58 +21,40 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="container">
|
<div id="container">
|
||||||
|
<div id="zoom-toolbar">
|
||||||
|
<button id="zoom-out" class="zoom-btn">−</button>
|
||||||
|
<span id="zoom-value" class="zoom-percent">100%</span>
|
||||||
|
<button id="zoom-in" class="zoom-btn">+</button>
|
||||||
|
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.01" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="info-panel">
|
<div id="info-panel">
|
||||||
<h1>全球海底电缆系统</h1>
|
<h1>智能星球计划</h1>
|
||||||
<div class="subtitle">3D地形球形地图可视化 | 高分辨率卫星图</div>
|
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
|
||||||
<div class="zoom-controls">
|
|
||||||
<div style="width: 100%;">
|
<div id="info-card" class="info-card" style="display: none;">
|
||||||
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">缩放控制</h3>
|
<div class="info-card-header">
|
||||||
<div class="zoom-buttons">
|
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||||
<button id="zoom-in">放大</button>
|
<h3 id="info-card-title">详情</h3>
|
||||||
<button id="zoom-out">缩小</button>
|
|
||||||
<button id="zoom-reset">重置</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="slider-container" style="margin-top: 10px;">
|
<div id="info-card-content"></div>
|
||||||
<div class="slider-label">
|
|
||||||
<span>缩放级别:</span>
|
|
||||||
<span id="zoom-value">1.0x</span>
|
|
||||||
</div>
|
|
||||||
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.1" value="1">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="cable-details" class="cable-info">
|
|
||||||
<h3 id="cable-name">点击电缆查看详情</h3>
|
|
||||||
<div class="cable-property">
|
|
||||||
<span class="property-label">所有者:</span>
|
|
||||||
<span id="cable-owner" class="property-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="cable-property">
|
|
||||||
<span class="property-label">状态:</span>
|
|
||||||
<span id="cable-status" class="property-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="cable-property">
|
|
||||||
<span class="property-label">长度:</span>
|
|
||||||
<span id="cable-length" class="property-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="cable-property">
|
|
||||||
<span class="property-label">经纬度:</span>
|
|
||||||
<span id="cable-coords" class="property-value">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="cable-property">
|
|
||||||
<span class="property-label">投入使用时间:</span>
|
|
||||||
<span id="cable-rfs" class="property-value">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<button id="rotate-toggle">暂停旋转</button>
|
|
||||||
<button id="reset-view">重置视图</button>
|
|
||||||
<button id="toggle-terrain">显示地形</button>
|
|
||||||
<button id="reload-data">重新加载数据</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="error-message" class="error-message"></div>
|
<div id="error-message" class="error-message"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="control-toolbar">
|
||||||
|
<div class="toolbar-items">
|
||||||
|
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">🔄<span class="tooltip">自动旋转</span></button>
|
||||||
|
<button id="reset-view" class="toolbar-btn" title="重置视图">🎯<span class="tooltip">重置视图</span></button>
|
||||||
|
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">⛰️<span class="tooltip">显示/隐藏地形</span></button>
|
||||||
|
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">🛰️<span class="tooltip">显示/隐藏卫星</span></button>
|
||||||
|
<button id="toggle-trails" class="toolbar-btn" title="显示/隐藏轨迹">✨<span class="tooltip">显示/隐藏轨迹</span></button>
|
||||||
|
<button id="reload-data" class="toolbar-btn" title="重新加载数据">🔃<span class="tooltip">重新加载数据</span></button>
|
||||||
|
</div>
|
||||||
|
<button id="toolbar-toggle" class="toolbar-btn" title="展开/收起工具栏"><span class="toggle-circle"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="coordinates-display">
|
<div id="coordinates-display">
|
||||||
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
|
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
|
||||||
<div class="coord-item">
|
<div class="coord-item">
|
||||||
@@ -124,6 +107,10 @@
|
|||||||
<span class="stats-label">地形:</span>
|
<span class="stats-label">地形:</span>
|
||||||
<span class="stats-value" id="terrain-status">开启</span>
|
<span class="stats-value" id="terrain-status">开启</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stats-item">
|
||||||
|
<span class="stats-label">卫星:</span>
|
||||||
|
<span class="stats-value" id="satellite-count">0 颗</span>
|
||||||
|
</div>
|
||||||
<div class="stats-item">
|
<div class="stats-item">
|
||||||
<span class="stats-label">视角距离:</span>
|
<span class="stats-label">视角距离:</span>
|
||||||
<span class="stats-value" id="camera-distance">300 km</span>
|
<span class="stats-value" id="camera-distance">300 km</span>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import * as THREE from 'three';
|
|||||||
|
|
||||||
import { CONFIG, CABLE_COLORS, PATHS } from './constants.js';
|
import { CONFIG, CABLE_COLORS, PATHS } from './constants.js';
|
||||||
import { latLonToVector3 } from './utils.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 cableLines = [];
|
||||||
export let landingPoints = [];
|
export let landingPoints = [];
|
||||||
@@ -312,8 +313,7 @@ export function handleCableClick(cable) {
|
|||||||
lockedCable = cable;
|
lockedCable = cable;
|
||||||
|
|
||||||
const data = cable.userData;
|
const data = cable.userData;
|
||||||
// console.log(data)
|
showInfoCard('cable', {
|
||||||
updateCableDetails({
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
owner: data.owner,
|
owner: data.owner,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
@@ -327,14 +327,6 @@ export function handleCableClick(cable) {
|
|||||||
|
|
||||||
export function clearCableSelection() {
|
export function clearCableSelection() {
|
||||||
lockedCable = null;
|
lockedCable = null;
|
||||||
updateCableDetails({
|
|
||||||
name: '点击电缆查看详情',
|
|
||||||
owner: '-',
|
|
||||||
status: '-',
|
|
||||||
length: '-',
|
|
||||||
coords: '-',
|
|
||||||
rfs: '-'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCableLines() {
|
export function getCableLines() {
|
||||||
|
|||||||
144
frontend/public/earth/js/controls.js
vendored
144
frontend/public/earth/js/controls.js
vendored
@@ -3,6 +3,8 @@
|
|||||||
import { CONFIG } from './constants.js';
|
import { CONFIG } from './constants.js';
|
||||||
import { updateZoomDisplay, showStatusMessage } from './ui.js';
|
import { updateZoomDisplay, showStatusMessage } from './ui.js';
|
||||||
import { toggleTerrain } from './earth.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 autoRotate = true;
|
||||||
export let zoomLevel = 1.0;
|
export let zoomLevel = 1.0;
|
||||||
@@ -20,20 +22,86 @@ export function setupControls(camera, renderer, scene, earth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupZoomControls(camera) {
|
function setupZoomControls(camera) {
|
||||||
document.getElementById('zoom-in').addEventListener('click', () => {
|
let zoomInterval = null;
|
||||||
zoomLevel = Math.min(zoomLevel + 0.5, CONFIG.maxZoom);
|
let lastDirection = 0;
|
||||||
applyZoom(camera);
|
let isSnapped = false;
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('zoom-out').addEventListener('click', () => {
|
const MIN_PERCENT = CONFIG.minZoom * 100;
|
||||||
zoomLevel = Math.max(zoomLevel - 0.5, CONFIG.minZoom);
|
const MAX_PERCENT = CONFIG.maxZoom * 100;
|
||||||
applyZoom(camera);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('zoom-reset').addEventListener('click', () => {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
zoomLevel = 1.0;
|
||||||
applyZoom(camera);
|
showStatusMessage('缩放已重置到100%', 'info');
|
||||||
showStatusMessage('缩放已重置', 'info');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const slider = document.getElementById('zoom-slider');
|
const slider = document.getElementById('zoom-slider');
|
||||||
@@ -111,13 +179,12 @@ export function resetView(camera) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupRotateControls(camera, earth) {
|
function setupRotateControls(camera, earth) {
|
||||||
document.getElementById('rotate-toggle').addEventListener('click', () => {
|
document.getElementById('rotate-toggle').addEventListener('click', function() {
|
||||||
toggleAutoRotate();
|
toggleAutoRotate();
|
||||||
const isOn = autoRotate;
|
showStatusMessage(autoRotate ? '自动旋转已开启' : '自动旋转已暂停', 'info');
|
||||||
showStatusMessage(isOn ? '自动旋转已开启' : '自动旋转已暂停', 'info');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('reset-view').addEventListener('click', () => {
|
document.getElementById('reset-view').addEventListener('click', function() {
|
||||||
if (!earthObj) return;
|
if (!earthObj) return;
|
||||||
|
|
||||||
const startRotX = earthObj.rotation.x;
|
const startRotX = earthObj.rotation.x;
|
||||||
@@ -143,18 +210,45 @@ function setupRotateControls(camera, earth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupTerrainControls() {
|
function setupTerrainControls() {
|
||||||
document.getElementById('toggle-terrain').addEventListener('click', () => {
|
document.getElementById('toggle-terrain').addEventListener('click', function() {
|
||||||
showTerrain = !showTerrain;
|
showTerrain = !showTerrain;
|
||||||
toggleTerrain(showTerrain);
|
toggleTerrain(showTerrain);
|
||||||
const btn = document.getElementById('toggle-terrain');
|
this.classList.toggle('active', showTerrain);
|
||||||
btn.textContent = showTerrain ? '隐藏地形' : '显示地形';
|
this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
|
||||||
|
document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
|
||||||
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
|
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('reload-data').addEventListener('click', () => {
|
document.getElementById('toggle-satellites').addEventListener('click', function() {
|
||||||
showStatusMessage('重新加载数据...', 'info');
|
const showSats = !getShowSatellites();
|
||||||
window.location.reload();
|
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) {
|
function setupMouseControls(camera, renderer) {
|
||||||
@@ -192,7 +286,9 @@ export function setAutoRotate(value) {
|
|||||||
autoRotate = value;
|
autoRotate = value;
|
||||||
const btn = document.getElementById('rotate-toggle');
|
const btn = document.getElementById('rotate-toggle');
|
||||||
if (btn) {
|
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;
|
autoRotate = !autoRotate;
|
||||||
const btn = document.getElementById('rotate-toggle');
|
const btn = document.getElementById('rotate-toggle');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
|
btn.classList.toggle('active', autoRotate);
|
||||||
|
const tooltip = btn.querySelector('.tooltip');
|
||||||
|
if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '自动旋转';
|
||||||
}
|
}
|
||||||
if (window.clearLockedCable) {
|
if (window.clearLockedCable) {
|
||||||
window.clearLockedCable();
|
window.clearLockedCable();
|
||||||
|
|||||||
121
frontend/public/earth/js/info-card.js
Normal file
121
frontend/public/earth/js/info-card.js
Normal file
@@ -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 += `
|
||||||
|
<div class="info-card-property">
|
||||||
|
<span class="info-card-label">${field.label}</span>
|
||||||
|
<span class="info-card-value">${value}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -8,14 +8,15 @@ import {
|
|||||||
updateCoordinatesDisplay,
|
updateCoordinatesDisplay,
|
||||||
updateZoomDisplay,
|
updateZoomDisplay,
|
||||||
updateEarthStats,
|
updateEarthStats,
|
||||||
updateCableDetails,
|
|
||||||
setLoading,
|
setLoading,
|
||||||
showTooltip,
|
showTooltip,
|
||||||
hideTooltip
|
hideTooltip
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
|
import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
|
||||||
import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById } from './cables.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 { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate } from './controls.js';
|
||||||
|
import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js';
|
||||||
|
|
||||||
export let scene, camera, renderer;
|
export let scene, camera, renderer;
|
||||||
let simplex;
|
let simplex;
|
||||||
@@ -49,11 +50,13 @@ export function init() {
|
|||||||
document.getElementById('container').appendChild(renderer.domElement);
|
document.getElementById('container').appendChild(renderer.domElement);
|
||||||
|
|
||||||
addLights();
|
addLights();
|
||||||
|
initInfoCard();
|
||||||
const earthObj = createEarth(scene);
|
const earthObj = createEarth(scene);
|
||||||
createClouds(scene, earthObj);
|
createClouds(scene, earthObj);
|
||||||
createTerrain(scene, earthObj, simplex);
|
createTerrain(scene, earthObj, simplex);
|
||||||
createStars(scene);
|
createStars(scene);
|
||||||
createGridLines(scene, earthObj);
|
createGridLines(scene, earthObj);
|
||||||
|
createSatellites(scene, earthObj);
|
||||||
|
|
||||||
setupControls(camera, renderer, scene, earthObj);
|
setupControls(camera, renderer, scene, earthObj);
|
||||||
setupEventListeners(camera, renderer);
|
setupEventListeners(camera, renderer);
|
||||||
@@ -80,7 +83,19 @@ function addLights() {
|
|||||||
scene.add(pointLight);
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
console.log('开始加载电缆数据...');
|
console.log('开始加载电缆数据...');
|
||||||
@@ -88,11 +103,29 @@ async function loadData() {
|
|||||||
console.log('电缆数据加载完成');
|
console.log('电缆数据加载完成');
|
||||||
await loadLandingPoints(scene, getEarth());
|
await loadLandingPoints(scene, getEarth());
|
||||||
console.log('登陆点数据加载完成');
|
console.log('登陆点数据加载完成');
|
||||||
|
|
||||||
|
const satCount = await loadSatellites();
|
||||||
|
console.log(`卫星数据加载完成: ${satCount} 颗`);
|
||||||
|
updateSatellitePositions();
|
||||||
|
console.log('卫星位置已更新');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载数据失败:', error);
|
console.error('加载数据失败:', error);
|
||||||
showStatusMessage('加载数据失败: ' + error.message, 'error');
|
showStatusMessage('加载数据失败: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
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) {
|
function setupEventListeners(camera, renderer) {
|
||||||
@@ -171,34 +204,25 @@ function onMouseMove(event, camera) {
|
|||||||
c.material.opacity = 1;
|
c.material.opacity = 1;
|
||||||
});
|
});
|
||||||
hoveredCable = cable;
|
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;
|
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();
|
hideTooltip();
|
||||||
} else {
|
} else {
|
||||||
if (lockedCable && lockedCableData) {
|
if (lockedCable) {
|
||||||
document.getElementById('cable-name').textContent =
|
handleCableClick(lockedCable);
|
||||||
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 || '-';
|
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('cable-name').textContent = '点击电缆查看详情';
|
hideInfoCard();
|
||||||
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 = '-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
|
const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
|
||||||
@@ -278,6 +302,45 @@ function onClick(event, camera, renderer) {
|
|||||||
|
|
||||||
setAutoRotate(false);
|
setAutoRotate(false);
|
||||||
handleCableClick(clickedCable);
|
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 {
|
} else {
|
||||||
if (lockedCable) {
|
if (lockedCable) {
|
||||||
const prevCableId = lockedCable.userData.cableId;
|
const prevCableId = lockedCable.userData.cableId;
|
||||||
@@ -315,6 +378,8 @@ function animate() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSatellitePositions(16);
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,4 +399,9 @@ window.clearLockedCable = function() {
|
|||||||
clearCableSelection();
|
clearCableSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.clearSelection = function() {
|
||||||
|
hideInfoCard();
|
||||||
|
window.clearLockedCable();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
321
frontend/public/earth/js/satellites.js
Normal file
321
frontend/public/earth/js/satellites.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) {
|
|||||||
|
|
||||||
// Update zoom display
|
// Update zoom display
|
||||||
export function updateZoomDisplay(zoomLevel, distance) {
|
export function updateZoomDisplay(zoomLevel, distance) {
|
||||||
document.getElementById('zoom-value').textContent = zoomLevel.toFixed(1) + 'x';
|
const percent = Math.round(zoomLevel * 100);
|
||||||
document.getElementById('zoom-level').textContent = '缩放: ' + zoomLevel.toFixed(1) + 'x';
|
document.getElementById('zoom-value').textContent = percent + '%';
|
||||||
|
document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
|
||||||
document.getElementById('zoom-slider').value = zoomLevel;
|
document.getElementById('zoom-slider').value = zoomLevel;
|
||||||
document.getElementById('camera-distance').textContent = distance + ' km';
|
document.getElementById('camera-distance').textContent = distance + ' km';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
|
|||||||
export function vector3ToLatLon(vector) {
|
export function vector3ToLatLon(vector) {
|
||||||
const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
|
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 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 {
|
return {
|
||||||
lat: parseFloat(lat.toFixed(4)),
|
lat: parseFloat(lat.toFixed(4)),
|
||||||
@@ -30,26 +34,43 @@ export function vector3ToLatLon(vector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert screen coordinates to Earth surface 3D coordinates
|
// 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 raycaster = new THREE.Raycaster();
|
||||||
const mouse = new THREE.Vector2(
|
const mouse = new THREE.Vector2();
|
||||||
(x / window.innerWidth) * 2 - 1,
|
|
||||||
-(y / window.innerHeight) * 2 + 1
|
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);
|
raycaster.setFromCamera(mouse, camera);
|
||||||
const intersects = raycaster.intersectObject(earth);
|
const intersects = raycaster.intersectObject(earth);
|
||||||
|
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
return intersects[0].point;
|
const localPoint = intersects[0].point.clone();
|
||||||
|
earth.worldToLocal(localPoint);
|
||||||
|
return localPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate simplified distance between two points
|
// Calculate accurate spherical distance between two points (Haversine formula)
|
||||||
export function calculateDistance(lat1, lon1, lat2, lon2) {
|
export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) {
|
||||||
const dx = lon2 - lon1;
|
const toRad = (angle) => (angle * Math.PI) / 180;
|
||||||
const dy = lat2 - lat1;
|
|
||||||
return Math.sqrt(dx * dx + dy * dy);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://backend:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:8000',
|
target: 'ws://backend:8000',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
@@ -29,4 +29,7 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['satellite.js'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
133
restart.sh
133
restart.sh
@@ -1,134 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Planet 重启脚本 - 停止并重启所有服务
|
# Planet 重启脚本 - 使用 volume 映射,代码改动自动同步
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# 颜色定义
|
echo "🔄 重启智能星球计划..."
|
||||||
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 ""
|
||||||
|
|
||||||
# 停止服务
|
# 停止并重建容器(volume 映射会自动同步代码)
|
||||||
echo -e "${YELLOW}🛑 停止服务...${NC}"
|
echo "🛑 停止容器..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
# 停止后端
|
echo "🚀 启动服务..."
|
||||||
if pgrep -f "uvicorn.*app.main:app" > /dev/null 2>&1; then
|
docker-compose up -d
|
||||||
pkill -f "uvicorn.*app.main:app" 2>/dev/null || true
|
|
||||||
echo " ✅ 后端已停止"
|
|
||||||
else
|
|
||||||
echo " ℹ️ 后端未运行"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 停止前端
|
# 等待服务就绪
|
||||||
if pgrep -f "vite" > /dev/null 2>&1; then
|
echo "⏳ 等待服务就绪..."
|
||||||
pkill -f "vite" 2>/dev/null || true
|
sleep 10
|
||||||
echo " ✅ 前端已停止"
|
|
||||||
else
|
|
||||||
echo " ℹ️ 前端未运行"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 停止 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 ""
|
||||||
echo -e "${YELLOW}⏳ 等待进程退出...${NC}"
|
echo "📊 服务状态:"
|
||||||
sleep 2
|
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||||
|
|
||||||
# 检查端口是否已释放
|
|
||||||
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 ""
|
echo ""
|
||||||
echo -e "${GREEN}✅ 服务已停止${NC}"
|
echo "✅ 完成!"
|
||||||
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 " 前端: http://localhost:3000"
|
||||||
|
echo " 后端: http://localhost:8000"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}📝 日志:${NC}"
|
echo "💡 代码改动会自动同步,无需重启"
|
||||||
echo " tail -f /tmp/planet_backend.log"
|
|
||||||
echo " tail -f /tmp/planet_frontend.log"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user