Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7647379de | ||
|
|
0f89372d71 |
@@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import math
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
@@ -23,6 +24,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
|
||||
router = APIRouter()
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
@@ -782,9 +786,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/geo/landing-points")
|
||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
records = await _load_current_collected_data(db, "arcgis_landing_points")
|
||||
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation")
|
||||
cable_records = await _load_current_collected_data(db, "arcgis_cables")
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
[
|
||||
"arcgis_landing_points",
|
||||
"arcgis_cable_landing_relation",
|
||||
"arcgis_cables",
|
||||
],
|
||||
)
|
||||
records = records_by_source.get("arcgis_landing_points", [])
|
||||
relation_records = records_by_source.get(
|
||||
"arcgis_cable_landing_relation",
|
||||
[],
|
||||
)
|
||||
cable_records = records_by_source.get("arcgis_cables", [])
|
||||
|
||||
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
|
||||
relation_records,
|
||||
@@ -804,6 +819,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Terrain tile upstream error: {exc.response.status_code}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
|
||||
@@ -8,8 +8,36 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
||||
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
||||
- BGP 事件图标新增填充 W 形波动符号(flap 类型),替换原有难以辨认的贝塞尔细线
|
||||
- 巡航/点击激活时其余卫星自动降饱和度 + 增加透明度以突出焦点;海缆未受影响时同步变暗
|
||||
|
||||
### 🔧 Improvements
|
||||
- 修复巡航轮播期间 BGP 事件 polling 刷新导致标记闪烁消失的问题(clearBGPData 延迟到请求完成后执行)
|
||||
- 点击与巡航锁定颜色统一为 hover 色(0.92, 0.98, 1.0 全透明),移除锁定态脉冲动画
|
||||
- 巡航连接折线转折点从尖角调整为钝角(linkElbowDropPx),提升连线可读性
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
## [0.30.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增真实地形图层:后端代理 Terrarium DEM 瓦片(`/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png`),前端新增 `terrain.js` 负责瓦片拉取、顶点位移与按海拔着色
|
||||
- 设置弹窗新增"地形"分组,支持通过滑块实时调整地形图层透明度
|
||||
|
||||
### 🔧 Improvements
|
||||
- 地形按钮改为异步加载,首次点击显示进度提示并在失败时自动回退
|
||||
- 启动阶段改用 `applyImmediateView` 直接应用初始视角,`showStatusMessage` / `queueStatusMessage` 区分即时与队列态状态消息,加载中不再被临时状态打断
|
||||
- 控制面板抽取 `applyTerrainUiState` / `getViewRotation` 收敛地形切换与视角旋转的重复 UI 同步逻辑
|
||||
|
||||
---
|
||||
|
||||
## [0.29.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
|
||||
472
docs/earth/earth-real-terrain-plan.md
Normal file
472
docs/earth/earth-real-terrain-plan.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Earth Real Terrain Plan
|
||||
|
||||
## Goal
|
||||
|
||||
将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。
|
||||
|
||||
当前占位实现位于:
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
具体问题:
|
||||
|
||||
- `createTerrain()` 直接对球体顶点应用 `simplex noise`
|
||||
- 没有真实海拔数据来源
|
||||
- 没有分辨率分层
|
||||
- 没有和当前相机/视角配套的性能控制
|
||||
|
||||
## Constraints
|
||||
|
||||
本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎:
|
||||
|
||||
- 地球主体仍然是一个 Three.js sphere
|
||||
- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上
|
||||
- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换
|
||||
- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形
|
||||
|
||||
## Recommended Data Source
|
||||
|
||||
### Primary recommendation
|
||||
|
||||
使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入:
|
||||
|
||||
- Mapzen/AWS `Terrarium` elevation tiles
|
||||
参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
|
||||
原因:
|
||||
|
||||
- 已经是全球瓦片化高程
|
||||
- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe
|
||||
- 编码简单稳定:
|
||||
- `heightMeters = (R * 256 + G + B / 256) - 32768`
|
||||
- 不需要我们先离线拼整球 DEM
|
||||
|
||||
### Data quality upgrade path
|
||||
|
||||
如果后面第一阶段效果确认可用,再逐步升级到底层源:
|
||||
|
||||
- Copernicus DEM GLO-30
|
||||
参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles
|
||||
|
||||
这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。
|
||||
|
||||
## Why Not Replace the Engine
|
||||
|
||||
不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因:
|
||||
|
||||
- 现有 Earth 业务对象都依附当前球面坐标
|
||||
- 切引擎会同时波及:
|
||||
- 海缆绘制
|
||||
- 卫星/轨迹
|
||||
- BGP 标记
|
||||
- HUD 与交互
|
||||
- 这是“重做一页”,不是“给地形层接真实数据”
|
||||
|
||||
所以推荐路线是:
|
||||
|
||||
- 保持当前 sphere globe
|
||||
- 为 sphere 增加真实高度位移层
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
分三期推进。
|
||||
|
||||
### Phase 1 — Global Heightmap Terrain Overlay
|
||||
|
||||
目标:
|
||||
|
||||
- 地形层切换后显示真实海拔起伏
|
||||
- 全球范围可用
|
||||
- 性能可控
|
||||
|
||||
做法:
|
||||
|
||||
1. 新增 terrain 数据模块
|
||||
|
||||
建议文件:
|
||||
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
职责:
|
||||
|
||||
- 选择 DEM zoom level
|
||||
- 请求 Terrarium tiles
|
||||
- 解码 tile 高程
|
||||
- 将高程重采样到当前地形球体网格
|
||||
|
||||
2. 替换 `createTerrain()`
|
||||
|
||||
当前:
|
||||
|
||||
- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形
|
||||
|
||||
调整后:
|
||||
|
||||
- `createTerrain()` 只负责创建 terrain mesh 骨架
|
||||
- 真正的顶点位移由 terrain 模块异步注入
|
||||
|
||||
3. 第一阶段采用“整球低分辨率位移”
|
||||
|
||||
不要一上来做动态 patch stitching。第一阶段更稳的办法是:
|
||||
|
||||
- 保留一张全球 terrain sphere
|
||||
- 使用较低分辨率几何
|
||||
- 例如 `SphereGeometry(radius, 192, 192)` 或 `256/256`
|
||||
- 运行时按一个固定地形 zoom(如 `z=4` 或 `z=5`)抓取覆盖全球的 Terrarium tiles
|
||||
- 将 tile 解码后重投影到经纬度采样网格
|
||||
- 将每个球面顶点按真实高度抬升
|
||||
|
||||
这样第一阶段就能做到:
|
||||
|
||||
- 有真实地形
|
||||
- 不需要复杂的局部 LOD
|
||||
- 不会让现有球体对象体系爆炸
|
||||
|
||||
### Phase 2 — View-Aware Refinement
|
||||
|
||||
目标:
|
||||
|
||||
- 正面可见区域更精细
|
||||
- 背面与远处维持低成本
|
||||
|
||||
做法:
|
||||
|
||||
- 引入“基础全球地形 + 当前视角高分局部补丁”
|
||||
- 正面区域额外抓更高 zoom 的高程 tile
|
||||
- 只替换局部顶点位移或局部 overlay mesh
|
||||
|
||||
这一阶段适合在第一阶段稳定后做。
|
||||
|
||||
### Phase 3 — Normals / Shading / Terrain UX
|
||||
|
||||
目标:
|
||||
|
||||
- 地形不仅有起伏,还更好看、更可读
|
||||
|
||||
包括:
|
||||
|
||||
- 根据高度生成更合理的 normals
|
||||
- 调整 terrain material,使山脉/高原更易读
|
||||
- 可选加入:
|
||||
- hillshade
|
||||
- contour lines
|
||||
- snowline / bathymetry tint
|
||||
|
||||
## Calibration Overlay Before More Terrain Tuning
|
||||
|
||||
在当前项目里,terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。
|
||||
|
||||
没有清晰的海岸线、国界线和地表分层时,人眼很难判断:
|
||||
|
||||
- 山脉是不是在应该高的地方高
|
||||
- terrain 是否真的贴在正确的大陆位置上
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否存在偏移
|
||||
|
||||
这里要明确区分两件事:
|
||||
|
||||
- 国界线不会修好错误的 terrain
|
||||
- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准
|
||||
|
||||
所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。
|
||||
|
||||
### Recommended order for the calibration layer
|
||||
|
||||
1. 海岸线
|
||||
2. 国界线
|
||||
3. 再继续调 terrain
|
||||
|
||||
原因:
|
||||
|
||||
- 海岸线比国界线更基础,也更接近真实地表边界
|
||||
- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系
|
||||
- 国界线更多是政治边界,只能作为辅助参照
|
||||
|
||||
如果只加国界线,不加海岸线,效果仍然可能会怪,因为:
|
||||
|
||||
- 很多国界线本来就是人为直线
|
||||
- 它们并不总是跟真实地形走
|
||||
|
||||
### Suggested layer order during debugging
|
||||
|
||||
建议调试期临时把地球层次明确成:
|
||||
|
||||
1. base earth texture
|
||||
2. coastline / borders overlay
|
||||
3. terrain relief
|
||||
4. cables / landing points / bgp / satellites
|
||||
|
||||
这样会比现在更容易判断:
|
||||
|
||||
- 山脉是否位于正确区域
|
||||
- terrain 是否和地表对齐
|
||||
- 国界/海岸是否漂移
|
||||
|
||||
### Suggested data source for the calibration overlay
|
||||
|
||||
优先用 `Natural Earth` 的轻量全球矢量数据:
|
||||
|
||||
- 海岸线(coastline)
|
||||
- Admin 0 国界线(country borders)
|
||||
|
||||
优点:
|
||||
|
||||
- 全球一致
|
||||
- 轻量
|
||||
- 很适合当前 Three.js globe 做 overlay
|
||||
|
||||
### Recommended execution path
|
||||
|
||||
#### Phase A — Add reference overlays
|
||||
|
||||
先加两层可开关的参考线:
|
||||
|
||||
- 海岸线
|
||||
- 国界线
|
||||
|
||||
这两层的目标不是最终美术表现,而是调试 / 校准。
|
||||
|
||||
#### Phase B — Recalibrate terrain against coastline
|
||||
|
||||
有了海岸线以后,再重新看 terrain:
|
||||
|
||||
- terrain 是否和大陆边缘错位
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移
|
||||
|
||||
#### Phase C — Decide whether to keep the current terrain path
|
||||
|
||||
这时再决定后面的路线:
|
||||
|
||||
- 如果发现真实高程整体是对的,只是缺少 shading / readability
|
||||
继续保留当前 DEM + terrain overlay 路线
|
||||
- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭
|
||||
再考虑重做 terrain pipeline
|
||||
|
||||
### Practical recommendation
|
||||
|
||||
当前阶段不建议“从头开始重做 terrain”。
|
||||
|
||||
更稳的策略是:
|
||||
|
||||
- 暂停继续盲调 terrain 参数
|
||||
- 先补海岸线 / 国界线作为校准参照层
|
||||
- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移”
|
||||
|
||||
## Recommended Geometry Model
|
||||
|
||||
### First usable model
|
||||
|
||||
保留一层独立 terrain sphere:
|
||||
|
||||
- base earth sphere:贴纹理、昼夜、海洋
|
||||
- terrain sphere:略高于地球半径,真实高程位移
|
||||
|
||||
建议:
|
||||
|
||||
- `terrainBaseRadius = CONFIG.earthRadius + 0.2`
|
||||
- 高度缩放使用真实米制换算,再乘一个可调 exaggeration
|
||||
|
||||
示例关系:
|
||||
|
||||
- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration`
|
||||
|
||||
建议第一阶段 `exaggeration = 1.3 ~ 1.8`
|
||||
|
||||
因为完全真实比例在全球球体上会太平,看不出来。
|
||||
|
||||
## Tile Decoding Plan
|
||||
|
||||
### Terrarium decode
|
||||
|
||||
对于每个高程 tile 像素:
|
||||
|
||||
```text
|
||||
heightMeters = (R * 256 + G + B / 256) - 32768
|
||||
```
|
||||
|
||||
### Sampling path
|
||||
|
||||
对于 terrain mesh 上每个顶点:
|
||||
|
||||
1. 将顶点方向转成经纬度
|
||||
2. 将经纬度映射到 Web Mercator tile 坐标
|
||||
3. 找到对应的 tile 和像素
|
||||
4. 解码高程
|
||||
5. 将顶点沿法线方向抬升
|
||||
|
||||
### Needed helpers
|
||||
|
||||
建议新增:
|
||||
|
||||
- `latLonToTileXY(lat, lon, z)`
|
||||
- `tilePixelFromLatLon(lat, lon, z, tileSize)`
|
||||
- `decodeTerrariumHeight(r, g, b)`
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
为了不让地形开关每次重开都重新抓全量 tile:
|
||||
|
||||
- terrain tile 按 `z/x/y` 存到内存缓存
|
||||
- terrain mesh 结果也缓存一份
|
||||
- 当用户关闭/开启 terrain:
|
||||
- 直接复用已有位移结果
|
||||
|
||||
建议:
|
||||
|
||||
- `Map<string, Float32Array | ImageBitmap>`
|
||||
|
||||
## Material Strategy
|
||||
|
||||
第一阶段不要复杂化。
|
||||
|
||||
建议 terrain material:
|
||||
|
||||
- 半透明低饱和地形色
|
||||
- 比 base earth 稍亮或稍偏冷
|
||||
- 保留当前 HUD 风格下的可读性
|
||||
|
||||
第一阶段不需要:
|
||||
|
||||
- 真实土地覆被纹理
|
||||
- 独立卫星影像贴 terrain
|
||||
|
||||
因为那会和现有地球纹理、云层、昼夜 shader 打架。
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files to change
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 重写 `createTerrain()`
|
||||
- 删除 simplex noise 占位逻辑
|
||||
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 初始化 terrain 数据加载
|
||||
- 控制 terrain readiness / loading message
|
||||
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- `toggleTerrain` 逻辑保持,但应能区分:
|
||||
- mesh 已就绪
|
||||
- 正在加载
|
||||
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 新增 `TERRAIN_CONFIG`
|
||||
- 新文件:
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
### Suggested new config
|
||||
|
||||
建议新增:
|
||||
|
||||
```js
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
baseRadiusOffset: 0.2,
|
||||
exaggeration: 1.5,
|
||||
opacity: 0.55,
|
||||
color: 0x6c876f,
|
||||
maxConcurrentRequests: 8,
|
||||
cacheEnabled: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Loading UX
|
||||
|
||||
地形第一次开启时,不能像现在一样瞬时切换。
|
||||
|
||||
建议:
|
||||
|
||||
- 如果地形数据尚未准备:
|
||||
- 顶部状态条显示:`正在加载真实地形数据...`
|
||||
- 完成后:
|
||||
- `真实地形已就绪`
|
||||
|
||||
如果加载失败:
|
||||
|
||||
- 保留 base earth
|
||||
- 显示轻量错误提示
|
||||
- 不要让 terrain 开关卡死在“开”状态
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Global tile count too high
|
||||
|
||||
即使 `z=5` 全球 tile 数也不少。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段限定低 zoom
|
||||
- 并发上限
|
||||
- 缓存
|
||||
|
||||
### 2. Mesh resolution too low
|
||||
|
||||
如果球面分段太低,山脉会被抹平。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段先选一个中等分辨率
|
||||
- 用 exaggeration 保证可见性
|
||||
|
||||
### 3. Existing overlays may z-fight with terrain
|
||||
|
||||
海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。
|
||||
|
||||
缓解:
|
||||
|
||||
- terrain sphere 单独作为 overlay
|
||||
- overlay 保持略低或略高的固定 offset
|
||||
- 必要时局部调整 landing point / cable altitude offset
|
||||
|
||||
### 4. Mercator sampling distortion near poles
|
||||
|
||||
Web Mercator 在高纬会有失真。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段接受
|
||||
- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
第一阶段完成后,应满足:
|
||||
|
||||
1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声
|
||||
2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认
|
||||
3. 关闭/重新开启 terrain 不重复全量请求
|
||||
4. 不破坏:
|
||||
- 海缆
|
||||
- 卫星
|
||||
- BGP
|
||||
- 地球昼夜
|
||||
- 天球层
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
1. 引入 `TERRAIN_CONFIG`
|
||||
2. 新建 `terrain.js`
|
||||
3. 实现 Terrarium tile 请求与 decode
|
||||
4. 用低 zoom 全球 tile 构建真实 terrain sphere
|
||||
5. 接管 `toggleTerrain()`
|
||||
6. 调整 terrain material 和高度 exaggeration
|
||||
7. 做缓存
|
||||
8. 再考虑第二阶段局部高分 refinement
|
||||
|
||||
## Source References
|
||||
|
||||
- Mapzen Terrarium / AWS terrain tiles
|
||||
[Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
- Terrarium tile experiments / format background
|
||||
[mapzen/terrarium](https://github.com/mapzen/terrarium)
|
||||
- Copernicus DEM overview
|
||||
[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
|
||||
## Recommendation Summary
|
||||
|
||||
如果现在就要开始做,我建议直接按这条路线开工:
|
||||
|
||||
- 第一阶段接入 Terrarium 全球高程 tile
|
||||
- 替换掉当前 simplex 假地形
|
||||
- 先做一层真实可见的全球 terrain overlay
|
||||
- 等第一阶段稳定,再做视角高分 refinement
|
||||
|
||||
这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.29.2`
|
||||
- `dev` 当前开发分支历史推导到:`0.31.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
|
||||
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
||||
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
||||
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.29.2",
|
||||
"version": "0.31.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -279,18 +279,8 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hud-error-message {
|
||||
color: #ff4444;
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
display: none;
|
||||
padding: 10px;
|
||||
background-color: rgba(255, 68, 68, 0.1);
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #ff4444;
|
||||
}
|
||||
|
||||
.earth-status-message {
|
||||
.earth-status-message,
|
||||
.earth-error-message {
|
||||
position: absolute;
|
||||
top: calc(20px * var(--hud-scale));
|
||||
left: 50%;
|
||||
@@ -311,6 +301,7 @@
|
||||
0 0 18px rgba(145, 186, 255, 0.06);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
@@ -324,18 +315,27 @@
|
||||
opacity 0.28s ease;
|
||||
}
|
||||
|
||||
.earth-status-message.visible {
|
||||
.earth-status-message.visible,
|
||||
.earth-error-message.visible {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earth-error-message {
|
||||
top: calc(62px * var(--hud-scale));
|
||||
z-index: 211;
|
||||
min-width: min(calc(220px * var(--hud-scale)), 58vw);
|
||||
}
|
||||
|
||||
/* ── Indicator: single dot (transient) or three dots (loading) ── */
|
||||
|
||||
.earth-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.earth-status-dot {
|
||||
@@ -350,6 +350,8 @@
|
||||
}
|
||||
|
||||
.earth-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -536,7 +538,7 @@
|
||||
|
||||
.earth-settings-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-kicker-size);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -608,10 +610,119 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-item--stacked {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earth-settings-item--stacked:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.earth-settings-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.earth-settings-segmented {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn.is-active {
|
||||
color: var(--hud-title);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.earth-settings-slider {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
appearance: none;
|
||||
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-settings-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
|
||||
linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider-value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 46px;
|
||||
text-align: right;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-settings-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -161,6 +161,76 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.info-card-cruise-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease;
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
.info-card-cruise-link polyline {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.98);
|
||||
stroke-width: 2.15;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter:
|
||||
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
|
||||
drop-shadow(0 0 2px rgba(6, 14, 28, 0.56))
|
||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||
}
|
||||
|
||||
.info-card-cruise-link circle {
|
||||
fill: rgba(255, 255, 255, 0.98);
|
||||
stroke: rgba(7, 16, 32, 0.72);
|
||||
stroke-width: 1.0;
|
||||
filter:
|
||||
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
|
||||
drop-shadow(0 0 2px rgba(6, 14, 28, 0.54))
|
||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating polyline {
|
||||
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle {
|
||||
animation: cruiseConnectorNodeIn 0.22s ease forwards;
|
||||
animation-delay: 0.22s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorDraw {
|
||||
from {
|
||||
stroke-dashoffset: var(--connector-length, 0px);
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorNodeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Info Card ────────────────────────────────────────────────── */
|
||||
|
||||
.info-card {
|
||||
|
||||
@@ -133,8 +133,8 @@
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: calc(-1 * var(--hud-gap-sm));
|
||||
margin-bottom: calc(-1 * var(--hud-gap-sm));
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tv-panel-meta {
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
.tv-panel-player {
|
||||
position: relative;
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(220px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
@@ -424,6 +424,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-content hud-panel__body">
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">旋转</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">旋转模式</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn is-active"
|
||||
data-rotation-mode="rotate"
|
||||
aria-pressed="true"
|
||||
>
|
||||
旋转模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn"
|
||||
data-rotation-mode="cruise"
|
||||
aria-pressed="false"
|
||||
>
|
||||
巡航模式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
@@ -469,6 +498,30 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
|
||||
@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
|
||||
}
|
||||
|
||||
function drawWaveSymbol(context) {
|
||||
context.lineWidth = 12;
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(18, 76);
|
||||
context.bezierCurveTo(34, 46, 46, 46, 64, 76);
|
||||
context.bezierCurveTo(80, 106, 94, 106, 110, 76);
|
||||
context.stroke();
|
||||
context.moveTo(14, 100);
|
||||
context.lineTo(38, 26);
|
||||
context.lineTo(64, 100);
|
||||
context.lineTo(90, 26);
|
||||
context.lineTo(114, 100);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function drawBurstSymbol(context) {
|
||||
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
|
||||
}
|
||||
|
||||
export async function loadBGPAnomalies(scene, earth) {
|
||||
clearBGPData(earth);
|
||||
|
||||
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
|
||||
if (!collectorsResponse.ok) {
|
||||
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
||||
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
? collectorsPayload.features
|
||||
: [];
|
||||
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
|
||||
|
||||
clearBGPData(earth);
|
||||
|
||||
totalAnomalyCount = selectedEventData.totalAnomalyCount;
|
||||
totalIncidentCount = selectedEventData.totalIncidentCount;
|
||||
activeEventCountByCollector.clear();
|
||||
@@ -1351,7 +1353,7 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
|
||||
const now = performance.now();
|
||||
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
||||
const hasLockedLayer = Boolean(
|
||||
@@ -1459,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const isLinkedCollectorLocked =
|
||||
lockedObjectType === "bgp_collector" &&
|
||||
lockedObject?.userData?.collector === marker.userData.collector;
|
||||
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
|
||||
const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
|
||||
const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
|
||||
const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
|
||||
const isActive = isLocked || isLinkedCollectorLocked || isCruise;
|
||||
const isHovered = marker.userData.state === "hover";
|
||||
const pulse =
|
||||
0.5 +
|
||||
@@ -1477,18 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
|
||||
if (isLocked || isLinkedCollectorLocked) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity =
|
||||
0.9 +
|
||||
0.1 * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
markerColor = 0xfff1a8;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isCruise) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isHovered) {
|
||||
scale *= BGP_CONFIG.marker.hoverScale;
|
||||
opacity = 0.9;
|
||||
ringBaseOpacity *= 1.05;
|
||||
} else if (isOtherLocked) {
|
||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||
opacity = 0.1;
|
||||
opacity = 0.22;
|
||||
markerColor = 0x7d8ca3;
|
||||
ringBaseOpacity = 0.02;
|
||||
} else {
|
||||
@@ -1500,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
marker.material.color.setHex(markerColor);
|
||||
marker.material.opacity = opacity;
|
||||
marker.visible = showBGP;
|
||||
marker.renderOrder = isActive ? 7 : 3;
|
||||
|
||||
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
||||
const applyRingState = (ring, phase, maxScale) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export let lockedCable = null;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
let landingPointGeometry = null;
|
||||
const landingPointWorldPosition = new THREE.Vector3();
|
||||
|
||||
function clamp(value, min, max) {
|
||||
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
|
||||
if (owner) {
|
||||
owner.remove(object);
|
||||
}
|
||||
if (object.geometry) {
|
||||
if (object.geometry && !object.userData?.sharedGeometry) {
|
||||
object.geometry.dispose();
|
||||
}
|
||||
if (object.material) {
|
||||
@@ -245,9 +246,12 @@ export function clearCableData(earthObj = null) {
|
||||
clearLandingPoints(earthObj);
|
||||
}
|
||||
|
||||
export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载电缆数据...");
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
if (!response.ok) {
|
||||
@@ -344,11 +348,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
textureQuality: "8K 卫星图",
|
||||
});
|
||||
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
}
|
||||
return cableLines.length;
|
||||
}
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj) {
|
||||
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载登陆点数据...");
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
@@ -363,70 +370,69 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
|
||||
const sphereGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
if (!landingPointGeometry) {
|
||||
landingPointGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
}
|
||||
let validCount = 0;
|
||||
|
||||
try {
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const properties = feature.properties || {};
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const properties = feature.properties || {};
|
||||
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = latLonToVector3(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
sphereGeometry.clone(),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
};
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
validCount++;
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
sphereGeometry.dispose();
|
||||
|
||||
const position = latLonToVector3(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
landingPointGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
sharedGeometry: true,
|
||||
};
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
validCount++;
|
||||
}
|
||||
|
||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
||||
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
}
|
||||
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
}
|
||||
return validCount;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,26 @@ export const CONFIG = {
|
||||
dragRotationScaleMax: 2.0,
|
||||
};
|
||||
|
||||
export const ROTATION_MODE = {
|
||||
ROTATE: "rotate",
|
||||
CRUISE: "cruise",
|
||||
};
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
pollIntervalMs: 15_000,
|
||||
maxPolledEvents: 200,
|
||||
cardAnchorXRatio: 0.68,
|
||||
cardAnchorYRatio: 0.24,
|
||||
linkMarkerGapPx: 18,
|
||||
linkPanelGapPx: 12,
|
||||
linkElbowOffsetPx: 72,
|
||||
linkAnchorHeightRatio: 0.26,
|
||||
linkForcedBendPx: 34,
|
||||
linkElbowDropPx: 24,
|
||||
};
|
||||
|
||||
export const HUD_CONFIG = {
|
||||
scaleReferenceWidth: 1920,
|
||||
scaleReferenceHeight: 1080,
|
||||
@@ -74,6 +94,25 @@ export const CELESTIAL_CONFIG = {
|
||||
backLightColor: 0x2b4c78,
|
||||
};
|
||||
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
opacity: 0.62,
|
||||
color: 0x7f9d7f,
|
||||
emissive: 0x061008,
|
||||
specular: 0x233126,
|
||||
shininess: 10,
|
||||
urlTemplate:
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
@@ -150,6 +189,8 @@ export const CABLE_STATE = {
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
initialLoadCount: 2400,
|
||||
hydrateFullAfterInitialLoad: true,
|
||||
trailLength: 10,
|
||||
dotSize: 4,
|
||||
ringSize: 0.07,
|
||||
|
||||
482
frontend/public/earth/js/controls.js
vendored
482
frontend/public/earth/js/controls.js
vendored
@@ -1,9 +1,15 @@
|
||||
// controls.js - Zoom, rotate and toggle controls
|
||||
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
|
||||
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||
import { toggleTerrain } from "./earth.js";
|
||||
import {
|
||||
ensureTerrainReady,
|
||||
isTerrainReady,
|
||||
getTerrainOpacity,
|
||||
setTerrainOpacity,
|
||||
} from "./terrain.js";
|
||||
import {
|
||||
reloadData,
|
||||
clearLockedObject,
|
||||
@@ -29,6 +35,7 @@ export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
export let showTerrain = false;
|
||||
export let layoutExpanded = false;
|
||||
export let rotationMode = ROTATION_MODE.ROTATE;
|
||||
|
||||
let earthObj = null;
|
||||
let listeners = [];
|
||||
@@ -51,6 +58,7 @@ const TOOLBAR_ARCH_RISE_PX = 40;
|
||||
const TOOLBAR_SIDE_PADDING_PX = 12;
|
||||
const TOOLBAR_BOTTOM_CLEARANCE_PX = 34;
|
||||
const TOOLBAR_EXTRA_HEIGHT_PX = 34;
|
||||
const HUD_EDGE_GAP_PX = 20;
|
||||
const SETTINGS_MODAL_OPEN_ANIMATION_MS = 420;
|
||||
const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
|
||||
const SETTINGS_SHEET_MIN_SCALE = 0.06;
|
||||
@@ -58,6 +66,56 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
let settingsModalTimer = null;
|
||||
let settingsSheetAnimation = null;
|
||||
let terrainToggleToken = 0;
|
||||
let focusViewAnimationToken = 0;
|
||||
|
||||
function getViewRotation(targetLat, targetRotLon) {
|
||||
const latRot = (targetLat * Math.PI) / 180;
|
||||
return {
|
||||
x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient,
|
||||
y: -((targetRotLon * Math.PI) / 180),
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchRotationModeChange() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:rotation-mode-change", {
|
||||
detail: {
|
||||
mode: rotationMode,
|
||||
active: autoRotate,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function applyTerrainUiState(button, enabled) {
|
||||
showTerrain = enabled;
|
||||
toggleTerrain(enabled);
|
||||
updateLayerButtonState(button, enabled);
|
||||
setButtonTooltip(button, enabled ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus) terrainStatus.textContent = enabled ? "开启" : "关闭";
|
||||
}
|
||||
|
||||
export function applyImmediateView(targetEarthObj, camera, options = {}) {
|
||||
if (!targetEarthObj) return;
|
||||
|
||||
const {
|
||||
lat = EARTH_CONFIG.chinaLat,
|
||||
rotLon = EARTH_CONFIG.chinaRotLon,
|
||||
zoom = 1.0,
|
||||
} = options;
|
||||
const nextRotation = getViewRotation(lat, rotLon);
|
||||
|
||||
targetEarthObj.rotation.x = nextRotation.x;
|
||||
targetEarthObj.rotation.y = nextRotation.y;
|
||||
zoomLevel = zoom;
|
||||
|
||||
if (camera) {
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSettingsSheetAnimation() {
|
||||
if (settingsSheetAnimation) {
|
||||
@@ -316,7 +374,45 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
|
||||
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
|
||||
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
|
||||
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||
const safeOpacity = Math.round(nextOpacity * 100);
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
terrainOpacitySlider.value = nextOpacity.toFixed(2);
|
||||
}
|
||||
if (terrainOpacityValue) {
|
||||
terrainOpacityValue.textContent = `${safeOpacity}%`;
|
||||
}
|
||||
};
|
||||
|
||||
syncTerrainOpacityUi(getTerrainOpacity());
|
||||
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
bindListener(terrainOpacitySlider, "input", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
const nextOpacity = Number.parseFloat(target.value);
|
||||
const appliedOpacity = setTerrainOpacity(
|
||||
Number.isFinite(nextOpacity) ? nextOpacity : getTerrainOpacity(),
|
||||
);
|
||||
syncTerrainOpacityUi(appliedOpacity);
|
||||
});
|
||||
}
|
||||
|
||||
rotationModeButtons.forEach((button) => {
|
||||
bindListener(button, "click", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
const nextMode = target.dataset.rotationMode;
|
||||
if (!nextMode) return;
|
||||
setRotationMode(nextMode);
|
||||
});
|
||||
});
|
||||
|
||||
syncAllHudPanelToggles();
|
||||
syncRotationModeButtons();
|
||||
}
|
||||
|
||||
function setupHudPanelControls() {
|
||||
@@ -333,6 +429,116 @@ function setupHudPanelControls() {
|
||||
});
|
||||
}
|
||||
|
||||
function capturePanelAnchor(app, panel, desiredLeft, desiredTop) {
|
||||
// 拖拽期间:按用户给出的绝对位置重置 anchor,轴模式回到 left/top。
|
||||
// clamp 真的触发时由 syncPanelAnchorFromClamp 改写成 right/bottom 模式。
|
||||
panel.dataset.anchorXSide = "left";
|
||||
panel.dataset.anchorX = String(desiredLeft);
|
||||
panel.dataset.anchorYSide = "top";
|
||||
panel.dataset.anchorY = String(desiredTop);
|
||||
}
|
||||
|
||||
function getHudScaleValue() {
|
||||
const rawScale = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--hud-scale")
|
||||
.trim();
|
||||
const parsedScale = Number.parseFloat(rawScale);
|
||||
return Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1;
|
||||
}
|
||||
|
||||
function getPreferredHudEdgeGap() {
|
||||
return HUD_EDGE_GAP_PX * getHudScaleValue();
|
||||
}
|
||||
|
||||
function syncDraggedPanelSize(panel) {
|
||||
const dragWidthBase = Number.parseFloat(panel.dataset.dragWidthBase ?? "");
|
||||
if (!Number.isFinite(dragWidthBase)) return;
|
||||
const nextWidth = dragWidthBase * getHudScaleValue();
|
||||
panel.style.width = `${nextWidth}px`;
|
||||
}
|
||||
|
||||
function syncPanelAnchorFromClamp(
|
||||
app,
|
||||
panel,
|
||||
desiredLeft,
|
||||
desiredTop,
|
||||
clampedLeft,
|
||||
clampedTop,
|
||||
) {
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
// 只在 clamp 真的改了坐标时切换贴边方向:
|
||||
// clamp 把 left 往小推 → 右边/下方的边碰到 panel 了 → 切到 right/bottom 模式。
|
||||
// 这里保存的是“恢复时应回到的默认边距”,不是 shrink 期间瞬时的 0 间距。
|
||||
// clamp 把 left 往大推 → 左边/上方的边(含 brand L 区)碰到 panel 了 → 记到 left/top 模式。
|
||||
const preferredGap = getPreferredHudEdgeGap();
|
||||
if (clampedLeft < desiredLeft) {
|
||||
panel.dataset.anchorXSide = "right";
|
||||
panel.dataset.anchorX = String(preferredGap);
|
||||
} else if (clampedLeft > desiredLeft) {
|
||||
panel.dataset.anchorXSide = "left";
|
||||
panel.dataset.anchorX = String(clampedLeft <= 0 ? preferredGap : clampedLeft);
|
||||
}
|
||||
if (clampedTop < desiredTop) {
|
||||
panel.dataset.anchorYSide = "bottom";
|
||||
panel.dataset.anchorY = String(preferredGap);
|
||||
} else if (clampedTop > desiredTop) {
|
||||
panel.dataset.anchorYSide = "top";
|
||||
panel.dataset.anchorY = String(clampedTop <= 0 ? preferredGap : clampedTop);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnchorDesiredPosition(app, panel) {
|
||||
syncDraggedPanelSize(panel);
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const anchorX = parseFloat(panel.dataset.anchorX ?? "");
|
||||
const anchorY = parseFloat(panel.dataset.anchorY ?? "");
|
||||
const fallbackLeft = parseFloat(panel.style.left) || 0;
|
||||
const fallbackTop = parseFloat(panel.style.top) || 0;
|
||||
|
||||
const desiredLeft = Number.isFinite(anchorX)
|
||||
? panel.dataset.anchorXSide === "right"
|
||||
? appRect.width - panelRect.width - anchorX
|
||||
: anchorX
|
||||
: fallbackLeft;
|
||||
const desiredTop = Number.isFinite(anchorY)
|
||||
? panel.dataset.anchorYSide === "bottom"
|
||||
? appRect.height - panelRect.height - anchorY
|
||||
: anchorY
|
||||
: fallbackTop;
|
||||
|
||||
return { desiredLeft, desiredTop };
|
||||
}
|
||||
|
||||
function clampDraggedPanelPosition(app, panel, desiredLeft, desiredTop) {
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const brandPanel = document.getElementById("brand-panel");
|
||||
const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
|
||||
const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
|
||||
const brandRight = brandRect ? brandRect.right - appRect.left : 0;
|
||||
|
||||
const maxLeft = Math.max(0, appRect.width - panelRect.width);
|
||||
const maxTop = Math.max(0, appRect.height - panelRect.height);
|
||||
let nextLeft = Math.min(Math.max(desiredLeft, 0), maxLeft);
|
||||
let nextTop = Math.min(Math.max(desiredTop, 0), maxTop);
|
||||
|
||||
// Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。
|
||||
// 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
|
||||
// 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
|
||||
if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
|
||||
const leftAdjust = brandRight - nextLeft;
|
||||
const topAdjust = brandBottom - nextTop;
|
||||
if (leftAdjust <= topAdjust) {
|
||||
nextLeft = brandRight;
|
||||
} else {
|
||||
nextTop = brandBottom;
|
||||
}
|
||||
}
|
||||
return { left: nextLeft, top: nextTop };
|
||||
}
|
||||
|
||||
function setupDraggableHudPanels() {
|
||||
const app = document.getElementById("container");
|
||||
const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR);
|
||||
@@ -358,40 +564,22 @@ function setupDraggableHudPanels() {
|
||||
|
||||
const onMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const brandPanel = document.getElementById("brand-panel");
|
||||
const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
|
||||
const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
|
||||
const brandRight = brandRect ? brandRect.right - appRect.left : 0;
|
||||
|
||||
let nextLeft = Math.min(
|
||||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||||
appRect.width - panelRect.width,
|
||||
const desiredLeft = startLeft + (event.clientX - startPointerX);
|
||||
const desiredTop = startTop + (event.clientY - startPointerY);
|
||||
capturePanelAnchor(app, panel, desiredLeft, desiredTop);
|
||||
const { left, top } = clampDraggedPanelPosition(
|
||||
app,
|
||||
panel,
|
||||
desiredLeft,
|
||||
desiredTop,
|
||||
);
|
||||
let nextTop = Math.min(
|
||||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||||
appRect.height - panelRect.height,
|
||||
);
|
||||
// Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。
|
||||
// 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
|
||||
// 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
|
||||
if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
|
||||
const leftAdjust = brandRight - nextLeft;
|
||||
const topAdjust = brandBottom - nextTop;
|
||||
if (leftAdjust <= topAdjust) {
|
||||
nextLeft = brandRight;
|
||||
} else {
|
||||
nextTop = brandBottom;
|
||||
}
|
||||
}
|
||||
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
panel.style.left = `${left}px`;
|
||||
panel.style.top = `${top}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
panel.dataset.dragged = "true";
|
||||
syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
|
||||
};
|
||||
|
||||
bindListener(handle, "pointerdown", (event) => {
|
||||
@@ -408,6 +596,9 @@ function setupDraggableHudPanels() {
|
||||
panel.dataset.originalParentId = panel.parentElement?.id || "";
|
||||
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
|
||||
const capturedWidth = panelRect.width;
|
||||
panel.dataset.dragWidthBase = String(
|
||||
capturedWidth / Math.max(getHudScaleValue(), 0.001),
|
||||
);
|
||||
panel.style.position = "absolute";
|
||||
panel.style.width = `${capturedWidth}px`;
|
||||
panel.style.margin = "0";
|
||||
@@ -422,6 +613,7 @@ function setupDraggableHudPanels() {
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
panel.dataset.dragged = "true";
|
||||
capturePanelAnchor(app, panel, startLeft, startTop);
|
||||
panel.classList.add("is-dragging");
|
||||
document.body.style.userSelect = "none";
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
@@ -432,6 +624,30 @@ function setupDraggableHudPanels() {
|
||||
bindListener(handle, "pointercancel", stopDragging);
|
||||
bindListener(handle, "lostpointercapture", stopDragging);
|
||||
});
|
||||
|
||||
const reclampDraggedPanels = () => {
|
||||
draggablePanels.forEach((panel) => {
|
||||
if (panel.dataset.dragged !== "true") return;
|
||||
syncDraggedPanelSize(panel);
|
||||
const { desiredLeft, desiredTop } = resolveAnchorDesiredPosition(
|
||||
app,
|
||||
panel,
|
||||
);
|
||||
const { left, top } = clampDraggedPanelPosition(
|
||||
app,
|
||||
panel,
|
||||
desiredLeft,
|
||||
desiredTop,
|
||||
);
|
||||
panel.style.left = `${left}px`;
|
||||
panel.style.top = `${top}px`;
|
||||
syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
|
||||
});
|
||||
};
|
||||
|
||||
bindListener(window, "resize", () => {
|
||||
window.requestAnimationFrame(reclampDraggedPanels);
|
||||
});
|
||||
}
|
||||
|
||||
function clearForcedFloatingClose() {
|
||||
@@ -666,9 +882,14 @@ function applyZoom(camera) {
|
||||
}
|
||||
|
||||
function animateValue(start, end, duration, onUpdate, onComplete) {
|
||||
const animationToken = ++focusViewAnimationToken;
|
||||
const startTime = performance.now();
|
||||
|
||||
function update(currentTime) {
|
||||
if (animationToken !== focusViewAnimationToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||
@@ -678,7 +899,7 @@ function animateValue(start, end, duration, onUpdate, onComplete) {
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update);
|
||||
} else if (onComplete) {
|
||||
} else if (onComplete && animationToken === focusViewAnimationToken) {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
@@ -689,59 +910,37 @@ function animateValue(start, end, duration, onUpdate, onComplete) {
|
||||
export function resetView(camera) {
|
||||
if (!earthObj) return;
|
||||
|
||||
function animateToView(targetLat, targetLon, targetRotLon) {
|
||||
const latRot = (targetLat * Math.PI) / 180;
|
||||
const targetRotX =
|
||||
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
|
||||
const targetRotY = -((targetRotLon * Math.PI) / 180);
|
||||
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
const startZoom = zoomLevel;
|
||||
const targetZoom = 1.0;
|
||||
|
||||
animateValue(
|
||||
0,
|
||||
1,
|
||||
800,
|
||||
(progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
|
||||
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
|
||||
|
||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
},
|
||||
() => {
|
||||
zoomLevel = 1.0;
|
||||
showStatusMessage("视角已重置", "info");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
animateToView(
|
||||
pos.coords.latitude,
|
||||
pos.coords.longitude,
|
||||
-pos.coords.longitude,
|
||||
),
|
||||
focusEarthView(camera, {
|
||||
lat: pos.coords.latitude,
|
||||
lon: pos.coords.longitude,
|
||||
rotLon: pos.coords.longitude - 270,
|
||||
zoom: 1.0,
|
||||
duration: 800,
|
||||
suppressStatus: false,
|
||||
}),
|
||||
() =>
|
||||
animateToView(
|
||||
EARTH_CONFIG.chinaLat,
|
||||
EARTH_CONFIG.chinaLon,
|
||||
EARTH_CONFIG.chinaRotLon,
|
||||
),
|
||||
focusEarthView(camera, {
|
||||
lat: EARTH_CONFIG.chinaLat,
|
||||
lon: EARTH_CONFIG.chinaLon,
|
||||
rotLon: EARTH_CONFIG.chinaRotLon,
|
||||
zoom: 1.0,
|
||||
duration: 800,
|
||||
suppressStatus: false,
|
||||
}),
|
||||
{ timeout: 5000, enableHighAccuracy: false },
|
||||
);
|
||||
} else {
|
||||
animateToView(
|
||||
EARTH_CONFIG.chinaLat,
|
||||
EARTH_CONFIG.chinaLon,
|
||||
EARTH_CONFIG.chinaRotLon,
|
||||
);
|
||||
focusEarthView(camera, {
|
||||
lat: EARTH_CONFIG.chinaLat,
|
||||
lon: EARTH_CONFIG.chinaLon,
|
||||
rotLon: EARTH_CONFIG.chinaRotLon,
|
||||
zoom: 1.0,
|
||||
duration: 800,
|
||||
suppressStatus: false,
|
||||
});
|
||||
}
|
||||
|
||||
clearLockedObject();
|
||||
@@ -753,7 +952,8 @@ function setupRotateControls(camera) {
|
||||
|
||||
bindListener(rotateBtn, "click", () => {
|
||||
const isRotating = toggleAutoRotate();
|
||||
showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
|
||||
const label = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
|
||||
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
|
||||
});
|
||||
|
||||
updateRotateUI();
|
||||
@@ -878,15 +1078,30 @@ function setupTerrainControls() {
|
||||
showStatusMessage("搜索功能待开发", "info");
|
||||
});
|
||||
|
||||
bindListener(terrainBtn, "click", function () {
|
||||
showTerrain = !showTerrain;
|
||||
toggleTerrain(showTerrain);
|
||||
updateLayerButtonState(this, showTerrain);
|
||||
setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus)
|
||||
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
|
||||
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
|
||||
bindListener(terrainBtn, "click", async function () {
|
||||
const nextShowTerrain = !showTerrain;
|
||||
const toggleToken = ++terrainToggleToken;
|
||||
|
||||
if (!nextShowTerrain) {
|
||||
applyTerrainUiState(this, false);
|
||||
showStatusMessage("地形已隐藏", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isTerrainReady()) {
|
||||
showStatusMessage("正在加载真实地形数据...", "info");
|
||||
await ensureTerrainReady();
|
||||
}
|
||||
if (toggleToken !== terrainToggleToken) return;
|
||||
|
||||
applyTerrainUiState(this, true);
|
||||
showStatusMessage("真实地形已显示", "success");
|
||||
} catch (error) {
|
||||
console.error("加载真实地形失败:", error);
|
||||
applyTerrainUiState(this, false);
|
||||
showStatusMessage("真实地形暂时不可用", "error");
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(satellitesBtn, "click", async function () {
|
||||
@@ -911,6 +1126,9 @@ function setupTerrainControls() {
|
||||
const showNextBGP = !getShowBGP();
|
||||
clearSelectionIfHiding(!showNextBGP);
|
||||
toggleBGP(showNextBGP);
|
||||
if (!showNextBGP && rotationMode === ROTATION_MODE.CRUISE && autoRotate) {
|
||||
setAutoRotate(false);
|
||||
}
|
||||
updateLayerButtonState(this, showNextBGP);
|
||||
setButtonTooltip(this, showNextBGP ? "隐藏BGP观测" : "显示BGP观测");
|
||||
const bgpCountEl = document.getElementById("bgp-anomaly-count");
|
||||
@@ -1208,28 +1426,111 @@ export function getAutoRotate() {
|
||||
return autoRotate;
|
||||
}
|
||||
|
||||
function getRotationModeLabel(mode = rotationMode) {
|
||||
return mode === ROTATION_MODE.CRUISE ? "巡航模式" : "旋转模式";
|
||||
}
|
||||
|
||||
function syncRotationModeButtons() {
|
||||
const buttons = document.querySelectorAll("[data-rotation-mode]");
|
||||
buttons.forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const isActive = button.dataset.rotationMode === rotationMode;
|
||||
button.classList.toggle("is-active", isActive);
|
||||
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function updateRotateUI() {
|
||||
const btn = document.getElementById("rotate-toggle");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", autoRotate);
|
||||
btn.classList.toggle("is-stopped", !autoRotate);
|
||||
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
|
||||
const activeLabel =
|
||||
rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
|
||||
if (tooltip) {
|
||||
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
|
||||
}
|
||||
btn.title = `${getRotationModeLabel()} · ${activeLabel}`;
|
||||
}
|
||||
|
||||
syncRotationModeButtons();
|
||||
}
|
||||
|
||||
export function setAutoRotate(value) {
|
||||
autoRotate = value;
|
||||
updateRotateUI();
|
||||
dispatchRotationModeChange();
|
||||
}
|
||||
|
||||
export function toggleAutoRotate() {
|
||||
autoRotate = !autoRotate;
|
||||
updateRotateUI();
|
||||
clearLockedObject();
|
||||
dispatchRotationModeChange();
|
||||
return autoRotate;
|
||||
}
|
||||
|
||||
export function getRotationMode() {
|
||||
return rotationMode;
|
||||
}
|
||||
|
||||
export function setRotationMode(nextMode) {
|
||||
const normalizedMode =
|
||||
nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE;
|
||||
const changed = normalizedMode !== rotationMode;
|
||||
rotationMode = normalizedMode;
|
||||
updateRotateUI();
|
||||
dispatchRotationModeChange();
|
||||
if (changed) {
|
||||
showStatusMessage(
|
||||
normalizedMode === ROTATION_MODE.CRUISE ? "已切换到巡航模式" : "已切换到旋转模式",
|
||||
"info",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function focusEarthView(camera, options = {}) {
|
||||
if (!earthObj || !camera) return Promise.resolve();
|
||||
|
||||
const {
|
||||
lat = EARTH_CONFIG.chinaLat,
|
||||
lon = EARTH_CONFIG.chinaLon,
|
||||
rotLon = lon - 270,
|
||||
zoom = 1.0,
|
||||
duration = 800,
|
||||
suppressStatus = true,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const nextRotation = getViewRotation(lat, rotLon);
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
const startZoom = zoomLevel;
|
||||
|
||||
animateValue(
|
||||
0,
|
||||
1,
|
||||
duration,
|
||||
(progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
|
||||
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
|
||||
zoomLevel = startZoom + (zoom - startZoom) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
},
|
||||
() => {
|
||||
zoomLevel = zoom;
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("视角已重置", "info");
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getZoomLevel() {
|
||||
return zoomLevel;
|
||||
}
|
||||
@@ -1278,6 +1579,11 @@ function resetPanelInlineLayout(panel) {
|
||||
panel.style.width = "";
|
||||
panel.style.margin = "";
|
||||
delete panel.dataset.dragged;
|
||||
delete panel.dataset.anchorX;
|
||||
delete panel.dataset.anchorY;
|
||||
delete panel.dataset.anchorXSide;
|
||||
delete panel.dataset.anchorYSide;
|
||||
delete panel.dataset.dragWidthBase;
|
||||
}
|
||||
|
||||
function isPanelVisible(panel) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
@@ -212,34 +212,35 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function createTerrain(scene, earthObj, simplex) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
const positionAttribute = geometry.getAttribute('position');
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const x = positionAttribute.getX(i);
|
||||
const y = positionAttribute.getY(i);
|
||||
const z = positionAttribute.getZ(i);
|
||||
|
||||
const noise = simplex(x / 20, y / 20, z / 20);
|
||||
const height = 1 + noise * 0.02;
|
||||
|
||||
positionAttribute.setXYZ(i, x * height, y * height, z * height);
|
||||
}
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
TERRAIN_CONFIG.geometryWidthSegments,
|
||||
TERRAIN_CONFIG.geometryHeightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x00aa00,
|
||||
flatShading: true,
|
||||
color: TERRAIN_CONFIG.color,
|
||||
emissive: TERRAIN_CONFIG.emissive,
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
vertexAlphas: true,
|
||||
transparent: true,
|
||||
opacity: 0.7
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
});
|
||||
|
||||
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
earthObj.add(terrain);
|
||||
|
||||
|
||||
return terrain;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ function mountCard() {
|
||||
cardMounted = true;
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y) {
|
||||
function positionPanel(panel, x, y, options = {}) {
|
||||
if (!panel) return;
|
||||
const margin = 12;
|
||||
const offset = 14;
|
||||
@@ -251,6 +251,22 @@ function positionPanel(panel, x, y) {
|
||||
const estW = Math.min(300 * scale, vpW - 32);
|
||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||
|
||||
if (options.absolute === true) {
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(margin, x),
|
||||
Math.max(margin, vpW - estW - margin),
|
||||
);
|
||||
const clampedTop = Math.min(
|
||||
Math.max(margin, y),
|
||||
Math.max(margin, vpH - estH - margin),
|
||||
);
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
let left = x + offset;
|
||||
let top = y + offset;
|
||||
|
||||
@@ -263,10 +279,10 @@ function positionPanel(panel, x, y) {
|
||||
panel.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function showPanel(x, y) {
|
||||
function showPanel(x, y, options = {}) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
if (x != null && y != null) positionPanel(panel, x, y);
|
||||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||
panel.classList.add('is-visible');
|
||||
}
|
||||
|
||||
@@ -327,7 +343,7 @@ export function showInfoCard(type, data, options = {}) {
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showPanel(options.x, options.y);
|
||||
showPanel(options.x, options.y, options);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
let satellitePoints = null;
|
||||
let satelliteBackdropPoints = null;
|
||||
let satelliteTrails = null;
|
||||
let satelliteData = [];
|
||||
let showSatellites = false;
|
||||
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
|
||||
let lockedDotSprite = null;
|
||||
let predictedOrbitLine = null;
|
||||
let relatedSatelliteSprites = [];
|
||||
let highlightedSatelliteIndices = null;
|
||||
let earthObjRef = null;
|
||||
let sceneRef = null;
|
||||
let cameraRef = null;
|
||||
@@ -24,6 +26,7 @@ let lockedSatelliteIndex = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let positionUpdateAccumulator = 0;
|
||||
let satelliteCapacity = 0;
|
||||
let satelliteSatrecCache = new Map();
|
||||
|
||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
@@ -117,6 +120,10 @@ export function updateBreathingPhase(deltaTime = 16) {
|
||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||
}
|
||||
|
||||
function getBreathingPulse(phase) {
|
||||
return 0.5 + 0.5 * Math.sin(phase);
|
||||
}
|
||||
|
||||
export function getSatelliteLegendItems() {
|
||||
const presentKeys = new Set();
|
||||
|
||||
@@ -204,6 +211,37 @@ function createDotTexture() {
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createBackdropDotTexture() {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = DOT_TEXTURE_SIZE;
|
||||
canvas.height = DOT_TEXTURE_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const center = DOT_TEXTURE_SIZE / 2;
|
||||
const radius = center - 1;
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
center,
|
||||
center,
|
||||
0,
|
||||
center,
|
||||
center,
|
||||
radius,
|
||||
);
|
||||
gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
|
||||
gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
|
||||
gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
|
||||
gradient.addColorStop(1, "rgba(7, 14, 27, 0)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
const size = DOT_TEXTURE_SIZE * 2;
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -226,8 +264,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
export function createSatellites(scene, earthObj) {
|
||||
initSatelliteScene(scene, earthObj);
|
||||
const dotTexture = createDotTexture();
|
||||
const backdropTexture = createBackdropDotTexture();
|
||||
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
const backdropGeometry = new THREE.BufferGeometry();
|
||||
|
||||
const backdropMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize * 1.28,
|
||||
map: backdropTexture,
|
||||
color: 0x0b1626,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.04,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const pointsMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize,
|
||||
@@ -237,29 +288,45 @@ export function createSatellites(scene, earthObj) {
|
||||
opacity: 0.9,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.1,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
|
||||
satelliteBackdropPoints.visible = false;
|
||||
satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
|
||||
satelliteBackdropPoints.renderOrder = 5;
|
||||
|
||||
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
|
||||
satellitePoints.visible = false;
|
||||
satellitePoints.userData = { type: "satellitePoints" };
|
||||
satellitePoints.renderOrder = 6;
|
||||
|
||||
const originalScale = { x: 1, y: 1, z: 1 };
|
||||
satellitePoints.onBeforeRender = () => {
|
||||
const syncPointScale = () => {
|
||||
if (earthObj && earthObj.scale.x !== 1) {
|
||||
satellitePoints.scale.set(
|
||||
originalScale.x / earthObj.scale.x,
|
||||
originalScale.y / earthObj.scale.y,
|
||||
originalScale.z / earthObj.scale.z,
|
||||
);
|
||||
const scaleX = originalScale.x / earthObj.scale.x;
|
||||
const scaleY = originalScale.y / earthObj.scale.y;
|
||||
const scaleZ = originalScale.z / earthObj.scale.z;
|
||||
satellitePoints.scale.set(scaleX, scaleY, scaleZ);
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
|
||||
}
|
||||
} else {
|
||||
satellitePoints.scale.set(
|
||||
originalScale.x,
|
||||
originalScale.y,
|
||||
originalScale.z,
|
||||
);
|
||||
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.scale.set(
|
||||
originalScale.x,
|
||||
originalScale.y,
|
||||
originalScale.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
satelliteBackdropPoints.onBeforeRender = syncPointScale;
|
||||
satellitePoints.onBeforeRender = syncPointScale;
|
||||
|
||||
earthObj.add(satelliteBackdropPoints);
|
||||
earthObj.add(satellitePoints);
|
||||
|
||||
const trailGeometry = new THREE.BufferGeometry();
|
||||
@@ -281,7 +348,12 @@ export function createSatellites(scene, earthObj) {
|
||||
return satellitePoints;
|
||||
}
|
||||
|
||||
function getRequestedSatelliteLimit() {
|
||||
function getRequestedSatelliteLimit(limitOverride) {
|
||||
if (limitOverride === null) return null;
|
||||
if (Number.isFinite(limitOverride) && limitOverride > 0) {
|
||||
return Math.floor(limitOverride);
|
||||
}
|
||||
|
||||
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
||||
}
|
||||
|
||||
@@ -295,13 +367,50 @@ function createSatellitePositionState() {
|
||||
}
|
||||
|
||||
function ensureSatelliteCapacity(count) {
|
||||
if (!satellitePoints || !satelliteTrails) return;
|
||||
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
|
||||
|
||||
const nextCapacity = Math.max(count, 0);
|
||||
if (nextCapacity === satelliteCapacity) return;
|
||||
|
||||
const previousPointPositions =
|
||||
satellitePoints.geometry.attributes.position?.array || null;
|
||||
const previousBackdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position?.array || null;
|
||||
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
|
||||
const previousTrailPositions =
|
||||
satelliteTrails.geometry.attributes.position?.array || null;
|
||||
const previousTrailColors =
|
||||
satelliteTrails.geometry.attributes.color?.array || null;
|
||||
const previousSatellitePositions = satellitePositions;
|
||||
const previousCapacity = satelliteCapacity;
|
||||
|
||||
const positions = new Float32Array(nextCapacity * 3);
|
||||
const backdropPositions = new Float32Array(nextCapacity * 3);
|
||||
const colors = new Float32Array(nextCapacity * 3);
|
||||
if (previousPointPositions) {
|
||||
positions.set(
|
||||
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
|
||||
);
|
||||
}
|
||||
if (previousBackdropPositions) {
|
||||
backdropPositions.set(
|
||||
previousBackdropPositions.subarray(
|
||||
0,
|
||||
Math.min(previousBackdropPositions.length, backdropPositions.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (previousColors) {
|
||||
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(backdropPositions, 3),
|
||||
);
|
||||
satelliteBackdropPoints.geometry.setDrawRange(
|
||||
0,
|
||||
Math.min(previousCapacity, nextCapacity),
|
||||
);
|
||||
satellitePoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions, 3),
|
||||
@@ -310,10 +419,26 @@ function ensureSatelliteCapacity(count) {
|
||||
"color",
|
||||
new THREE.BufferAttribute(colors, 3),
|
||||
);
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
|
||||
|
||||
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
if (previousTrailPositions) {
|
||||
trailPositions.set(
|
||||
previousTrailPositions.subarray(
|
||||
0,
|
||||
Math.min(previousTrailPositions.length, trailPositions.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (previousTrailColors) {
|
||||
trailColors.set(
|
||||
previousTrailColors.subarray(
|
||||
0,
|
||||
Math.min(previousTrailColors.length, trailColors.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(trailPositions, 3),
|
||||
@@ -323,10 +448,19 @@ function ensureSatelliteCapacity(count) {
|
||||
new THREE.BufferAttribute(trailColors, 3),
|
||||
);
|
||||
|
||||
satellitePositions = Array.from(
|
||||
{ length: nextCapacity },
|
||||
createSatellitePositionState,
|
||||
);
|
||||
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
||||
const previousState = previousSatellitePositions[index];
|
||||
if (!previousState) {
|
||||
return createSatellitePositionState();
|
||||
}
|
||||
|
||||
return {
|
||||
current: previousState.current.clone(),
|
||||
trail: previousState.trail.slice(),
|
||||
trailIndex: previousState.trailIndex,
|
||||
trailCount: previousState.trailCount,
|
||||
};
|
||||
});
|
||||
satelliteCapacity = nextCapacity;
|
||||
}
|
||||
|
||||
@@ -337,7 +471,7 @@ function computeSatellitePosition(satellite, time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, time);
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
@@ -382,6 +516,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
|
||||
return twoline2satrec(tleLines.line1, tleLines.line2);
|
||||
}
|
||||
|
||||
function getSatelliteSatrecCacheKey(props) {
|
||||
if (!props?.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.tle_line1 && props.tle_line2) {
|
||||
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
|
||||
}
|
||||
|
||||
if (props.epoch) {
|
||||
return [
|
||||
"elements",
|
||||
props.norad_cat_id,
|
||||
props.epoch,
|
||||
props.inclination,
|
||||
props.raan,
|
||||
props.eccentricity,
|
||||
props.arg_of_perigee,
|
||||
props.mean_anomaly,
|
||||
props.mean_motion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrBuildSatrec(props, fallbackTime) {
|
||||
const cacheKey = getSatelliteSatrecCacheKey(props);
|
||||
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
|
||||
return satelliteSatrecCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, fallbackTime);
|
||||
if (cacheKey && satrec && !satrec.error) {
|
||||
satelliteSatrecCache.set(cacheKey, satrec);
|
||||
}
|
||||
return satrec;
|
||||
}
|
||||
|
||||
function computeTleChecksum(line) {
|
||||
let sum = 0;
|
||||
|
||||
@@ -491,8 +664,8 @@ function generateFallbackPosition(satellite, index, total) {
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
export async function loadSatellites() {
|
||||
const limit = getRequestedSatelliteLimit();
|
||||
export async function loadSatellites(options = {}) {
|
||||
const limit = getRequestedSatelliteLimit(options.limit);
|
||||
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
|
||||
if (limit !== null) {
|
||||
url.searchParams.set("limit", String(limit));
|
||||
@@ -505,13 +678,17 @@ export async function loadSatellites() {
|
||||
|
||||
const data = await response.json();
|
||||
satelliteData = data.features || [];
|
||||
satelliteSatrecCache = new Map();
|
||||
ensureSatelliteCapacity(satelliteData.length);
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
return satelliteData.length;
|
||||
return {
|
||||
count: satelliteData.length,
|
||||
requestedLimit: limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
if (!satellitePoints || satelliteData.length === 0) return;
|
||||
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
|
||||
|
||||
const shouldUpdateTrails =
|
||||
showSatellites || showTrails || lockedSatelliteIndex !== null;
|
||||
@@ -528,6 +705,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positionUpdateAccumulator = 0;
|
||||
|
||||
const positions = satellitePoints.geometry.attributes.position.array;
|
||||
const backdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position.array;
|
||||
const colors = satellitePoints.geometry.attributes.color.array;
|
||||
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
||||
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
||||
@@ -559,13 +738,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3] = pos.x;
|
||||
positions[i * 3 + 1] = pos.y;
|
||||
positions[i * 3 + 2] = pos.z;
|
||||
backdropPositions[i * 3] = pos.x;
|
||||
backdropPositions[i * 3 + 1] = pos.y;
|
||||
backdropPositions[i * 3 + 2] = pos.z;
|
||||
|
||||
const rule = getSatelliteLegendRule(props);
|
||||
const { r, g, b } = getSatelliteRuleColor(rule);
|
||||
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
colors[i * 3 + 2] = b;
|
||||
if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) {
|
||||
const lum = r * 0.299 + g * 0.587 + b * 0.114;
|
||||
colors[i * 3] = lum * 0.75 + r * 0.25;
|
||||
colors[i * 3 + 1] = lum * 0.75 + g * 0.25;
|
||||
colors[i * 3 + 2] = lum * 0.75 + b * 0.25;
|
||||
} else {
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
colors[i * 3 + 2] = b;
|
||||
}
|
||||
|
||||
const satPosition = satellitePositions[i];
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
@@ -601,6 +790,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3] = 0;
|
||||
positions[i * 3 + 1] = 0;
|
||||
positions[i * 3 + 2] = 0;
|
||||
backdropPositions[i * 3] = 0;
|
||||
backdropPositions[i * 3 + 1] = 0;
|
||||
backdropPositions[i * 3 + 2] = 0;
|
||||
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||
@@ -613,6 +805,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
||||
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
||||
satellitePoints.geometry.setDrawRange(0, count);
|
||||
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, count);
|
||||
|
||||
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
||||
@@ -631,6 +825,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
|
||||
export function toggleSatellites(visible) {
|
||||
showSatellites = visible;
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.visible = visible;
|
||||
}
|
||||
if (satellitePoints) {
|
||||
satellitePoints.visible = visible;
|
||||
}
|
||||
@@ -821,10 +1018,15 @@ export function hideLockedRing() {
|
||||
|
||||
export function updateLockedRingPosition(position) {
|
||||
if (!position) return;
|
||||
if (!lockedRingSprite || !lockedDotSprite) {
|
||||
showHoverRing(position, true);
|
||||
}
|
||||
if (lockedRingSprite) {
|
||||
lockedRingSprite.position.copy(position);
|
||||
const ringPulse = getBreathingPulse(breathingPhase);
|
||||
const breathScale =
|
||||
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
1 +
|
||||
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
lockedRingSprite.scale.set(
|
||||
SATELLITE_CONFIG.ringSize * breathScale,
|
||||
SATELLITE_CONFIG.ringSize * breathScale,
|
||||
@@ -832,20 +1034,21 @@ export function updateLockedRingPosition(position) {
|
||||
);
|
||||
lockedRingSprite.material.opacity =
|
||||
SATELLITE_CONFIG.breathingOpacityMin +
|
||||
Math.sin(breathingPhase) *
|
||||
ringPulse *
|
||||
(SATELLITE_CONFIG.breathingOpacityMax -
|
||||
SATELLITE_CONFIG.breathingOpacityMin);
|
||||
}
|
||||
|
||||
if (lockedDotSprite) {
|
||||
lockedDotSprite.position.copy(position);
|
||||
const dotPulse = getBreathingPulse(breathingPhase);
|
||||
const dotBreathScale =
|
||||
1 +
|
||||
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||
(dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
|
||||
lockedDotSprite.material.opacity =
|
||||
SATELLITE_CONFIG.dotOpacityMin +
|
||||
Math.sin(breathingPhase) *
|
||||
dotPulse *
|
||||
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
|
||||
}
|
||||
}
|
||||
@@ -881,6 +1084,15 @@ export function setSatelliteRingState(index, state, position) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyDimMaterialState(isDimmed) {
|
||||
if (satellitePoints) {
|
||||
satellitePoints.material.opacity = isDimmed ? 0.32 : 0.9;
|
||||
}
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.material.opacity = isDimmed ? 0.12 : 0.42;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRelatedSatelliteHighlights() {
|
||||
relatedSatelliteSprites.forEach((item) => {
|
||||
if (item.sprite) {
|
||||
@@ -888,12 +1100,16 @@ export function clearRelatedSatelliteHighlights() {
|
||||
}
|
||||
});
|
||||
relatedSatelliteSprites = [];
|
||||
highlightedSatelliteIndices = null;
|
||||
applyDimMaterialState(false);
|
||||
}
|
||||
|
||||
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
|
||||
clearRelatedSatelliteHighlights();
|
||||
if (!Array.isArray(indices) || indices.length === 0) return;
|
||||
|
||||
highlightedSatelliteIndices = new Set(indices);
|
||||
applyDimMaterialState(true);
|
||||
indices.forEach((index) => {
|
||||
const pos = satellitePositions?.[index]?.current;
|
||||
if (!pos) return;
|
||||
@@ -1049,10 +1265,12 @@ export function hidePredictedOrbit() {
|
||||
|
||||
export function clearSatelliteData() {
|
||||
satelliteData = [];
|
||||
satelliteSatrecCache = new Map();
|
||||
selectedSatellite = null;
|
||||
lockedSatelliteIndex = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
positionUpdateAccumulator = 0;
|
||||
breathingPhase = 0;
|
||||
|
||||
satellitePositions.forEach((position) => {
|
||||
position.current.set(0, 0, 0);
|
||||
@@ -1075,6 +1293,16 @@ export function clearSatelliteData() {
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
const backdropPositionAttr =
|
||||
satelliteBackdropPoints.geometry.attributes.position;
|
||||
if (backdropPositionAttr?.array) {
|
||||
backdropPositionAttr.array.fill(0);
|
||||
backdropPositionAttr.needsUpdate = true;
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteTrails) {
|
||||
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
|
||||
const trailColorAttr = satelliteTrails.geometry.attributes.color;
|
||||
@@ -1097,6 +1325,11 @@ export function clearSatelliteData() {
|
||||
export function resetSatelliteState() {
|
||||
clearSatelliteData();
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
disposeObject3D(satelliteBackdropPoints);
|
||||
satelliteBackdropPoints = null;
|
||||
}
|
||||
|
||||
if (satellitePoints) {
|
||||
disposeObject3D(satellitePoints);
|
||||
satellitePoints = null;
|
||||
@@ -1109,6 +1342,7 @@ export function resetSatelliteState() {
|
||||
|
||||
satellitePositions = [];
|
||||
satelliteCapacity = 0;
|
||||
satelliteSatrecCache = new Map();
|
||||
showSatellites = false;
|
||||
showTrails = true;
|
||||
}
|
||||
|
||||
304
frontend/public/earth/js/terrain.js
Normal file
304
frontend/public/earth/js/terrain.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, TERRAIN_CONFIG } from "./constants.js";
|
||||
import { vector3ToLatLon } from "./utils.js";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371000;
|
||||
const TERRAIN_COLOR_STOPS = [
|
||||
{ height: 0, color: new THREE.Color(0x5f7f5b) },
|
||||
{ height: 800, color: new THREE.Color(0x7f9564) },
|
||||
{ height: 1800, color: new THREE.Color(0x9e956c) },
|
||||
{ height: 3200, color: new THREE.Color(0x9f866a) },
|
||||
{ height: 5200, color: new THREE.Color(0xc6c0b1) },
|
||||
{ height: 7800, color: new THREE.Color(0xe8e5de) },
|
||||
];
|
||||
|
||||
let terrainMesh = null;
|
||||
let terrainLoadPromise = null;
|
||||
let terrainReady = false;
|
||||
let terrainFailed = false;
|
||||
let terrainTileCache = new Map();
|
||||
let terrainVertexSamples = null;
|
||||
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
|
||||
function clampLatitude(lat) {
|
||||
return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878);
|
||||
}
|
||||
|
||||
function buildTileUrl(z, x, y) {
|
||||
return TERRAIN_CONFIG.urlTemplate
|
||||
.replace("{z}", String(z))
|
||||
.replace("{x}", String(x))
|
||||
.replace("{y}", String(y));
|
||||
}
|
||||
|
||||
function getTerrainCanvas(size) {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(size, size);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function decodeTerrainTile(z, x, y) {
|
||||
const cacheKey = `${z}/${x}/${y}`;
|
||||
if (terrainTileCache.has(cacheKey)) {
|
||||
return terrainTileCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const tilePromise = (async () => {
|
||||
const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||
bitmap.close?.();
|
||||
const { data, width, height } = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
return { data, width, height };
|
||||
})();
|
||||
|
||||
terrainTileCache.set(cacheKey, tilePromise);
|
||||
return tilePromise;
|
||||
}
|
||||
|
||||
function decodeTerrariumHeight(tile, pixelX, pixelY) {
|
||||
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
|
||||
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
|
||||
const index = (safeY * tile.width + safeX) * 4;
|
||||
const r = tile.data[index];
|
||||
const g = tile.data[index + 1];
|
||||
const b = tile.data[index + 2];
|
||||
return (r * 256 + g + b / 256) - 32768;
|
||||
}
|
||||
|
||||
function latLonToTileSample(lat, lon, z, tileSize) {
|
||||
const n = 2 ** z;
|
||||
const clampedLat = clampLatitude(lat);
|
||||
const latRad = THREE.MathUtils.degToRad(clampedLat);
|
||||
const normalizedX = ((lon + 180) / 360) * n;
|
||||
const normalizedY =
|
||||
((1 -
|
||||
Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
|
||||
2) *
|
||||
n;
|
||||
|
||||
const tileX = THREE.MathUtils.euclideanModulo(
|
||||
Math.floor(normalizedX),
|
||||
n,
|
||||
);
|
||||
const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1);
|
||||
const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize);
|
||||
const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize);
|
||||
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
pixelX,
|
||||
pixelY,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerrainVertexSamples(positionAttribute) {
|
||||
const z = TERRAIN_CONFIG.baseZoom;
|
||||
const tileSize = TERRAIN_CONFIG.tileSize;
|
||||
const samples = [];
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const direction = new THREE.Vector3(
|
||||
positionAttribute.getX(i),
|
||||
positionAttribute.getY(i),
|
||||
positionAttribute.getZ(i),
|
||||
).normalize();
|
||||
const { lat, lon } = vector3ToLatLon(direction);
|
||||
const sample = latLonToTileSample(lat, lon, z, tileSize);
|
||||
samples.push({
|
||||
index: i,
|
||||
direction,
|
||||
...sample,
|
||||
});
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function sampleTerrainColor(heightMeters) {
|
||||
if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) {
|
||||
return TERRAIN_COLOR_STOPS[0].color;
|
||||
}
|
||||
|
||||
for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) {
|
||||
const lower = TERRAIN_COLOR_STOPS[i - 1];
|
||||
const upper = TERRAIN_COLOR_STOPS[i];
|
||||
if (heightMeters <= upper.height) {
|
||||
const t =
|
||||
(heightMeters - lower.height) / Math.max(upper.height - lower.height, 1);
|
||||
return lower.color.clone().lerp(upper.color, t);
|
||||
}
|
||||
}
|
||||
|
||||
return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color;
|
||||
}
|
||||
|
||||
async function runWithConcurrency(items, limit, worker) {
|
||||
const queue = [...items];
|
||||
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function fetchRequiredTiles(samples) {
|
||||
const uniqueKeys = Array.from(
|
||||
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
|
||||
);
|
||||
const resolvedTiles = new Map();
|
||||
|
||||
await runWithConcurrency(
|
||||
uniqueKeys,
|
||||
TERRAIN_CONFIG.maxConcurrentRequests,
|
||||
async (key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||
},
|
||||
);
|
||||
|
||||
return resolvedTiles;
|
||||
}
|
||||
|
||||
function applyTerrainDisplacement(samples, mesh, resolvedTiles) {
|
||||
const geometry = mesh.geometry;
|
||||
const positionAttribute = geometry.getAttribute("position");
|
||||
let colorAttribute = geometry.getAttribute("color");
|
||||
if (!colorAttribute || colorAttribute.itemSize !== 4) {
|
||||
colorAttribute = new THREE.BufferAttribute(
|
||||
new Float32Array(positionAttribute.count * 4),
|
||||
4,
|
||||
);
|
||||
geometry.setAttribute("color", colorAttribute);
|
||||
}
|
||||
const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset;
|
||||
|
||||
samples.forEach((sample) => {
|
||||
const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`;
|
||||
const tile = resolvedTiles.get(tileKey);
|
||||
if (!tile) return;
|
||||
const rawElevationMeters = decodeTerrariumHeight(
|
||||
tile,
|
||||
sample.pixelX,
|
||||
sample.pixelY,
|
||||
);
|
||||
const elevationMeters = Math.max(0, rawElevationMeters);
|
||||
const heightWorld =
|
||||
(elevationMeters / EARTH_RADIUS_METERS) *
|
||||
CONFIG.earthRadius *
|
||||
TERRAIN_CONFIG.exaggeration;
|
||||
const radius = baseRadius + heightWorld;
|
||||
const tint = sampleTerrainColor(elevationMeters);
|
||||
const landAlpha = THREE.MathUtils.clamp(
|
||||
elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
positionAttribute.setXYZ(
|
||||
sample.index,
|
||||
sample.direction.x * radius,
|
||||
sample.direction.y * radius,
|
||||
sample.direction.z * radius,
|
||||
);
|
||||
colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha);
|
||||
});
|
||||
|
||||
positionAttribute.needsUpdate = true;
|
||||
colorAttribute.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
export function registerTerrainMesh(mesh) {
|
||||
terrainMesh = mesh;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainLoadPromise = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
terrainVertexSamples = mesh
|
||||
? buildTerrainVertexSamples(mesh.geometry.getAttribute("position"))
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isTerrainReady() {
|
||||
return terrainReady;
|
||||
}
|
||||
|
||||
export async function ensureTerrainReady() {
|
||||
if (!terrainMesh || !TERRAIN_CONFIG.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (terrainReady) {
|
||||
return true;
|
||||
}
|
||||
if (terrainLoadPromise) {
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
terrainLoadPromise = (async () => {
|
||||
try {
|
||||
const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples);
|
||||
applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles);
|
||||
terrainReady = true;
|
||||
terrainFailed = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
terrainFailed = true;
|
||||
console.error("加载真实地形失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
terrainLoadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
export function clearTerrainData() {
|
||||
terrainMesh = null;
|
||||
terrainLoadPromise = null;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainVertexSamples = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
}
|
||||
|
||||
export function setTerrainOpacity(nextOpacity) {
|
||||
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
return terrainOpacity;
|
||||
}
|
||||
|
||||
export function getTerrainOpacity() {
|
||||
return terrainOpacity;
|
||||
}
|
||||
@@ -9,6 +9,11 @@ let statusQueue = [];
|
||||
let statusBusy = false;
|
||||
let loadingActive = false;
|
||||
let loadingLockedWidth = 0;
|
||||
let pendingLoadingMessage = "";
|
||||
|
||||
function createStatusEntry(message, type = "info") {
|
||||
return { message, type };
|
||||
}
|
||||
|
||||
function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
@@ -67,6 +72,10 @@ function buildStatusContent(statusEl, message, type) {
|
||||
statusEl.appendChild(text);
|
||||
}
|
||||
|
||||
function buildPersistentErrorContent(errorEl, message) {
|
||||
buildStatusContent(errorEl, message, "error");
|
||||
}
|
||||
|
||||
function hideStatusElement(statusEl, onHidden) {
|
||||
statusEl.classList.remove("visible");
|
||||
statusHideTimeoutId = setTimeout(() => {
|
||||
@@ -113,7 +122,15 @@ function startTransientStatus(message, type = "info") {
|
||||
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
statusQueue.push({ message, type });
|
||||
if (loadingActive) {
|
||||
statusQueue.unshift(createStatusEntry(message, type));
|
||||
return;
|
||||
}
|
||||
startTransientStatus(message, type);
|
||||
}
|
||||
|
||||
export function queueStatusMessage(message, type = "info") {
|
||||
statusQueue.push(createStatusEntry(message, type));
|
||||
processStatusQueue();
|
||||
}
|
||||
|
||||
@@ -179,7 +196,12 @@ export function setLoading(loading) {
|
||||
loadingActive = true;
|
||||
statusBusy = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
buildStatusContent(statusEl, "正在加载...", "loading");
|
||||
buildStatusContent(
|
||||
statusEl,
|
||||
pendingLoadingMessage || "正在加载...",
|
||||
"loading",
|
||||
);
|
||||
pendingLoadingMessage = "";
|
||||
statusEl.className = `${STATUS_BASE_CLASS} loading`;
|
||||
setElementDisplay(statusEl, true, "inline-flex");
|
||||
statusEl.offsetHeight;
|
||||
@@ -188,6 +210,7 @@ export function setLoading(loading) {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
});
|
||||
} else {
|
||||
pendingLoadingMessage = "";
|
||||
if (!statusEl.classList.contains("loading")) {
|
||||
loadingActive = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
@@ -205,7 +228,10 @@ export function setLoading(loading) {
|
||||
|
||||
export function setLoadingMessage(title) {
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) return;
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) {
|
||||
pendingLoadingMessage = title;
|
||||
return;
|
||||
}
|
||||
const textEl = statusEl.querySelector(".earth-status-text");
|
||||
if (textEl) {
|
||||
textEl.textContent = title;
|
||||
@@ -237,16 +263,21 @@ export function hideTooltip() {
|
||||
export function showError(message) {
|
||||
const errorEl = getElement("error-message");
|
||||
if (!errorEl) return;
|
||||
errorEl.textContent = message;
|
||||
setElementDisplay(errorEl, true);
|
||||
buildPersistentErrorContent(errorEl, message);
|
||||
errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
|
||||
setElementDisplay(errorEl, true, "inline-flex");
|
||||
errorEl.offsetHeight;
|
||||
errorEl.classList.add("visible");
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
export function hideError() {
|
||||
const errorEl = getElement("error-message");
|
||||
if (errorEl) {
|
||||
errorEl.classList.remove("visible");
|
||||
setElementDisplay(errorEl, false);
|
||||
errorEl.textContent = "";
|
||||
errorEl.className = "earth-error-message";
|
||||
errorEl.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +286,7 @@ export function clearUiState() {
|
||||
statusQueue = [];
|
||||
statusBusy = false;
|
||||
loadingActive = false;
|
||||
pendingLoadingMessage = "";
|
||||
|
||||
const statusEl = getElement("status-message");
|
||||
if (statusEl) {
|
||||
|
||||
18
planet.sh
18
planet.sh
@@ -1054,6 +1054,14 @@ start_ai_provider_service() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
ai_provider_service_healthy() {
|
||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
|
||||
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
|
||||
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
|
||||
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_database_services_healthy() {
|
||||
local retry=1
|
||||
|
||||
@@ -1130,7 +1138,15 @@ start_backend_service() {
|
||||
log_success "启动数据库已就绪"
|
||||
sleep 3
|
||||
|
||||
start_ai_provider_service "$ai_provider_port"
|
||||
# Backend depends on AI Provider reachability, but a backend-only restart
|
||||
# should reuse the existing healthy provider instead of rebuilding or
|
||||
# restarting it.
|
||||
if ai_provider_service_healthy "$ai_provider_port"; then
|
||||
log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
|
||||
else
|
||||
log_note "AI Provider 当前不健康,先执行托底启动"
|
||||
start_ai_provider_service "$ai_provider_port"
|
||||
fi
|
||||
|
||||
if [ "$backend_port_requested" -eq 1 ]; then
|
||||
kill_port_if_requested "$backend_port" "后端"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.29.2"
|
||||
version = "0.31.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user