Stabilize Earth module and fix satellite TLE handling

This commit is contained in:
linkong
2026-03-26 10:29:50 +08:00
parent 3fd6cbb6f7
commit ce5feba3b9
14 changed files with 2132 additions and 1069 deletions

View File

@@ -11,6 +11,7 @@ from sqlalchemy import select, func
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from app.core.collected_data_fields import get_record_field 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.db.session import get_db
from app.models.collected_data import CollectedData from app.models.collected_data import CollectedData
from app.services.cable_graph import build_graph_from_data, CableGraph 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: if not norad_id:
continue 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( features.append(
{ {
"type": "Feature", "type": "Feature",
@@ -174,6 +189,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
"mean_motion": metadata.get("mean_motion"), "mean_motion": metadata.get("mean_motion"),
"bstar": metadata.get("bstar"), "bstar": metadata.get("bstar"),
"classification_type": metadata.get("classification_type"), "classification_type": metadata.get("classification_type"),
"tle_line1": tle_line1,
"tle_line2": tle_line2,
"data_type": "satellite_tle", "data_type": "satellite_tle",
}, },
} }

View File

@@ -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

View File

@@ -8,6 +8,7 @@ import json
from typing import Dict, Any, List from typing import Dict, Any, List
import httpx import httpx
from app.core.satellite_tle import build_tle_lines_from_elements
from app.services.collectors.base import BaseCollector 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]]: def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
transformed = [] transformed = []
for item in raw_data: 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( transformed.append(
{ {
"name": item.get("OBJECT_NAME", "Unknown"), "name": item.get("OBJECT_NAME", "Unknown"),
@@ -80,6 +92,10 @@ class CelesTrakTLECollector(BaseCollector):
"mean_motion_dot": item.get("MEAN_MOTION_DOT"), "mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), "mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"), "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,
}, },
} }
) )

View File

@@ -10,6 +10,7 @@ import httpx
from app.services.collectors.base import BaseCollector from app.services.collectors.base import BaseCollector
from app.core.data_sources import get_data_sources_config from app.core.data_sources import get_data_sources_config
from app.core.satellite_tle import build_tle_lines_from_elements
class SpaceTrackTLECollector(BaseCollector): class SpaceTrackTLECollector(BaseCollector):
@@ -169,25 +170,41 @@ class SpaceTrackTLECollector(BaseCollector):
"""Transform TLE data to internal format""" """Transform TLE data to internal format"""
transformed = [] transformed = []
for item in raw_data: 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( transformed.append(
{ {
"name": item.get("OBJECT_NAME", "Unknown"), "name": item.get("OBJECT_NAME", "Unknown"),
"norad_cat_id": item.get("NORAD_CAT_ID"), "reference_date": item.get("EPOCH", ""),
"international_designator": item.get("INTL_DESIGNATOR"), "metadata": {
"epoch": item.get("EPOCH"), "norad_cat_id": item.get("NORAD_CAT_ID"),
"mean_motion": item.get("MEAN_MOTION"), "international_designator": item.get("INTL_DESIGNATOR"),
"eccentricity": item.get("ECCENTRICITY"), "epoch": item.get("EPOCH"),
"inclination": item.get("INCLINATION"), "mean_motion": item.get("MEAN_MOTION"),
"raan": item.get("RAAN"), "eccentricity": item.get("ECCENTRICITY"),
"arg_of_perigee": item.get("ARG_OF_PERIGEE"), "inclination": item.get("INCLINATION"),
"mean_anomaly": item.get("MEAN_ANOMALY"), "raan": item.get("RAAN"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"), "arg_of_perigee": item.get("ARG_OF_PERIGEE"),
"classification_type": item.get("CLASSIFICATION_TYPE"), "mean_anomaly": item.get("MEAN_ANOMALY"),
"element_set_no": item.get("ELEMENT_SET_NO"), "ephemeris_type": item.get("EPHEMERIS_TYPE"),
"rev_at_epoch": item.get("REV_AT_EPOCH"), "classification_type": item.get("CLASSIFICATION_TYPE"),
"bstar": item.get("BSTAR"), "element_set_no": item.get("ELEMENT_SET_NO"),
"mean_motion_dot": item.get("MEAN_MOTION_DOT"), "rev_at_epoch": item.get("REV_AT_EPOCH"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"), "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 return transformed

210
docs/earth-module-plan.md Normal file
View File

@@ -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 全量清理
- 错误状态隔离
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。

View File

@@ -1,65 +1,108 @@
// cables.js - Cable loading and rendering module // 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 { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from "./constants.js";
import { latLonToVector3 } from './utils.js'; import { latLonToVector3 } from "./utils.js";
import { updateEarthStats, showStatusMessage } from './ui.js'; import { updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from './info-card.js'; import { showInfoCard } from "./info-card.js";
export let cableLines = []; export let cableLines = [];
export let landingPoints = []; export let landingPoints = [];
export let lockedCable = null; export let lockedCable = null;
let cableIdMap = new Map(); let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true; 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) { function getCableColor(properties) {
if (properties.color) { 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); return parseInt(properties.color.substring(1), 16);
} else if (typeof properties.color === 'number') { }
if (typeof properties.color === "number") {
return properties.color; return properties.color;
} }
} }
const cableName = properties.Name || properties.cableName || properties.shortname || ''; const cableName =
if (cableName.includes('Americas II')) { properties.Name || properties.cableName || properties.shortname || "";
return CABLE_COLORS['Americas II']; if (cableName.includes("Americas II")) {
} else if (cableName.includes('AU Aleutian A')) { return CABLE_COLORS["Americas II"];
return CABLE_COLORS['AU Aleutian A']; }
} else if (cableName.includes('AU Aleutian B')) { if (cableName.includes("AU Aleutian A")) {
return CABLE_COLORS['AU Aleutian B']; return CABLE_COLORS["AU Aleutian A"];
}
if (cableName.includes("AU Aleutian B")) {
return CABLE_COLORS["AU Aleutian B"];
} }
return CABLE_COLORS.default; return CABLE_COLORS.default;
} }
function createCableLine(points, color, properties, earthObj) { function createCableLine(points, color, properties) {
if (points.length < 2) return null; if (points.length < 2) return null;
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points); const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
lineGeometry.computeBoundingSphere();
const lineMaterial = new THREE.LineBasicMaterial({ const lineMaterial = new THREE.LineBasicMaterial({
color: color, color,
linewidth: 1, linewidth: 1,
transparent: true, transparent: true,
opacity: 1.0, opacity: 1.0,
depthTest: true, depthTest: true,
depthWrite: true depthWrite: true,
}); });
const cableLine = new THREE.Line(lineGeometry, lineMaterial); 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 = { cableLine.userData = {
type: 'cable', type: "cable",
cableId: cableId, cableId,
name: properties.Name || properties.cableName || 'Unknown', name: properties.Name || properties.cableName || "Unknown",
owner: properties.owner || properties.owners || '-', owner: properties.owner || properties.owners || "-",
status: properties.status || '-', status: properties.status || "-",
length: properties.length || '-', length: properties.length || "-",
coords: '-', coords: "-",
rfs: properties.rfs || '-', rfs: properties.rfs || "-",
originalColor: color originalColor: color,
localCenter:
lineGeometry.boundingSphere?.center?.clone() || new THREE.Vector3(),
}; };
cableLine.renderOrder = 1; cableLine.renderOrder = 1;
@@ -71,15 +114,27 @@ function createCableLine(points, color, properties, earthObj) {
return cableLine; return cableLine;
} }
function calculateGreatCirclePoints(lat1, lon1, lat2, lon2, radius, segments = 50) { function calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
radius,
segments = 50,
) {
const points = []; const points = [];
const phi1 = lat1 * Math.PI / 180; const phi1 = (lat1 * Math.PI) / 180;
const lambda1 = lon1 * Math.PI / 180; const lambda1 = (lon1 * Math.PI) / 180;
const phi2 = lat2 * Math.PI / 180; const phi2 = (lat2 * Math.PI) / 180;
const lambda2 = lon2 * 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 dLambda = Math.min(
const cosDelta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda); 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))); let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
@@ -107,223 +162,237 @@ function calculateGreatCirclePoints(lat1, lon1, lat2, lon2, radius, segments = 5
let y = A * y1 + B * y2; let y = A * y1 + B * y2;
let z = A * z1 + B * z2; let z = A * z1 + B * z2;
const norm = Math.sqrt(x*x + y*y + z*z); const norm = Math.sqrt(x * x + y * y + z * z);
x = x / norm * radius; x = (x / norm) * radius;
y = y / norm * radius; y = (y / norm) * radius;
z = z / norm * radius; z = (z / norm) * radius;
const lat = Math.asin(z / radius) * 180 / Math.PI; const lat = (Math.asin(z / radius) * 180) / Math.PI;
let lon = Math.atan2(y, x) * 180 / Math.PI; let lon = (Math.atan2(y, x) * 180) / Math.PI;
if (lon > 180) lon -= 360; if (lon > 180) lon -= 360;
if (lon < -180) lon += 360; if (lon < -180) lon += 360;
const point = latLonToVector3(lat, lon, radius); points.push(latLonToVector3(lat, lon, radius));
points.push(point);
} }
return points; 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) { export async function loadGeoJSONFromPath(scene, earthObj) {
try { console.log("正在加载电缆数据...");
console.log('正在加载电缆数据...'); showStatusMessage("正在加载电缆数据...", "warning");
showStatusMessage('正在加载电缆数据...', 'warning');
const response = await fetch(PATHS.cablesApi); const response = await fetch(PATHS.cablesApi);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`); throw new Error(`电缆接口返回 HTTP ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
throw new Error("无效的电缆 GeoJSON 格式");
}
cableLines.forEach(line => earthObj.remove(line)); clearCableLines(earthObj);
cableLines = [];
if (!data.features || !Array.isArray(data.features)) { for (const feature of data.features) {
throw new Error('无效的GeoJSON格式'); const geometry = feature.geometry;
} const properties = feature.properties || {};
const cableCount = data.features.length; if (!geometry || !geometry.coordinates) continue;
document.getElementById('cable-count').textContent = cableCount + '个';
const inServiceCount = data.features.filter( const color = getCableColor(properties);
feature => feature.properties && feature.properties.status === 'In Service'
).length;
const statusEl = document.getElementById('cable-status-summary'); if (geometry.type === "MultiLineString") {
if (statusEl) { for (const lineCoords of geometry.coordinates) {
statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`; if (!lineCoords || lineCoords.length < 2) continue;
}
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;
const points = []; 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];
for (let i = 0; i < allCoords.length - 1; i++) { const segment = calculateGreatCirclePoints(
const lon1 = allCoords[i][0]; lat1,
const lat1 = allCoords[i][1]; lon1,
const lon2 = allCoords[i + 1][0]; lat2,
const lat2 = allCoords[i + 1][1]; lon2,
CONFIG.earthRadius + 0.2,
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50); 50,
if (i === 0) { );
points.push(...segment); points.push(...(i === 0 ? segment : segment.slice(1)));
} else {
points.push(...segment.slice(1));
}
} }
if (points.length >= 2) { const line = createCableLine(points, color, properties);
const line = createCableLine(points, color, properties, earthObj); if (line) {
if (line) { cableLines.push(line);
cableLines.push(line); earthObj.add(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) { 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 { 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) { for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue; if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates; const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {}; const properties = feature.properties || {};
if (typeof lon !== 'number' || typeof lat !== 'number' || if (
isNaN(lon) || isNaN(lat) || typeof lon !== "number" ||
Math.abs(lat) > 90 || Math.abs(lon) > 180) { typeof lat !== "number" ||
Number.isNaN(lon) ||
Number.isNaN(lat) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
continue; continue;
} }
const position = latLonToVector3(lat, lon, 100.1); const position = latLonToVector3(lat, lon, CONFIG.earthRadius + 0.1);
if (
if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) { Number.isNaN(position.x) ||
Number.isNaN(position.y) ||
Number.isNaN(position.z)
) {
continue; 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.position.copy(position);
sphere.userData = { sphere.userData = {
type: 'landingPoint', type: "landingPoint",
name: properties.name || '未知登陆站', name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [], cableNames: properties.cable_names || [],
country: properties.country || '未知国家', country: properties.country || "未知国家",
status: properties.status || 'Unknown' status: properties.status || "Unknown",
}; };
earthObj.add(sphere); earthObj.add(sphere);
landingPoints.push(sphere); landingPoints.push(sphere);
validCount++; validCount++;
} }
} finally {
console.log(`成功创建 ${validCount} 个登陆点标记`); sphereGeometry.dispose();
showStatusMessage(`成功加载 ${validCount} 个登陆点`, 'success');
const lpCountEl = document.getElementById('landing-point-count');
if (lpCountEl) {
lpCountEl.textContent = validCount + '个';
}
} catch (error) {
console.error('加载登陆点数据失败:', error);
} }
const landingPointCountEl = document.getElementById("landing-point-count");
if (landingPointCountEl) {
landingPointCountEl.textContent = validCount + "个";
}
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
return validCount;
} }
export function handleCableClick(cable) { export function handleCableClick(cable) {
lockedCable = cable; lockedCable = cable;
const data = cable.userData; const data = cable.userData;
showInfoCard('cable', { showInfoCard("cable", {
name: data.name, name: data.name,
owner: data.owner, owner: data.owner,
status: data.status, status: data.status,
length: data.length, length: data.length,
coords: data.coords, coords: data.coords,
rfs: data.rfs rfs: data.rfs,
}); });
showStatusMessage(`已锁定: ${data.name}`, 'info'); showStatusMessage(`已锁定: ${data.name}`, "info");
} }
export function clearCableSelection() { export function clearCableSelection() {
@@ -342,8 +411,6 @@ export function getLandingPoints() {
return landingPoints; return landingPoints;
} }
const cableStates = new Map();
export function getCableState(cableId) { export function getCableState(cableId) {
return cableStates.get(cableId) || CABLE_STATE.NORMAL; return cableStates.get(cableId) || CABLE_STATE.NORMAL;
} }
@@ -365,7 +432,9 @@ export function getCableStateInfo() {
} }
export function getLandingPointsByCableName(cableName) { 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() { export function getAllLandingPoints() {
@@ -376,8 +445,9 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5; const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
const brightness = 0.3; const brightness = 0.3;
landingPoints.forEach(lp => { landingPoints.forEach((lp) => {
const isRelated = !dimAll && lp.userData.cableNames?.includes(lockedCableName); const isRelated =
!dimAll && lp.userData.cableNames?.includes(lockedCableName);
if (isRelated) { if (isRelated) {
lp.material.color.setHex(0xffaa00); lp.material.color.setHex(0xffaa00);
@@ -388,8 +458,7 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
} else { } else {
const r = 255 * brightness; const r = 255 * brightness;
const g = 170 * brightness; const g = 170 * brightness;
const b = 0 * brightness; lp.material.color.setRGB(r / 255, g / 255, 0);
lp.material.color.setRGB(r / 255, g / 255, b / 255);
lp.material.emissive.setHex(0x000000); lp.material.emissive.setHex(0x000000);
lp.material.emissiveIntensity = 0; lp.material.emissiveIntensity = 0;
lp.material.opacity = 0.3; lp.material.opacity = 0.3;
@@ -399,7 +468,7 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
} }
export function resetLandingPointVisualState() { export function resetLandingPointVisualState() {
landingPoints.forEach(lp => { landingPoints.forEach((lp) => {
lp.material.color.setHex(0xffaa00); lp.material.color.setHex(0xffaa00);
lp.material.emissive.setHex(0x442200); lp.material.emissive.setHex(0x442200);
lp.material.emissiveIntensity = 0.5; lp.material.emissiveIntensity = 0.5;
@@ -410,10 +479,10 @@ export function resetLandingPointVisualState() {
export function toggleCables(show) { export function toggleCables(show) {
cablesVisible = show; cablesVisible = show;
cableLines.forEach(cable => { cableLines.forEach((cable) => {
cable.visible = cablesVisible; cable.visible = cablesVisible;
}); });
landingPoints.forEach(lp => { landingPoints.forEach((lp) => {
lp.visible = cablesVisible; lp.visible = cablesVisible;
}); });
} }

View File

@@ -54,7 +54,7 @@ export const CABLE_STATE = {
}; };
export const SATELLITE_CONFIG = { export const SATELLITE_CONFIG = {
maxCount: 5000, maxCount: 10000,
trailLength: 10, trailLength: 10,
dotSize: 4, dotSize: 4,
ringSize: 0.07, ringSize: 0.07,

View File

@@ -1,11 +1,16 @@
// controls.js - Zoom, rotate and toggle controls // controls.js - Zoom, rotate and toggle controls
import { CONFIG, EARTH_CONFIG } from './constants.js'; import { CONFIG, EARTH_CONFIG } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from './ui.js'; import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain } from './earth.js'; import { toggleTerrain } from "./earth.js";
import { reloadData, clearLockedObject } from './main.js'; import { reloadData, clearLockedObject } from "./main.js";
import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js'; import {
import { toggleCables, getShowCables } from './cables.js'; toggleSatellites,
toggleTrails,
getShowSatellites,
getSatelliteCount,
} from "./satellites.js";
import { toggleCables, getShowCables } from "./cables.js";
export let autoRotate = true; export let autoRotate = true;
export let zoomLevel = 1.0; export let zoomLevel = 1.0;
@@ -13,8 +18,26 @@ export let showTerrain = false;
export let isDragging = false; export let isDragging = false;
let earthObj = null; 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) { export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
earthObj = earth; earthObj = earth;
setupZoomControls(camera); setupZoomControls(camera);
setupWheelZoom(camera, renderer); setupWheelZoom(camera, renderer);
@@ -35,7 +58,8 @@ function setupZoomControls(camera) {
function doZoomStep(direction) { function doZoomStep(direction) {
let currentPercent = Math.round(zoomLevel * 100); 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 > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT; if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
@@ -57,7 +81,7 @@ function setupZoomControls(camera) {
function startContinuousZoom(direction) { function startContinuousZoom(direction) {
doContinuousZoom(direction); doContinuousZoom(direction);
zoomInterval = setInterval(() => { zoomInterval = window.setInterval(() => {
doContinuousZoom(direction); doContinuousZoom(direction);
}, LONG_PRESS_TICK); }, LONG_PRESS_TICK);
} }
@@ -76,7 +100,7 @@ function setupZoomControls(camera) {
function handleMouseDown(direction) { function handleMouseDown(direction) {
startTime = Date.now(); startTime = Date.now();
stopZoom(); stopZoom();
holdTimeout = setTimeout(() => { holdTimeout = window.setTimeout(() => {
startContinuousZoom(direction); startContinuousZoom(direction);
}, HOLD_THRESHOLD); }, HOLD_THRESHOLD);
} }
@@ -89,47 +113,71 @@ function setupZoomControls(camera) {
} }
} }
document.getElementById('zoom-in').addEventListener('mousedown', () => handleMouseDown(1)); cleanupFns.push(stopZoom);
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)); const zoomIn = document.getElementById("zoom-in");
document.getElementById('zoom-out').addEventListener('mouseup', () => handleMouseUp(-1)); const zoomOut = document.getElementById("zoom-out");
document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom); const zoomValue = document.getElementById("zoom-value");
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() { 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 startZoomVal = zoomLevel;
const targetZoom = 1.0; const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoomVal; const startDistance = CONFIG.defaultCameraZ / startZoomVal;
const targetDistance = CONFIG.defaultCameraZ / targetZoom; const targetDistance = CONFIG.defaultCameraZ / targetZoom;
animateValue(0, 1, 600, (progress) => { animateValue(
const ease = 1 - Math.pow(1 - progress, 3); 0,
zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease; 1,
camera.position.z = CONFIG.defaultCameraZ / zoomLevel; 600,
const distance = startDistance + (targetDistance - startDistance) * ease; (progress) => {
updateZoomDisplay(zoomLevel, distance.toFixed(0)); const ease = 1 - Math.pow(1 - progress, 3);
}, () => { zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
zoomLevel = 1.0; camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
showStatusMessage('缩放已重置到100%', 'info'); const distance =
}); startDistance + (targetDistance - startDistance) * ease;
updateZoomDisplay(zoomLevel, distance.toFixed(0));
},
() => {
zoomLevel = 1.0;
showStatusMessage("缩放已重置到100%", "info");
},
);
}); });
} }
function setupWheelZoom(camera, renderer) { function setupWheelZoom(camera, renderer) {
renderer.domElement.addEventListener('wheel', (e) => { bindListener(
e.preventDefault(); renderer?.domElement,
if (e.deltaY < 0) { "wheel",
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom); (e) => {
} else { e.preventDefault();
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom); if (e.deltaY < 0) {
} zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
applyZoom(camera); } else {
}, { passive: false }); zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
},
{ passive: false },
);
} }
function applyZoom(camera) { function applyZoom(camera) {
@@ -163,126 +211,163 @@ export function resetView(camera) {
if (!earthObj) return; if (!earthObj) return;
function animateToView(targetLat, targetLon, targetRotLon) { function animateToView(targetLat, targetLon, targetRotLon) {
const latRot = targetLat * Math.PI / 180; const latRot = (targetLat * Math.PI) / 180;
const targetRotX = EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient; const targetRotX =
const targetRotY = -(targetRotLon * Math.PI / 180); EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
const targetRotY = -((targetRotLon * Math.PI) / 180);
const startRotX = earthObj.rotation.x; const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y; const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel; const startZoom = zoomLevel;
const targetZoom = 1.0; const targetZoom = 1.0;
animateValue(0, 1, 800, (progress) => { animateValue(
const ease = 1 - Math.pow(1 - progress, 3); 0,
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease; 1,
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease; 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; zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel; camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0)); updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}, () => { },
zoomLevel = 1.0; () => {
showStatusMessage('视角已重置', 'info'); zoomLevel = 1.0;
}); showStatusMessage("视角已重置", "info");
},
);
} }
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => animateToView(pos.coords.latitude, pos.coords.longitude, -pos.coords.longitude), (pos) =>
() => animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon), animateToView(
{ timeout: 5000, enableHighAccuracy: false } pos.coords.latitude,
pos.coords.longitude,
-pos.coords.longitude,
),
() =>
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
),
{ timeout: 5000, enableHighAccuracy: false },
); );
} else { } else {
animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon); animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
);
} }
if (typeof window.clearLockedCable === 'function') { clearLockedObject();
window.clearLockedCable();
}
} }
function setupRotateControls(camera, earth) { function setupRotateControls(camera) {
const rotateBtn = document.getElementById('rotate-toggle'); const rotateBtn = document.getElementById("rotate-toggle");
const resetViewBtn = document.getElementById("reset-view");
rotateBtn.addEventListener('click', function() { bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate(); const isRotating = toggleAutoRotate();
showStatusMessage(isRotating ? '自动旋转已开启' : '自动旋转已暂停', 'info'); showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
}); });
updateRotateUI(); updateRotateUI();
document.getElementById('reset-view').addEventListener('click', function() { bindListener(resetViewBtn, "click", () => {
resetView(camera); resetView(camera);
}); });
} }
function setupTerrainControls() { 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; showTerrain = !showTerrain;
toggleTerrain(showTerrain); toggleTerrain(showTerrain);
this.classList.toggle('active', showTerrain); this.classList.toggle("active", showTerrain);
this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形'; const tooltip = this.querySelector(".tooltip");
document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭'; if (tooltip) tooltip.textContent = showTerrain ? "隐藏地形" : "显示地形";
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info'); 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(); const showSats = !getShowSatellites();
if (!showSats) { if (!showSats) {
clearLockedObject(); clearLockedObject();
} }
toggleSatellites(showSats); toggleSatellites(showSats);
this.classList.toggle('active', showSats); this.classList.toggle("active", showSats);
this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星'; const tooltip = this.querySelector(".tooltip");
document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗'; if (tooltip) tooltip.textContent = showSats ? "隐藏卫星" : "显示卫星";
showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info'); const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl)
satelliteCountEl.textContent = getSatelliteCount() + " 颗";
showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info");
}); });
document.getElementById('toggle-trails').addEventListener('click', function() { bindListener(trailsBtn, "click", function () {
const isActive = this.classList.contains('active'); const isActive = this.classList.contains("active");
const showTrails = !isActive; const nextShowTrails = !isActive;
toggleTrails(showTrails); toggleTrails(nextShowTrails);
this.classList.toggle('active', showTrails); this.classList.toggle("active", nextShowTrails);
this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹'; const tooltip = this.querySelector(".tooltip");
showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info'); if (tooltip) tooltip.textContent = nextShowTrails ? "隐藏轨迹" : "显示轨迹";
showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
}); });
document.getElementById('toggle-cables').addEventListener('click', function() { bindListener(cablesBtn, "click", function () {
const showCables = !getShowCables(); const showNextCables = !getShowCables();
if (!showCables) { if (!showNextCables) {
clearLockedObject(); clearLockedObject();
} }
toggleCables(showCables); toggleCables(showNextCables);
this.classList.toggle('active', showCables); this.classList.toggle("active", showNextCables);
this.querySelector('.tooltip').textContent = showCables ? '隐藏线缆' : '显示线缆'; const tooltip = this.querySelector(".tooltip");
showStatusMessage(showCables ? '线缆已显示' : '线缆已隐藏', 'info'); if (tooltip) tooltip.textContent = showNextCables ? "隐藏线缆" : "显示线缆";
showStatusMessage(showNextCables ? "线缆已显示" : "线缆已隐藏", "info");
}); });
document.getElementById('reload-data').addEventListener('click', async () => { bindListener(reloadBtn, "click", async () => {
await reloadData(); await reloadData();
showStatusMessage('数据已重新加载', 'success');
}); });
const toolbarToggle = document.getElementById('toolbar-toggle');
const toolbar = document.getElementById('control-toolbar');
if (toolbarToggle && toolbar) { if (toolbarToggle && toolbar) {
toolbarToggle.addEventListener('click', () => { bindListener(toolbarToggle, "click", () => {
toolbar.classList.toggle('collapsed'); toolbar.classList.toggle("collapsed");
}); });
} }
} }
export function teardownControls() {
resetCleanup();
}
export function getAutoRotate() { export function getAutoRotate() {
return autoRotate; return autoRotate;
} }
function updateRotateUI() { function updateRotateUI() {
const btn = document.getElementById('rotate-toggle'); const btn = document.getElementById("rotate-toggle");
if (btn) { if (btn) {
btn.classList.toggle('active', autoRotate); btn.classList.toggle("active", autoRotate);
btn.innerHTML = autoRotate ? '⏸️' : '▶️'; btn.innerHTML = autoRotate ? "⏸️" : "▶️";
const tooltip = btn.querySelector('.tooltip'); const tooltip = btn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '开始旋转'; if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
} }
} }
@@ -294,9 +379,7 @@ export function setAutoRotate(value) {
export function toggleAutoRotate() { export function toggleAutoRotate() {
autoRotate = !autoRotate; autoRotate = !autoRotate;
updateRotateUI(); updateRotateUI();
if (window.clearLockedCable) { clearLockedObject();
window.clearLockedCable();
}
return autoRotate; return autoRotate;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,87 @@
// satellites.js - Satellite visualization module with real SGP4 positions and animations // satellites.js - Satellite visualization module with real SGP4 positions and animations
import * as THREE from 'three'; import * as THREE from "three";
import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js'; import { twoline2satrec, propagate } from "satellite.js";
import { CONFIG, SATELLITE_CONFIG } from './constants.js'; import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
let satellitePoints = null; let satellitePoints = null;
let satelliteTrails = null; let satelliteTrails = null;
let satelliteData = []; let satelliteData = [];
let showSatellites = false; let showSatellites = false;
let showTrails = true; let showTrails = true;
let animationTime = 0;
let selectedSatellite = null; let selectedSatellite = null;
let satellitePositions = []; let satellitePositions = [];
let hoverRingSprite = null; let hoverRingSprite = null;
let lockedRingSprite = null; let lockedRingSprite = null;
let lockedDotSprite = 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 MAX_SATELLITES = SATELLITE_CONFIG.maxCount;
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32; 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() { function createDotTexture() {
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
canvas.width = DOT_TEXTURE_SIZE; canvas.width = DOT_TEXTURE_SIZE;
canvas.height = 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 center = DOT_TEXTURE_SIZE / 2;
const radius = center - 2; const radius = center - 2;
const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius); const gradient = ctx.createRadialGradient(
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); center,
gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)'); center,
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); 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.fillStyle = gradient;
ctx.beginPath(); ctx.beginPath();
@@ -49,12 +93,12 @@ function createDotTexture() {
return texture; return texture;
} }
function createRingTexture(innerRadius, outerRadius, color = '#ffffff') { function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const size = DOT_TEXTURE_SIZE * 2; const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
const center = size / 2; const center = size / 2;
ctx.strokeStyle = color; ctx.strokeStyle = color;
@@ -73,12 +117,14 @@ export function createSatellites(scene, earthObj) {
const positions = new Float32Array(MAX_SATELLITES * 3); const positions = new Float32Array(MAX_SATELLITES * 3);
const colors = new Float32Array(MAX_SATELLITES * 3); const colors = new Float32Array(MAX_SATELLITES * 3);
const dotTexture = createDotTexture(); const dotTexture = createDotTexture();
const pointsGeometry = new THREE.BufferGeometry(); const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); pointsGeometry.setAttribute(
pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); "position",
new THREE.BufferAttribute(positions, 3),
);
pointsGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const pointsMaterial = new THREE.PointsMaterial({ const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize, size: SATELLITE_CONFIG.dotSize,
@@ -87,23 +133,27 @@ export function createSatellites(scene, earthObj) {
transparent: true, transparent: true,
opacity: 0.9, opacity: 0.9,
sizeAttenuation: false, sizeAttenuation: false,
alphaTest: 0.1 alphaTest: 0.1,
}); });
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial); satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false; satellitePoints.visible = false;
satellitePoints.userData = { type: 'satellitePoints' }; satellitePoints.userData = { type: "satellitePoints" };
const originalScale = { x: 1, y: 1, z: 1 }; const originalScale = { x: 1, y: 1, z: 1 };
satellitePoints.onBeforeRender = (renderer, scene, camera, geometry, material) => { satellitePoints.onBeforeRender = () => {
if (earthObj && earthObj.scale.x !== 1) { if (earthObj && earthObj.scale.x !== 1) {
satellitePoints.scale.set( satellitePoints.scale.set(
originalScale.x / earthObj.scale.x, originalScale.x / earthObj.scale.x,
originalScale.y / earthObj.scale.y, originalScale.y / earthObj.scale.y,
originalScale.z / earthObj.scale.z originalScale.z / earthObj.scale.z,
); );
} else { } else {
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z); satellitePoints.scale.set(
originalScale.x,
originalScale.y,
originalScale.z,
);
} }
}; };
@@ -113,31 +163,35 @@ export function createSatellites(scene, earthObj) {
const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3);
const trailGeometry = new THREE.BufferGeometry(); const trailGeometry = new THREE.BufferGeometry();
trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3)); trailGeometry.setAttribute(
trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3)); "position",
new THREE.BufferAttribute(trailPositions, 3),
);
trailGeometry.setAttribute(
"color",
new THREE.BufferAttribute(trailColors, 3),
);
const trailMaterial = new THREE.LineBasicMaterial({ const trailMaterial = new THREE.LineBasicMaterial({
vertexColors: true, vertexColors: true,
transparent: true, transparent: true,
opacity: 0.3, opacity: 0.3,
blending: THREE.AdditiveBlending blending: THREE.AdditiveBlending,
}); });
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial); satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
satelliteTrails.visible = false; satelliteTrails.visible = false;
satelliteTrails.userData = { type: 'satelliteTrails' }; satelliteTrails.userData = { type: "satelliteTrails" };
earthObj.add(satelliteTrails); earthObj.add(satelliteTrails);
satellitePositions = []; satellitePositions = Array.from({ length: MAX_SATELLITES }, () => ({
for (let i = 0; i < MAX_SATELLITES; i++) { current: new THREE.Vector3(),
satellitePositions.push({ trail: [],
current: new THREE.Vector3(), trailIndex: 0,
trail: [], trailCount: 0,
trailIndex: 0, }));
trailCount: 0
});
}
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satellitePoints; return satellitePoints;
} }
@@ -148,30 +202,7 @@ function computeSatellitePosition(satellite, time) {
return null; return null;
} }
const noradId = props.norad_cat_id; const satrec = buildSatrecFromProperties(props, time);
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);
if (!satrec || satrec.error) { if (!satrec || satrec.error) {
return null; return null;
} }
@@ -185,22 +216,118 @@ function computeSatellitePosition(satellite, time) {
const y = positionAndVelocity.position.y; const y = positionAndVelocity.position.y;
const z = positionAndVelocity.position.z; const z = positionAndVelocity.position.z;
if (!x || !y || !z) { if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null; return null;
} }
const r = Math.sqrt(x * x + y * y + z * z); const r = Math.sqrt(x * x + y * y + z * z);
const earthRadius = 6371; const displayRadius = CONFIG.earthRadius * 1.05;
const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05;
const scale = displayRadius / r; const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale); return new THREE.Vector3(x * scale, y * scale, z * scale);
} catch (e) { } catch (error) {
return null; 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) { function generateFallbackPosition(satellite, index, total) {
const radius = CONFIG.earthRadius + 5; const radius = CONFIG.earthRadius + 5;
@@ -209,12 +336,15 @@ function generateFallbackPosition(satellite, index, total) {
const raan = satellite.properties?.raan || 0; const raan = satellite.properties?.raan || 0;
const meanAnomaly = satellite.properties?.mean_anomaly || 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 randomOffset = (hash % 1000) / 1000;
const normalizedIndex = index / total; const normalizedIndex = index / total;
const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180); const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180;
const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1); const phi =
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
const adjustedPhi = Math.abs(phi % Math.PI); const adjustedPhi = Math.abs(phi % Math.PI);
const adjustedTheta = theta + randomOffset * Math.PI * 2; const adjustedTheta = theta + randomOffset * Math.PI * 2;
@@ -227,54 +357,60 @@ function generateFallbackPosition(satellite, index, total) {
} }
export async function loadSatellites() { export async function loadSatellites() {
try { const response = await fetch(
const response = await fetch(SATELLITE_API); `${SATELLITE_CONFIG.apiPath}?limit=${SATELLITE_CONFIG.maxCount}`,
if (!response.ok) { );
throw new Error(`HTTP ${response.status}`); 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 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; 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 positions = satellitePoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array; const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array; const trailColors = satelliteTrails.geometry.attributes.color.array;
const baseTime = new Date(Date.now() + elapsedMs);
const baseTime = new Date();
const count = Math.min(satelliteData.length, MAX_SATELLITES); const count = Math.min(satelliteData.length, MAX_SATELLITES);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const satellite = satelliteData[i]; const satellite = satelliteData[i];
const props = satellite.properties; const props = satellite.properties;
const timeOffset = (i / count) * 2 * Math.PI * 0.1; 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); let pos = computeSatellitePosition(satellite, adjustedTime);
if (!pos) { if (!pos) {
pos = generateFallbackPosition(satellite, i, count); pos = generateFallbackPosition(satellite, i, count);
} }
satellitePositions[i].current.copy(pos); satellitePositions[i].current.copy(pos);
const satPos = satellitePositions[i]; if (shouldUpdateTrails && i !== lockedSatelliteIndex) {
if (i !== window.lockedSatelliteIndex) { const satPos = satellitePositions[i];
satPos.trail[satPos.trailIndex] = pos.clone(); satPos.trail[satPos.trailIndex] = pos.clone();
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
@@ -285,63 +421,67 @@ export function updateSatellitePositions(deltaTime = 0) {
positions[i * 3 + 2] = pos.z; positions[i * 3 + 2] = pos.z;
const inclination = props?.inclination || 53; const inclination = props?.inclination || 53;
const name = props?.name || ''; const name = props?.name || "";
const isStarlink = name.includes('STARLINK'); const isStarlink = name.includes("STARLINK");
const isGeo = inclination > 20 && inclination < 30; const isGeo = inclination > 20 && inclination < 30;
const isIridium = name.includes('IRIDIUM'); const isIridium = name.includes("IRIDIUM");
let r, g, b; let r;
let g;
let b;
if (isStarlink) { if (isStarlink) {
r = 0.0; g = 0.9; b = 1.0; r = 0.0;
g = 0.9;
b = 1.0;
} else if (isGeo) { } else if (isGeo) {
r = 1.0; g = 0.8; b = 0.0; r = 1.0;
g = 0.8;
b = 0.0;
} else if (isIridium) { } 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) { } 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 { } 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] = r;
colors[i * 3 + 1] = g; colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b; colors[i * 3 + 2] = b;
const sp = satellitePositions[i]; const satPosition = satellitePositions[i];
const trail = sp.trail;
const tc = sp.trailCount;
const ti = sp.trailIndex;
for (let j = 0; j < TRAIL_LENGTH; j++) { for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3; const trailIdx = (i * TRAIL_LENGTH + j) * 3;
if (j < tc) { if (j < satPosition.trailCount) {
const idx = (ti - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH; const idx =
const t = trail[idx]; (satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) %
if (t) { TRAIL_LENGTH;
trailPositions[trailIdx] = t.x; const trailPoint = satPosition.trail[idx];
trailPositions[trailIdx + 1] = t.y; if (trailPoint) {
trailPositions[trailIdx + 2] = t.z; trailPositions[trailIdx] = trailPoint.x;
const alpha = (j + 1) / tc; trailPositions[trailIdx + 1] = trailPoint.y;
trailPositions[trailIdx + 2] = trailPoint.z;
const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha; trailColors[trailIdx] = r * alpha;
trailColors[trailIdx + 1] = g * alpha; trailColors[trailIdx + 1] = g * alpha;
trailColors[trailIdx + 2] = b * alpha; trailColors[trailIdx + 2] = b * alpha;
} else { continue;
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;
} }
} 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;
} }
} }
@@ -415,159 +555,177 @@ export function getSatellitePositions() {
return satellitePositions; return satellitePositions;
} }
export function isSatelliteFrontFacing(index, camera) { export function setSatelliteCamera(camera) {
if (!earthObjRef || !camera) return true; cameraRef = camera;
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;
} }
let earthObjRef = null; export function setLockedSatelliteIndex(index) {
let sceneRef = null; lockedSatelliteIndex = index;
}
export function showHoverRing(position, isLocked = false) { export function isSatelliteFrontFacing(index, camera = cameraRef) {
if (!sceneRef || !earthObjRef) return; if (!earthObjRef || !camera) return true;
if (!satellitePositions || !satellitePositions[index]) return true;
const ringTexture = createRingTexture(8, 12, isLocked ? '#ffcc00' : '#ffffff'); const satPos = satellitePositions[index].current;
const spriteMaterial = new THREE.SpriteMaterial({ if (!satPos) return true;
map: ringTexture,
transparent: true,
opacity: 0.8,
depthTest: false,
sizeAttenuation: false
});
const ringSize = SATELLITE_CONFIG.ringSize; scratchWorldSatellitePosition
const sprite = new THREE.Sprite(spriteMaterial); .copy(satPos)
sprite.position.copy(position); .applyMatrix4(earthObjRef.matrixWorld);
scratchToCamera.subVectors(camera.position, earthObjRef.position).normalize();
scratchToSatellite
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
.normalize();
const camera = window.camera; return scratchToCamera.dot(scratchToSatellite) > 0;
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;
} }
function createBrighterDotCanvas() { function createBrighterDotCanvas() {
const size = DOT_TEXTURE_SIZE * 2; const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
const center = size / 2; const center = size / 2;
const gradient = ctx.createRadialGradient(center, center, 0, center, center, center); const gradient = ctx.createRadialGradient(
gradient.addColorStop(0, 'rgba(255, 255, 200, 1)'); center,
gradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.9)'); center,
gradient.addColorStop(0.7, 'rgba(255, 180, 50, 0.5)'); 0,
gradient.addColorStop(1, 'rgba(255, 150, 0, 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.fillStyle = gradient;
ctx.fillRect(0, 0, size, size); ctx.fillRect(0, 0, size, size);
return canvas; return canvas;
} }
export function hideHoverRings() { function createRingSprite(position, isLocked = false) {
if (!earthObjRef) return; 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 (hoverRingSprite) { if (hoverRingSprite) {
earthObjRef.remove(hoverRingSprite); disposeObject3D(hoverRingSprite);
hoverRingSprite = null; hoverRingSprite = null;
} }
} }
export function hideLockedRing() { export function hideLockedRing() {
if (!earthObjRef) return;
if (lockedRingSprite) { if (lockedRingSprite) {
earthObjRef.remove(lockedRingSprite); disposeObject3D(lockedRingSprite);
lockedRingSprite = null; lockedRingSprite = null;
} }
if (lockedDotSprite) { if (lockedDotSprite) {
earthObjRef.remove(lockedDotSprite); disposeObject3D(lockedDotSprite);
lockedDotSprite = null; lockedDotSprite = null;
} }
} }
export function updateLockedRingPosition(position) { export function updateLockedRingPosition(position) {
const ringSize = SATELLITE_CONFIG.ringSize; if (!position) return;
const camera = window.camera; if (lockedRingSprite) {
const cameraDistance = camera ? camera.position.distanceTo(position) : 400;
if (lockedRingSprite && position) {
lockedRingSprite.position.copy(position); lockedRingSprite.position.copy(position);
const breathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; const breathScale =
lockedRingSprite.scale.set(ringSize * breathScale, ringSize * breathScale, 1); 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
const breathOpacity = SATELLITE_CONFIG.breathingOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin); lockedRingSprite.scale.set(
lockedRingSprite.material.opacity = breathOpacity; 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); lockedDotSprite.position.copy(position);
const dotBreathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; const dotBreathScale =
lockedDotSprite.scale.set(4 * cameraDistance / 200 * dotBreathScale, 4 * cameraDistance / 200 * dotBreathScale, 1); 1 +
lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); 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) { 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) { if (hoverRingSprite && position) {
hoverRingSprite.position.copy(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) { export function setSatelliteRingState(index, state, position) {
switch (state) { switch (state) {
case 'hover': case "hover":
hideHoverRings(); hideHoverRings();
showHoverRing(position, false); showHoverRing(position, false);
break; break;
case 'locked': case "locked":
hideHoverRings(); hideHoverRings();
showHoverRing(position, true); showHoverRing(position, true);
break; break;
case 'none': case "none":
hideHoverRings(); hideHoverRings();
hideLockedRing(); hideLockedRing();
break; break;
@@ -579,39 +737,39 @@ export function initSatelliteScene(scene, earth) {
earthObjRef = earth; earthObjRef = earth;
} }
let predictedOrbitLine = null;
function calculateOrbitalPeriod(meanMotion) { function calculateOrbitalPeriod(meanMotion) {
return 86400 / meanMotion; return 86400 / meanMotion;
} }
function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { function calculatePredictedOrbit(
satellite,
periodSeconds,
sampleInterval = 10,
) {
const points = []; const points = [];
const samples = Math.ceil(periodSeconds / sampleInterval); const samples = Math.ceil(periodSeconds / sampleInterval);
const now = new Date(); const now = new Date();
// Full orbit: from now to now+period (complete circle forward)
for (let i = 0; i <= samples; i++) { for (let i = 0; i <= samples; i++) {
const time = new Date(now.getTime() + i * sampleInterval * 1000); const time = new Date(now.getTime() + i * sampleInterval * 1000);
const pos = computeSatellitePosition(satellite, time); const pos = computeSatellitePosition(satellite, time);
if (pos) points.push(pos); if (pos) points.push(pos);
} }
// If we don't have enough points, use fallback orbit
if (points.length < samples * 0.5) { if (points.length < samples * 0.5) {
points.length = 0; points.length = 0;
const radius = CONFIG.earthRadius + 5; const radius = CONFIG.earthRadius + 5;
const noradId = satellite.properties?.norad_cat_id || 0;
const inclination = satellite.properties?.inclination || 53; const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0; const raan = satellite.properties?.raan || 0;
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
for (let i = 0; i <= samples; i++) { for (let i = 0; i <= samples; i++) {
const theta = (i / samples) * Math.PI * 2; const theta = (i / samples) * Math.PI * 2;
const phi = (inclination * Math.PI / 180); const phi = (inclination * Math.PI) / 180;
const x = radius * Math.sin(phi) * Math.cos(theta + raan * Math.PI / 180); const x =
radius * Math.sin(phi) * Math.cos(theta + (raan * Math.PI) / 180);
const y = radius * Math.cos(phi); 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)); points.push(new THREE.Vector3(x, y, z));
} }
} }
@@ -621,11 +779,10 @@ function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10)
export function showPredictedOrbit(satellite) { export function showPredictedOrbit(satellite) {
hidePredictedOrbit(); hidePredictedOrbit();
if (!earthObjRef) return;
const props = satellite.properties; const meanMotion = satellite.properties?.mean_motion || 15;
const meanMotion = props?.mean_motion || 15;
const periodSeconds = calculateOrbitalPeriod(meanMotion); const periodSeconds = calculateOrbitalPeriod(meanMotion);
const points = calculatePredictedOrbit(satellite, periodSeconds); const points = calculatePredictedOrbit(satellite, periodSeconds);
if (points.length < 2) return; if (points.length < 2) return;
@@ -644,14 +801,14 @@ export function showPredictedOrbit(satellite) {
} }
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const material = new THREE.LineBasicMaterial({ const material = new THREE.LineBasicMaterial({
vertexColors: true, vertexColors: true,
transparent: true, transparent: true,
opacity: 0.8, opacity: 0.8,
blending: THREE.AdditiveBlending blending: THREE.AdditiveBlending,
}); });
predictedOrbitLine = new THREE.Line(geometry, material); predictedOrbitLine = new THREE.Line(geometry, material);
@@ -660,9 +817,62 @@ export function showPredictedOrbit(satellite) {
export function hidePredictedOrbit() { export function hidePredictedOrbit() {
if (predictedOrbitLine) { if (predictedOrbitLine) {
earthObjRef.remove(predictedOrbitLine); disposeObject3D(predictedOrbitLine);
predictedOrbitLine.geometry.dispose();
predictedOrbitLine.material.dispose();
predictedOrbitLine = null; 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;
}

View File

@@ -1,71 +1,125 @@
// ui.js - UI update functions // ui.js - UI update functions
let statusTimeoutId = null;
// Show status message // Show status message
export function showStatusMessage(message, type = 'info') { export function showStatusMessage(message, type = "info") {
const statusEl = document.getElementById('status-message'); const statusEl = document.getElementById("status-message");
if (!statusEl) return;
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
statusEl.textContent = message; statusEl.textContent = message;
statusEl.className = `status-message ${type}`; statusEl.className = `status-message ${type}`;
statusEl.style.display = 'block'; statusEl.style.display = "block";
setTimeout(() => { statusTimeoutId = setTimeout(() => {
statusEl.style.display = 'none'; statusEl.style.display = "none";
statusEl.textContent = "";
statusTimeoutId = null;
}, 3000); }, 3000);
} }
// Update coordinates display // Update coordinates display
export function updateCoordinatesDisplay(lat, lon, alt = 0) { export function updateCoordinatesDisplay(lat, lon, alt = 0) {
document.getElementById('longitude-value').textContent = lon.toFixed(2) + '°'; const longitudeEl = document.getElementById("longitude-value");
document.getElementById('latitude-value').textContent = lat.toFixed(2) + '°'; const latitudeEl = document.getElementById("latitude-value");
document.getElementById('mouse-coords').textContent = const mouseCoordsEl = document.getElementById("mouse-coords");
`鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
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 // Update zoom display
export function updateZoomDisplay(zoomLevel, distance) { export function updateZoomDisplay(zoomLevel, distance) {
const percent = Math.round(zoomLevel * 100); const percent = Math.round(zoomLevel * 100);
document.getElementById('zoom-value').textContent = percent + '%'; const zoomValueEl = document.getElementById("zoom-value");
document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%'; const zoomLevelEl = document.getElementById("zoom-level");
const slider = document.getElementById('zoom-slider'); 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; if (slider) slider.value = zoomLevel;
document.getElementById('camera-distance').textContent = distance + ' km'; if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
} }
// Update earth stats // Update earth stats
export function updateEarthStats(stats) { export function updateEarthStats(stats) {
document.getElementById('cable-count').textContent = stats.cableCount || 0; const cableCountEl = document.getElementById("cable-count");
document.getElementById('landing-point-count').textContent = stats.landingPointCount || 0; const landingPointCountEl = document.getElementById("landing-point-count");
document.getElementById('terrain-status').textContent = stats.terrainOn ? '开启' : '关闭'; const terrainStatusEl = document.getElementById("terrain-status");
document.getElementById('texture-quality').textContent = stats.textureQuality || '8K 卫星图'; 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 // Show/hide loading
export function setLoading(loading) { export function setLoading(loading) {
const loadingEl = document.getElementById('loading'); const loadingEl = document.getElementById("loading");
loadingEl.style.display = loading ? 'block' : 'none'; if (!loadingEl) return;
loadingEl.style.display = loading ? "block" : "none";
} }
// Show tooltip // Show tooltip
export function showTooltip(x, y, content) { export function showTooltip(x, y, content) {
const tooltip = document.getElementById('tooltip'); const tooltip = document.getElementById("tooltip");
if (!tooltip) return;
tooltip.innerHTML = content; tooltip.innerHTML = content;
tooltip.style.left = x + 'px'; tooltip.style.left = x + "px";
tooltip.style.top = y + 'px'; tooltip.style.top = y + "px";
tooltip.style.display = 'block'; tooltip.style.display = "block";
} }
// Hide tooltip // Hide tooltip
export function hideTooltip() { export function hideTooltip() {
document.getElementById('tooltip').style.display = 'none'; const tooltip = document.getElementById("tooltip");
if (tooltip) {
tooltip.style.display = "none";
}
} }
// Show error message // Show error message
export function showError(message) { export function showError(message) {
const errorEl = document.getElementById('error-message'); const errorEl = document.getElementById("error-message");
if (!errorEl) return;
errorEl.textContent = message; errorEl.textContent = message;
errorEl.style.display = 'block'; errorEl.style.display = "block";
} }
// Hide error message // Hide error message
export function hideError() { 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();
} }

View File

@@ -1,8 +1,8 @@
// utils.js - Utility functions for coordinate conversion // 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 // Convert latitude/longitude to 3D vector
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) { export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
@@ -18,10 +18,12 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
// Convert 3D vector to latitude/longitude // Convert 3D vector to latitude/longitude
export function vector3ToLatLon(vector) { export function vector3ToLatLon(vector) {
const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z); const radius = Math.sqrt(
const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI); 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; let lon = (Math.atan2(vector.z, -vector.x) * 180) / Math.PI - 180;
while (lon <= -180) lon += 360; while (lon <= -180) lon += 360;
while (lon > 180) lon -= 360; while (lon > 180) lon -= 360;
@@ -29,15 +31,20 @@ export function vector3ToLatLon(vector) {
return { return {
lat: parseFloat(lat.toFixed(4)), lat: parseFloat(lat.toFixed(4)),
lon: parseFloat(lon.toFixed(4)), lon: parseFloat(lon.toFixed(4)),
alt: radius - CONFIG.earthRadius alt: radius - CONFIG.earthRadius,
}; };
} }
// Convert screen coordinates to Earth surface 3D coordinates // Convert screen coordinates to Earth surface 3D coordinates
export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) { export function screenToEarthCoords(
const raycaster = new THREE.Raycaster(); clientX,
const mouse = new THREE.Vector2(); clientY,
camera,
earth,
domElement = document.body,
raycaster = new THREE.Raycaster(),
mouse = new THREE.Vector2(),
) {
if (domElement === document.body) { if (domElement === document.body) {
mouse.x = (clientX / window.innerWidth) * 2 - 1; mouse.x = (clientX / window.innerWidth) * 2 - 1;
mouse.y = -(clientY / window.innerHeight) * 2 + 1; mouse.y = -(clientY / window.innerHeight) * 2 + 1;
@@ -60,15 +67,24 @@ export function screenToEarthCoords(clientX, clientY, camera, earth, domElement
} }
// Calculate accurate spherical distance between two points (Haversine formula) // 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 toRad = (angle) => (angle * Math.PI) / 180;
const dLat = toRad(lat2 - lat1); const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1); const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + const a =
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.sin(dLon / 2) * Math.sin(dLon / 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)); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

View File

@@ -3,14 +3,14 @@ function Earth() {
<iframe <iframe
src="/earth/index.html" src="/earth/index.html"
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
border: 'none', border: "none",
display: 'block', display: "block",
}} }}
title="3D Earth" title="3D Earth"
/> />
) );
} }
export default Earth export default Earth;

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "1.0.0" version = "0.19.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },