diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py
index c3e295e6..215183f5 100644
--- a/backend/app/api/v1/visualization.py
+++ b/backend/app/api/v1/visualization.py
@@ -11,6 +11,7 @@ from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
from app.core.collected_data_fields import get_record_field
+from app.core.satellite_tle import build_tle_lines_from_elements
from app.db.session import get_db
from app.models.collected_data import CollectedData
from app.services.cable_graph import build_graph_from_data, CableGraph
@@ -155,6 +156,20 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
if not norad_id:
continue
+ tle_line1 = metadata.get("tle_line1")
+ tle_line2 = metadata.get("tle_line2")
+ if not tle_line1 or not tle_line2:
+ tle_line1, tle_line2 = build_tle_lines_from_elements(
+ norad_cat_id=norad_id,
+ 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"),
+ )
+
features.append(
{
"type": "Feature",
@@ -174,6 +189,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
"mean_motion": metadata.get("mean_motion"),
"bstar": metadata.get("bstar"),
"classification_type": metadata.get("classification_type"),
+ "tle_line1": tle_line1,
+ "tle_line2": tle_line2,
"data_type": "satellite_tle",
},
}
diff --git a/backend/app/core/satellite_tle.py b/backend/app/core/satellite_tle.py
new file mode 100644
index 00000000..a4392aff
--- /dev/null
+++ b/backend/app/core/satellite_tle.py
@@ -0,0 +1,116 @@
+"""Helpers for building stable TLE lines from orbital elements."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Optional
+
+
+def compute_tle_checksum(line: str) -> str:
+ """Compute the standard modulo-10 checksum for a TLE line."""
+ total = 0
+
+ for char in line[:68]:
+ if char.isdigit():
+ total += int(char)
+ elif char == "-":
+ total += 1
+
+ return str(total % 10)
+
+
+def _parse_epoch(value: Any) -> Optional[datetime]:
+ if not value:
+ return None
+ if isinstance(value, datetime):
+ return value
+ if isinstance(value, str):
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ return None
+
+
+def build_tle_line1(norad_cat_id: Any, epoch: Any) -> Optional[str]:
+ """Build a valid TLE line 1 from the NORAD id and epoch."""
+ epoch_date = _parse_epoch(epoch)
+ if not norad_cat_id or epoch_date is None:
+ return None
+
+ epoch_year = epoch_date.year % 100
+ start_of_year = datetime(epoch_date.year, 1, 1, tzinfo=epoch_date.tzinfo)
+ day_of_year = (epoch_date - start_of_year).days + 1
+ ms_of_day = (
+ epoch_date.hour * 3600000
+ + epoch_date.minute * 60000
+ + epoch_date.second * 1000
+ + int(epoch_date.microsecond / 1000)
+ )
+ day_fraction = ms_of_day / 86400000
+ decimal_fraction = f"{day_fraction:.8f}"[1:]
+ epoch_str = f"{epoch_year:02d}{day_of_year:03d}{decimal_fraction}"
+
+ core = (
+ f"1 {int(norad_cat_id):05d}U 00001A {epoch_str}"
+ " .00000000 00000-0 00000-0 0 999"
+ )
+ return core + compute_tle_checksum(core)
+
+
+def build_tle_line2(
+ norad_cat_id: Any,
+ inclination: Any,
+ raan: Any,
+ eccentricity: Any,
+ arg_of_perigee: Any,
+ mean_anomaly: Any,
+ mean_motion: Any,
+) -> Optional[str]:
+ """Build a valid TLE line 2 from the standard orbital elements."""
+ required = [
+ norad_cat_id,
+ inclination,
+ raan,
+ eccentricity,
+ arg_of_perigee,
+ mean_anomaly,
+ mean_motion,
+ ]
+ if any(value is None for value in required):
+ return None
+
+ eccentricity_digits = str(round(float(eccentricity) * 10_000_000)).zfill(7)
+ core = (
+ f"2 {int(norad_cat_id):05d}"
+ f" {float(inclination):8.4f}"
+ f" {float(raan):8.4f}"
+ f" {eccentricity_digits}"
+ f" {float(arg_of_perigee):8.4f}"
+ f" {float(mean_anomaly):8.4f}"
+ f" {float(mean_motion):11.8f}"
+ "00000"
+ )
+ return core + compute_tle_checksum(core)
+
+
+def build_tle_lines_from_elements(
+ *,
+ norad_cat_id: Any,
+ epoch: Any,
+ inclination: Any,
+ raan: Any,
+ eccentricity: Any,
+ arg_of_perigee: Any,
+ mean_anomaly: Any,
+ mean_motion: Any,
+) -> tuple[Optional[str], Optional[str]]:
+ """Build both TLE lines from a metadata payload."""
+ line1 = build_tle_line1(norad_cat_id, epoch)
+ line2 = build_tle_line2(
+ norad_cat_id,
+ inclination,
+ raan,
+ eccentricity,
+ arg_of_perigee,
+ mean_anomaly,
+ mean_motion,
+ )
+ return line1, line2
diff --git a/backend/app/services/collectors/celestrak.py b/backend/app/services/collectors/celestrak.py
index a0e91d43..e6c5f749 100644
--- a/backend/app/services/collectors/celestrak.py
+++ b/backend/app/services/collectors/celestrak.py
@@ -8,6 +8,7 @@ import json
from typing import Dict, Any, List
import httpx
+from app.core.satellite_tle import build_tle_lines_from_elements
from app.services.collectors.base import BaseCollector
@@ -61,6 +62,17 @@ class CelesTrakTLECollector(BaseCollector):
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
transformed = []
for item in raw_data:
+ tle_line1, tle_line2 = build_tle_lines_from_elements(
+ norad_cat_id=item.get("NORAD_CAT_ID"),
+ epoch=item.get("EPOCH"),
+ inclination=item.get("INCLINATION"),
+ raan=item.get("RA_OF_ASC_NODE"),
+ eccentricity=item.get("ECCENTRICITY"),
+ arg_of_perigee=item.get("ARG_OF_PERICENTER"),
+ mean_anomaly=item.get("MEAN_ANOMALY"),
+ mean_motion=item.get("MEAN_MOTION"),
+ )
+
transformed.append(
{
"name": item.get("OBJECT_NAME", "Unknown"),
@@ -80,6 +92,10 @@ class CelesTrakTLECollector(BaseCollector):
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
+ # Prefer the original TLE lines when the source provides them.
+ # If they are missing, store a normalized TLE pair built once on the backend.
+ "tle_line1": item.get("TLE_LINE1") or tle_line1,
+ "tle_line2": item.get("TLE_LINE2") or tle_line2,
},
}
)
diff --git a/backend/app/services/collectors/spacetrack.py b/backend/app/services/collectors/spacetrack.py
index 4f66c97d..bc960f6b 100644
--- a/backend/app/services/collectors/spacetrack.py
+++ b/backend/app/services/collectors/spacetrack.py
@@ -10,6 +10,7 @@ import httpx
from app.services.collectors.base import BaseCollector
from app.core.data_sources import get_data_sources_config
+from app.core.satellite_tle import build_tle_lines_from_elements
class SpaceTrackTLECollector(BaseCollector):
@@ -169,25 +170,41 @@ class SpaceTrackTLECollector(BaseCollector):
"""Transform TLE data to internal format"""
transformed = []
for item in raw_data:
+ tle_line1, tle_line2 = build_tle_lines_from_elements(
+ norad_cat_id=item.get("NORAD_CAT_ID"),
+ epoch=item.get("EPOCH"),
+ inclination=item.get("INCLINATION"),
+ raan=item.get("RAAN"),
+ eccentricity=item.get("ECCENTRICITY"),
+ arg_of_perigee=item.get("ARG_OF_PERIGEE"),
+ mean_anomaly=item.get("MEAN_ANOMALY"),
+ mean_motion=item.get("MEAN_MOTION"),
+ )
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"),
+ "reference_date": item.get("EPOCH", ""),
+ "metadata": {
+ "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"),
+ # Prefer original lines from the source, but keep a backend-built pair as a stable fallback.
+ "tle_line1": item.get("TLE_LINE1") or item.get("TLE1") or tle_line1,
+ "tle_line2": item.get("TLE_LINE2") or item.get("TLE2") or tle_line2,
+ },
}
)
return transformed
diff --git a/docs/earth-module-plan.md b/docs/earth-module-plan.md
new file mode 100644
index 00000000..f0c43a67
--- /dev/null
+++ b/docs/earth-module-plan.md
@@ -0,0 +1,210 @@
+# Earth 模块整治计划
+
+## 背景
+
+`planet` 前端中的 Earth 模块是当前最重要的大屏 3D 星球展示能力,但它仍以 legacy iframe 页面形式存在:
+
+- React 页面入口仅为 [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
+- 实际 3D 实现位于 [frontend/public/earth](/home/ray/dev/linkong/planet/frontend/public/earth)
+
+当前模块已经具备基础展示能力,但在生命周期、性能、可恢复性、可维护性方面存在明显隐患,不适合长期无人值守的大屏场景直接扩展。
+
+## 目标
+
+本计划的目标不是立刻重写 Earth,而是分阶段把它从“能跑的 legacy 展示页”提升到“可稳定运行、可持续演进的大屏核心模块”。
+
+核心目标:
+
+1. 先止血,解决资源泄漏、重载污染、假性卡顿等稳定性问题
+2. 再梳理数据加载、交互和渲染循环,降低性能风险
+3. 最后逐步从 iframe legacy 向可控模块化架构迁移
+
+## 现阶段主要问题
+
+### 1. 生命周期缺失
+
+- 没有统一 `destroy()` / 卸载清理逻辑
+- `requestAnimationFrame`
+- `window/document/dom listeners`
+- `THREE` geometry / material / texture
+- 运行时全局状态
+ 都没有系统回收
+
+### 2. 数据重载不完整
+
+- `reloadData()` 没有彻底清理旧场景对象
+- cable、landing point、satellite 相关缓存与对象存在累积风险
+
+### 3. 渲染与命中检测成本高
+
+- 鼠标移动时频繁创建 `Raycaster` / `Vector2`
+- cable 命中前会重复做 bounding box 计算
+- 卫星每帧计算量偏高
+
+### 4. 状态管理分裂
+
+- 大量依赖 `window.*` 全局桥接
+- 模块之间靠隐式共享状态通信
+- React 外层无法有效感知 Earth 内部状态
+
+### 5. 错误恢复弱
+
+- 数据加载失败主要依赖 `console` 和轻提示
+- 缺少统一重试、降级、局部失败隔离机制
+
+## 分阶段计划
+
+## Phase 1:稳定性止血
+
+目标:
+
+- 不改视觉主形态
+- 优先解决泄漏、卡死、重载污染
+
+### 任务
+
+1. 补 Earth 生命周期管理
+
+- 为 [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 增加:
+ - `init()`
+ - `destroy()`
+ - `reloadData()`
+ 三类明确入口
+- 统一记录并释放:
+ - animation frame id
+ - interval / timeout
+ - DOM 事件监听
+ - `window` 暴露对象
+
+2. 增加场景对象清理层
+
+- 为 cable / landing point / satellite sprite / orbit line 提供统一清理函数
+- reload 前先 dispose 旧对象,再重新加载
+
+3. 增加 stale 状态恢复
+
+- 页面重新进入时,先清理上一次遗留选择态、hover 态、锁定态
+- 避免 iframe reload 后出现旧状态残留
+
+4. 加强失败提示
+
+- 电缆、登陆点、卫星加载拆分为独立状态
+- 某一类数据失败时,其它类型仍可继续显示
+- 提供明确的页面内提示而不是只打 console
+
+### 验收标准
+
+- 页面重复进入 / 离开后内存不持续上涨
+- 连续多次点“重新加载数据”后对象数量不异常增加
+- 单一数据源加载失败时页面不整体失效
+
+## Phase 2:性能优化
+
+目标:
+
+- 控制鼠标交互和动画循环成本
+- 提升大屏长时间运行的稳定帧率
+
+### 任务
+
+1. 复用交互对象
+
+- 复用 `Raycaster`、`Vector2`、中间 `Vector3`
+- 避免 `mousemove` 热路径中频繁 new 对象
+
+2. 优化 cable 命中逻辑
+
+- 提前缓存 cable 中心点 / bounding 数据
+- 移除 `mousemove` 内重复 `computeBoundingBox()`
+- 必要时增加分层命中:
+ - 先粗筛
+ - 再精确相交
+
+3. 改造动画循环
+
+- 使用真实 `deltaTime`
+- 把卫星位置更新、呼吸动画、视觉状态更新拆成独立阶段
+- 为不可见对象减少无意义更新
+
+4. 卫星轨迹与预测轨道优化
+
+- 评估轨迹更新频率
+- 对高开销几何计算增加缓存
+- 限制预测轨道生成频次
+
+### 验收标准
+
+- 鼠标移动时不明显掉帧
+- 中高数据量下动画速度不受帧率明显影响
+- 长时间运行 CPU/GPU 占用更平稳
+
+## Phase 3:架构收编
+
+目标:
+
+- 降低 legacy iframe 架构带来的维护成本
+- 让 React 主应用重新获得对 Earth 模块的控制力
+
+### 任务
+
+1. 抽离 Earth App Shell
+
+- 将数据加载、错误状态、控制面板状态抽到更明确的模块边界
+- 减少 `window.*` 全局依赖
+
+2. 规范模块通信
+
+- 统一 `main / controls / cables / satellites / ui` 的状态流
+- 明确只读配置、运行时状态、渲染对象的职责分层
+
+3. 评估去 iframe 迁移
+
+- 中期可以保留 public/legacy 资源目录
+- 但逐步把 Earth 作为前端内嵌模块而不是完全孤立页面
+
+### 验收标准
+
+- Earth 内部状态不再大量依赖全局变量
+- React 外层可以感知 Earth 加载状态和错误状态
+- 后续功能开发不再必须修改多个 legacy 文件才能完成
+
+## 优先级建议
+
+### P0
+
+- 生命周期清理
+- reload 清理
+- stale 状态恢复
+
+### P1
+
+- 命中检测优化
+- 动画 `deltaTime`
+- 数据加载失败隔离
+
+### P2
+
+- 全局状态收编
+- iframe 架构迁移
+
+## 推荐实施顺序
+
+1. 先做 Phase 1
+2. 再做交互热路径与动画循环优化
+3. 最后再考虑架构迁移
+
+## 风险提示
+
+1. Earth 是 legacy 模块,修复时容易牵一发而动全身
+2. 如果不先补清理逻辑,后续所有性能优化收益都会被泄漏问题吃掉
+3. 如果过早重写而不先止血,短期会影响现有演示稳定性
+
+## 当前建议
+
+最值得马上启动的是一个小范围稳定性 sprint:
+
+- 生命周期清理
+- reload 全量清理
+- 错误状态隔离
+
+这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
index 8bdfa33f..a2c375e1 100644
--- a/frontend/public/earth/js/cables.js
+++ b/frontend/public/earth/js/cables.js
@@ -1,329 +1,398 @@
// cables.js - Cable loading and rendering module
-import * as THREE from 'three';
+import * as THREE from "three";
-import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from './constants.js';
-import { latLonToVector3 } from './utils.js';
-import { updateEarthStats, showStatusMessage } from './ui.js';
-import { showInfoCard } from './info-card.js';
+import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from "./constants.js";
+import { latLonToVector3 } from "./utils.js";
+import { updateEarthStats, showStatusMessage } from "./ui.js";
+import { showInfoCard } from "./info-card.js";
export let cableLines = [];
export let landingPoints = [];
export let lockedCable = null;
let cableIdMap = new Map();
+let cableStates = new Map();
let cablesVisible = true;
+function disposeMaterial(material) {
+ if (!material) return;
+
+ if (Array.isArray(material)) {
+ material.forEach(disposeMaterial);
+ return;
+ }
+
+ if (material.map) {
+ material.map.dispose();
+ }
+ material.dispose();
+}
+
+function disposeObject(object, parent) {
+ if (!object) return;
+ const owner = parent || object.parent;
+ if (owner) {
+ owner.remove(object);
+ }
+ if (object.geometry) {
+ object.geometry.dispose();
+ }
+ if (object.material) {
+ disposeMaterial(object.material);
+ }
+}
+
function getCableColor(properties) {
if (properties.color) {
- if (typeof properties.color === 'string' && properties.color.startsWith('#')) {
+ if (
+ typeof properties.color === "string" &&
+ properties.color.startsWith("#")
+ ) {
return parseInt(properties.color.substring(1), 16);
- } else if (typeof properties.color === 'number') {
+ }
+ if (typeof properties.color === "number") {
return properties.color;
}
}
-
- const cableName = properties.Name || properties.cableName || properties.shortname || '';
- if (cableName.includes('Americas II')) {
- return CABLE_COLORS['Americas II'];
- } else if (cableName.includes('AU Aleutian A')) {
- return CABLE_COLORS['AU Aleutian A'];
- } else if (cableName.includes('AU Aleutian B')) {
- return CABLE_COLORS['AU Aleutian B'];
+
+ const cableName =
+ properties.Name || properties.cableName || properties.shortname || "";
+ if (cableName.includes("Americas II")) {
+ return CABLE_COLORS["Americas II"];
}
-
+ if (cableName.includes("AU Aleutian A")) {
+ return CABLE_COLORS["AU Aleutian A"];
+ }
+ if (cableName.includes("AU Aleutian B")) {
+ return CABLE_COLORS["AU Aleutian B"];
+ }
+
return CABLE_COLORS.default;
}
-function createCableLine(points, color, properties, earthObj) {
+function createCableLine(points, color, properties) {
if (points.length < 2) return null;
-
+
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
-
- const lineMaterial = new THREE.LineBasicMaterial({
- color: color,
+ lineGeometry.computeBoundingSphere();
+
+ const lineMaterial = new THREE.LineBasicMaterial({
+ color,
linewidth: 1,
transparent: true,
opacity: 1.0,
depthTest: true,
- depthWrite: true
+ depthWrite: true,
});
-
+
const cableLine = new THREE.Line(lineGeometry, lineMaterial);
- const cableId = properties.cable_id || properties.id || properties.Name || Math.random().toString(36);
+ const cableId =
+ properties.cable_id ||
+ properties.id ||
+ properties.Name ||
+ Math.random().toString(36);
cableLine.userData = {
- type: 'cable',
- cableId: cableId,
- name: properties.Name || properties.cableName || 'Unknown',
- owner: properties.owner || properties.owners || '-',
- status: properties.status || '-',
- length: properties.length || '-',
- coords: '-',
- rfs: properties.rfs || '-',
- originalColor: color
+ type: "cable",
+ cableId,
+ name: properties.Name || properties.cableName || "Unknown",
+ owner: properties.owner || properties.owners || "-",
+ status: properties.status || "-",
+ length: properties.length || "-",
+ coords: "-",
+ rfs: properties.rfs || "-",
+ originalColor: color,
+ localCenter:
+ lineGeometry.boundingSphere?.center?.clone() || new THREE.Vector3(),
};
cableLine.renderOrder = 1;
-
+
if (!cableIdMap.has(cableId)) {
cableIdMap.set(cableId, []);
}
cableIdMap.get(cableId).push(cableLine);
-
+
return cableLine;
}
-function calculateGreatCirclePoints(lat1, lon1, lat2, lon2, radius, segments = 50) {
+function calculateGreatCirclePoints(
+ lat1,
+ lon1,
+ lat2,
+ lon2,
+ radius,
+ segments = 50,
+) {
const points = [];
- const phi1 = lat1 * Math.PI / 180;
- const lambda1 = lon1 * Math.PI / 180;
- const phi2 = lat2 * Math.PI / 180;
- const lambda2 = lon2 * Math.PI / 180;
-
- const dLambda = Math.min(Math.abs(lambda2 - lambda1), 2 * Math.PI - Math.abs(lambda2 - lambda1));
- const cosDelta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
-
+ const phi1 = (lat1 * Math.PI) / 180;
+ const lambda1 = (lon1 * Math.PI) / 180;
+ const phi2 = (lat2 * Math.PI) / 180;
+ const lambda2 = (lon2 * Math.PI) / 180;
+
+ const dLambda = Math.min(
+ Math.abs(lambda2 - lambda1),
+ 2 * Math.PI - Math.abs(lambda2 - lambda1),
+ );
+ const cosDelta =
+ Math.sin(phi1) * Math.sin(phi2) +
+ Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
+
let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
-
+
if (delta < 0.01) {
const p1 = latLonToVector3(lat1, lon1, radius);
const p2 = latLonToVector3(lat2, lon2, radius);
return [p1, p2];
}
-
+
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const sinDelta = Math.sin(delta);
const A = Math.sin((1 - t) * delta) / sinDelta;
const B = Math.sin(t * delta) / sinDelta;
-
+
const x1 = Math.cos(phi1) * Math.cos(lambda1);
const y1 = Math.cos(phi1) * Math.sin(lambda1);
const z1 = Math.sin(phi1);
-
+
const x2 = Math.cos(phi2) * Math.cos(lambda2);
const y2 = Math.cos(phi2) * Math.sin(lambda2);
const z2 = Math.sin(phi2);
-
+
let x = A * x1 + B * x2;
let y = A * y1 + B * y2;
let z = A * z1 + B * z2;
-
- const norm = Math.sqrt(x*x + y*y + z*z);
- x = x / norm * radius;
- y = y / norm * radius;
- z = z / norm * radius;
-
- const lat = Math.asin(z / radius) * 180 / Math.PI;
- let lon = Math.atan2(y, x) * 180 / Math.PI;
-
+
+ const norm = Math.sqrt(x * x + y * y + z * z);
+ x = (x / norm) * radius;
+ y = (y / norm) * radius;
+ z = (z / norm) * radius;
+
+ const lat = (Math.asin(z / radius) * 180) / Math.PI;
+ let lon = (Math.atan2(y, x) * 180) / Math.PI;
+
if (lon > 180) lon -= 360;
if (lon < -180) lon += 360;
-
- const point = latLonToVector3(lat, lon, radius);
- points.push(point);
+
+ points.push(latLonToVector3(lat, lon, radius));
}
-
+
return points;
}
+export function clearCableLines(earthObj = null) {
+ cableLines.forEach((line) => disposeObject(line, earthObj));
+ cableLines = [];
+ cableIdMap = new Map();
+ cableStates.clear();
+}
+
+export function clearLandingPoints(earthObj = null) {
+ landingPoints.forEach((point) => disposeObject(point, earthObj));
+ landingPoints = [];
+}
+
+export function clearCableData(earthObj = null) {
+ clearCableSelection();
+ clearCableLines(earthObj);
+ clearLandingPoints(earthObj);
+}
+
export async function loadGeoJSONFromPath(scene, earthObj) {
- try {
- console.log('正在加载电缆数据...');
- showStatusMessage('正在加载电缆数据...', 'warning');
-
- const response = await fetch(PATHS.cablesApi);
- if (!response.ok) {
- throw new Error(`HTTP错误: ${response.status}`);
- }
-
- const data = await response.json();
-
- cableLines.forEach(line => earthObj.remove(line));
- cableLines = [];
-
- if (!data.features || !Array.isArray(data.features)) {
- throw new Error('无效的GeoJSON格式');
- }
-
- const cableCount = data.features.length;
- document.getElementById('cable-count').textContent = cableCount + '个';
-
- const inServiceCount = data.features.filter(
- feature => feature.properties && feature.properties.status === 'In Service'
- ).length;
-
- const statusEl = document.getElementById('cable-status-summary');
- if (statusEl) {
- statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
- }
-
- for (const feature of data.features) {
- const geometry = feature.geometry;
- const properties = feature.properties || {};
-
- if (!geometry || !geometry.coordinates) continue;
-
- const color = getCableColor(properties);
- console.log('电缆 properties:', JSON.stringify(properties));
-
- if (geometry.type === 'MultiLineString') {
- for (const lineCoords of geometry.coordinates) {
- if (!lineCoords || lineCoords.length < 2) continue;
-
- const points = [];
- for (let i = 0; i < lineCoords.length - 1; i++) {
- const lon1 = lineCoords[i][0];
- const lat1 = lineCoords[i][1];
- const lon2 = lineCoords[i + 1][0];
- const lat2 = lineCoords[i + 1][1];
-
- const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
- if (i === 0) {
- points.push(...segment);
- } else {
- points.push(...segment.slice(1));
- }
- }
-
- if (points.length >= 2) {
- const line = createCableLine(points, color, properties, earthObj);
- if (line) {
- cableLines.push(line);
- earthObj.add(line);
- console.log('添加线缆成功');
- }
- }
- }
- } else if (geometry.type === 'LineString') {
- const allCoords = geometry.coordinates;
+ console.log("正在加载电缆数据...");
+ showStatusMessage("正在加载电缆数据...", "warning");
+
+ const response = await fetch(PATHS.cablesApi);
+ if (!response.ok) {
+ throw new Error(`电缆接口返回 HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (!data.features || !Array.isArray(data.features)) {
+ throw new Error("无效的电缆 GeoJSON 格式");
+ }
+
+ clearCableLines(earthObj);
+
+ for (const feature of data.features) {
+ const geometry = feature.geometry;
+ const properties = feature.properties || {};
+
+ if (!geometry || !geometry.coordinates) continue;
+
+ const color = getCableColor(properties);
+
+ if (geometry.type === "MultiLineString") {
+ for (const lineCoords of geometry.coordinates) {
+ if (!lineCoords || lineCoords.length < 2) continue;
+
const points = [];
-
- for (let i = 0; i < allCoords.length - 1; i++) {
- const lon1 = allCoords[i][0];
- const lat1 = allCoords[i][1];
- const lon2 = allCoords[i + 1][0];
- const lat2 = allCoords[i + 1][1];
-
- const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
- if (i === 0) {
- points.push(...segment);
- } else {
- points.push(...segment.slice(1));
- }
+ for (let i = 0; i < lineCoords.length - 1; i++) {
+ const lon1 = lineCoords[i][0];
+ const lat1 = lineCoords[i][1];
+ const lon2 = lineCoords[i + 1][0];
+ const lat2 = lineCoords[i + 1][1];
+
+ const segment = calculateGreatCirclePoints(
+ lat1,
+ lon1,
+ lat2,
+ lon2,
+ CONFIG.earthRadius + 0.2,
+ 50,
+ );
+ points.push(...(i === 0 ? segment : segment.slice(1)));
}
-
- if (points.length >= 2) {
- const line = createCableLine(points, color, properties, earthObj);
- if (line) {
- cableLines.push(line);
- earthObj.add(line);
- }
+
+ const line = createCableLine(points, color, properties);
+ if (line) {
+ cableLines.push(line);
+ earthObj.add(line);
}
}
+ } else if (geometry.type === "LineString") {
+ const points = [];
+ for (let i = 0; i < geometry.coordinates.length - 1; i++) {
+ const lon1 = geometry.coordinates[i][0];
+ const lat1 = geometry.coordinates[i][1];
+ const lon2 = geometry.coordinates[i + 1][0];
+ const lat2 = geometry.coordinates[i + 1][1];
+
+ const segment = calculateGreatCirclePoints(
+ lat1,
+ lon1,
+ lat2,
+ lon2,
+ CONFIG.earthRadius + 0.2,
+ 50,
+ );
+ points.push(...(i === 0 ? segment : segment.slice(1)));
+ }
+
+ const line = createCableLine(points, color, properties);
+ if (line) {
+ cableLines.push(line);
+ earthObj.add(line);
+ }
}
-
- updateEarthStats({
- cableCount: cableLines.length,
- landingPointCount: landingPoints.length,
- terrainOn: false,
- textureQuality: '8K 卫星图'
- });
-
- showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, 'success');
- document.getElementById('loading').style.display = 'none';
-
- } catch (error) {
- console.error('加载电缆数据失败:', error);
- showStatusMessage('加载电缆数据失败: ' + error.message, 'error');
}
+
+ const cableCount = data.features.length;
+ const inServiceCount = data.features.filter(
+ (feature) =>
+ feature.properties && feature.properties.status === "In Service",
+ ).length;
+
+ const cableCountEl = document.getElementById("cable-count");
+ const statusEl = document.getElementById("cable-status-summary");
+ if (cableCountEl) cableCountEl.textContent = cableCount + "个";
+ if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
+
+ updateEarthStats({
+ cableCount: cableLines.length,
+ landingPointCount: landingPoints.length,
+ terrainOn: false,
+ textureQuality: "8K 卫星图",
+ });
+
+ showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
+ return cableLines.length;
}
export async function loadLandingPoints(scene, earthObj) {
+ console.log("正在加载登陆点数据...");
+
+ const response = await fetch(PATHS.landingPointsApi);
+ if (!response.ok) {
+ throw new Error(`登陆点接口返回 HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (!data.features || !Array.isArray(data.features)) {
+ throw new Error("无效的登陆点 GeoJSON 格式");
+ }
+
+ clearLandingPoints(earthObj);
+
+ const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16);
+ let validCount = 0;
+
try {
- console.log('正在加载登陆点数据...');
-
- const response = await fetch(PATHS.landingPointsApi);
- if (!response.ok) {
- console.error('HTTP错误:', response.status);
- return;
- }
-
- const data = await response.json();
-
- if (!data.features || !Array.isArray(data.features)) {
- console.error('无效的GeoJSON格式');
- return;
- }
-
- landingPoints = [];
- let validCount = 0;
-
- const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16);
- const sphereMaterial = new THREE.MeshStandardMaterial({
- color: 0xffaa00,
- emissive: 0x442200,
- emissiveIntensity: 0.5
- });
-
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
-
+
const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {};
-
- if (typeof lon !== 'number' || typeof lat !== 'number' ||
- isNaN(lon) || isNaN(lat) ||
- Math.abs(lat) > 90 || Math.abs(lon) > 180) {
+
+ if (
+ typeof lon !== "number" ||
+ typeof lat !== "number" ||
+ Number.isNaN(lon) ||
+ Number.isNaN(lat) ||
+ Math.abs(lat) > 90 ||
+ Math.abs(lon) > 180
+ ) {
continue;
}
-
- const position = latLonToVector3(lat, lon, 100.1);
-
- if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) {
+
+ const position = latLonToVector3(lat, lon, CONFIG.earthRadius + 0.1);
+ if (
+ Number.isNaN(position.x) ||
+ Number.isNaN(position.y) ||
+ Number.isNaN(position.z)
+ ) {
continue;
}
-
- const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial.clone());
+
+ const sphere = new THREE.Mesh(
+ sphereGeometry.clone(),
+ new THREE.MeshStandardMaterial({
+ color: 0xffaa00,
+ emissive: 0x442200,
+ emissiveIntensity: 0.5,
+ transparent: true,
+ opacity: 1,
+ }),
+ );
sphere.position.copy(position);
sphere.userData = {
- type: 'landingPoint',
- name: properties.name || '未知登陆站',
+ type: "landingPoint",
+ name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
- country: properties.country || '未知国家',
- status: properties.status || 'Unknown'
+ country: properties.country || "未知国家",
+ status: properties.status || "Unknown",
};
-
+
earthObj.add(sphere);
landingPoints.push(sphere);
validCount++;
}
-
- console.log(`成功创建 ${validCount} 个登陆点标记`);
- showStatusMessage(`成功加载 ${validCount} 个登陆点`, 'success');
-
- const lpCountEl = document.getElementById('landing-point-count');
- if (lpCountEl) {
- lpCountEl.textContent = validCount + '个';
- }
-
- } catch (error) {
- console.error('加载登陆点数据失败:', error);
+ } finally {
+ sphereGeometry.dispose();
}
+
+ const landingPointCountEl = document.getElementById("landing-point-count");
+ if (landingPointCountEl) {
+ landingPointCountEl.textContent = validCount + "个";
+ }
+
+ showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
+ return validCount;
}
export function handleCableClick(cable) {
lockedCable = cable;
-
+
const data = cable.userData;
- showInfoCard('cable', {
+ showInfoCard("cable", {
name: data.name,
owner: data.owner,
status: data.status,
length: data.length,
coords: data.coords,
- rfs: data.rfs
+ rfs: data.rfs,
});
-
- showStatusMessage(`已锁定: ${data.name}`, 'info');
+
+ showStatusMessage(`已锁定: ${data.name}`, "info");
}
export function clearCableSelection() {
@@ -342,8 +411,6 @@ export function getLandingPoints() {
return landingPoints;
}
-const cableStates = new Map();
-
export function getCableState(cableId) {
return cableStates.get(cableId) || CABLE_STATE.NORMAL;
}
@@ -365,7 +432,9 @@ export function getCableStateInfo() {
}
export function getLandingPointsByCableName(cableName) {
- return landingPoints.filter(lp => lp.userData.cableNames?.includes(cableName));
+ return landingPoints.filter((lp) =>
+ lp.userData.cableNames?.includes(cableName),
+ );
}
export function getAllLandingPoints() {
@@ -375,10 +444,11 @@ export function getAllLandingPoints() {
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
const brightness = 0.3;
-
- landingPoints.forEach(lp => {
- const isRelated = !dimAll && lp.userData.cableNames?.includes(lockedCableName);
-
+
+ landingPoints.forEach((lp) => {
+ const isRelated =
+ !dimAll && lp.userData.cableNames?.includes(lockedCableName);
+
if (isRelated) {
lp.material.color.setHex(0xffaa00);
lp.material.emissive.setHex(0x442200);
@@ -388,8 +458,7 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
} else {
const r = 255 * brightness;
const g = 170 * brightness;
- const b = 0 * brightness;
- lp.material.color.setRGB(r / 255, g / 255, b / 255);
+ lp.material.color.setRGB(r / 255, g / 255, 0);
lp.material.emissive.setHex(0x000000);
lp.material.emissiveIntensity = 0;
lp.material.opacity = 0.3;
@@ -399,7 +468,7 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
}
export function resetLandingPointVisualState() {
- landingPoints.forEach(lp => {
+ landingPoints.forEach((lp) => {
lp.material.color.setHex(0xffaa00);
lp.material.emissive.setHex(0x442200);
lp.material.emissiveIntensity = 0.5;
@@ -410,10 +479,10 @@ export function resetLandingPointVisualState() {
export function toggleCables(show) {
cablesVisible = show;
- cableLines.forEach(cable => {
+ cableLines.forEach((cable) => {
cable.visible = cablesVisible;
});
- landingPoints.forEach(lp => {
+ landingPoints.forEach((lp) => {
lp.visible = cablesVisible;
});
}
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
index fbb027a7..b1c81d9b 100644
--- a/frontend/public/earth/js/constants.js
+++ b/frontend/public/earth/js/constants.js
@@ -54,7 +54,7 @@ export const CABLE_STATE = {
};
export const SATELLITE_CONFIG = {
- maxCount: 5000,
+ maxCount: 10000,
trailLength: 10,
dotSize: 4,
ringSize: 0.07,
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index 6ef89950..f15aa2c1 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -1,11 +1,16 @@
// controls.js - Zoom, rotate and toggle controls
-import { CONFIG, EARTH_CONFIG } from './constants.js';
-import { updateZoomDisplay, showStatusMessage } from './ui.js';
-import { toggleTerrain } from './earth.js';
-import { reloadData, clearLockedObject } from './main.js';
-import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js';
-import { toggleCables, getShowCables } from './cables.js';
+import { CONFIG, EARTH_CONFIG } from "./constants.js";
+import { updateZoomDisplay, showStatusMessage } from "./ui.js";
+import { toggleTerrain } from "./earth.js";
+import { reloadData, clearLockedObject } from "./main.js";
+import {
+ toggleSatellites,
+ toggleTrails,
+ getShowSatellites,
+ getSatelliteCount,
+} from "./satellites.js";
+import { toggleCables, getShowCables } from "./cables.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -13,8 +18,26 @@ export let showTerrain = false;
export let isDragging = false;
let earthObj = null;
+let listeners = [];
+let cleanupFns = [];
+
+function bindListener(element, eventName, handler, options) {
+ if (!element) return;
+ element.addEventListener(eventName, handler, options);
+ listeners.push(() =>
+ element.removeEventListener(eventName, handler, options),
+ );
+}
+
+function resetCleanup() {
+ cleanupFns.forEach((cleanup) => cleanup());
+ cleanupFns = [];
+ listeners.forEach((cleanup) => cleanup());
+ listeners = [];
+}
export function setupControls(camera, renderer, scene, earth) {
+ resetCleanup();
earthObj = earth;
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
@@ -29,39 +52,40 @@ function setupZoomControls(camera) {
const HOLD_THRESHOLD = 150;
const LONG_PRESS_TICK = 50;
const CLICK_STEP = 10;
-
+
const MIN_PERCENT = CONFIG.minZoom * 100;
const MAX_PERCENT = CONFIG.maxZoom * 100;
-
+
function doZoomStep(direction) {
let currentPercent = Math.round(zoomLevel * 100);
- let newPercent = direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
-
+ let newPercent =
+ direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
+
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
-
+
zoomLevel = newPercent / 100;
applyZoom(camera);
}
-
+
function doContinuousZoom(direction) {
let currentPercent = Math.round(zoomLevel * 100);
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
-
+
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
-
+
zoomLevel = newPercent / 100;
applyZoom(camera);
}
-
+
function startContinuousZoom(direction) {
doContinuousZoom(direction);
- zoomInterval = setInterval(() => {
+ zoomInterval = window.setInterval(() => {
doContinuousZoom(direction);
}, LONG_PRESS_TICK);
}
-
+
function stopZoom() {
if (zoomInterval) {
clearInterval(zoomInterval);
@@ -72,15 +96,15 @@ function setupZoomControls(camera) {
holdTimeout = null;
}
}
-
+
function handleMouseDown(direction) {
startTime = Date.now();
stopZoom();
- holdTimeout = setTimeout(() => {
+ holdTimeout = window.setTimeout(() => {
startContinuousZoom(direction);
}, HOLD_THRESHOLD);
}
-
+
function handleMouseUp(direction) {
const heldTime = Date.now() - startTime;
stopZoom();
@@ -88,48 +112,72 @@ function setupZoomControls(camera) {
doZoomStep(direction);
}
}
-
- document.getElementById('zoom-in').addEventListener('mousedown', () => handleMouseDown(1));
- document.getElementById('zoom-in').addEventListener('mouseup', () => handleMouseUp(1));
- document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom);
- document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(1); });
- document.getElementById('zoom-in').addEventListener('touchend', () => handleMouseUp(1));
-
- document.getElementById('zoom-out').addEventListener('mousedown', () => handleMouseDown(-1));
- document.getElementById('zoom-out').addEventListener('mouseup', () => handleMouseUp(-1));
- document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom);
- document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(-1); });
- document.getElementById('zoom-out').addEventListener('touchend', () => handleMouseUp(-1));
-
- document.getElementById('zoom-value').addEventListener('click', function() {
+
+ cleanupFns.push(stopZoom);
+
+ const zoomIn = document.getElementById("zoom-in");
+ const zoomOut = document.getElementById("zoom-out");
+ const zoomValue = document.getElementById("zoom-value");
+
+ bindListener(zoomIn, "mousedown", () => handleMouseDown(1));
+ bindListener(zoomIn, "mouseup", () => handleMouseUp(1));
+ bindListener(zoomIn, "mouseleave", stopZoom);
+ bindListener(zoomIn, "touchstart", (e) => {
+ e.preventDefault();
+ handleMouseDown(1);
+ });
+ bindListener(zoomIn, "touchend", () => handleMouseUp(1));
+
+ bindListener(zoomOut, "mousedown", () => handleMouseDown(-1));
+ bindListener(zoomOut, "mouseup", () => handleMouseUp(-1));
+ bindListener(zoomOut, "mouseleave", stopZoom);
+ bindListener(zoomOut, "touchstart", (e) => {
+ e.preventDefault();
+ handleMouseDown(-1);
+ });
+ bindListener(zoomOut, "touchend", () => handleMouseUp(-1));
+
+ bindListener(zoomValue, "click", () => {
const startZoomVal = zoomLevel;
const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
-
- animateValue(0, 1, 600, (progress) => {
- const ease = 1 - Math.pow(1 - progress, 3);
- zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
- camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
- const distance = startDistance + (targetDistance - startDistance) * ease;
- updateZoomDisplay(zoomLevel, distance.toFixed(0));
- }, () => {
- zoomLevel = 1.0;
- showStatusMessage('缩放已重置到100%', 'info');
- });
+
+ animateValue(
+ 0,
+ 1,
+ 600,
+ (progress) => {
+ const ease = 1 - Math.pow(1 - progress, 3);
+ zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
+ camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
+ const distance =
+ startDistance + (targetDistance - startDistance) * ease;
+ updateZoomDisplay(zoomLevel, distance.toFixed(0));
+ },
+ () => {
+ zoomLevel = 1.0;
+ showStatusMessage("缩放已重置到100%", "info");
+ },
+ );
});
}
function setupWheelZoom(camera, renderer) {
- renderer.domElement.addEventListener('wheel', (e) => {
- e.preventDefault();
- if (e.deltaY < 0) {
- zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
- } else {
- zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
- }
- applyZoom(camera);
- }, { passive: false });
+ bindListener(
+ renderer?.domElement,
+ "wheel",
+ (e) => {
+ e.preventDefault();
+ if (e.deltaY < 0) {
+ zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
+ } else {
+ zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
+ }
+ applyZoom(camera);
+ },
+ { passive: false },
+ );
}
function applyZoom(camera) {
@@ -140,149 +188,186 @@ function applyZoom(camera) {
function animateValue(start, end, duration, onUpdate, onComplete) {
const startTime = performance.now();
-
+
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
-
+
const current = start + (end - start) * easeProgress;
onUpdate(current);
-
+
if (progress < 1) {
requestAnimationFrame(update);
} else if (onComplete) {
onComplete();
}
}
-
+
requestAnimationFrame(update);
}
export function resetView(camera) {
if (!earthObj) return;
-
+
function animateToView(targetLat, targetLon, targetRotLon) {
- const latRot = targetLat * Math.PI / 180;
- const targetRotX = EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
- const targetRotY = -(targetRotLon * Math.PI / 180);
-
+ const latRot = (targetLat * Math.PI) / 180;
+ const targetRotX =
+ EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
+ const targetRotY = -((targetRotLon * Math.PI) / 180);
+
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const targetZoom = 1.0;
-
- animateValue(0, 1, 800, (progress) => {
- const ease = 1 - Math.pow(1 - progress, 3);
- earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
- earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
-
- zoomLevel = startZoom + (targetZoom - startZoom) * ease;
- camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
- updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
- }, () => {
- zoomLevel = 1.0;
- showStatusMessage('视角已重置', 'info');
- });
+
+ animateValue(
+ 0,
+ 1,
+ 800,
+ (progress) => {
+ const ease = 1 - Math.pow(1 - progress, 3);
+ earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
+ earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
+
+ zoomLevel = startZoom + (targetZoom - startZoom) * ease;
+ camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
+ updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
+ },
+ () => {
+ zoomLevel = 1.0;
+ showStatusMessage("视角已重置", "info");
+ },
+ );
}
-
+
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
- (pos) => animateToView(pos.coords.latitude, pos.coords.longitude, -pos.coords.longitude),
- () => animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon),
- { timeout: 5000, enableHighAccuracy: false }
+ (pos) =>
+ animateToView(
+ pos.coords.latitude,
+ pos.coords.longitude,
+ -pos.coords.longitude,
+ ),
+ () =>
+ animateToView(
+ EARTH_CONFIG.chinaLat,
+ EARTH_CONFIG.chinaLon,
+ EARTH_CONFIG.chinaRotLon,
+ ),
+ { timeout: 5000, enableHighAccuracy: false },
);
} else {
- animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon);
- }
-
- if (typeof window.clearLockedCable === 'function') {
- window.clearLockedCable();
+ animateToView(
+ EARTH_CONFIG.chinaLat,
+ EARTH_CONFIG.chinaLon,
+ EARTH_CONFIG.chinaRotLon,
+ );
}
+
+ clearLockedObject();
}
-function setupRotateControls(camera, earth) {
- const rotateBtn = document.getElementById('rotate-toggle');
-
- rotateBtn.addEventListener('click', function() {
+function setupRotateControls(camera) {
+ const rotateBtn = document.getElementById("rotate-toggle");
+ const resetViewBtn = document.getElementById("reset-view");
+
+ bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
- showStatusMessage(isRotating ? '自动旋转已开启' : '自动旋转已暂停', 'info');
+ showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
});
-
+
updateRotateUI();
-
- document.getElementById('reset-view').addEventListener('click', function() {
+
+ bindListener(resetViewBtn, "click", () => {
resetView(camera);
});
}
function setupTerrainControls() {
- document.getElementById('toggle-terrain').addEventListener('click', function() {
+ const terrainBtn = document.getElementById("toggle-terrain");
+ const satellitesBtn = document.getElementById("toggle-satellites");
+ const trailsBtn = document.getElementById("toggle-trails");
+ const cablesBtn = document.getElementById("toggle-cables");
+ const reloadBtn = document.getElementById("reload-data");
+ const toolbarToggle = document.getElementById("toolbar-toggle");
+ const toolbar = document.getElementById("control-toolbar");
+
+ bindListener(terrainBtn, "click", function () {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
- this.classList.toggle('active', showTerrain);
- this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
- document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
- showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
+ this.classList.toggle("active", showTerrain);
+ const tooltip = this.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = showTerrain ? "隐藏地形" : "显示地形";
+ const terrainStatus = document.getElementById("terrain-status");
+ if (terrainStatus)
+ terrainStatus.textContent = showTerrain ? "开启" : "关闭";
+ showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
});
-
- document.getElementById('toggle-satellites').addEventListener('click', function() {
+
+ bindListener(satellitesBtn, "click", function () {
const showSats = !getShowSatellites();
if (!showSats) {
clearLockedObject();
}
toggleSatellites(showSats);
- this.classList.toggle('active', showSats);
- this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星';
- document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗';
- showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info');
+ this.classList.toggle("active", showSats);
+ const tooltip = this.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = showSats ? "隐藏卫星" : "显示卫星";
+ const satelliteCountEl = document.getElementById("satellite-count");
+ if (satelliteCountEl)
+ satelliteCountEl.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');
+
+ bindListener(trailsBtn, "click", function () {
+ const isActive = this.classList.contains("active");
+ const nextShowTrails = !isActive;
+ toggleTrails(nextShowTrails);
+ this.classList.toggle("active", nextShowTrails);
+ const tooltip = this.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = nextShowTrails ? "隐藏轨迹" : "显示轨迹";
+ showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
});
-
- document.getElementById('toggle-cables').addEventListener('click', function() {
- const showCables = !getShowCables();
- if (!showCables) {
+
+ bindListener(cablesBtn, "click", function () {
+ const showNextCables = !getShowCables();
+ if (!showNextCables) {
clearLockedObject();
}
- toggleCables(showCables);
- this.classList.toggle('active', showCables);
- this.querySelector('.tooltip').textContent = showCables ? '隐藏线缆' : '显示线缆';
- showStatusMessage(showCables ? '线缆已显示' : '线缆已隐藏', 'info');
+ toggleCables(showNextCables);
+ this.classList.toggle("active", showNextCables);
+ const tooltip = this.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = showNextCables ? "隐藏线缆" : "显示线缆";
+ showStatusMessage(showNextCables ? "线缆已显示" : "线缆已隐藏", "info");
});
-
- document.getElementById('reload-data').addEventListener('click', async () => {
+
+ bindListener(reloadBtn, "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');
+ bindListener(toolbarToggle, "click", () => {
+ toolbar.classList.toggle("collapsed");
});
}
}
+export function teardownControls() {
+ resetCleanup();
+}
+
export function getAutoRotate() {
return autoRotate;
}
function updateRotateUI() {
- const btn = document.getElementById('rotate-toggle');
+ const btn = document.getElementById("rotate-toggle");
if (btn) {
- btn.classList.toggle('active', autoRotate);
- btn.innerHTML = autoRotate ? '⏸️' : '▶️';
- const tooltip = btn.querySelector('.tooltip');
- if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '开始旋转';
+ btn.classList.toggle("active", autoRotate);
+ btn.innerHTML = autoRotate ? "⏸️" : "▶️";
+ const tooltip = btn.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
}
}
@@ -294,9 +379,7 @@ export function setAutoRotate(value) {
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
- if (window.clearLockedCable) {
- window.clearLockedCable();
- }
+ clearLockedObject();
return autoRotate;
}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 3c1c08c0..03cf3d5e 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -1,31 +1,89 @@
-import * as THREE from 'three';
-import { createNoise3D } from 'simplex-noise';
+import * as THREE from "three";
+import { createNoise3D } from "simplex-noise";
-import { CONFIG, CABLE_CONFIG, CABLE_STATE } from './constants.js';
-import { latLonToVector3, vector3ToLatLon, screenToEarthCoords } from './utils.js';
-import {
- showStatusMessage,
- updateCoordinatesDisplay,
- updateZoomDisplay,
+import { CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
+import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
+import {
+ showStatusMessage,
+ updateCoordinatesDisplay,
+ updateZoomDisplay,
updateEarthStats,
setLoading,
showTooltip,
- hideTooltip
-} from './ui.js';
-import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
-import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints, getShowCables } from './cables.js';
-import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, updateBreathingPhase, isSatelliteFrontFacing } from './satellites.js';
-import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js';
-import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js';
+ hideTooltip,
+ showError,
+ hideError,
+ clearUiState,
+} from "./ui.js";
+import {
+ createEarth,
+ createClouds,
+ createTerrain,
+ createStars,
+ createGridLines,
+ getEarth,
+} from "./earth.js";
+import {
+ loadGeoJSONFromPath,
+ loadLandingPoints,
+ handleCableClick,
+ clearCableSelection,
+ getCableLines,
+ getCableState,
+ setCableState,
+ clearAllCableStates,
+ applyLandingPointVisualState,
+ resetLandingPointVisualState,
+ getShowCables,
+ clearCableData,
+} from "./cables.js";
+import {
+ createSatellites,
+ loadSatellites,
+ updateSatellitePositions,
+ toggleSatellites,
+ getShowSatellites,
+ getSatelliteCount,
+ selectSatellite,
+ getSatellitePoints,
+ setSatelliteRingState,
+ updateLockedRingPosition,
+ updateHoverRingPosition,
+ getSatellitePositions,
+ showPredictedOrbit,
+ hidePredictedOrbit,
+ updateBreathingPhase,
+ isSatelliteFrontFacing,
+ setSatelliteCamera,
+ setLockedSatelliteIndex,
+ resetSatelliteState,
+ clearSatelliteData,
+} from "./satellites.js";
+import {
+ setupControls,
+ getAutoRotate,
+ getShowTerrain,
+ setAutoRotate,
+ resetView,
+ teardownControls,
+} from "./controls.js";
+import {
+ initInfoCard,
+ showInfoCard,
+ hideInfoCard,
+ setInfoCardNoBorder,
+} from "./info-card.js";
+
+export let scene;
+export let camera;
+export let renderer;
-export let scene, camera, renderer;
let simplex;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
let hoveredCable = null;
let hoveredSatellite = null;
let hoveredSatelliteIndex = null;
-let cableLockedData = null;
let lockedSatellite = null;
let lockedSatelliteIndex = null;
let lockedObject = null;
@@ -35,20 +93,89 @@ let isLongDrag = false;
let lastSatClickTime = 0;
let lastSatClickIndex = 0;
let lastSatClickPos = { x: 0, y: 0 };
+let earthTexture = null;
+let animationFrameId = null;
+let initialized = false;
+let destroyed = false;
+let isDataLoading = false;
+let currentLoadToken = 0;
-export function clearLockedObject() {
- hidePredictedOrbit();
+const clock = new THREE.Clock();
+const interactionRaycaster = new THREE.Raycaster();
+const interactionMouse = new THREE.Vector2();
+const scratchCameraToEarth = new THREE.Vector3();
+const scratchCableCenter = new THREE.Vector3();
+const scratchCableDirection = new THREE.Vector3();
+
+const cleanupFns = [];
+
+function bindListener(target, eventName, handler, options) {
+ if (!target) return;
+ target.addEventListener(eventName, handler, options);
+ cleanupFns.push(() =>
+ target.removeEventListener(eventName, handler, options),
+ );
+}
+
+function disposeMaterial(material) {
+ if (!material) return;
+ if (Array.isArray(material)) {
+ material.forEach(disposeMaterial);
+ return;
+ }
+
+ if (material.map) material.map.dispose();
+ if (material.alphaMap) material.alphaMap.dispose();
+ if (material.aoMap) material.aoMap.dispose();
+ if (material.bumpMap) material.bumpMap.dispose();
+ if (material.displacementMap) material.displacementMap.dispose();
+ if (material.emissiveMap) material.emissiveMap.dispose();
+ if (material.envMap) material.envMap.dispose();
+ if (material.lightMap) material.lightMap.dispose();
+ if (material.metalnessMap) material.metalnessMap.dispose();
+ if (material.normalMap) material.normalMap.dispose();
+ if (material.roughnessMap) material.roughnessMap.dispose();
+ if (material.specularMap) material.specularMap.dispose();
+ material.dispose();
+}
+
+function disposeSceneObject(object) {
+ if (!object) return;
+
+ for (let i = object.children.length - 1; i >= 0; i -= 1) {
+ disposeSceneObject(object.children[i]);
+ }
+
+ if (object.geometry) {
+ object.geometry.dispose();
+ }
+
+ if (object.material) {
+ disposeMaterial(object.material);
+ }
+
+ if (object.parent) {
+ object.parent.remove(object);
+ }
+}
+
+function clearRuntimeSelection() {
hoveredCable = null;
hoveredSatellite = null;
hoveredSatelliteIndex = null;
- clearAllCableStates();
- setSatelliteRingState(null, 'none', null);
lockedObject = null;
lockedObjectType = null;
lockedSatellite = null;
lockedSatelliteIndex = null;
- window.lockedSatelliteIndex = null;
- cableLockedData = null;
+ setLockedSatelliteIndex(null);
+}
+
+export function clearLockedObject() {
+ hidePredictedOrbit();
+ clearAllCableStates();
+ clearCableSelection();
+ setSatelliteRingState(null, "none", null);
+ clearRuntimeSelection();
}
function isSameCable(cable1, cable2) {
@@ -60,93 +187,143 @@ function isSameCable(cable1, cable2) {
}
function showCableInfo(cable) {
- showInfoCard('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
+ rfs: cable.userData.rfs,
});
}
function showSatelliteInfo(props) {
const meanMotion = props?.mean_motion || 0;
- const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-';
+ const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
const ecc = props?.eccentricity || 0;
const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0);
-
- showInfoCard('satellite', {
- name: props?.name || '-',
+
+ showInfoCard("satellite", {
+ name: props?.name || "-",
norad_id: props?.norad_cat_id,
- inclination: props?.inclination ? props.inclination.toFixed(2) : '-',
- period: period,
- perigee: perigee,
- apogee: apogee
+ inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
+ period,
+ perigee,
+ apogee,
});
}
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
-
- allCables.forEach(c => {
- const cableId = c.userData.cableId;
+
+ allCables.forEach((cable) => {
+ const cableId = cable.userData.cableId;
const state = getCableState(cableId);
-
+
switch (state) {
case CABLE_STATE.LOCKED:
- c.material.opacity = CABLE_CONFIG.lockedOpacityMin + pulse * (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
- c.material.color.setRGB(1, 1, 1);
+ cable.material.opacity =
+ CABLE_CONFIG.lockedOpacityMin +
+ pulse *
+ (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
+ cable.material.color.setRGB(1, 1, 1);
break;
case CABLE_STATE.HOVERED:
- c.material.opacity = 1;
- c.material.color.setRGB(1, 1, 1);
+ cable.material.opacity = 1;
+ cable.material.color.setRGB(1, 1, 1);
break;
case CABLE_STATE.NORMAL:
default:
- if ((lockedObjectType === 'cable' && lockedObject) || (lockedObjectType === 'satellite' && lockedSatellite)) {
- c.material.opacity = CABLE_CONFIG.otherOpacity;
- const origColor = c.userData.originalColor;
+ if (
+ (lockedObjectType === "cable" && lockedObject) ||
+ (lockedObjectType === "satellite" && lockedSatellite)
+ ) {
+ cable.material.opacity = CABLE_CONFIG.otherOpacity;
+ const origColor = cable.userData.originalColor;
const brightness = CABLE_CONFIG.otherBrightness;
- c.material.color.setRGB(
- ((origColor >> 16) & 255) / 255 * brightness,
- ((origColor >> 8) & 255) / 255 * brightness,
- (origColor & 255) / 255 * brightness
+ cable.material.color.setRGB(
+ (((origColor >> 16) & 255) / 255) * brightness,
+ (((origColor >> 8) & 255) / 255) * brightness,
+ ((origColor & 255) / 255) * brightness,
);
} else {
- c.material.opacity = 1;
- c.material.color.setHex(c.userData.originalColor);
+ cable.material.opacity = 1;
+ cable.material.color.setHex(cable.userData.originalColor);
}
}
});
}
-window.addEventListener('error', (e) => {
- console.error('全局错误:', e.error);
- showStatusMessage('加载错误: ' + e.error?.message, 'error');
+function updatePointerFromEvent(event) {
+ interactionMouse.set(
+ (event.clientX / window.innerWidth) * 2 - 1,
+ -(event.clientY / window.innerHeight) * 2 + 1,
+ );
+ interactionRaycaster.setFromCamera(interactionMouse, camera);
+}
+
+function buildLoadErrorMessage(errors) {
+ if (errors.length === 0) return "";
+ return errors
+ .map(
+ ({ label, reason }) =>
+ `${label}加载失败: ${reason?.message || String(reason)}`,
+ )
+ .join(";");
+}
+
+function updateStatsSummary() {
+ updateEarthStats({
+ cableCount: getCableLines().length,
+ landingPointCount:
+ document.getElementById("landing-point-count")?.textContent || 0,
+ terrainOn: getShowTerrain(),
+ textureQuality: "8K 卫星图",
+ });
+}
+
+window.addEventListener("error", (event) => {
+ console.error("全局错误:", event.error);
});
-window.addEventListener('unhandledrejection', (e) => {
- console.error('未处理的Promise错误:', e.reason);
+window.addEventListener("unhandledrejection", (event) => {
+ console.error("未处理的 Promise 错误:", event.reason);
});
export function init() {
+ if (initialized && !destroyed) return;
+
+ destroyed = false;
+ initialized = true;
simplex = createNoise3D();
-
+
scene = new THREE.Scene();
-
- camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
+ camera = new THREE.PerspectiveCamera(
+ 75,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 1000,
+ );
camera.position.z = CONFIG.defaultCameraZ;
- window.camera = camera;
-
- renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
+ setSatelliteCamera(camera);
+
+ renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ alpha: false,
+ powerPreference: "high-performance",
+ });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x0a0a1a, 1);
renderer.setPixelRatio(window.devicePixelRatio);
- document.getElementById('container').appendChild(renderer.domElement);
-
+
+ const container = document.getElementById("container");
+ if (container) {
+ container.querySelector("canvas")?.remove();
+ container.appendChild(renderer.domElement);
+ }
+
addLights();
initInfoCard();
const earthObj = createEarth(scene);
@@ -155,241 +332,295 @@ export function init() {
createStars(scene);
createGridLines(scene, earthObj);
createSatellites(scene, earthObj);
-
+
setupControls(camera, renderer, scene, earthObj);
resetView(camera);
- setupEventListeners(camera, renderer);
-
+ setupEventListeners();
+
+ clock.start();
loadData();
-
animate();
+ registerGlobalApi();
+}
+
+function registerGlobalApi() {
+ window.__planetEarth = {
+ reloadData,
+ clearSelection: () => {
+ hideInfoCard();
+ clearLockedObject();
+ },
+ destroy,
+ init,
+ };
}
function addLights() {
- const ambientLight = new THREE.AmbientLight(0x404060);
- scene.add(ambientLight);
-
+ scene.add(new THREE.AmbientLight(0x404060));
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(5, 3, 5);
scene.add(directionalLight);
-
+
const backLight = new THREE.DirectionalLight(0x446688, 0.3);
backLight.position.set(-5, 0, -5);
scene.add(backLight);
-
+
const pointLight = new THREE.PointLight(0xffffff, 0.4);
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
}
-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;
- }
- }
-
+ if (!scene || !camera || !renderer) return;
+ if (isDataLoading) return;
+
+ const earth = getEarth();
+ if (!earth) return;
+
+ const loadToken = ++currentLoadToken;
+ isDataLoading = true;
+ hideError();
setLoading(true);
- try {
- console.log('开始加载数据...');
- await Promise.all([
- (async () => {
- await loadGeoJSONFromPath(scene, getEarth());
- console.log('电缆数据加载完成');
- await loadLandingPoints(scene, getEarth());
- console.log('登陆点数据加载完成');
- })(),
- (async () => {
- const satCount = await loadSatellites();
- console.log(`卫星数据加载完成: ${satCount} 颗`);
- document.getElementById('satellite-count').textContent = satCount + ' 颗';
- updateSatellitePositions();
- console.log('卫星位置已更新');
- toggleSatellites(true);
- const satBtn = document.getElementById('toggle-satellites');
- if (satBtn) {
- satBtn.classList.add('active');
- satBtn.querySelector('.tooltip').textContent = '隐藏卫星';
- }
- })()
- ]);
- } catch (error) {
- console.error('加载数据失败:', error);
- showStatusMessage('加载数据失败: ' + error.message, 'error');
+ clearLockedObject();
+ hideInfoCard();
+
+ if (showWhiteSphere && earth.material) {
+ earthTexture = earth.material.map;
+ earth.material.map = null;
+ earth.material.color.setHex(0xffffff);
+ earth.material.needsUpdate = true;
}
+
+ const results = await Promise.allSettled([
+ loadGeoJSONFromPath(scene, earth),
+ loadLandingPoints(scene, earth),
+ (async () => {
+ clearSatelliteData();
+ const satelliteCount = await loadSatellites();
+ const satelliteCountEl = document.getElementById("satellite-count");
+ if (satelliteCountEl) {
+ satelliteCountEl.textContent = `${satelliteCount} 颗`;
+ }
+ updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
+ toggleSatellites(true);
+ const satBtn = document.getElementById("toggle-satellites");
+ if (satBtn) {
+ satBtn.classList.add("active");
+ const tooltip = satBtn.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = "隐藏卫星";
+ }
+ return satelliteCount;
+ })(),
+ ]);
+
+ if (loadToken !== currentLoadToken) {
+ isDataLoading = false;
+ return;
+ }
+
+ const errors = [];
+ if (results[0].status === "rejected") {
+ errors.push({ label: "电缆", reason: results[0].reason });
+ }
+ if (results[1].status === "rejected") {
+ errors.push({ label: "登陆点", reason: results[1].reason });
+ }
+ if (results[2].status === "rejected") {
+ errors.push({ label: "卫星", reason: results[2].reason });
+ }
+
+ if (errors.length > 0) {
+ const errorMessage = buildLoadErrorMessage(errors);
+ showError(errorMessage);
+ showStatusMessage(errorMessage, "error");
+ } else {
+ hideError();
+ showStatusMessage("数据已重新加载", "success");
+ }
+
+ updateStatsSummary();
setLoading(false);
-
- if (showWhiteSphere) {
- const earth = getEarth();
- if (earth && earth.material) {
- earth.material.map = earthTexture;
- earth.material.color.setHex(0xffffff);
- earth.material.needsUpdate = true;
- }
+ isDataLoading = false;
+
+ if (showWhiteSphere && earth.material) {
+ earth.material.map = earthTexture;
+ earth.material.color.setHex(0xffffff);
+ earth.material.needsUpdate = true;
}
}
+const POSITION_UPDATE_FORCE_DELTA = 250;
+
export async function reloadData() {
await loadData(true);
}
-function setupEventListeners(camera, renderer) {
- window.addEventListener('resize', () => onWindowResize(camera, renderer));
-
- renderer.domElement.addEventListener('mousemove', (e) => onMouseMove(e, camera));
- renderer.domElement.addEventListener('mousedown', onMouseDown);
- renderer.domElement.addEventListener('mouseup', onMouseUp);
- renderer.domElement.addEventListener('click', (e) => onClick(e, camera, renderer));
+function setupEventListeners() {
+ const handleResize = () => onWindowResize();
+ const handleMouseMove = (event) => onMouseMove(event);
+ const handleMouseDown = (event) => onMouseDown(event);
+ const handleMouseUp = () => onMouseUp();
+ const handleClick = (event) => onClick(event);
+ const handlePageHide = () => destroy();
+
+ bindListener(window, "resize", handleResize);
+ bindListener(window, "pagehide", handlePageHide);
+ bindListener(window, "beforeunload", handlePageHide);
+ bindListener(renderer.domElement, "mousemove", handleMouseMove);
+ bindListener(renderer.domElement, "mousedown", handleMouseDown);
+ bindListener(renderer.domElement, "mouseup", handleMouseUp);
+ bindListener(renderer.domElement, "mouseleave", handleMouseUp);
+ bindListener(renderer.domElement, "click", handleClick);
}
-function onWindowResize(camera, renderer) {
+function onWindowResize() {
+ if (!camera || !renderer) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
-function getFrontFacingCables(cableLines, camera) {
+function getFrontFacingCables(cableLines) {
const earth = getEarth();
if (!earth) return cableLines;
-
- const cameraDir = new THREE.Vector3();
- camera.getWorldDirection(cameraDir);
-
- return cableLines.filter(cable => {
- const cablePos = new THREE.Vector3();
- cable.geometry.computeBoundingBox();
- const boundingBox = cable.geometry.boundingBox;
- if (boundingBox) {
- boundingBox.getCenter(cablePos);
- cable.localToWorld(cablePos);
+
+ scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
+
+ return cableLines.filter((cable) => {
+ if (!cable.userData.localCenter) {
+ return true;
}
-
- const toCamera = new THREE.Vector3().subVectors(camera.position, earth.position).normalize();
- const toCable = new THREE.Vector3().subVectors(cablePos, earth.position).normalize();
-
- return toCamera.dot(toCable) > 0;
+
+ scratchCableCenter.copy(cable.userData.localCenter);
+ cable.localToWorld(scratchCableCenter);
+ scratchCableDirection
+ .subVectors(scratchCableCenter, earth.position)
+ .normalize();
+ return scratchCameraToEarth.dot(scratchCableDirection) > 0;
});
}
-function onMouseMove(event, camera) {
+function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
-
- const raycaster = new THREE.Raycaster();
- const mouse = new THREE.Vector2(
- (event.clientX / window.innerWidth) * 2 - 1,
- -(event.clientY / window.innerHeight) * 2 + 1
- );
-
- raycaster.setFromCamera(mouse, camera);
-
- const allCableLines = getCableLines();
- const frontCables = getFrontFacingCables(allCableLines, camera);
- const intersects = raycaster.intersectObjects(frontCables);
-
- const hasHoveredCable = intersects.length > 0;
+
+ if (isDragging) {
+ if (Date.now() - dragStartTime > 500) {
+ isLongDrag = true;
+ }
+
+ const deltaX = event.clientX - previousMousePosition.x;
+ const deltaY = event.clientY - previousMousePosition.y;
+ earth.rotation.y += deltaX * 0.005;
+ earth.rotation.x += deltaY * 0.005;
+ previousMousePosition = { x: event.clientX, y: event.clientY };
+ hideTooltip();
+ return;
+ }
+
+ updatePointerFromEvent(event);
+
+ const frontCables = getFrontFacingCables(getCableLines());
+ const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
+
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
if (getShowSatellites()) {
const satPoints = getSatellitePoints();
if (satPoints) {
- const satIntersects = raycaster.intersectObject(satPoints);
+ const satIntersects = interactionRaycaster.intersectObject(satPoints);
if (satIntersects.length > 0) {
const satIndex = satIntersects[0].index;
if (isSatelliteFrontFacing(satIndex, camera)) {
hoveredSatIndexFromIntersect = satIndex;
- hoveredSat = selectSatellite(hoveredSatIndexFromIntersect);
+ hoveredSat = selectSatellite(satIndex);
}
}
}
}
- const hasHoveredSatellite = hoveredSat && hoveredSat.properties;
-
- if (hoveredCable) {
- if (!hasHoveredCable || !isSameCable(intersects[0]?.object, hoveredCable)) {
- if (!isSameCable(hoveredCable, lockedObject)) {
- setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
- }
- hoveredCable = null;
+
+ if (
+ hoveredCable &&
+ (!cableIntersects.length ||
+ !isSameCable(cableIntersects[0]?.object, hoveredCable))
+ ) {
+ if (!isSameCable(hoveredCable, lockedObject)) {
+ setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
}
+ hoveredCable = null;
}
-
- if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== hoveredSatIndexFromIntersect) {
+
+ if (
+ hoveredSatelliteIndex !== null &&
+ hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
+ ) {
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
- setSatelliteRingState(hoveredSatelliteIndex, 'none', null);
+ setSatelliteRingState(hoveredSatelliteIndex, "none", null);
}
hoveredSatelliteIndex = null;
}
-
- if (hasHoveredCable && getShowCables()) {
- const cable = intersects[0].object;
+
+ if (cableIntersects.length > 0 && getShowCables()) {
+ const cable = cableIntersects[0].object;
+ hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) {
- hoveredCable = cable;
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
- } else {
- hoveredCable = cable;
}
-
showCableInfo(cable);
setInfoCardNoBorder(true);
hideTooltip();
- } else if (hasHoveredSatellite) {
+ } else if (hoveredSat?.properties) {
hoveredSatellite = hoveredSat;
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
const satPositions = getSatellitePositions();
if (satPositions && satPositions[hoveredSatelliteIndex]) {
- setSatelliteRingState(hoveredSatelliteIndex, 'hover', satPositions[hoveredSatelliteIndex].current);
+ setSatelliteRingState(
+ hoveredSatelliteIndex,
+ "hover",
+ satPositions[hoveredSatelliteIndex].current,
+ );
}
}
showSatelliteInfo(hoveredSat.properties);
setInfoCardNoBorder(true);
- } else if (lockedObjectType === 'cable' && lockedObject) {
+ } else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject);
- } else if (lockedObjectType === 'satellite' && lockedSatellite) {
- if (lockedSatelliteIndex !== null && lockedSatelliteIndex !== undefined) {
- const satPositions = getSatellitePositions();
- if (satPositions && satPositions[lockedSatelliteIndex]) {
- setSatelliteRingState(lockedSatelliteIndex, 'locked', satPositions[lockedSatelliteIndex].current);
- }
+ } else if (lockedObjectType === "satellite" && lockedSatellite) {
+ const satPositions = getSatellitePositions();
+ if (lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex]) {
+ setSatelliteRingState(
+ lockedSatelliteIndex,
+ "locked",
+ satPositions[lockedSatelliteIndex].current,
+ );
}
showSatelliteInfo(lockedSatellite.properties);
} else {
hideInfoCard();
}
-
- const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
+
+ const earthPoint = screenToEarthCoords(
+ event.clientX,
+ event.clientY,
+ camera,
+ earth,
+ document.body,
+ interactionRaycaster,
+ interactionMouse,
+ );
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
-
- if (!isDragging) {
- showTooltip(event.clientX + 10, event.clientY + 10,
- `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`);
- }
+ showTooltip(
+ event.clientX + 10,
+ event.clientY + 10,
+ `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`,
+ );
} else {
hideTooltip();
}
-
- if (isDragging) {
- if (Date.now() - dragStartTime > 500) {
- isLongDrag = true;
- }
-
- const deltaX = event.clientX - previousMousePosition.x;
- const deltaY = event.clientY - previousMousePosition.y;
-
- earth.rotation.y += deltaX * 0.005;
- earth.rotation.x += deltaY * 0.005;
-
- previousMousePosition = { x: event.clientX, y: event.clientY };
- }
}
function onMouseDown(event) {
@@ -397,161 +628,185 @@ function onMouseDown(event) {
dragStartTime = Date.now();
isLongDrag = false;
previousMousePosition = { x: event.clientX, y: event.clientY };
- document.getElementById('container').classList.add('dragging');
+ document.getElementById("container")?.classList.add("dragging");
hideTooltip();
}
function onMouseUp() {
isDragging = false;
- document.getElementById('container').classList.remove('dragging');
+ document.getElementById("container")?.classList.remove("dragging");
}
-function onClick(event, camera, renderer) {
+function onClick(event) {
const earth = getEarth();
if (!earth) return;
-
- const raycaster = new THREE.Raycaster();
- const mouse = new THREE.Vector2(
- (event.clientX / window.innerWidth) * 2 - 1,
- -(event.clientY / window.innerHeight) * 2 + 1
+
+ updatePointerFromEvent(event);
+
+ const cableIntersects = interactionRaycaster.intersectObjects(
+ getFrontFacingCables(getCableLines()),
);
-
- raycaster.setFromCamera(mouse, camera);
-
- const allCableLines = getCableLines();
- const frontCables = getFrontFacingCables(allCableLines, camera);
- const intersects = raycaster.intersectObjects(frontCables);
- const satIntersects = getShowSatellites() ? raycaster.intersectObject(getSatellitePoints()) : [];
-
- if (intersects.length > 0 && getShowCables()) {
+ const satIntersects = getShowSatellites()
+ ? interactionRaycaster.intersectObject(getSatellitePoints())
+ : [];
+
+ if (cableIntersects.length > 0 && getShowCables()) {
clearLockedObject();
-
- const clickedCable = intersects[0].object;
+
+ const clickedCable = cableIntersects[0].object;
const cableId = clickedCable.userData.cableId;
-
setCableState(cableId, CABLE_STATE.LOCKED);
-
+
lockedObject = clickedCable;
- lockedObjectType = 'cable';
- cableLockedData = { ...clickedCable.userData };
-
+ lockedObjectType = "cable";
setAutoRotate(false);
handleCableClick(clickedCable);
- } else if (satIntersects.length > 0) {
+ return;
+ }
+
+ if (satIntersects.length > 0) {
const now = Date.now();
const clickX = event.clientX;
const clickY = event.clientY;
-
- let selectedIndex;
- const frontFacingSats = satIntersects.filter(s => isSatelliteFrontFacing(s.index, camera));
+
+ const frontFacingSats = satIntersects.filter((sat) =>
+ isSatelliteFrontFacing(sat.index, camera),
+ );
if (frontFacingSats.length === 0) return;
-
- if (frontFacingSats.length > 1 &&
- now - lastSatClickTime < 500 &&
- Math.abs(clickX - lastSatClickPos.x) < 30 &&
- Math.abs(clickY - lastSatClickPos.y) < 30) {
- const currentIdx = frontFacingSats.findIndex(s => s.index === lastSatClickIndex);
- selectedIndex = frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index;
- } else {
- selectedIndex = frontFacingSats[0].index;
+
+ let selectedIndex = frontFacingSats[0].index;
+ if (
+ frontFacingSats.length > 1 &&
+ now - lastSatClickTime < 500 &&
+ Math.abs(clickX - lastSatClickPos.x) < 30 &&
+ Math.abs(clickY - lastSatClickPos.y) < 30
+ ) {
+ const currentIdx = frontFacingSats.findIndex(
+ (sat) => sat.index === lastSatClickIndex,
+ );
+ selectedIndex =
+ frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index;
}
-
+
lastSatClickTime = now;
lastSatClickIndex = selectedIndex;
lastSatClickPos = { x: clickX, y: clickY };
-
+
const sat = selectSatellite(selectedIndex);
-
- if (sat && sat.properties) {
- clearLockedObject();
-
- lockedObject = sat;
- lockedObjectType = 'satellite';
- lockedSatellite = sat;
- lockedSatelliteIndex = selectedIndex;
- window.lockedSatelliteIndex = selectedIndex;
- showPredictedOrbit(sat);
- setAutoRotate(false);
-
- const satPositions = getSatellitePositions();
- if (satPositions && satPositions[selectedIndex]) {
- setSatelliteRingState(selectedIndex, 'locked', satPositions[selectedIndex].current);
- }
-
- const props = sat.properties;
-
- const meanMotion = props.mean_motion || 0;
- const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-';
-
- const ecc = props.eccentricity || 0;
- const earthRadius = 6371;
- const perigee = (earthRadius * (1 - ecc)).toFixed(0);
- const apogee = (earthRadius * (1 + ecc)).toFixed(0);
-
- showInfoCard('satellite', {
- name: props.name,
- norad_id: props.norad_cat_id,
- inclination: props.inclination ? props.inclination.toFixed(2) : '-',
- period: period,
- perigee: perigee,
- apogee: apogee
- });
-
- showStatusMessage('已选择: ' + props.name, 'info');
- }
- } else {
- if (!isLongDrag) {
- clearLockedObject();
- setAutoRotate(true);
- clearCableSelection();
+ if (!sat?.properties) return;
+
+ clearLockedObject();
+
+ lockedObject = sat;
+ lockedObjectType = "satellite";
+ lockedSatellite = sat;
+ lockedSatelliteIndex = selectedIndex;
+ setLockedSatelliteIndex(selectedIndex);
+ showPredictedOrbit(sat);
+ setAutoRotate(false);
+
+ const satPositions = getSatellitePositions();
+ if (satPositions?.[selectedIndex]) {
+ setSatelliteRingState(
+ selectedIndex,
+ "locked",
+ satPositions[selectedIndex].current,
+ );
}
+
+ showSatelliteInfo(sat.properties);
+ showStatusMessage("已选择: " + sat.properties.name, "info");
+ return;
+ }
+
+ if (!isLongDrag) {
+ clearLockedObject();
+ setAutoRotate(true);
}
}
function animate() {
- requestAnimationFrame(animate);
-
+ if (destroyed) return;
+
+ animationFrameId = requestAnimationFrame(animate);
+
const earth = getEarth();
-
+ const deltaTime = clock.getDelta() * 1000;
+
if (getAutoRotate() && earth) {
- earth.rotation.y += CONFIG.rotationSpeed;
+ earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16);
}
-
+
applyCableVisualState();
-
- if (lockedObjectType === 'cable' && lockedObject) {
+
+ if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false);
- } else if (lockedObjectType === 'satellite' && lockedSatellite) {
+ } else if (lockedObjectType === "satellite" && lockedSatellite) {
applyLandingPointVisualState(null, true);
} else {
resetLandingPointVisualState();
}
-
- updateSatellitePositions(16);
-
- const satPositions = getSatellitePositions();
-
- // 更新呼吸动画相位
- updateBreathingPhase();
- if (lockedObjectType === 'satellite' && lockedSatelliteIndex !== null) {
- if (satPositions && satPositions[lockedSatelliteIndex]) {
- updateLockedRingPosition(satPositions[lockedSatelliteIndex].current);
- }
- } else if (hoveredSatelliteIndex !== null && satPositions && satPositions[hoveredSatelliteIndex]) {
+ updateSatellitePositions(deltaTime);
+ updateBreathingPhase(deltaTime);
+
+ const satPositions = getSatellitePositions();
+ if (
+ lockedObjectType === "satellite" &&
+ lockedSatelliteIndex !== null &&
+ satPositions?.[lockedSatelliteIndex]
+ ) {
+ updateLockedRingPosition(satPositions[lockedSatelliteIndex].current);
+ } else if (
+ hoveredSatelliteIndex !== null &&
+ satPositions?.[hoveredSatelliteIndex]
+ ) {
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
}
-
+
renderer.render(scene, camera);
}
-window.clearLockedCable = function() {
+export function destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ currentLoadToken += 1;
+ isDataLoading = false;
+
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+
+ teardownControls();
+ while (cleanupFns.length) {
+ const cleanup = cleanupFns.pop();
+ cleanup?.();
+ }
+
clearLockedObject();
-};
+ clearCableData(getEarth());
+ resetSatelliteState();
+ clearUiState();
-window.clearSelection = function() {
- hideInfoCard();
- window.clearLockedCable();
-};
+ if (scene) {
+ disposeSceneObject(scene);
+ }
-document.addEventListener('DOMContentLoaded', init);
+ if (renderer) {
+ renderer.dispose();
+ if (typeof renderer.forceContextLoss === "function") {
+ renderer.forceContextLoss();
+ }
+ renderer.domElement?.remove();
+ }
+
+ scene = null;
+ camera = null;
+ renderer = null;
+ initialized = false;
+
+ delete window.__planetEarth;
+}
+
+document.addEventListener("DOMContentLoaded", init);
diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js
index 597ed25a..09e26800 100644
--- a/frontend/public/earth/js/satellites.js
+++ b/frontend/public/earth/js/satellites.js
@@ -1,68 +1,112 @@
// 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, SATELLITE_CONFIG } from './constants.js';
+import * as THREE from "three";
+import { twoline2satrec, propagate } from "satellite.js";
+import { CONFIG, SATELLITE_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 = [];
let hoverRingSprite = null;
let lockedRingSprite = null;
let lockedDotSprite = null;
-export let breathingPhase = 0;
+let predictedOrbitLine = null;
+let earthObjRef = null;
+let sceneRef = null;
+let cameraRef = null;
+let lockedSatelliteIndex = null;
+let positionUpdateAccumulator = 0;
-export function updateBreathingPhase() {
- breathingPhase += SATELLITE_CONFIG.breathingSpeed;
-}
-
-const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount;
const MAX_SATELLITES = SATELLITE_CONFIG.maxCount;
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
+const POSITION_UPDATE_INTERVAL_MS = 250;
+
+const scratchWorldSatellitePosition = new THREE.Vector3();
+const scratchToCamera = new THREE.Vector3();
+const scratchToSatellite = new THREE.Vector3();
+
+export let breathingPhase = 0;
+
+export function updateBreathingPhase(deltaTime = 16) {
+ breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
+}
+
+function disposeMaterial(material) {
+ if (!material) return;
+ if (Array.isArray(material)) {
+ material.forEach(disposeMaterial);
+ return;
+ }
+ if (material.map) {
+ material.map.dispose();
+ }
+ material.dispose();
+}
+
+function disposeObject3D(object, parent = earthObjRef) {
+ if (!object) return;
+ if (parent) {
+ parent.remove(object);
+ } else if (object.parent) {
+ object.parent.remove(object);
+ }
+ if (object.geometry) {
+ object.geometry.dispose();
+ }
+ if (object.material) {
+ disposeMaterial(object.material);
+ }
+}
function createDotTexture() {
- const canvas = document.createElement('canvas');
+ const canvas = document.createElement("canvas");
canvas.width = DOT_TEXTURE_SIZE;
canvas.height = DOT_TEXTURE_SIZE;
- const ctx = canvas.getContext('2d');
+ const ctx = canvas.getContext("2d");
const center = DOT_TEXTURE_SIZE / 2;
const radius = center - 2;
-
- const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius);
- gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
- gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)');
- gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
-
+
+ const gradient = ctx.createRadialGradient(
+ center,
+ center,
+ 0,
+ center,
+ center,
+ radius,
+ );
+ gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
+ gradient.addColorStop(0.5, "rgba(255, 255, 255, 0.8)");
+ gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
+
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(center, center, radius, 0, Math.PI * 2);
ctx.fill();
-
+
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
-function createRingTexture(innerRadius, outerRadius, color = '#ffffff') {
+function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const size = DOT_TEXTURE_SIZE * 2;
- const canvas = document.createElement('canvas');
+ const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
- const ctx = canvas.getContext('2d');
+ const ctx = canvas.getContext("2d");
const center = size / 2;
-
+
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2);
ctx.stroke();
-
+
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
@@ -70,16 +114,18 @@ function createRingTexture(innerRadius, outerRadius, color = '#ffffff') {
export function createSatellites(scene, earthObj) {
initSatelliteScene(scene, earthObj);
-
+
const positions = new Float32Array(MAX_SATELLITES * 3);
const colors = new Float32Array(MAX_SATELLITES * 3);
-
const dotTexture = createDotTexture();
-
+
const pointsGeometry = new THREE.BufferGeometry();
- pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
-
+ pointsGeometry.setAttribute(
+ "position",
+ new THREE.BufferAttribute(positions, 3),
+ );
+ pointsGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
+
const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize,
map: dotTexture,
@@ -87,57 +133,65 @@ export function createSatellites(scene, earthObj) {
transparent: true,
opacity: 0.9,
sizeAttenuation: false,
- alphaTest: 0.1
+ alphaTest: 0.1,
});
-
+
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false;
- satellitePoints.userData = { type: 'satellitePoints' };
-
+ satellitePoints.userData = { type: "satellitePoints" };
+
const originalScale = { x: 1, y: 1, z: 1 };
- satellitePoints.onBeforeRender = (renderer, scene, camera, geometry, material) => {
+ satellitePoints.onBeforeRender = () => {
if (earthObj && earthObj.scale.x !== 1) {
satellitePoints.scale.set(
originalScale.x / earthObj.scale.x,
originalScale.y / earthObj.scale.y,
- originalScale.z / earthObj.scale.z
+ originalScale.z / earthObj.scale.z,
);
} else {
- satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
+ satellitePoints.scale.set(
+ originalScale.x,
+ originalScale.y,
+ originalScale.z,
+ );
}
};
-
+
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));
-
+ 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
+ blending: THREE.AdditiveBlending,
});
-
+
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
satelliteTrails.visible = false;
- satelliteTrails.userData = { type: 'satelliteTrails' };
+ satelliteTrails.userData = { type: "satelliteTrails" };
earthObj.add(satelliteTrails);
-
- satellitePositions = [];
- for (let i = 0; i < MAX_SATELLITES; i++) {
- satellitePositions.push({
- current: new THREE.Vector3(),
- trail: [],
- trailIndex: 0,
- trailCount: 0
- });
- }
-
+
+ satellitePositions = Array.from({ length: MAX_SATELLITES }, () => ({
+ current: new THREE.Vector3(),
+ trail: [],
+ trailIndex: 0,
+ trailCount: 0,
+ }));
+
+ positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satellitePoints;
}
@@ -147,209 +201,295 @@ function computeSatellitePosition(satellite, time) {
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 || '';
-
- // Simplified epoch calculation
- let epochDate = epoch && epoch.length >= 10 ? new Date(epoch) : time;
- const epochYear = epochDate.getUTCFullYear() % 100;
- const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
- const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
- const msOfDay = epochDate.getUTCHours() * 3600000 + epochDate.getUTCMinutes() * 60000 + epochDate.getUTCSeconds() * 1000 + epochDate.getUTCMilliseconds();
- const dayFraction = msOfDay / 86400000;
- const epochStr = String(epochYear).padStart(2, '0') + String(dayOfYear).padStart(3, '0') + '.' + dayFraction.toFixed(8).substring(2);
-
- // Format eccentricity as "0.0001652" (7 chars after decimal)
- const eccStr = '0' + eccentricity.toFixed(7);
- const tleLine1 = `1 ${noradId.toString().padStart(5)}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 9999`;
- const tleLine2 = `2 ${noradId.toString().padStart(5)} ${raan.toFixed(4).padStart(8)} ${inclination.toFixed(4).padStart(8)} ${eccStr.substring(1)} ${argOfPerigee.toFixed(4).padStart(8)} ${meanAnomaly.toFixed(4).padStart(8)} ${meanMotion.toFixed(8).padStart(11)} 0 9999`;
-
- const satrec = twoline2satrec(tleLine1, tleLine2);
+
+ const satrec = buildSatrecFromProperties(props, time);
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) {
+
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(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 displayRadius = CONFIG.earthRadius * 1.05;
const scale = displayRadius / r;
-
+
return new THREE.Vector3(x * scale, y * scale, z * scale);
- } catch (e) {
+ } catch (error) {
return null;
}
}
+function buildSatrecFromProperties(props, fallbackTime) {
+ if (props.tle_line1 && props.tle_line2) {
+ // Prefer source-provided TLE lines so the client does not need to rebuild them.
+ const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
+ if (!satrec.error) {
+ return satrec;
+ }
+ }
+
+ const tleLines = buildTleLinesFromElements(props, fallbackTime);
+ if (!tleLines) {
+ return null;
+ }
+
+ return twoline2satrec(tleLines.line1, tleLines.line2);
+}
+
+function computeTleChecksum(line) {
+ let sum = 0;
+
+ for (const char of line.slice(0, 68)) {
+ if (char >= "0" && char <= "9") {
+ sum += Number(char);
+ } else if (char === "-") {
+ sum += 1;
+ }
+ }
+
+ return String(sum % 10);
+}
+
+function buildTleLinesFromElements(props, fallbackTime) {
+ if (!props?.norad_cat_id) {
+ return null;
+ }
+
+ const requiredValues = [
+ props.inclination,
+ props.raan,
+ props.eccentricity,
+ props.arg_of_perigee,
+ props.mean_anomaly,
+ props.mean_motion,
+ ];
+ if (requiredValues.some((value) => value === null || value === undefined)) {
+ return null;
+ }
+
+ const epochDate =
+ props.epoch && String(props.epoch).length >= 10
+ ? new Date(props.epoch)
+ : fallbackTime;
+ if (Number.isNaN(epochDate.getTime())) {
+ return null;
+ }
+
+ const epochYear = epochDate.getUTCFullYear() % 100;
+ const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
+ const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
+ const msOfDay =
+ epochDate.getUTCHours() * 3600000 +
+ epochDate.getUTCMinutes() * 60000 +
+ epochDate.getUTCSeconds() * 1000 +
+ epochDate.getUTCMilliseconds();
+ const dayFraction = msOfDay / 86400000;
+ const epochStr =
+ String(epochYear).padStart(2, "0") +
+ String(dayOfYear).padStart(3, "0") +
+ dayFraction.toFixed(8).slice(1);
+
+ const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
+ .toString()
+ .padStart(7, "0");
+
+ // Keep a local fallback for historical rows that do not have stored TLE lines yet.
+ const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
+ const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
+ props.inclination,
+ )
+ .toFixed(4)
+ .padStart(
+ 8,
+ )} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
+ props.arg_of_perigee,
+ )
+ .toFixed(4)
+ .padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
+ props.mean_motion,
+ )
+ .toFixed(8)
+ .padStart(11)}00000`;
+
+ return {
+ line1: line1Core + computeTleChecksum(line1Core),
+ line2: line2Core + computeTleChecksum(line2Core),
+ };
+}
+
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 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 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.length;
- } catch (error) {
- console.error('Failed to load satellites:', error);
- return [];
+ const response = await fetch(
+ `${SATELLITE_CONFIG.apiPath}?limit=${SATELLITE_CONFIG.maxCount}`,
+ );
+ if (!response.ok) {
+ throw new Error(`卫星接口返回 HTTP ${response.status}`);
}
+
+ const data = await response.json();
+ satelliteData = data.features || [];
+ positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
+ return satelliteData.length;
}
-export function updateSatellitePositions(deltaTime = 0) {
+export function updateSatellitePositions(deltaTime = 0, force = false) {
if (!satellitePoints || satelliteData.length === 0) return;
-
- animationTime += deltaTime * 0.001;
-
+
+ const shouldUpdateTrails =
+ showSatellites || showTrails || lockedSatelliteIndex !== null;
+ positionUpdateAccumulator += deltaTime;
+
+ if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) {
+ return;
+ }
+
+ const elapsedMs = Math.max(
+ positionUpdateAccumulator,
+ POSITION_UPDATE_INTERVAL_MS,
+ );
+ positionUpdateAccumulator = 0;
+
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 baseTime = new Date(Date.now() + elapsedMs);
const count = Math.min(satelliteData.length, MAX_SATELLITES);
-
+
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);
-
+ 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);
-
- const satPos = satellitePositions[i];
- if (i !== window.lockedSatelliteIndex) {
+
+ if (shouldUpdateTrails && i !== lockedSatelliteIndex) {
+ const satPos = satellitePositions[i];
satPos.trail[satPos.trailIndex] = pos.clone();
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
}
-
+
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 name = props?.name || "";
+ const isStarlink = name.includes("STARLINK");
const isGeo = inclination > 20 && inclination < 30;
- const isIridium = name.includes('IRIDIUM');
-
- let r, g, b;
+ const isIridium = name.includes("IRIDIUM");
+
+ let r;
+ let g;
+ let b;
if (isStarlink) {
- r = 0.0; g = 0.9; b = 1.0;
+ r = 0.0;
+ g = 0.9;
+ b = 1.0;
} else if (isGeo) {
- r = 1.0; g = 0.8; b = 0.0;
+ r = 1.0;
+ g = 0.8;
+ b = 0.0;
} else if (isIridium) {
- r = 1.0; g = 0.5; b = 0.0;
+ r = 1.0;
+ g = 0.5;
+ b = 0.0;
} else if (inclination > 50 && inclination < 70) {
- r = 0.0; g = 1.0; b = 0.3;
+ r = 0.0;
+ g = 1.0;
+ b = 0.3;
} else {
- r = 1.0; g = 1.0; b = 1.0;
+ r = 1.0;
+ g = 1.0;
+ b = 1.0;
}
-
+
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
-
- const sp = satellitePositions[i];
- const trail = sp.trail;
- const tc = sp.trailCount;
- const ti = sp.trailIndex;
-
+
+ const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
-
- if (j < tc) {
- const idx = (ti - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH;
- const t = trail[idx];
- if (t) {
- trailPositions[trailIdx] = t.x;
- trailPositions[trailIdx + 1] = t.y;
- trailPositions[trailIdx + 2] = t.z;
- const alpha = (j + 1) / tc;
+
+ if (j < satPosition.trailCount) {
+ const idx =
+ (satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) %
+ TRAIL_LENGTH;
+ const trailPoint = satPosition.trail[idx];
+ if (trailPoint) {
+ trailPositions[trailIdx] = trailPoint.x;
+ trailPositions[trailIdx + 1] = trailPoint.y;
+ trailPositions[trailIdx + 2] = trailPoint.z;
+ const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha;
trailColors[trailIdx + 1] = g * alpha;
trailColors[trailIdx + 2] = b * alpha;
- } else {
- trailPositions[trailIdx] = pos.x;
- trailPositions[trailIdx + 1] = pos.y;
- trailPositions[trailIdx + 2] = pos.z;
- trailColors[trailIdx] = 0;
- trailColors[trailIdx + 1] = 0;
- trailColors[trailIdx + 2] = 0;
+ continue;
}
- } else {
- trailPositions[trailIdx] = pos.x;
- trailPositions[trailIdx + 1] = pos.y;
- trailPositions[trailIdx + 2] = pos.z;
- trailColors[trailIdx] = 0;
- trailColors[trailIdx + 1] = 0;
- trailColors[trailIdx + 2] = 0;
}
+
+ trailPositions[trailIdx] = pos.x;
+ trailPositions[trailIdx + 1] = pos.y;
+ trailPositions[trailIdx + 2] = pos.z;
+ trailColors[trailIdx] = 0;
+ trailColors[trailIdx + 1] = 0;
+ trailColors[trailIdx + 2] = 0;
}
}
-
+
for (let i = count; i < MAX_SATELLITES; 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;
@@ -357,11 +497,11 @@ export function updateSatellitePositions(deltaTime = 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;
}
@@ -415,159 +555,177 @@ export function getSatellitePositions() {
return satellitePositions;
}
-export function isSatelliteFrontFacing(index, camera) {
- if (!earthObjRef || !camera) return true;
- const positions = satellitePositions;
- if (!positions || !positions[index]) return true;
-
- const satPos = positions[index].current;
- if (!satPos) return true;
-
- const worldSatPos = satPos.clone().applyMatrix4(earthObjRef.matrixWorld);
- const toCamera = new THREE.Vector3().subVectors(camera.position, earthObjRef.position).normalize();
- const toSat = new THREE.Vector3().subVectors(worldSatPos, earthObjRef.position).normalize();
-
- return toCamera.dot(toSat) > 0;
+export function setSatelliteCamera(camera) {
+ cameraRef = camera;
}
-let earthObjRef = null;
-let sceneRef = null;
+export function setLockedSatelliteIndex(index) {
+ lockedSatelliteIndex = index;
+}
-export function showHoverRing(position, isLocked = false) {
- if (!sceneRef || !earthObjRef) return;
-
- const ringTexture = createRingTexture(8, 12, isLocked ? '#ffcc00' : '#ffffff');
- const spriteMaterial = new THREE.SpriteMaterial({
- map: ringTexture,
- transparent: true,
- opacity: 0.8,
- depthTest: false,
- sizeAttenuation: false
- });
-
- const ringSize = SATELLITE_CONFIG.ringSize;
- const sprite = new THREE.Sprite(spriteMaterial);
- sprite.position.copy(position);
-
- const camera = window.camera;
- const cameraDistance = camera ? camera.position.distanceTo(position) : 400;
- const scale = ringSize;
- sprite.scale.set(scale, scale, 1);
-
- earthObjRef.add(sprite);
-
- if (isLocked) {
- if (lockedRingSprite) {
- earthObjRef.remove(lockedRingSprite);
- }
- lockedRingSprite = sprite;
-
- if (lockedDotSprite) {
- earthObjRef.remove(lockedDotSprite);
- }
- const dotCanvas = createBrighterDotCanvas();
- const dotTexture = new THREE.CanvasTexture(dotCanvas);
- dotTexture.needsUpdate = true;
- const dotMaterial = new THREE.SpriteMaterial({
- map: dotTexture,
- transparent: true,
- opacity: 1.0,
- depthTest: false
- });
- lockedDotSprite = new THREE.Sprite(dotMaterial);
- lockedDotSprite.position.copy(position);
- lockedDotSprite.scale.set(4 * cameraDistance / 200, 4 * cameraDistance / 200, 1);
-
- earthObjRef.add(lockedDotSprite);
- } else {
- if (hoverRingSprite) {
- earthObjRef.remove(hoverRingSprite);
- }
- hoverRingSprite = sprite;
- }
-
- return sprite;
+export function isSatelliteFrontFacing(index, camera = cameraRef) {
+ if (!earthObjRef || !camera) return true;
+ if (!satellitePositions || !satellitePositions[index]) return true;
+
+ const satPos = satellitePositions[index].current;
+ if (!satPos) return true;
+
+ scratchWorldSatellitePosition
+ .copy(satPos)
+ .applyMatrix4(earthObjRef.matrixWorld);
+ scratchToCamera.subVectors(camera.position, earthObjRef.position).normalize();
+ scratchToSatellite
+ .subVectors(scratchWorldSatellitePosition, earthObjRef.position)
+ .normalize();
+
+ return scratchToCamera.dot(scratchToSatellite) > 0;
}
function createBrighterDotCanvas() {
const size = DOT_TEXTURE_SIZE * 2;
- const canvas = document.createElement('canvas');
+ const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
- const ctx = canvas.getContext('2d');
+ const ctx = canvas.getContext("2d");
const center = size / 2;
- const gradient = ctx.createRadialGradient(center, center, 0, center, center, center);
- gradient.addColorStop(0, 'rgba(255, 255, 200, 1)');
- gradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.9)');
- gradient.addColorStop(0.7, 'rgba(255, 180, 50, 0.5)');
- gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
+ const gradient = ctx.createRadialGradient(
+ center,
+ center,
+ 0,
+ center,
+ center,
+ center,
+ );
+ gradient.addColorStop(0, "rgba(255, 255, 200, 1)");
+ gradient.addColorStop(0.3, "rgba(255, 220, 100, 0.9)");
+ gradient.addColorStop(0.7, "rgba(255, 180, 50, 0.5)");
+ gradient.addColorStop(1, "rgba(255, 150, 0, 0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
return canvas;
}
+function createRingSprite(position, isLocked = false) {
+ if (!earthObjRef) return null;
+
+ const ringTexture = createRingTexture(
+ 8,
+ 12,
+ isLocked ? "#ffcc00" : "#ffffff",
+ );
+ const spriteMaterial = new THREE.SpriteMaterial({
+ map: ringTexture,
+ transparent: true,
+ opacity: 0.8,
+ depthTest: false,
+ sizeAttenuation: false,
+ });
+
+ const sprite = new THREE.Sprite(spriteMaterial);
+ sprite.position.copy(position);
+ sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
+ earthObjRef.add(sprite);
+ return sprite;
+}
+
+export function showHoverRing(position, isLocked = false) {
+ if (!earthObjRef || !position) return null;
+
+ if (isLocked) {
+ hideLockedRing();
+ lockedRingSprite = createRingSprite(position, true);
+
+ const dotCanvas = createBrighterDotCanvas();
+ const dotTexture = new THREE.CanvasTexture(dotCanvas);
+ const dotMaterial = new THREE.SpriteMaterial({
+ map: dotTexture,
+ transparent: true,
+ opacity: 1.0,
+ depthTest: false,
+ });
+ lockedDotSprite = new THREE.Sprite(dotMaterial);
+ lockedDotSprite.position.copy(position);
+ lockedDotSprite.scale.set(4, 4, 1);
+ earthObjRef.add(lockedDotSprite);
+ return lockedRingSprite;
+ }
+
+ hideHoverRings();
+ hoverRingSprite = createRingSprite(position, false);
+ return hoverRingSprite;
+}
+
export function hideHoverRings() {
- if (!earthObjRef) return;
-
if (hoverRingSprite) {
- earthObjRef.remove(hoverRingSprite);
+ disposeObject3D(hoverRingSprite);
hoverRingSprite = null;
}
}
export function hideLockedRing() {
- if (!earthObjRef) return;
if (lockedRingSprite) {
- earthObjRef.remove(lockedRingSprite);
+ disposeObject3D(lockedRingSprite);
lockedRingSprite = null;
}
if (lockedDotSprite) {
- earthObjRef.remove(lockedDotSprite);
+ disposeObject3D(lockedDotSprite);
lockedDotSprite = null;
}
}
export function updateLockedRingPosition(position) {
- const ringSize = SATELLITE_CONFIG.ringSize;
- const camera = window.camera;
- const cameraDistance = camera ? camera.position.distanceTo(position) : 400;
- if (lockedRingSprite && position) {
+ if (!position) return;
+ if (lockedRingSprite) {
lockedRingSprite.position.copy(position);
- const breathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
- lockedRingSprite.scale.set(ringSize * breathScale, ringSize * breathScale, 1);
- const breathOpacity = SATELLITE_CONFIG.breathingOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin);
- lockedRingSprite.material.opacity = breathOpacity;
+ const breathScale =
+ 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
+ lockedRingSprite.scale.set(
+ SATELLITE_CONFIG.ringSize * breathScale,
+ SATELLITE_CONFIG.ringSize * breathScale,
+ 1,
+ );
+ lockedRingSprite.material.opacity =
+ SATELLITE_CONFIG.breathingOpacityMin +
+ Math.sin(breathingPhase) *
+ (SATELLITE_CONFIG.breathingOpacityMax -
+ SATELLITE_CONFIG.breathingOpacityMin);
}
- if (lockedDotSprite && position) {
+
+ if (lockedDotSprite) {
lockedDotSprite.position.copy(position);
- const dotBreathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
- lockedDotSprite.scale.set(4 * cameraDistance / 200 * dotBreathScale, 4 * cameraDistance / 200 * dotBreathScale, 1);
- lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
+ const dotBreathScale =
+ 1 +
+ Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
+ lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
+ lockedDotSprite.material.opacity =
+ SATELLITE_CONFIG.dotOpacityMin +
+ Math.sin(breathingPhase) *
+ (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
}
}
export function updateHoverRingPosition(position) {
- const ringSize = SATELLITE_CONFIG.ringSize;
- const camera = window.camera;
- const cameraDistance = camera ? camera.position.distanceTo(position) : 400;
- const scale = ringSize;
if (hoverRingSprite && position) {
hoverRingSprite.position.copy(position);
- hoverRingSprite.scale.set(scale, scale, 1);
+ hoverRingSprite.scale.set(
+ SATELLITE_CONFIG.ringSize,
+ SATELLITE_CONFIG.ringSize,
+ 1,
+ );
}
}
export function setSatelliteRingState(index, state, position) {
switch (state) {
- case 'hover':
+ case "hover":
hideHoverRings();
showHoverRing(position, false);
break;
- case 'locked':
+ case "locked":
hideHoverRings();
showHoverRing(position, true);
break;
- case 'none':
+ case "none":
hideHoverRings();
hideLockedRing();
break;
@@ -579,90 +737,142 @@ export function initSatelliteScene(scene, earth) {
earthObjRef = earth;
}
-let predictedOrbitLine = null;
-
function calculateOrbitalPeriod(meanMotion) {
return 86400 / meanMotion;
}
-function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) {
+function calculatePredictedOrbit(
+ satellite,
+ periodSeconds,
+ sampleInterval = 10,
+) {
const points = [];
const samples = Math.ceil(periodSeconds / sampleInterval);
const now = new Date();
-
- // Full orbit: from now to now+period (complete circle forward)
+
for (let i = 0; i <= samples; i++) {
const time = new Date(now.getTime() + i * sampleInterval * 1000);
const pos = computeSatellitePosition(satellite, time);
if (pos) points.push(pos);
}
-
- // If we don't have enough points, use fallback orbit
+
if (points.length < samples * 0.5) {
points.length = 0;
const radius = CONFIG.earthRadius + 5;
- const noradId = satellite.properties?.norad_cat_id || 0;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
- const meanAnomaly = satellite.properties?.mean_anomaly || 0;
-
+
for (let i = 0; i <= samples; i++) {
const theta = (i / samples) * Math.PI * 2;
- const phi = (inclination * Math.PI / 180);
- const x = radius * Math.sin(phi) * Math.cos(theta + raan * Math.PI / 180);
+ const phi = (inclination * Math.PI) / 180;
+ const x =
+ radius * Math.sin(phi) * Math.cos(theta + (raan * Math.PI) / 180);
const y = radius * Math.cos(phi);
- const z = radius * Math.sin(phi) * Math.sin(theta + raan * Math.PI / 180);
+ const z =
+ radius * Math.sin(phi) * Math.sin(theta + (raan * Math.PI) / 180);
points.push(new THREE.Vector3(x, y, z));
}
}
-
+
return points;
}
export function showPredictedOrbit(satellite) {
hidePredictedOrbit();
-
- const props = satellite.properties;
- const meanMotion = props?.mean_motion || 15;
+ if (!earthObjRef) return;
+
+ const meanMotion = satellite.properties?.mean_motion || 15;
const periodSeconds = calculateOrbitalPeriod(meanMotion);
-
const points = calculatePredictedOrbit(satellite, periodSeconds);
if (points.length < 2) return;
-
+
const positions = new Float32Array(points.length * 3);
const colors = new Float32Array(points.length * 3);
-
+
for (let i = 0; i < points.length; i++) {
positions[i * 3] = points[i].x;
positions[i * 3 + 1] = points[i].y;
positions[i * 3 + 2] = points[i].z;
-
+
const t = i / (points.length - 1);
colors[i * 3] = 1 - t * 0.4;
colors[i * 3 + 1] = 1 - t * 0.6;
colors[i * 3 + 2] = t;
}
-
+
const geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
-
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
+
const material = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.8,
- blending: THREE.AdditiveBlending
+ blending: THREE.AdditiveBlending,
});
-
+
predictedOrbitLine = new THREE.Line(geometry, material);
earthObjRef.add(predictedOrbitLine);
}
export function hidePredictedOrbit() {
if (predictedOrbitLine) {
- earthObjRef.remove(predictedOrbitLine);
- predictedOrbitLine.geometry.dispose();
- predictedOrbitLine.material.dispose();
+ disposeObject3D(predictedOrbitLine);
predictedOrbitLine = null;
}
}
+
+export function clearSatelliteData() {
+ satelliteData = [];
+ selectedSatellite = null;
+ lockedSatelliteIndex = null;
+ positionUpdateAccumulator = 0;
+
+ satellitePositions.forEach((position) => {
+ position.current.set(0, 0, 0);
+ position.trail = [];
+ position.trailIndex = 0;
+ position.trailCount = 0;
+ });
+
+ if (satellitePoints) {
+ const positions = satellitePoints.geometry.attributes.position.array;
+ const colors = satellitePoints.geometry.attributes.color.array;
+ positions.fill(0);
+ colors.fill(0);
+ satellitePoints.geometry.attributes.position.needsUpdate = true;
+ satellitePoints.geometry.attributes.color.needsUpdate = true;
+ satellitePoints.geometry.setDrawRange(0, 0);
+ }
+
+ if (satelliteTrails) {
+ const trailPositions = satelliteTrails.geometry.attributes.position.array;
+ const trailColors = satelliteTrails.geometry.attributes.color.array;
+ trailPositions.fill(0);
+ trailColors.fill(0);
+ satelliteTrails.geometry.attributes.position.needsUpdate = true;
+ satelliteTrails.geometry.attributes.color.needsUpdate = true;
+ }
+
+ hideHoverRings();
+ hideLockedRing();
+ hidePredictedOrbit();
+}
+
+export function resetSatelliteState() {
+ clearSatelliteData();
+
+ if (satellitePoints) {
+ disposeObject3D(satellitePoints);
+ satellitePoints = null;
+ }
+
+ if (satelliteTrails) {
+ disposeObject3D(satelliteTrails);
+ satelliteTrails = null;
+ }
+
+ satellitePositions = [];
+ showSatellites = false;
+ showTrails = true;
+}
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index da36d359..3bb9f65a 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -1,71 +1,125 @@
// ui.js - UI update functions
+let statusTimeoutId = null;
+
// Show status message
-export function showStatusMessage(message, type = 'info') {
- const statusEl = document.getElementById('status-message');
+export function showStatusMessage(message, type = "info") {
+ const statusEl = document.getElementById("status-message");
+ if (!statusEl) return;
+
+ if (statusTimeoutId) {
+ clearTimeout(statusTimeoutId);
+ statusTimeoutId = null;
+ }
+
statusEl.textContent = message;
statusEl.className = `status-message ${type}`;
- statusEl.style.display = 'block';
-
- setTimeout(() => {
- statusEl.style.display = 'none';
+ statusEl.style.display = "block";
+
+ statusTimeoutId = setTimeout(() => {
+ statusEl.style.display = "none";
+ statusEl.textContent = "";
+ statusTimeoutId = null;
}, 3000);
}
// Update coordinates display
export function updateCoordinatesDisplay(lat, lon, alt = 0) {
- document.getElementById('longitude-value').textContent = lon.toFixed(2) + '°';
- document.getElementById('latitude-value').textContent = lat.toFixed(2) + '°';
- document.getElementById('mouse-coords').textContent =
- `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
+ const longitudeEl = document.getElementById("longitude-value");
+ const latitudeEl = document.getElementById("latitude-value");
+ const mouseCoordsEl = document.getElementById("mouse-coords");
+
+ if (longitudeEl) longitudeEl.textContent = lon.toFixed(2) + "°";
+ if (latitudeEl) latitudeEl.textContent = lat.toFixed(2) + "°";
+ if (mouseCoordsEl) {
+ mouseCoordsEl.textContent = `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
+ }
}
// Update zoom display
export function updateZoomDisplay(zoomLevel, distance) {
const percent = Math.round(zoomLevel * 100);
- document.getElementById('zoom-value').textContent = percent + '%';
- document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
- const slider = document.getElementById('zoom-slider');
+ const zoomValueEl = document.getElementById("zoom-value");
+ const zoomLevelEl = document.getElementById("zoom-level");
+ const slider = document.getElementById("zoom-slider");
+ const cameraDistanceEl = document.getElementById("camera-distance");
+
+ if (zoomValueEl) zoomValueEl.textContent = percent + "%";
+ if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
if (slider) slider.value = zoomLevel;
- document.getElementById('camera-distance').textContent = distance + ' km';
+ if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
}
// Update earth stats
export function updateEarthStats(stats) {
- document.getElementById('cable-count').textContent = stats.cableCount || 0;
- document.getElementById('landing-point-count').textContent = stats.landingPointCount || 0;
- document.getElementById('terrain-status').textContent = stats.terrainOn ? '开启' : '关闭';
- document.getElementById('texture-quality').textContent = stats.textureQuality || '8K 卫星图';
+ const cableCountEl = document.getElementById("cable-count");
+ const landingPointCountEl = document.getElementById("landing-point-count");
+ const terrainStatusEl = document.getElementById("terrain-status");
+ const textureQualityEl = document.getElementById("texture-quality");
+
+ if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
+ if (landingPointCountEl)
+ landingPointCountEl.textContent = stats.landingPointCount || 0;
+ if (terrainStatusEl)
+ terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
+ if (textureQualityEl)
+ textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
}
// Show/hide loading
export function setLoading(loading) {
- const loadingEl = document.getElementById('loading');
- loadingEl.style.display = loading ? 'block' : 'none';
+ const loadingEl = document.getElementById("loading");
+ if (!loadingEl) return;
+ loadingEl.style.display = loading ? "block" : "none";
}
// Show tooltip
export function showTooltip(x, y, content) {
- const tooltip = document.getElementById('tooltip');
+ const tooltip = document.getElementById("tooltip");
+ if (!tooltip) return;
tooltip.innerHTML = content;
- tooltip.style.left = x + 'px';
- tooltip.style.top = y + 'px';
- tooltip.style.display = 'block';
+ tooltip.style.left = x + "px";
+ tooltip.style.top = y + "px";
+ tooltip.style.display = "block";
}
// Hide tooltip
export function hideTooltip() {
- document.getElementById('tooltip').style.display = 'none';
+ const tooltip = document.getElementById("tooltip");
+ if (tooltip) {
+ tooltip.style.display = "none";
+ }
}
// Show error message
export function showError(message) {
- const errorEl = document.getElementById('error-message');
+ const errorEl = document.getElementById("error-message");
+ if (!errorEl) return;
errorEl.textContent = message;
- errorEl.style.display = 'block';
+ errorEl.style.display = "block";
}
// Hide error message
export function hideError() {
- document.getElementById('error-message').style.display = 'none';
+ const errorEl = document.getElementById("error-message");
+ if (errorEl) {
+ errorEl.style.display = "none";
+ errorEl.textContent = "";
+ }
+}
+
+export function clearUiState() {
+ if (statusTimeoutId) {
+ clearTimeout(statusTimeoutId);
+ statusTimeoutId = null;
+ }
+
+ const statusEl = document.getElementById("status-message");
+ if (statusEl) {
+ statusEl.style.display = "none";
+ statusEl.textContent = "";
+ }
+
+ hideTooltip();
+ hideError();
}
diff --git a/frontend/public/earth/js/utils.js b/frontend/public/earth/js/utils.js
index dcc70ebe..1bd3ee94 100644
--- a/frontend/public/earth/js/utils.js
+++ b/frontend/public/earth/js/utils.js
@@ -1,8 +1,8 @@
// utils.js - Utility functions for coordinate conversion
-import * as THREE from 'three';
+import * as THREE from "three";
-import { CONFIG } from './constants.js';
+import { CONFIG } from "./constants.js";
// Convert latitude/longitude to 3D vector
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
@@ -18,26 +18,33 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
// Convert 3D vector to latitude/longitude
export function vector3ToLatLon(vector) {
- const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
- const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI);
-
- let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
-
+ 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;
+
+ let lon = (Math.atan2(vector.z, -vector.x) * 180) / Math.PI - 180;
+
while (lon <= -180) lon += 360;
while (lon > 180) lon -= 360;
return {
lat: parseFloat(lat.toFixed(4)),
lon: parseFloat(lon.toFixed(4)),
- alt: radius - CONFIG.earthRadius
+ alt: radius - CONFIG.earthRadius,
};
}
// Convert screen coordinates to Earth surface 3D coordinates
-export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) {
- const raycaster = new THREE.Raycaster();
- const mouse = new THREE.Vector2();
-
+export function screenToEarthCoords(
+ clientX,
+ clientY,
+ camera,
+ earth,
+ domElement = document.body,
+ raycaster = new THREE.Raycaster(),
+ mouse = new THREE.Vector2(),
+) {
if (domElement === document.body) {
mouse.x = (clientX / window.innerWidth) * 2 - 1;
mouse.y = -(clientY / window.innerHeight) * 2 + 1;
@@ -60,17 +67,26 @@ export function screenToEarthCoords(clientX, clientY, camera, earth, domElement
}
// Calculate accurate spherical distance between two points (Haversine formula)
-export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) {
+export function calculateDistance(
+ lat1,
+ lon1,
+ lat2,
+ lon2,
+ radius = CONFIG.earthRadius,
+) {
const toRad = (angle) => (angle * Math.PI) / 180;
-
+
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
-
- const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
- Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
- Math.sin(dLon / 2) * Math.sin(dLon / 2);
-
+
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(toRad(lat1)) *
+ Math.cos(toRad(lat2)) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
-
+
return radius * c;
}
diff --git a/frontend/src/pages/Earth/Earth.tsx b/frontend/src/pages/Earth/Earth.tsx
index da2896d1..c2ab36d7 100644
--- a/frontend/src/pages/Earth/Earth.tsx
+++ b/frontend/src/pages/Earth/Earth.tsx
@@ -3,14 +3,14 @@ function Earth() {
- )
+ );
}
-export default Earth
+export default Earth;
diff --git a/uv.lock b/uv.lock
index 6ce747bb..c94250c3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
-version = "1.0.0"
+version = "0.19.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },