Stabilize Earth module and fix satellite TLE handling
This commit is contained in:
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
116
backend/app/core/satellite_tle.py
Normal file
116
backend/app/core/satellite_tle.py
Normal 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
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
210
docs/earth-module-plan.md
Normal 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 全量清理
|
||||||
|
- 错误状态隔离
|
||||||
|
|
||||||
|
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。
|
||||||
@@ -1,329 +1,398 @@
|
|||||||
// 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')) {
|
|
||||||
return CABLE_COLORS['AU Aleutian B'];
|
|
||||||
}
|
}
|
||||||
|
if (cableName.includes("AU Aleutian A")) {
|
||||||
|
return CABLE_COLORS["AU Aleutian A"];
|
||||||
|
}
|
||||||
|
if (cableName.includes("AU Aleutian B")) {
|
||||||
|
return CABLE_COLORS["AU Aleutian B"];
|
||||||
|
}
|
||||||
|
|
||||||
return CABLE_COLORS.default;
|
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({
|
|
||||||
color: color,
|
const lineMaterial = new THREE.LineBasicMaterial({
|
||||||
|
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;
|
||||||
|
|
||||||
if (!cableIdMap.has(cableId)) {
|
if (!cableIdMap.has(cableId)) {
|
||||||
cableIdMap.set(cableId, []);
|
cableIdMap.set(cableId, []);
|
||||||
}
|
}
|
||||||
cableIdMap.get(cableId).push(cableLine);
|
cableIdMap.get(cableId).push(cableLine);
|
||||||
|
|
||||||
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)));
|
||||||
|
|
||||||
if (delta < 0.01) {
|
if (delta < 0.01) {
|
||||||
const p1 = latLonToVector3(lat1, lon1, radius);
|
const p1 = latLonToVector3(lat1, lon1, radius);
|
||||||
const p2 = latLonToVector3(lat2, lon2, radius);
|
const p2 = latLonToVector3(lat2, lon2, radius);
|
||||||
return [p1, p2];
|
return [p1, p2];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i <= segments; i++) {
|
for (let i = 0; i <= segments; i++) {
|
||||||
const t = i / segments;
|
const t = i / segments;
|
||||||
const sinDelta = Math.sin(delta);
|
const sinDelta = Math.sin(delta);
|
||||||
const A = Math.sin((1 - t) * delta) / sinDelta;
|
const A = Math.sin((1 - t) * delta) / sinDelta;
|
||||||
const B = Math.sin(t * delta) / sinDelta;
|
const B = Math.sin(t * delta) / sinDelta;
|
||||||
|
|
||||||
const x1 = Math.cos(phi1) * Math.cos(lambda1);
|
const x1 = Math.cos(phi1) * Math.cos(lambda1);
|
||||||
const y1 = Math.cos(phi1) * Math.sin(lambda1);
|
const y1 = Math.cos(phi1) * Math.sin(lambda1);
|
||||||
const z1 = Math.sin(phi1);
|
const z1 = Math.sin(phi1);
|
||||||
|
|
||||||
const x2 = Math.cos(phi2) * Math.cos(lambda2);
|
const x2 = Math.cos(phi2) * Math.cos(lambda2);
|
||||||
const y2 = Math.cos(phi2) * Math.sin(lambda2);
|
const y2 = Math.cos(phi2) * Math.sin(lambda2);
|
||||||
const z2 = Math.sin(phi2);
|
const z2 = Math.sin(phi2);
|
||||||
|
|
||||||
let x = A * x1 + B * x2;
|
let x = A * x1 + B * x2;
|
||||||
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));
|
}
|
||||||
cableLines = [];
|
|
||||||
|
clearCableLines(earthObj);
|
||||||
if (!data.features || !Array.isArray(data.features)) {
|
|
||||||
throw new Error('无效的GeoJSON格式');
|
for (const feature of data.features) {
|
||||||
}
|
const geometry = feature.geometry;
|
||||||
|
const properties = feature.properties || {};
|
||||||
const cableCount = data.features.length;
|
|
||||||
document.getElementById('cable-count').textContent = cableCount + '个';
|
if (!geometry || !geometry.coordinates) continue;
|
||||||
|
|
||||||
const inServiceCount = data.features.filter(
|
const color = getCableColor(properties);
|
||||||
feature => feature.properties && feature.properties.status === 'In Service'
|
|
||||||
).length;
|
if (geometry.type === "MultiLineString") {
|
||||||
|
for (const lineCoords of geometry.coordinates) {
|
||||||
const statusEl = document.getElementById('cable-status-summary');
|
if (!lineCoords || lineCoords.length < 2) continue;
|
||||||
if (statusEl) {
|
|
||||||
statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const feature of data.features) {
|
|
||||||
const geometry = feature.geometry;
|
|
||||||
const properties = feature.properties || {};
|
|
||||||
|
|
||||||
if (!geometry || !geometry.coordinates) continue;
|
|
||||||
|
|
||||||
const color = getCableColor(properties);
|
|
||||||
console.log('电缆 properties:', JSON.stringify(properties));
|
|
||||||
|
|
||||||
if (geometry.type === 'MultiLineString') {
|
|
||||||
for (const lineCoords of geometry.coordinates) {
|
|
||||||
if (!lineCoords || lineCoords.length < 2) continue;
|
|
||||||
|
|
||||||
const points = [];
|
|
||||||
for (let i = 0; i < lineCoords.length - 1; i++) {
|
|
||||||
const lon1 = lineCoords[i][0];
|
|
||||||
const lat1 = lineCoords[i][1];
|
|
||||||
const lon2 = lineCoords[i + 1][0];
|
|
||||||
const lat2 = lineCoords[i + 1][1];
|
|
||||||
|
|
||||||
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
|
|
||||||
if (i === 0) {
|
|
||||||
points.push(...segment);
|
|
||||||
} else {
|
|
||||||
points.push(...segment.slice(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (points.length >= 2) {
|
|
||||||
const line = createCableLine(points, color, properties, earthObj);
|
|
||||||
if (line) {
|
|
||||||
cableLines.push(line);
|
|
||||||
earthObj.add(line);
|
|
||||||
console.log('添加线缆成功');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (geometry.type === 'LineString') {
|
|
||||||
const allCoords = geometry.coordinates;
|
|
||||||
const points = [];
|
const points = [];
|
||||||
|
for (let i = 0; i < lineCoords.length - 1; i++) {
|
||||||
for (let i = 0; i < allCoords.length - 1; i++) {
|
const lon1 = lineCoords[i][0];
|
||||||
const lon1 = allCoords[i][0];
|
const lat1 = lineCoords[i][1];
|
||||||
const lat1 = allCoords[i][1];
|
const lon2 = lineCoords[i + 1][0];
|
||||||
const lon2 = allCoords[i + 1][0];
|
const lat2 = lineCoords[i + 1][1];
|
||||||
const lat2 = allCoords[i + 1][1];
|
|
||||||
|
const segment = calculateGreatCirclePoints(
|
||||||
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
|
lat1,
|
||||||
if (i === 0) {
|
lon1,
|
||||||
points.push(...segment);
|
lat2,
|
||||||
} else {
|
lon2,
|
||||||
points.push(...segment.slice(1));
|
CONFIG.earthRadius + 0.2,
|
||||||
}
|
50,
|
||||||
|
);
|
||||||
|
points.push(...(i === 0 ? segment : 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() {
|
||||||
@@ -375,10 +444,11 @@ export function getAllLandingPoints() {
|
|||||||
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
|
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);
|
||||||
lp.material.emissive.setHex(0x442200);
|
lp.material.emissive.setHex(0x442200);
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
355
frontend/public/earth/js/controls.js
vendored
355
frontend/public/earth/js/controls.js
vendored
@@ -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);
|
||||||
@@ -29,39 +52,40 @@ function setupZoomControls(camera) {
|
|||||||
const HOLD_THRESHOLD = 150;
|
const HOLD_THRESHOLD = 150;
|
||||||
const LONG_PRESS_TICK = 50;
|
const LONG_PRESS_TICK = 50;
|
||||||
const CLICK_STEP = 10;
|
const CLICK_STEP = 10;
|
||||||
|
|
||||||
const MIN_PERCENT = CONFIG.minZoom * 100;
|
const MIN_PERCENT = CONFIG.minZoom * 100;
|
||||||
const MAX_PERCENT = CONFIG.maxZoom * 100;
|
const MAX_PERCENT = CONFIG.maxZoom * 100;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
zoomLevel = newPercent / 100;
|
zoomLevel = newPercent / 100;
|
||||||
applyZoom(camera);
|
applyZoom(camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
function doContinuousZoom(direction) {
|
function doContinuousZoom(direction) {
|
||||||
let currentPercent = Math.round(zoomLevel * 100);
|
let currentPercent = Math.round(zoomLevel * 100);
|
||||||
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
|
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
zoomLevel = newPercent / 100;
|
zoomLevel = newPercent / 100;
|
||||||
applyZoom(camera);
|
applyZoom(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopZoom() {
|
function stopZoom() {
|
||||||
if (zoomInterval) {
|
if (zoomInterval) {
|
||||||
clearInterval(zoomInterval);
|
clearInterval(zoomInterval);
|
||||||
@@ -72,15 +96,15 @@ function setupZoomControls(camera) {
|
|||||||
holdTimeout = null;
|
holdTimeout = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseUp(direction) {
|
function handleMouseUp(direction) {
|
||||||
const heldTime = Date.now() - startTime;
|
const heldTime = Date.now() - startTime;
|
||||||
stopZoom();
|
stopZoom();
|
||||||
@@ -88,48 +112,72 @@ function setupZoomControls(camera) {
|
|||||||
doZoomStep(direction);
|
doZoomStep(direction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const zoomIn = document.getElementById("zoom-in");
|
||||||
document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(1); });
|
const zoomOut = document.getElementById("zoom-out");
|
||||||
document.getElementById('zoom-in').addEventListener('touchend', () => handleMouseUp(1));
|
const zoomValue = document.getElementById("zoom-value");
|
||||||
|
|
||||||
document.getElementById('zoom-out').addEventListener('mousedown', () => handleMouseDown(-1));
|
bindListener(zoomIn, "mousedown", () => handleMouseDown(1));
|
||||||
document.getElementById('zoom-out').addEventListener('mouseup', () => handleMouseUp(-1));
|
bindListener(zoomIn, "mouseup", () => handleMouseUp(1));
|
||||||
document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom);
|
bindListener(zoomIn, "mouseleave", stopZoom);
|
||||||
document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(-1); });
|
bindListener(zoomIn, "touchstart", (e) => {
|
||||||
document.getElementById('zoom-out').addEventListener('touchend', () => handleMouseUp(-1));
|
e.preventDefault();
|
||||||
|
handleMouseDown(1);
|
||||||
document.getElementById('zoom-value').addEventListener('click', function() {
|
});
|
||||||
|
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) {
|
||||||
@@ -140,149 +188,186 @@ function applyZoom(camera) {
|
|||||||
|
|
||||||
function animateValue(start, end, duration, onUpdate, onComplete) {
|
function animateValue(start, end, duration, onUpdate, onComplete) {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
function update(currentTime) {
|
function update(currentTime) {
|
||||||
const elapsed = currentTime - startTime;
|
const elapsed = currentTime - startTime;
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||||
|
|
||||||
const current = start + (end - start) * easeProgress;
|
const current = start + (end - start) * easeProgress;
|
||||||
onUpdate(current);
|
onUpdate(current);
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
requestAnimationFrame(update);
|
requestAnimationFrame(update);
|
||||||
} else if (onComplete) {
|
} else if (onComplete) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(update);
|
requestAnimationFrame(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetView(camera) {
|
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) => {
|
||||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
const ease = 1 - Math.pow(1 - progress, 3);
|
||||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
|
||||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
|
||||||
}, () => {
|
|
||||||
zoomLevel = 1.0;
|
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||||
showStatusMessage('视角已重置', 'info');
|
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||||
});
|
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
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,
|
||||||
if (typeof window.clearLockedCable === 'function') {
|
EARTH_CONFIG.chinaRotLon,
|
||||||
window.clearLockedCable();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearLockedObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,26 +18,33 @@ 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,
|
||||||
|
);
|
||||||
let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
|
const lat = 90 - (Math.acos(vector.y / radius) * 180) / Math.PI;
|
||||||
|
|
||||||
|
let lon = (Math.atan2(vector.z, -vector.x) * 180) / Math.PI - 180;
|
||||||
|
|
||||||
while (lon <= -180) lon += 360;
|
while (lon <= -180) lon += 360;
|
||||||
while (lon > 180) lon -= 360;
|
while (lon > 180) lon -= 360;
|
||||||
|
|
||||||
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,17 +67,26 @@ 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));
|
||||||
|
|
||||||
return radius * c;
|
return radius * c;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user