From ce5feba3b96b7cfb6473de36043f8d9757865d2e Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 26 Mar 2026 10:29:50 +0800 Subject: [PATCH] Stabilize Earth module and fix satellite TLE handling --- backend/app/api/v1/visualization.py | 17 + backend/app/core/satellite_tle.py | 116 +++ backend/app/services/collectors/celestrak.py | 16 + backend/app/services/collectors/spacetrack.py | 49 +- docs/earth-module-plan.md | 210 +++++ frontend/public/earth/js/cables.js | 537 ++++++----- frontend/public/earth/js/constants.js | 2 +- frontend/public/earth/js/controls.js | 355 ++++--- frontend/public/earth/js/main.js | 891 +++++++++++------- frontend/public/earth/js/satellites.js | 828 ++++++++++------ frontend/public/earth/js/ui.js | 110 ++- frontend/public/earth/js/utils.js | 56 +- frontend/src/pages/Earth/Earth.tsx | 12 +- uv.lock | 2 +- 14 files changed, 2132 insertions(+), 1069 deletions(-) create mode 100644 backend/app/core/satellite_tle.py create mode 100644 docs/earth-module-plan.md 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() {