Compare commits

..

5 Commits

Author SHA1 Message Date
rayd1o
437efc848c release: bump version to 0.31.3 2026-04-22 03:52:09 +08:00
rayd1o
003a46ac30 release: bump version to 0.31.2 2026-04-21 23:50:35 +08:00
rayd1o
4b0be4cb76 release: bump version to 0.31.1 2026-04-21 22:49:39 +08:00
linkong
b7647379de release: bump version to 0.31.0 2026-04-21 18:35:40 +08:00
linkong
0f89372d71 release: bump version to 0.30.0 2026-04-21 12:28:04 +08:00
66 changed files with 5731 additions and 590 deletions

View File

@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
详细文档: 详细文档:
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md) - [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) - [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) - [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) - [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
## 前端页面布局规范 ## 前端页面布局规范
@@ -346,7 +346,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
当前推荐参考实现: 当前推荐参考实现:
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) - [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) - [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
## License ## License

View File

@@ -20,3 +20,5 @@
- [x] 在 activity layer 之后继续补 `route leak``path instability / flap` detector - [x] 在 activity layer 之后继续补 `route leak``path instability / flap` detector
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation降低后续维护复杂度 - [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation降低后续维护复杂度
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性 - [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略

View File

@@ -1 +1 @@
0.29.2 0.31.3

View File

@@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
from datetime import UTC, datetime from datetime import UTC, datetime
import math 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.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from typing import List, Dict, Any, Optional 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 from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
router = APIRouter() router = APIRouter()
TERRAIN_TILE_URL_TEMPLATE = (
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
)
# ============== Converter Functions ============== # ============== Converter Functions ==============
@@ -782,9 +786,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/landing-points") @router.get("/geo/landing-points")
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
try: try:
records = await _load_current_collected_data(db, "arcgis_landing_points") records_by_source = await _load_current_collected_data_by_sources(
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation") db,
cable_records = await _load_current_collected_data(db, "arcgis_cables") [
"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( city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records, 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)}") 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") @router.get("/geo/all")
async def get_all_geojson(db: AsyncSession = Depends(get_db)): async def get_all_geojson(db: AsyncSession = Depends(get_db)):
records_by_source = await _load_current_collected_data_by_sources( records_by_source = await _load_current_collected_data_by_sources(

View File

@@ -8,8 +8,93 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.31.3] — 2026-04-22
### ✨ Highlights
- Earth 图层注册表和启动任务框架继续收口,启动顺序、启动模式、启动提示和任务注册现在都能从统一入口扩展
- 修复 Earth 普通旋转模式与巡航模式切换时的一组交互回归,同时让卫星/地形/昼夜模式的表现更稳定
### 🔧 Improvements
- 新增 [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) 启动任务注册表,支持 `registerLayerStartupTask(id, taskFactory)`,并拆成海缆 / 卫星 / BGP 独立注册函数
- Earth 图层控制改成注册表驱动,统一承载 `startupPriority``startupMode``startupLabel``startupMessage` 与图层持久化元信息
- Earth 设置支持持久化图层开关、旋转模式、HUD 面板显示状态、地形透明度与日夜模式,并提供一键重置
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 记录图层注册表、启动任务、设置持久化与巡航适配边界
### 🐛 Fixes
- 修复普通旋转模式下点击海缆 / 卫星 / BGP 后卡片和选中表现会被异常清空的问题
- 修复巡航模式切回旋转再切回巡航后无法继续自动巡航的问题
- 修复开启地形后卫星选中反馈层被高海拔区域吞掉的问题,并恢复轨道只在地球前半侧可见
- 修复关闭日夜模式后地球照明仍沿真实昼夜切换、亮部过曝和偏色的问题,改成更中性的 inspection lighting
- 修复 toolbar 收起态仍挡住地球交互,以及首帧短暂展开闪现的问题
---
## [0.31.0] — 2026-04-21
## [0.31.2] — 2026-04-21
### ✨ Highlights
- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机
- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场
### 🔧 Improvements
- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复
- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画
- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界
### 🐛 Fixes
- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题
- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题
- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点
---
## [0.31.1] — 2026-04-21
### ✨ Highlights
- Earth 图层开关状态统一成可复用的 `active / loading` 状态机,首次启用地形和卫星时不再像按钮失效
- 文档目录重构为 `docs/technical``docs/plans``docs/deprecated`,并吸收 `.sisyphus/plans` 中有价值的 Earth / 卫星 / UE5 草案
### 🔧 Improvements
- 新增 [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js),统一按钮 tooltip、`aria-busy`、禁用态和状态文本同步
- 地形图层支持 hover/focus 预热与空闲预热,首次点击等待前移,加载中状态持续可见
- 卫星图层启用前会立即切换为 `loading` 中间态,请求完成后再切回正常开关表现
### 🐛 Fixes
- 修复地形首次加载时通知过早消失、开关仍像关闭状态导致用户误判按钮损坏的问题
- 修复卫星接口较慢时按钮没有任何中间态反馈的问题
---
### ✨ 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.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 ## [0.29.2] — 2026-04-21
### ✨ Highlights ### ✨ Highlights
@@ -282,7 +367,7 @@ Released: 2026-04-12
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion. - Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling. - Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion. - Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/technical/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/technical/earth-news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
### Improved ### Improved
@@ -368,7 +453,7 @@ Released: 2026-04-10
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling. - Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page. - Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation. - Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
### Fixed ### Fixed
@@ -406,7 +491,7 @@ Released: 2026-04-10
### Improved ### Improved
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks. - Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.” - Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
## 0.24.6 ## 0.24.6
@@ -423,7 +508,7 @@ Released: 2026-04-10
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database. - Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads. - Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully. - Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
- Improved [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up. - Improved [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
### Fixed ### Fixed
@@ -529,8 +614,8 @@ Released: 2026-04-09
### Added ### Added
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance. - Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
- Added [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling. - Added [docs/frontend/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion. - Added [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
### Improved ### Improved
@@ -635,7 +720,7 @@ Released: 2026-04-07
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry. - Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service. - Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates. - Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns. - Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py). - Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
### Improved ### Improved
@@ -761,7 +846,7 @@ Released: 2026-04-02
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py). - Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels. - Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/earth/prefix-geography-plan.md). - Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-prefix-geography-plan.md).
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py). - Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py). - Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures. - Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
@@ -776,7 +861,7 @@ Released: 2026-04-02
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently. - Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime. - Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging. - Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work. - Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
### Fixed ### Fixed
@@ -934,7 +1019,7 @@ Released: 2026-03-31
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py). - Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py). - Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts. - Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md). - Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md).
### Improved ### Improved

View File

@@ -15,3 +15,8 @@
- 明确写明“已完成”的计划,优先归档 - 明确写明“已完成”的计划,优先归档
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档 - 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/` - 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
补充说明:
- 一部分归档文档来自外部或临时工作流草案,例如 sisyphus 生成的初稿
- 这类文档如果有可用内容,应先吸收到 `docs/plans/``docs/technical/`,再归档保留来源记录

View File

@@ -1,3 +1,5 @@
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
# 地球3D可视化架构重构计划 # 地球3D可视化架构重构计划
## 背景 ## 背景

View File

@@ -1,3 +1,5 @@
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
# 卫星预测轨道显示功能 # 卫星预测轨道显示功能
## TL;DR ## TL;DR

View File

@@ -1,3 +1,5 @@
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
# UE5 3D 大屏客户端开发计划 # UE5 3D 大屏客户端开发计划
## 项目概述 ## 项目概述

View File

@@ -1,3 +1,5 @@
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
# WebGL Instancing 卫星渲染优化计划 # WebGL Instancing 卫星渲染优化计划
## 背景 ## 背景

34
docs/plans/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Plans Docs
这里放“未来实施方案和未完成计划”的文档,重点回答:
- 我们准备做什么
- 为什么要做
- 分几期做
- 当前差距和下一步是什么
适合放入这里的内容:
- Earth / BGP / 地形 / 天球实施方案
- AI Playground 发展计划
- backend / datasource / agent roadmap
- UE5 MVP 方案
当前重点入口:
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
不适合放入这里的内容:
- 当前代码结构说明
- 组件现状和实现入口
- 已经落地的技术上下文说明
这些应放入:
- [docs/technical/README.md](/home/ray/dev/linkong/planet/docs/technical/README.md)

View File

@@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r
Related documents: Related documents:
- [aiprovider](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md) - [aiprovider](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md) - [datasource-health-plan](/home/ray/dev/linkong/planet/docs/plans/agents-datasource-health-plan.md)
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/agent-architecture-plan.md) - [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/plans/agents-agent-architecture-plan.md)
## Big Picture ## Big Picture

View File

@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
## Why This Layer Exists ## Why This Layer Exists
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md): Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md):
- incident density is naturally low - incident density is naturally low
- anomaly density is higher, but still not enough to keep the globe expressive all the time - anomaly density is higher, but still not enough to keep the globe expressive all the time
@@ -290,7 +290,7 @@ Each feature should include:
## Earth Rendering Plan ## Earth Rendering Plan
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md). Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-earth-rendering-plan.md).
### Layer Relationship ### Layer Relationship

View File

@@ -0,0 +1,98 @@
# Earth Predicted Orbit Plan
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/predicted-orbit.md`.
## Goal
在 Earth 中锁定卫星时,显示“预测轨道”而不是只有历史尾迹:
- 从当前时刻开始
- 绕地球一圈
- 当前点最亮
- 向后沿轨道逐步衰减
## Current State
当前已经有:
- 卫星历史轨迹
- 锁定卫星
- 轨道高亮与相关联动
但“预测轨道”仍然不是一套稳定、可验证的单独功能计划。
## Why It Is Valuable
预测轨道可以明显提升:
- 锁定卫星后的空间可读性
- 轨道类型辨识
- 演示解释力
相比短历史尾迹,预测轨道更符合用户对“这颗卫星接下来会怎么走”的预期。
## Scope
### Phase 1
- 锁定卫星时显示一整圈预测轨道
- 解锁时隐藏
- 不替代现有普通轨迹系统
### Phase 2
- 根据轨道类型调整采样率
- GEO / MEO / LEO 不同密度
- 进一步减少 fallback 轨迹的比例
## Implementation Direction
### 1. Orbit period
基于 `meanMotion` 估算轨道周期。
### 2. Predicted samples
以固定采样步长从 `now -> now + period` 推算轨迹点。
### 3. Render object lifecycle
预测轨道应是一个独立渲染对象:
- show
- update
- hide
- dispose
### 4. Visual semantics
预测轨道不应与普通尾迹混淆:
- 更稳定
- 更完整
- 透明度沿轨道衰减
- 当前点附近更亮
## Known Risks
### 1. TLE propagation gaps
部分卫星可能出现 SGP4 计算不足,需要 fallback。
### 2. Multiple orbit lines
必须确保:
- 锁定切换前先清旧轨道
- 页面隐藏/销毁时清理
### 3. Performance
GEO 轨道点数高,采样率需要按轨道类型分层。
## Acceptance
1. 锁定单颗卫星时只显示一条预测轨道
2. 解锁后轨道立即清除
3. 不同轨道类型下点数可控
4. 页面切换回来不会闪出旧轨道残留

View 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 架构的一条路。

View File

@@ -0,0 +1,111 @@
# Earth Renderer / Logic Separation Plan
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/earth-architecture-refactor.md`.
## Goal
将 Earth 前端继续往“逻辑层 / 状态层 / 渲染层”分离推进,降低后续这几类工作的耦合成本:
- Three.js 渲染重构
- 部分图层替换实现
- 未来 UE / Cesium 客户端迁移
- Earth 行为逻辑复用
## Why This Matters
当前 Earth 已经有一些良好分层,例如:
- 图层显隐入口
- Cable state 枚举与状态 map
- 交互逻辑与实际视觉效果的部分分离
但还没有形成一套更明确的统一规则。现在的风险是:
- 同一类对象的 hover / locked / hidden / loading 语义不一致
- 状态和渲染更新散落在多个模块
- 后续再加新图层时容易复制旧逻辑
## Target Architecture
Earth 对每类对象都尽量拆成三层:
1. `state layer`
- 保存对象状态
- 例如:`normal / hovered / locked / hidden / loading`
2. `logic layer`
- 处理点击、悬停、锁定、过滤、显隐切换
- 不直接关心 Three.js 具体材质怎么改
3. `renderer layer`
- 根据状态更新 Three.js / HUD 外观
- 是最容易针对不同渲染引擎替换的一层
## Current Good Signals
当前已经接近这条方向的地方:
- cable 状态管理
- 部分 landing point 状态同步
- layer button 的统一状态入口
- tooltip / legend / info-card 开始朝状态驱动靠拢
## Next Steps
### 1. Standardize object state enums
优先为这些对象建立更稳定的状态语义:
- cables
- satellites
- landing points
- BGP markers
- media / news 面板入口按钮
### 2. Unify state-to-visual adapters
为各模块建立更清晰的渲染适配函数,例如:
- `applyCableVisualState()`
- `applySatelliteVisualState()`
- `applyBGPVisualState()`
要求:
- 逻辑层只改状态
- 视觉层负责把状态映射到材质、透明度、发光、尺寸、文字
### 3. Separate Earth UI state from render state
HUD / 面板 / 图层按钮状态也需要和渲染状态分离:
- `loading`
- `active`
- `locked`
- `hidden`
- `error`
不要再让 UI 通过“猜渲染结果”推导业务状态。
### 4. Prepare migration-safe boundaries
后续如果做 UE / Cesium 客户端,尽量保留:
- 状态枚举
- 交互规则
- 数据层接口
只替换:
- Three.js 具体渲染实现
- HUD 展示实现
## Practical Rule
后续 Earth 新功能开发时,优先问三个问题:
1. 这个状态由谁持有?
2. 这个交互逻辑在哪一层处理?
3. 这个视觉变化是否能在不改逻辑的情况下单独替换?
如果答不上来,就说明还在把状态、逻辑、渲染揉在一起。

View File

@@ -0,0 +1,82 @@
# Earth WebGL Instancing Satellites Plan
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/webgl-instancing-satellites.md`.
## Goal
把 Earth 卫星渲染从当前方案继续推进到更适合高数量卫星的 instancing 方向,目标是:
- 支持更多卫星
- 降低渲染压力
- 仍然保留当前数据层和交互层
## Why It Matters
当前卫星系统已经具备:
- 数据加载
- 轨迹
- 选择/锁定
- 图例
- 相关区域联动
但当卫星数量持续增加时,渲染层会越来越接近瓶颈。
## Recommended Direction
优先调研并原型验证:
- `InstancedBufferGeometry + custom shader`
而不是一开始就推倒重写成 raw WebGL。
原因:
- 仍能保留 Three.js 主架构
- 更容易渐进迁移
- 比继续堆普通点渲染更有上限
## What Should Stay
尽量保留这些层:
- 卫星数据获取
- 位置计算
- 锁定/悬停逻辑
- legend / info-card / 相关联动
主要替换的是:
- 卫星点渲染实现
- 颜色/大小等实例属性更新方式
## Phases
### Phase 1: Prototype
- 用 instancing 做最小原型
- 先只渲染卫星点
- 不碰轨迹系统
### Phase 2: Integrate
- 接入当前 `satellites.js` 数据层
- 保留当前选择和高亮语义
### Phase 3: Tune
- 调整可视大小
- 调整选中高亮方式
- 评估是否需要分层 LOD
## Risks
1. 透明度排序更复杂
2. Shader 调试成本更高
3. 选中态和 hover 态不能简单复用旧材质逻辑
## Acceptance
1. 在更高卫星数量下保持可接受帧率
2. 不破坏现有锁定/高亮语义
3. 图例、信息卡、相关卫星联动仍然成立

View File

@@ -30,7 +30,7 @@
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py) - [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py)
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py) - [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py) - [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md) - [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
### 2. 本地运行与配置打通 ### 2. 本地运行与配置打通
@@ -77,7 +77,7 @@
相关文件: 相关文件:
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) - [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) - [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
## 当前限制 ## 当前限制

View File

@@ -979,3 +979,37 @@ Content/
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是: 如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。** **在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
---
# 附录:来自 sisyphus 草案的补充
> 这部分吸收自一个 sisyphus-created draft原始草案已归档不再单独维护为主计划。
## 1. 项目骨架建议
原草案给过一个更偏“工程初始化”的目录示意,适合拿来做一期的命名参考:
- `Levels/`
- `Blueprints/`
- `Materials/`
- `Widgets/`
- `Source/PlanetAPI/`
- `Source/CesiumIntegration/`
- `Source/Visualization/`
这不是强制结构,但对 UE 初期整理目录很有帮助。
## 2. API 契约意识
原草案有一个很对的提醒:
- 一期虽然可以先走 HTTP
- 但数据模型命名不应只服务于一次性演示
- 后续 WebSocket 接入时,字段设计最好能沿用
所以当前主计划继续建议:
- 先做 HTTP 拉取
- 尽量把 UE 侧数据模型定义清楚
- 不要在蓝图各处散写临时 JSON 字段解析

26
docs/technical/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Technical Docs
这里放“当前实现和当前结构”的文档,重点回答:
- 现在代码是怎么组织的
- 当前入口在哪
- 状态和组件如何工作
- 后续改动应该沿着哪条实现边界继续走
适合放入这里的内容:
- 前端上下文
- Earth 前端结构
- 后端运行控制
- collector 现状
- 采集格式约定
不适合放入这里的内容:
- 尚未完成的 roadmap
- 未来迭代方案
- 大范围重构计划
这些应放入:
- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md)

View File

@@ -187,7 +187,7 @@ Current reality:
- that is expected, because incidents are aggregated and de-noised - that is expected, because incidents are aggregated and de-noised
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer - but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-region-aggregation-plan.md). Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md).
So the immediate next milestone is: So the immediate next milestone is:

View File

@@ -0,0 +1,380 @@
# Earth Frontend Context
本文件描述当前 Earth 大屏前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。
相关规则建议一起参考:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
## 当前目标
Earth 前端不是普通管理页,它是独立的大屏展示前端。当前产品目标是:
- 维持地球视图的空间感和可读性
- 让 HUD、图层、媒体面板、BGP、卫星、海缆等保持统一交互
- 把加载中、已启用、已隐藏、锁定中这类状态做清楚
## 当前入口
React 路由入口:
- [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
当前做法很简单:
- React 页面只负责提供一个全屏 `iframe`
- 真正的 Earth 应用运行在:
- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
所以 Earth 前端本质上是 `public/earth` 下的一套独立静态应用。
## 当前文件分层
### 1. 页面入口与结构
- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
职责:
- HUD 基础 DOM
- 图层面板
- 媒体面板
- 工具栏
- 设置弹窗
- 兼容旧元素 id
### 2. 主运行时
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
职责:
- 地球初始化
- Three.js 场景组装
- 数据加载与刷新
- 各图层集成
- Earth 级别状态同步
### 3. 地球控制层
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
职责:
- 工具栏交互
- 图层面板交互
- 旋转/缩放/布局
- HUD 面板拖拽
- 图层开关状态机
- Earth 设置读取、持久化与重置
这份文件是 Earth 前端当前最核心的 UI 控制入口。
### 4. UI 与状态消息
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
职责:
- loading 面板
- status message
- tooltip / error / 清理逻辑
### 5. 地球与地形
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
职责:
- 地球球体、云层、大气
- 真实地形 mesh
- terrain tile 拉取、解码、位移、着色
### 6. 图层模块
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
- [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js)
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
职责:
- 各自的数据层
- 开关行为
- 面板内容
- hover/lock/selection 语义
其中 Earth 启动加载链现在也拆成了两层:
- `controls.js`
- 提供图层注册表与启动元信息
- `layer-startup-tasks.js`
- 提供图层启动任务注册表
- 通过 `registerLayerStartupTask(id, taskFactory)` 扩展启动任务
- `main.js`
- 只负责读取排序后的启动图层,再按映射执行队列
其中巡航模式现在已经拆成两层:
- `cruise-sequencer.js`
- 负责目标队列顺序、停留时长、切换节奏、打断与恢复
- `callout-connector.js`
- 负责卡片连线 SVG、路径计算与绘制动画
- `bgp-cruise-adapter.js`
- 负责 BGP 巡航展示适配目标排序、卡片落点、连线路径、focus/overlay/info-card 时序
当前 BGP 巡航只是这套能力的一个调用方不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。
## 当前样式分层
Earth 的 CSS 不是一份大样式表,而是分层管理:
- [base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css)
- [hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css)
- [toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css)
- [layer-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/layer-panel.css)
- [info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css)
- [legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css)
- [earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css)
- [coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css)
- [tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css)
当前建议:
- 通用 HUD 壳层写进 `hud.css`
- 单一面板特性写进各自子文件
- 不要把业务状态样式再散回 `index.html`
## 当前图层开关状态语义
Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
- `inactive`
- `active`
- `loading`
当前入口在:
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
- [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js)
关键函数:
- `updateLayerButtonState(button, isActive)`
- `setLayerButtonState(button, options)`
`setLayerButtonState` 负责:
- `loading` 样式
- `aria-busy`
- 按钮禁用
- tooltip 更新
- 绑定状态文本更新
- 可选同步 `active`
因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。
另外Earth 图层控制现在已经收成“注册表驱动”:
- 图层元数据
- `id`
- `icon`
- `label`
- `meta`
- `buttonId`
- `persist`
- `startupPriority`
- `startupMode`
- `startupLabel`
- `startupMessage`
- 图层行为
- `getVisible()`
- `setVisible(next, options)`
当前入口仍在 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)。
这意味着后续新增图层时,优先应补一条图层注册定义,而不是同时去改:
- 图层面板 HTML
- 持久化快照
- 初始化恢复
- click 绑定
这四处现在都应该由注册表派生。
其中:
- `startupPriority`
- 描述图层参与启动加载时的顺序
- `startupMode`
- `visible`
- 仅当前图层处于启用/可见状态时,才加入启动加载队列
- `preload`
- 即使当前图层未显示,也会参与启动预加载
当前 `main.js` 会通过注册表读取排序后的启动图层列表,再动态拼装启动加载队列,而不是手写一串固定步骤。像 BGP 这类需要尽早准备数据、但不一定默认显示的图层,应该优先走 `startupMode: "preload"`,而不是在启动流程里写隐式特判。
此外,启动阶段给用户看的提示文案也应尽量从注册表派生:
- `startupLabel`
- 用于描述当前启动任务的业务名称
- `startupMessage`
- 用于描述启动中的提示文案
- 可以是字符串
- 也可以是对象,用于像海缆这种“准备阶段 / 主加载阶段”两段式文案
这样后续新增会参与启动加载的图层时,顺序、模式和提示文案都在同一处定义,不需要再去 `main.js` 里补第二套常量。
### `data-status-target`
图层按钮可以通过:
- `data-status-target`
指向一个状态文本节点。当前 terrain 已接入:
- 按钮:`#toggle-terrain`
- 状态节点:`#terrain-status`
以后别的异步图层也可以沿用这套约定。
## 当前设置持久化
Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一负责:
- 捕获默认值
-`localStorage` 读取上次设置
- 初始化应用当前设置
- 用户变更后即时持久化
- 一键重置回默认值
当前持久化的范围是:
- 旋转模式
- HUD 面板显示/隐藏
- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP`
- 地形透明度
也就是说Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`
## 当前地形链路
真实地形首次启用会慢,原因不只是一个:
1. 需要拉取 Terrarium 瓦片
2. 需要解码图片
3. 需要按顶点采样高程
4. 需要重新写入 geometry 和 color
5. 需要重新计算法线与包围体
当前入口在:
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
当前已经做了两层体验优化:
1. 图层开关 loading 状态持续可见
2. 页面空闲时会预热 `ensureTerrainReady()`
也就是说,后续再继续优化 terrain 时,优先顺序应该是:
1. 先保证用户感知正确
2. 再压缩首次等待
3. 最后才做更激进的几何/瓦片优化
## 当前高频风险点
### 1. 视觉状态和业务状态不同步
Earth 里最常见的 bug 不是“没渲染”,而是:
- 图层关了tooltip 还在
- 锁定对象隐藏了info card 还在
- legend 没跟图层切换
- loading 已结束,但按钮还像没开
后续改动必须优先检查状态同步。
### 2. HUD 布局问题先查结构,不要先打 CSS 补丁
Earth HUD 历史上反复出现:
- 面板只剩一条缝
- markdown 被裁掉
- tabs/iframe 被 `overflow: hidden` 吃掉
优先检查:
1. 谁负责高度
2. 谁负责滚动
3. 哪一层在裁剪
不要上来先加 `overflow: hidden` 或额外包装层。
### 3. Transitional path 必须收口
Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易积累:
- 旧 helper
- 旧 class
- 旧 fallback 逻辑
- 已废弃变体
每次大功能完成后,都要做一次 cleanup pass。
### 4. 巡航与业务事件不要再深度耦合
当前正确边界应该是:
- 通用巡航层只知道:
- 当前目标
- 队列顺序
- 相机 focus
- 停留 / 隐藏 / 切换
- 业务模块只负责:
- 提供目标队列
- 提供 focus 坐标
- 提供卡片内容
- 提供高亮/图层副作用
如果以后再给海缆、卫星或新闻做巡航,不应复制一套新的 `main.js` 状态变量,而应复用:
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 这种业务适配层模式
## 当前推荐改动方式
如果后续继续改 Earth建议按这个顺序
1. 先确认改的是:
- Three.js 渲染层
- HUD 结构层
- 图层状态层
- 面板内容层
2. 如果涉及图层按钮,优先接入统一状态机
3. 如果涉及可见性切换,检查 tooltip / legend / info-card / lock 是否一起收口
4. 如果涉及面板布局,先查结构再动 CSS
## 当前与控制台前端的边界
Earth 前端和控制台前端不是同一套 UI 系统:
- 控制台前端React + Ant Design 工作台
- Earth 前端:`public/earth` 原生 HUD + Three.js 展示面
因此:
- Earth 不应该直接复用 Ant Table / AppLayout 语义
- 控制台也不应该照搬 Earth HUD 动画和玻璃层语言
控制台相关结构见:
- [admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/frontend-admin-frontend-context.md)

View File

@@ -0,0 +1,236 @@
# Admin Frontend Context
本文件描述当前控制台前端的真实结构,目标是帮助后续页面开发、表格改造、布局治理和状态收口时快速找到正确入口。
相关规则建议一起参考:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
## 当前目标
控制台前端承担的是后台工作台,而不是展示型大屏。当前约束是:
- 页面默认遵循单屏工作区
- 主交互在内部模块滚动,而不是依赖整页无限变长
- 列表、表格、分析页优先保证主工作区可见
- 通用布局、滚动条、表格滚动行为尽量复用,不要每页各写一套
## 当前路由入口
主入口在:
- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
当前后台相关路由包括:
- `/admin`
- `/users`
- `/datasources`
- `/data`
- `/alerts/system`
- `/alerts/bgp`
- `/alerts/situational`
- `/bgp`
- `/playground`
- `/settings`
`/earth` 是独立展示页,不属于控制台骨架。
## 当前页面骨架
控制台公共壳层在:
- [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx)
职责:
- 左侧导航
- 折叠与展开
- 当前账号/版本信息
- 内容区高度闭合
- 全站统一侧边栏滚动条
当前结构是:
```tsx
<Layout className="dashboard-layout">
<Sider className="dashboard-sider">...</Sider>
<Layout>
<Content className="dashboard-content">
<div className="dashboard-content-inner">{children}</div>
</Content>
</Layout>
</Layout>
```
后续控制台页面应优先适配这套壳层,而不是重新定义全页高度语义。
## 当前共享组件
### 1. `Scrollbar`
文件:
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx)
用途:
- 控制台侧边栏这类普通内容容器
- 组件内部管理可见性、thumb 尺寸、拖拽和双轴 overflow 判定
当前约束:
- 滚动条必须是浮层,不参与布局
- 无 overflow 时不应留下可见痕迹
- 真实滚动仍交给原生容器,只替换可见层和交互层
### 2. `ScrollbarOverlay`
文件:
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx)
用途:
- Ant Table 这类内部已有滚动容器的区域
- 不接管滚动语义,只叠加新的滚动条可见层
当前使用场景:
- 数据源
- 采集数据
- 用户管理
- 设置页
- 告警页
- BGP 页面
### 3. `TableScrollRegion`
文件:
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/TableScrollRegion.tsx)
用途:
- 为表格滚动区提供统一包裹层
- 后续新表格页优先复用,不要重复写“表格区域 + overlay scrollbar”样板
### 4. 其他共享组件
- [MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx)
- [TableActions.tsx](/home/ray/dev/linkong/planet/frontend/src/components/TableActions/TableActions.tsx)
## 当前状态来源
### 1. 认证状态
文件:
- [auth.ts](/home/ray/dev/linkong/planet/frontend/src/stores/auth.ts)
职责:
- token
- 当前用户
- 登录/退出
`App.tsx` 用它判断是否进入登录页。
### 2. 业务数据网关
目前 AI / 态势感知相关服务集中在:
- [http-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/http-gateway.ts)
- [port.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/port.ts)
- [types.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/types.ts)
约束:
- 页面不要直接散落拼 URL
- 先通过 port/types 定义边界
- 再由 http/mock gateway 实现
## 当前页面分层建议
### 1. 仪表盘和摘要型页面
例如:
- [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx)
优先目标:
- 页头稳定
- 摘要卡片先紧凑化
- 主工作区占据主要高度
### 2. 表格型页面
例如:
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx)
- [DataList.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataList/DataList.tsx)
- [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx)
- [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx)
约束:
- 优先内部滚动
- 不要让表格撑爆整页
- 新表格区域优先复用 `TableScrollRegion` / `ScrollbarOverlay`
### 3. 复杂工作区页面
例如:
- [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
- [Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx)
约束:
- Tabs 里的内容不能套同一套高度逻辑
- 表格 tab、Markdown tab、配置 tab 要各自定义滚动责任
- AI 结果区、长文本区优先保证最小可读高度
## 当前布局约束
这些原则已经在项目里反复验证过:
1. 父容器高度链要闭合
2. `min-height: 0` 不能漏
3. overflow 责任必须明确
4. 不要用 `overflow: hidden` 掩盖结构问题
5. 不要为了摘要卡完整显示去压缩主工作区
6. 自定义滚动条必须是浮层,不得挤压内容宽度
详细经验见:
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
## 当前推荐改动方式
如果后续继续改后台页面,建议按这个顺序:
1. 先确认页面属于摘要页、表格页还是复杂工作区
2. 先接入现有壳层和滚动语义
3. 优先复用共享滚动组件
4. 最后再改视觉和细节交互
不要先写局部 CSS 补丁,再回头补结构。
## 当前明显边界
控制台前端和 Earth 前端不是一套系统:
- 控制台前端是 React + Ant Design 工作台
- Earth 前端是 `public/earth` 下的独立原生 HUD 系统
因此:
- 不要把 Earth 的 HUD/动画/状态机直接挪进控制台
- 不要把控制台表格/滚动策略硬套到 Earth HUD
Earth 相关结构见:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)

View File

@@ -16,12 +16,17 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.29.2` - `dev` 当前开发分支历史推导到:`0.31.3`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
| `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.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD并校正太阳受光方向 |
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示brand panel 去框并收敛昼夜与选中态可读性 | | `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示brand panel 去框并收敛昼夜与选中态可读性 |
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 | | `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.29.2", "version": "0.31.3",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {

View File

@@ -279,18 +279,8 @@
font-weight: 600; font-weight: 600;
} }
.hud-error-message { .earth-status-message,
color: #ff4444; .earth-error-message {
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 {
position: absolute; position: absolute;
top: calc(20px * var(--hud-scale)); top: calc(20px * var(--hud-scale));
left: 50%; left: 50%;
@@ -311,6 +301,7 @@
0 0 18px rgba(145, 186, 255, 0.06); 0 0 18px rgba(145, 186, 255, 0.06);
font-size: calc(0.84rem * var(--hud-scale)); font-size: calc(0.84rem * var(--hud-scale));
font-weight: 500; font-weight: 500;
line-height: 1.2;
letter-spacing: 0.01em; letter-spacing: 0.01em;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
@@ -324,18 +315,27 @@
opacity 0.28s ease; opacity 0.28s ease;
} }
.earth-status-message.visible { .earth-status-message.visible,
.earth-error-message.visible {
transform: translate(-50%, 0); transform: translate(-50%, 0);
opacity: 1; 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) ── */ /* ── Indicator: single dot (transient) or three dots (loading) ── */
.earth-status-indicator { .earth-status-indicator {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: calc(5px * var(--hud-scale)); gap: calc(5px * var(--hud-scale));
flex: 0 0 auto; flex: 0 0 auto;
align-self: center;
} }
.earth-status-dot { .earth-status-dot {
@@ -350,6 +350,8 @@
} }
.earth-status-text { .earth-status-text {
display: inline-flex;
align-items: center;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
@@ -536,7 +538,7 @@
.earth-settings-kicker { .earth-settings-kicker {
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: var(--hud-kicker-size); font-size: calc(0.72rem * var(--hud-scale));
letter-spacing: 0.16em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -546,6 +548,22 @@
flex-shrink: 0; flex-shrink: 0;
} }
.earth-settings-reset {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding-inline: calc(10px * var(--hud-scale));
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.06em;
text-transform: uppercase;
}
.earth-settings-reset .material-symbols-rounded {
font-size: calc(0.86rem * var(--hud-scale));
}
.earth-settings-content { .earth-settings-content {
overflow-y: auto; overflow-y: auto;
padding-right: 4px; padding-right: 4px;
@@ -573,7 +591,7 @@
.earth-settings-section-title { .earth-settings-section-title {
margin-bottom: 12px; margin-bottom: 12px;
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: var(--hud-kicker-size); font-size: calc(0.62rem * var(--hud-scale));
letter-spacing: 0.16em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -588,9 +606,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 12px;
padding: 15px 16px; padding: 11px 14px;
border-radius: 16px; border-radius: 14px;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025); rgba(255, 255, 255, 0.025);
@@ -608,10 +626,160 @@
transform: translateY(-1px); 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 { .earth-settings-link {
text-decoration: none; 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: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
border-radius: 999px;
font: inherit;
font-size: calc(0.7rem * var(--hud-scale));
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: calc(4px * var(--hud-scale));
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;
transition: background 0.18s ease;
}
.earth-settings-slider:hover {
background: linear-gradient(90deg, rgba(152, 184, 224, 0.44), rgba(114, 155, 202, 0.64));
}
.earth-settings-slider::-webkit-slider-thumb {
appearance: none;
width: calc(14px * var(--hud-scale));
height: calc(14px * var(--hud-scale));
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 4px 10px rgba(0, 0, 0, 0.24);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.earth-settings-slider:hover::-webkit-slider-thumb {
transform: scale(1.22);
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.32) 55%, transparent 70%),
linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98));
border-color: rgba(232, 244, 255, 0.72);
box-shadow:
0 0 0 3px rgba(145, 186, 255, 0.22),
0 6px 14px rgba(0, 0, 0, 0.3);
}
.earth-settings-slider:active::-webkit-slider-thumb {
transform: scale(1.08);
box-shadow:
0 0 0 4px rgba(145, 186, 255, 0.32),
0 4px 10px rgba(0, 0, 0, 0.28);
}
.earth-settings-slider::-moz-range-thumb {
width: calc(14px * var(--hud-scale));
height: calc(14px * var(--hud-scale));
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 4px 10px rgba(0, 0, 0, 0.24);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.earth-settings-slider:hover::-moz-range-thumb {
transform: scale(1.22);
background: linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98));
border-color: rgba(232, 244, 255, 0.72);
box-shadow:
0 0 0 3px rgba(145, 186, 255, 0.22),
0 6px 14px rgba(0, 0, 0, 0.3);
}
.earth-settings-slider:active::-moz-range-thumb {
transform: scale(1.08);
box-shadow:
0 0 0 4px rgba(145, 186, 255, 0.32),
0 4px 10px rgba(0, 0, 0, 0.28);
}
.earth-settings-slider-value {
flex: 0 0 auto;
min-width: calc(34px * var(--hud-scale));
text-align: right;
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.earth-settings-copy { .earth-settings-copy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -620,13 +788,13 @@
.earth-settings-item-title { .earth-settings-item-title {
color: var(--hud-text); color: var(--hud-text);
font-size: calc(0.98rem * var(--hud-scale)); font-size: calc(0.76rem * var(--hud-scale));
font-weight: 600; font-weight: 600;
} }
.earth-settings-item-subtitle { .earth-settings-item-subtitle {
color: var(--hud-text-muted); color: var(--hud-text-muted);
font-size: 0.82rem; font-size: calc(0.67rem * var(--hud-scale));
line-height: 1.4; line-height: 1.4;
} }
@@ -639,11 +807,11 @@
} }
.earth-settings-link-meta .material-symbols-rounded:first-child { .earth-settings-link-meta .material-symbols-rounded:first-child {
font-size: 1.1rem; font-size: calc(0.9rem * var(--hud-scale));
} }
.earth-settings-link-meta .material-symbols-rounded:last-child { .earth-settings-link-meta .material-symbols-rounded:last-child {
font-size: 1rem; font-size: calc(0.82rem * var(--hud-scale));
} }
.earth-settings-switch { .earth-settings-switch {
@@ -659,8 +827,8 @@
} }
.earth-settings-switch-track { .earth-settings-switch-track {
width: 48px; width: calc(38px * var(--hud-scale));
height: 30px; height: calc(22px * var(--hud-scale));
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(215, 229, 242, 0.12); border: 1px solid rgba(215, 229, 242, 0.12);
@@ -671,10 +839,10 @@
.earth-settings-switch-track::after { .earth-settings-switch-track::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 3px; top: calc(3px * var(--hud-scale));
left: 3px; left: calc(3px * var(--hud-scale));
width: 22px; width: calc(16px * var(--hud-scale));
height: 22px; height: calc(16px * var(--hud-scale));
border-radius: 50%; border-radius: 50%;
background: #edf4fc; background: #edf4fc;
box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26); box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26);
@@ -687,7 +855,7 @@
} }
.earth-settings-switch input:checked + .earth-settings-switch-track::after { .earth-settings-switch input:checked + .earth-settings-switch-track::after {
transform: translateX(18px); transform: translateX(calc(16px * var(--hud-scale)));
} }
@media (max-width: 960px) { @media (max-width: 960px) {

View File

@@ -161,6 +161,84 @@
pointer-events: auto; 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 {
opacity: 0;
}
.info-card-cruise-link.is-animating circle:first-of-type {
animation: cruiseConnectorNodeIn 0.14s ease forwards;
animation-delay: 0.02s;
}
.info-card-cruise-link.is-animating circle:last-of-type {
animation: cruiseConnectorNodeIn 0.16s ease forwards;
animation-delay: 0.34s;
}
@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 ────────────────────────────────────────────────── */
.info-card { .info-card {

View File

@@ -235,6 +235,7 @@
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
transition: opacity 0.18s ease;
} }
.layer-row-toggle-track { .layer-row-toggle-track {
@@ -247,6 +248,11 @@
transition: background 0.18s ease, border-color 0.18s ease; transition: background 0.18s ease, border-color 0.18s ease;
} }
.layer-row-toggle:disabled {
cursor: progress;
opacity: 1;
}
/* Thumb */ /* Thumb */
.layer-row-toggle-track::after { .layer-row-toggle-track::after {
content: ""; content: "";
@@ -261,6 +267,24 @@
transition: transform 0.18s ease, background 0.18s ease; transition: transform 0.18s ease, background 0.18s ease;
} }
.layer-row-toggle.is-loading .layer-row-toggle-track {
background: linear-gradient(
90deg,
rgba(104, 147, 221, 0.38),
rgba(143, 185, 255, 0.72),
rgba(104, 147, 221, 0.38)
);
background-size: 180% 100%;
border-color: rgba(223, 236, 252, 0.28);
animation: layer-toggle-loading-track 1.2s linear infinite;
}
.layer-row-toggle.is-loading .layer-row-toggle-track::after {
background: #f0f6ff;
transform: translateX(calc(7px * var(--hud-scale)));
animation: layer-toggle-loading-thumb 1s ease-in-out infinite;
}
/* Active (ON) state */ /* Active (ON) state */
.layer-row-toggle.active .layer-row-toggle-track { .layer-row-toggle.active .layer-row-toggle-track {
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78)); background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
@@ -272,5 +296,24 @@
transform: translateX(calc(14px * var(--hud-scale))); transform: translateX(calc(14px * var(--hud-scale)));
} }
@keyframes layer-toggle-loading-track {
0% {
background-position: 0% 50%;
}
100% {
background-position: 180% 50%;
}
}
@keyframes layer-toggle-loading-thumb {
0%,
100% {
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 0 rgba(174, 205, 255, 0.18);
}
50% {
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 calc(10px * var(--hud-scale)) rgba(174, 205, 255, 0.42);
}
}
/* Layout-expanded: layer panel slides off with .earth-left-column — no /* Layout-expanded: layer panel slides off with .earth-left-column — no
individual rule needed since the whole column translates together. */ individual rule needed since the whole column translates together. */

View File

@@ -9,6 +9,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 200;
pointer-events: none;
} }
.earth-toolbar-group, .earth-toolbar-group,
@@ -40,6 +41,7 @@
border: none; border: none;
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
pointer-events: none;
} }
.earth-toolbar-cluster { .earth-toolbar-cluster {
@@ -103,6 +105,14 @@
pointer-events: auto; pointer-events: auto;
} }
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
pointer-events: none;
}
.earth-toolbar-hub > * {
pointer-events: auto;
}
.earth-toolbar-orb > .liquid-glass-surface { .earth-toolbar-orb > .liquid-glass-surface {
animation: floatDock 4.6s ease-in-out infinite; animation: floatDock 4.6s ease-in-out infinite;
animation-delay: var(--orb-delay, 0s); animation-delay: var(--orb-delay, 0s);
@@ -120,7 +130,7 @@
border: none; border: none;
background: transparent; background: transparent;
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: 14px; font-size: calc(14px * var(--toolbar-scale));
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -361,7 +371,7 @@
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
transform: translate(-50%, 8px); transform: translate(-50%, calc(8px * var(--toolbar-scale)));
} }
.earth-toolbar-popover::before { .earth-toolbar-popover::before {
@@ -381,7 +391,7 @@
top: auto; top: auto;
right: auto; right: auto;
bottom: calc(100% + (12px * var(--toolbar-scale))); bottom: calc(100% + (12px * var(--toolbar-scale)));
transform: translate(-50%, 10px); transform: translate(-50%, calc(10px * var(--toolbar-scale)));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -398,16 +408,16 @@
.earth-zoom-toolbar .earth-zoom-btn, .earth-zoom-toolbar .earth-zoom-btn,
.earth-zoom-toolbar .earth-zoom-value { .earth-zoom-toolbar .earth-zoom-value {
width: 42px; width: calc(42px * var(--toolbar-scale));
min-width: 42px; min-width: calc(42px * var(--toolbar-scale));
border-radius: 50%; border-radius: 50%;
color: var(--hud-text-soft); color: var(--hud-text-soft);
animation: none; animation: none;
} }
.earth-zoom-toolbar .earth-zoom-btn { .earth-zoom-toolbar .earth-zoom-btn {
height: 42px; height: calc(42px * var(--toolbar-scale));
font-size: 20px; font-size: calc(20px * var(--toolbar-scale));
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
} }
@@ -416,9 +426,9 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 42px; height: calc(42px * var(--toolbar-scale));
padding: 0; padding: 0;
font-size: 0.68rem; font-size: calc(11px * var(--toolbar-scale));
letter-spacing: normal; letter-spacing: normal;
} }
@@ -429,15 +439,15 @@
.earth-toolbar-btn .earth-toolbar-tooltip { .earth-toolbar-btn .earth-toolbar-tooltip {
position: absolute; position: absolute;
bottom: 56px; bottom: calc(56px * var(--toolbar-scale));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: background:
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95)); linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
color: var(--hud-text); color: var(--hud-text);
padding: 6px 12px; padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
border-radius: 6px; border-radius: calc(6px * var(--toolbar-scale));
font-size: 12px; font-size: calc(12px * var(--toolbar-scale));
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
@@ -453,7 +463,7 @@
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip { .earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
bottom: 58px; bottom: calc(58px * var(--toolbar-scale));
} }
.earth-toolbar-btn .earth-toolbar-tooltip::after { .earth-toolbar-btn .earth-toolbar-tooltip::after {
@@ -462,6 +472,6 @@
top: 100%; top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
border: 6px solid transparent; border: calc(6px * var(--toolbar-scale)) solid transparent;
border-top-color: rgba(18, 31, 52, 0.96); border-top-color: rgba(18, 31, 52, 0.96);
} }

View File

@@ -133,8 +133,8 @@
max-height: 0; max-height: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
margin-top: calc(-1 * var(--hud-gap-sm)); margin-top: 0;
margin-bottom: calc(-1 * var(--hud-gap-sm)); margin-bottom: 0;
} }
.tv-panel-meta { .tv-panel-meta {
@@ -169,7 +169,7 @@
.tv-panel-player { .tv-panel-player {
position: relative; position: relative;
flex: 1 0 auto; flex: 1 1 auto;
min-height: calc(220px * var(--hud-scale)); min-height: calc(220px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale)); border-radius: calc(16px * var(--hud-scale));
overflow: hidden; overflow: hidden;

View File

@@ -98,7 +98,7 @@
<span class="layer-row-label">地形</span> <span class="layer-row-label">地形</span>
<span class="layer-row-meta">Terrain</span> <span class="layer-row-meta">Terrain</span>
</div> </div>
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示"> <button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
<span class="layer-row-toggle-track"></span> <span class="layer-row-toggle-track"></span>
</button> </button>
</div> </div>
@@ -150,11 +150,11 @@
</div> </div>
</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="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar"> <div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded"> <div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;"> <div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)"> <button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<span class="icon" aria-hidden="true"> <span class="icon" aria-hidden="true">
@@ -419,14 +419,57 @@
<div class="hud-panel__title-group"> <div class="hud-panel__title-group">
<div class="earth-settings-kicker">设置</div> <div class="earth-settings-kicker">设置</div>
</div> </div>
<button id="settings-reset" class="earth-settings-reset hud-panel__action" type="button" aria-label="重置设置">
<span class="material-symbols-rounded">restart_alt</span>
<span>重置</span>
</button>
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置"> <button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
<span class="material-symbols-rounded">close</span> <span class="material-symbols-rounded">close</span>
</button> </button>
</div> </div>
<div class="earth-settings-content hud-panel__body"> <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"> <section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div> <div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-daynight" type="checkbox" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-view-layers"> <label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span> <span class="earth-settings-item-title">图层控制</span>
@@ -469,6 +512,30 @@
</label> </label>
</div> </div>
</section> </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"> <section class="earth-settings-section">
<div class="earth-settings-section-title">系统</div> <div class="earth-settings-section-title">系统</div>
<div class="earth-settings-list"> <div class="earth-settings-list">

View File

@@ -0,0 +1,300 @@
import * as THREE from "three";
import { CRUISE_CONFIG, PATHS } from "./constants.js";
import { createElbowConnectorPoints } from "./callout-connector.js";
const scratchBGPWorldPosition = new THREE.Vector3();
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const CRUISE_CONNECTOR_DRAW_MS = 420;
const CRUISE_PRESENTATION_HIDE_MS = 220;
function getMarkerTimestamp(marker) {
const rawValue = marker?.userData?.created_at_raw;
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
export function createBGPCruiseAdapter({
camera,
getMarkers,
connector,
focusView,
setMarkerLocked,
clearMarkerState,
showMarkerOverlay,
applySatelliteHighlights,
showMarkerInfo,
hideInfo,
isInfoVisible,
getLockedObject,
refreshMarkers,
}) {
let currentMarkerId = null;
let cardPlacement = null;
let knownEventIds = new Set();
function getCurrentMarker() {
if (!currentMarkerId) return null;
return getMarkers().find((marker) => marker?.userData?.id === currentMarkerId) || null;
}
function getSortedMarkers() {
return getMarkers()
.slice()
.sort((a, b) => getMarkerTimestamp(b) - getMarkerTimestamp(a));
}
function getMarkerScreenCoords(marker) {
if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
const projected = scratchBGPWorldPosition.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getCardScreenCoords(marker) {
const markerCoords = getMarkerScreenCoords(marker);
if (!markerCoords) return null;
const hudScale =
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
) || 1;
const estimatedCardHeight = Math.min(
CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale,
window.innerHeight * 0.7,
);
const estimatedCardWidth = Math.min(
CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale,
window.innerWidth - CRUISE_CARD_VIEWPORT_PADDING_PX,
);
const x =
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
const y =
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
const margin = CRUISE_CARD_SCREEN_MARGIN_PX;
const clampedX = Math.min(
Math.max(margin, x),
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
);
const clampedY = Math.min(
Math.max(margin, y),
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
);
const anchorY = clampedY + Math.max(
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
estimatedCardHeight * 0.18,
);
return {
x: clampedX,
y: clampedY,
width: estimatedCardWidth,
height: estimatedCardHeight,
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
anchorY,
};
}
function getConnectorPath(marker) {
const markerCoords = getMarkerScreenCoords(marker);
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
if (!markerCoords || !targetCardCoords) return null;
return createElbowConnectorPoints(
markerCoords,
{
x: targetCardCoords.anchorX,
y: targetCardCoords.anchorY,
},
{
startFrom: "source",
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
},
);
}
function renderConnector(marker, { animate = false } = {}) {
const path = getConnectorPath(marker);
if (!path) return false;
return connector.render(path, { animate });
}
function extractFeatureIds(features = []) {
return features
.map((feature) => {
const properties = feature?.properties || {};
const coords = feature?.geometry?.coordinates || [];
return (
properties.id ||
properties.incident_key ||
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
);
})
.filter(Boolean);
}
return {
getSortedMarkers,
getCurrentMarker,
isPresentationVisible() {
return cardPlacement != null;
},
clearCurrentHighlight() {
const marker = getCurrentMarker();
if (marker && getLockedObject() !== marker) {
clearMarkerState(marker);
}
currentMarkerId = null;
},
async focusMarker(marker, { interrupt = false } = {}) {
if (!marker) return;
currentMarkerId = marker.userData?.id || null;
cardPlacement = getCardScreenCoords(marker);
setMarkerLocked(marker);
showMarkerOverlay(marker);
applySatelliteHighlights(marker);
await focusView({
lat: marker.userData?.latitude ?? 0,
lon: marker.userData?.longitude ?? 0,
rotLon: (marker.userData?.longitude ?? 0) - 270,
zoom: 1.0,
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
},
async presentMarker(marker, { context }) {
if (!marker) return false;
const startedAt = performance.now();
let connectorReady = false;
while (context.isCurrent()) {
connectorReady = renderConnector(marker, { animate: !connectorReady });
if (connectorReady) break;
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
break;
}
await context.nextFrame();
}
if (!connectorReady || !context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
if (!connectorDelayCompleted || !context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
await context.nextFrame();
if (!isInfoVisible()) {
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
await context.nextFrame();
}
if (!isInfoVisible() || !context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
return true;
},
async hidePresentation({ context }) {
if (!getLockedObject()) {
hideInfo();
}
connector.hide();
const hideDelayCompleted = await context.wait(CRUISE_PRESENTATION_HIDE_MS, {
secondary: true,
});
if (!hideDelayCompleted) return;
cardPlacement = null;
},
repositionConnector(marker) {
if (!cardPlacement || !marker || !connector.isVisible() || connector.isAnimating()) {
return;
}
renderConnector(marker, { animate: false });
},
resetPresentation() {
cardPlacement = null;
connector.hide();
},
syncKnownEventIds() {
knownEventIds = new Set(
getMarkers()
.map((marker) => marker?.userData?.id)
.filter(Boolean),
);
return knownEventIds;
},
async pollForNewMarkerIds() {
const [incidentResponse, anomalyResponse] = await Promise.all([
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
]);
if (!incidentResponse.ok || !anomalyResponse.ok) {
return [];
}
const [incidentPayload, anomalyPayload] = await Promise.all([
incidentResponse.json(),
anomalyResponse.json(),
]);
const incidentFeatures = Array.isArray(incidentPayload?.features)
? incidentPayload.features
: [];
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
? anomalyPayload.features
: [];
const selectedFeatures =
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
const nextIds = extractFeatureIds(selectedFeatures);
const newIds = nextIds.filter((id) => !knownEventIds.has(id));
if (newIds.length === 0) return [];
await refreshMarkers();
this.syncKnownEventIds();
return newIds;
},
};
}

View File

@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
} }
function drawWaveSymbol(context) { function drawWaveSymbol(context) {
context.lineWidth = 12;
context.lineCap = "round";
context.beginPath(); context.beginPath();
context.moveTo(18, 76); context.moveTo(14, 100);
context.bezierCurveTo(34, 46, 46, 46, 64, 76); context.lineTo(38, 26);
context.bezierCurveTo(80, 106, 94, 106, 110, 76); context.lineTo(64, 100);
context.stroke(); context.lineTo(90, 26);
context.lineTo(114, 100);
context.closePath();
context.fill();
} }
function drawBurstSymbol(context) { function drawBurstSymbol(context) {
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
} }
export async function loadBGPAnomalies(scene, earth) { export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi); const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
if (!collectorsResponse.ok) { if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`); throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
? collectorsPayload.features ? collectorsPayload.features
: []; : [];
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload); const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
clearBGPData(earth);
totalAnomalyCount = selectedEventData.totalAnomalyCount; totalAnomalyCount = selectedEventData.totalAnomalyCount;
totalIncidentCount = selectedEventData.totalIncidentCount; totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear(); 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(); const now = performance.now();
updateCollectorOverlayScan(lockedObjectType, lockedObject); updateCollectorOverlayScan(lockedObjectType, lockedObject);
const hasLockedLayer = Boolean( const hasLockedLayer = Boolean(
@@ -1459,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
const isLinkedCollectorLocked = const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" && lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.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 isHovered = marker.userData.state === "hover";
const pulse = const pulse =
0.5 + 0.5 +
@@ -1477,18 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
if (isLocked || isLinkedCollectorLocked) { if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = opacity = 0.9 + 0.1 * pulse;
0.9 +
0.1 * pulse;
markerColor = 0xfff1a8; markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2; 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) { } else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale; scale *= BGP_CONFIG.marker.hoverScale;
opacity = 0.9; opacity = 0.9;
ringBaseOpacity *= 1.05; ringBaseOpacity *= 1.05;
} else if (isOtherLocked) { } else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale; scale *= BGP_CONFIG.marker.dimmedScale;
opacity = 0.1; opacity = 0.22;
markerColor = 0x7d8ca3; markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02; ringBaseOpacity = 0.02;
} else { } else {
@@ -1500,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
marker.material.color.setHex(markerColor); marker.material.color.setHex(markerColor);
marker.material.opacity = opacity; marker.material.opacity = opacity;
marker.visible = showBGP; marker.visible = showBGP;
marker.renderOrder = isActive ? 7 : 3;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1; const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => { const applyRingState = (ring, phase, maxScale) => {

View File

@@ -20,6 +20,7 @@ export let lockedCable = null;
let cableIdMap = new Map(); let cableIdMap = new Map();
let cableStates = new Map(); let cableStates = new Map();
let cablesVisible = true; let cablesVisible = true;
let landingPointGeometry = null;
const landingPointWorldPosition = new THREE.Vector3(); const landingPointWorldPosition = new THREE.Vector3();
function clamp(value, min, max) { function clamp(value, min, max) {
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
if (owner) { if (owner) {
owner.remove(object); owner.remove(object);
} }
if (object.geometry) { if (object.geometry && !object.userData?.sharedGeometry) {
object.geometry.dispose(); object.geometry.dispose();
} }
if (object.material) { if (object.material) {
@@ -245,9 +246,12 @@ export function clearCableData(earthObj = null) {
clearLandingPoints(earthObj); clearLandingPoints(earthObj);
} }
export async function loadGeoJSONFromPath(scene, earthObj) { export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载电缆数据..."); console.log("正在加载电缆数据...");
showStatusMessage("正在加载电缆数据...", "warning"); if (!silent) {
showStatusMessage("正在加载电缆数据...", "warning");
}
const response = await fetch(PATHS.cablesApi); const response = await fetch(PATHS.cablesApi);
if (!response.ok) { if (!response.ok) {
@@ -344,11 +348,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
textureQuality: "8K 卫星图", textureQuality: "8K 卫星图",
}); });
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success"); if (!silent) {
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
}
return cableLines.length; return cableLines.length;
} }
export async function loadLandingPoints(scene, earthObj) { export async function loadLandingPoints(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载登陆点数据..."); console.log("正在加载登陆点数据...");
const response = await fetch(PATHS.landingPointsApi); const response = await fetch(PATHS.landingPointsApi);
@@ -363,70 +370,69 @@ export async function loadLandingPoints(scene, earthObj) {
clearLandingPoints(earthObj); clearLandingPoints(earthObj);
const sphereGeometry = new THREE.SphereGeometry( if (!landingPointGeometry) {
CABLE_CONFIG.landingPoint.radius, landingPointGeometry = new THREE.SphereGeometry(
CABLE_CONFIG.landingPoint.widthSegments, CABLE_CONFIG.landingPoint.radius,
CABLE_CONFIG.landingPoint.heightSegments, CABLE_CONFIG.landingPoint.widthSegments,
); CABLE_CONFIG.landingPoint.heightSegments,
);
}
let validCount = 0; let validCount = 0;
try { for (const feature of data.features) {
for (const feature of data.features) { if (!feature.geometry || !feature.geometry.coordinates) continue;
if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates; const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {}; const properties = feature.properties || {};
if ( if (
typeof lon !== "number" || typeof lon !== "number" ||
typeof lat !== "number" || typeof lat !== "number" ||
Number.isNaN(lon) || Number.isNaN(lon) ||
Number.isNaN(lat) || Number.isNaN(lat) ||
Math.abs(lat) > 90 || Math.abs(lat) > 90 ||
Math.abs(lon) > 180 Math.abs(lon) > 180
) { ) {
continue; 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++;
} }
} 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"); const landingPointCountEl = document.getElementById("landing-point-count");
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
landingPointCountEl.textContent = validCount + "个"; landingPointCountEl.textContent = validCount + "个";
} }
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success"); if (!silent) {
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
}
return validCount; return validCount;
} }

View File

@@ -0,0 +1,185 @@
const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
function createSvgElement(tagName) {
return document.createElementNS(SVG_NS, tagName);
}
export function createElbowConnectorPoints(source, target, options = {}) {
if (!source || !target) return null;
const {
startFrom = "source",
sourceGapPx = 12,
targetGapPx = 8,
elbowOffsetPx = 18,
elbowDropPx = 14,
} = options;
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
const targetPoint = { x: Number(target.x), y: Number(target.y) };
if (
!Number.isFinite(sourcePoint.x) ||
!Number.isFinite(sourcePoint.y) ||
!Number.isFinite(targetPoint.x) ||
!Number.isFinite(targetPoint.y)
) {
return null;
}
const horizontalDirection = sourcePoint.x <= targetPoint.x ? 1 : -1;
const startX = sourcePoint.x + horizontalDirection * sourceGapPx;
const startY = sourcePoint.y;
const endX = targetPoint.x - horizontalDirection * targetGapPx;
const endY = targetPoint.y;
const elbowX = endX - horizontalDirection * elbowOffsetPx;
const elbowY = Math.min(startY, endY) + elbowDropPx;
if (Math.abs(endX - startX) < 8 && Math.abs(endY - startY) < 8) {
return null;
}
const orderedPoints = [
{ x: startX, y: startY },
{ x: elbowX, y: elbowY },
{ x: endX, y: endY },
];
return {
points: startFrom === "target" ? orderedPoints.slice().reverse() : orderedPoints,
start: startFrom === "target" ? orderedPoints[2] : orderedPoints[0],
end: startFrom === "target" ? orderedPoints[0] : orderedPoints[2],
};
}
export class CalloutConnector {
constructor({
container = null,
containerId = "container",
className = DEFAULT_CLASS_NAME,
drawAnimationName = DEFAULT_DRAW_ANIMATION_NAME,
} = {}) {
this.container = container;
this.containerId = containerId;
this.className = className;
this.drawAnimationName = drawAnimationName;
this.connectorEl = null;
this.polylineEl = null;
this.startpointEl = null;
this.endpointEl = null;
}
resolveContainer() {
if (this.container instanceof HTMLElement) return this.container;
this.container = document.getElementById(this.containerId);
return this.container instanceof HTMLElement ? this.container : null;
}
ensure() {
if (this.connectorEl instanceof SVGSVGElement) {
return this.connectorEl;
}
const container = this.resolveContainer();
if (!container) return null;
const connector = createSvgElement("svg");
connector.setAttribute("class", this.className);
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
connector.setAttribute("preserveAspectRatio", "none");
const polyline = createSvgElement("polyline");
const startpoint = createSvgElement("circle");
const endpoint = createSvgElement("circle");
startpoint.setAttribute("r", "4");
endpoint.setAttribute("r", "4");
connector.append(startpoint, polyline, endpoint);
container.appendChild(connector);
connector.addEventListener("animationend", (event) => {
if (
event.animationName === this.drawAnimationName &&
this.connectorEl?.classList.contains("is-visible")
) {
if (this.polylineEl) {
this.polylineEl.style.strokeDashoffset = "0";
}
this.connectorEl?.classList.remove("is-animating");
}
});
this.connectorEl = connector;
this.polylineEl = polyline;
this.startpointEl = startpoint;
this.endpointEl = endpoint;
return connector;
}
isVisible() {
return this.connectorEl?.classList.contains("is-visible") === true;
}
isAnimating() {
return this.connectorEl?.classList.contains("is-animating") === true;
}
hide() {
const connector = this.ensure();
if (!connector) return;
connector.classList.remove("is-visible", "is-animating");
}
render(path, { animate = false } = {}) {
const connector = this.ensure();
if (
!connector ||
!this.polylineEl ||
!this.startpointEl ||
!this.endpointEl ||
!Array.isArray(path?.points) ||
path.points.length < 2
) {
return false;
}
const viewWidth = window.innerWidth;
const viewHeight = window.innerHeight;
connector.setAttribute("viewBox", `0 0 ${viewWidth} ${viewHeight}`);
const pointsText = path.points
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
.join(" ");
this.polylineEl.setAttribute("points", pointsText);
this.startpointEl.setAttribute("cx", path.start.x.toFixed(2));
this.startpointEl.setAttribute("cy", path.start.y.toFixed(2));
this.endpointEl.setAttribute("cx", path.end.x.toFixed(2));
this.endpointEl.setAttribute("cy", path.end.y.toFixed(2));
const totalLength =
typeof this.polylineEl.getTotalLength === "function"
? this.polylineEl.getTotalLength()
: 0;
this.polylineEl.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
this.polylineEl.style.strokeDashoffset =
totalLength > 0 ? `${animate ? totalLength : 0}` : "";
connector.style.setProperty(
"--connector-length",
totalLength > 0 ? `${totalLength}` : "0px",
);
connector.classList.add("is-visible");
if (animate && totalLength > 0) {
connector.classList.remove("is-animating");
void connector.getBoundingClientRect();
this.polylineEl.style.strokeDashoffset = `${totalLength}`;
connector.classList.add("is-animating");
} else {
connector.classList.remove("is-animating");
}
return totalLength > 0;
}
}

View File

@@ -1,7 +1,11 @@
import * as THREE from "three"; import * as THREE from "three";
import * as Astronomy from "astronomy-engine"; import * as Astronomy from "astronomy-engine";
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js"; import {
CELESTIAL_CONFIG,
EARTH_CONFIG,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { latLonToVector3 } from "./utils.js";
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
@@ -21,7 +25,11 @@ let moonDirection = defaultMoonDirection.clone();
let lastUpdatedAt = 0; let lastUpdatedAt = 0;
let linkedSunLight = null; let linkedSunLight = null;
let linkedBackLight = null; let linkedBackLight = null;
let linkedAmbientLight = null;
let linkedPointLight = null;
let linkedCamera = null;
let linkedEarth = null; let linkedEarth = null;
let dayNightLightingEnabled = true;
let brightStarSprites = []; let brightStarSprites = [];
let celestialRotationQuaternion = new THREE.Quaternion(); let celestialRotationQuaternion = new THREE.Quaternion();
let celestialViewQuaternion = new THREE.Quaternion(); let celestialViewQuaternion = new THREE.Quaternion();
@@ -333,10 +341,71 @@ function updateSpritePositions() {
} }
function updateLighting() { function updateLighting() {
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
const inspection = CELESTIAL_CONFIG.inspectionLighting;
const cameraDirection = linkedCamera
? linkedCamera.position.clone().normalize()
: defaultSunDirection.clone();
const worldUp = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
if (right.lengthSq() < 1e-6) {
right.set(1, 0, 0);
} else {
right.normalize();
}
const adjustedUp = new THREE.Vector3()
.crossVectors(cameraDirection, right)
.normalize();
const resolveInspectionDirection = (offset) =>
cameraDirection
.clone()
.multiplyScalar(offset.z)
.add(right.clone().multiplyScalar(offset.x))
.add(adjustedUp.clone().multiplyScalar(offset.y))
.normalize();
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(inspection.ambientColor);
linkedAmbientLight.intensity = inspection.ambientIntensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(inspection.keyLightColor);
linkedSunLight.intensity = inspection.keyLightIntensity;
linkedSunLight.position
.copy(resolveInspectionDirection(inspection.keyLightOffset))
.multiplyScalar(inspection.keyLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(inspection.backLightColor);
linkedBackLight.intensity = inspection.backLightIntensity;
linkedBackLight.position
.copy(resolveInspectionDirection(inspection.backLightOffset))
.multiplyScalar(inspection.backLightDistance);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(inspection.pointLightColor);
linkedPointLight.intensity = inspection.pointLightIntensity;
linkedPointLight.position
.copy(resolveInspectionDirection(inspection.pointLightOffset))
.multiplyScalar(inspection.pointLightDistance);
}
return;
}
const physicalSunDirection = getPhysicalSunDirection( const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()), new Date(lastUpdatedAt || Date.now()),
); );
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
}
if (linkedSunLight) { if (linkedSunLight) {
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor); linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity; linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
@@ -352,6 +421,16 @@ function updateLighting() {
.copy(physicalSunDirection) .copy(physicalSunDirection)
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7); .multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
} }
if (linkedPointLight) {
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
linkedPointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
}
} }
function computeCelestialState(date = new Date()) { function computeCelestialState(date = new Date()) {
@@ -364,14 +443,24 @@ function computeCelestialState(date = new Date()) {
export function initCelestialLayer( export function initCelestialLayer(
scene, scene,
{ camera = null, sunLight = null, backLight = null, earth = null } = {}, {
camera = null,
sunLight = null,
backLight = null,
ambientLight = null,
pointLight = null,
earth = null,
} = {},
) { ) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null; if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer(); disposeCelestialLayer();
linkedCamera = camera;
linkedSunLight = sunLight; linkedSunLight = sunLight;
linkedBackLight = backLight; linkedBackLight = backLight;
linkedAmbientLight = ambientLight;
linkedPointLight = pointLight;
linkedEarth = earth; linkedEarth = earth;
celestialRoot = new THREE.Group(); celestialRoot = new THREE.Group();
@@ -457,6 +546,10 @@ export function initCelestialLayer(
export function updateCelestialLayer(date = new Date(), camera = null) { export function updateCelestialLayer(date = new Date(), camera = null) {
if (!celestialRoot) return; if (!celestialRoot) return;
if (camera) {
linkedCamera = camera;
}
refreshCelestialView(); refreshCelestialView();
const now = date.getTime(); const now = date.getTime();
@@ -503,6 +596,11 @@ export function setCelestialFollow(nextFollow = {}) {
return getCelestialDebugState(); return getCelestialDebugState();
} }
export function setCelestialDayNightEnabled(enabled) {
dayNightLightingEnabled = enabled;
updateLighting();
}
export function disposeCelestialLayer() { export function disposeCelestialLayer() {
if (celestialRoot?.parent) { if (celestialRoot?.parent) {
celestialRoot.parent.remove(celestialRoot); celestialRoot.parent.remove(celestialRoot);
@@ -531,7 +629,11 @@ export function disposeCelestialLayer() {
lastUpdatedAt = 0; lastUpdatedAt = 0;
linkedSunLight = null; linkedSunLight = null;
linkedBackLight = null; linkedBackLight = null;
linkedAmbientLight = null;
linkedPointLight = null;
linkedCamera = null;
linkedEarth = null; linkedEarth = null;
dayNightLightingEnabled = true;
sunDirection.copy(defaultSunDirection); sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection); moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = { runtimeOrientationEuler = {

View File

@@ -12,6 +12,26 @@ export const CONFIG = {
dragRotationScaleMax: 2.0, 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 = { export const HUD_CONFIG = {
scaleReferenceWidth: 1920, scaleReferenceWidth: 1920,
scaleReferenceHeight: 1080, scaleReferenceHeight: 1080,
@@ -72,6 +92,64 @@ export const CELESTIAL_CONFIG = {
sunLightColor: 0xfff4df, sunLightColor: 0xfff4df,
backLightIntensity: 0.3, backLightIntensity: 0.3,
backLightColor: 0x2b4c78, backLightColor: 0x2b4c78,
inspectionLighting: {
enabled: true,
ambientIntensity: 0.64,
ambientColor: 0x707070,
keyLightIntensity: 0.92,
keyLightColor: 0xfcfcfb,
keyLightDistance: 380,
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
backLightIntensity: 0.26,
backLightColor: 0x8f96a0,
backLightDistance: 260,
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
pointLightIntensity: 0.36,
pointLightColor: 0xfafcff,
pointLightDistance: 320,
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
},
};
export const SCENE_LIGHT_CONFIG = {
ambient: {
color: 0x404060,
intensity: 1,
},
sun: {
color: 0xffffff,
intensity: 1.2,
position: { x: 5, y: 3, z: 5 },
},
back: {
color: 0x446688,
intensity: 0.3,
position: { x: -5, y: 0, z: -5 },
},
point: {
color: 0xffffff,
intensity: 0.4,
position: { x: 10, y: 10, z: 10 },
},
};
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 = { export const PATHS = {
@@ -150,7 +228,12 @@ export const CABLE_STATE = {
export const SATELLITE_CONFIG = { export const SATELLITE_CONFIG = {
maxCount: -1, maxCount: -1,
initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true,
trailLength: 10, trailLength: 10,
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotSize: 4, dotSize: 4,
ringSize: 0.07, ringSize: 0.07,
apiPath: '/api/v1/visualization/geo/satellites', apiPath: '/api/v1/visualization/geo/satellites',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
function nextAnimationFrame() {
return new Promise((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
export class CruiseSequencer {
constructor({
isActive,
getItems,
getItemId,
focusItem,
presentItem,
hideItem,
clearCurrent,
onStop,
dwellMs = 2400,
transitionGapMs = 24,
}) {
this.isActive = isActive;
this.getItems = getItems;
this.getItemId = getItemId;
this.focusItem = focusItem;
this.presentItem = presentItem;
this.hideItem = hideItem;
this.clearCurrent = clearCurrent;
this.onStop = onStop;
this.dwellMs = dwellMs;
this.transitionGapMs = transitionGapMs;
this.currentItemId = null;
this.currentIndex = -1;
this.queuedItemIds = [];
this.sequenceToken = 0;
this.advanceQueued = false;
this.advanceInterrupt = false;
this.advanceInFlight = false;
this.advanceLoopToken = 0;
this.primaryTimerId = null;
this.secondaryTimerId = null;
this.presentationVisible = false;
}
getCurrentItem() {
if (!this.currentItemId) return null;
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
}
getCurrentItemId() {
return this.currentItemId;
}
isPresentationPinned() {
return this.presentationVisible;
}
isBusy() {
return this.advanceInFlight || this.presentationVisible;
}
enqueue(itemIds = []) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
this.queuedItemIds = Array.from(
new Set([...itemIds.filter(Boolean), ...this.queuedItemIds]),
);
}
setPresentationVisible(visible) {
this.presentationVisible = Boolean(visible);
}
clearTimers() {
if (this.primaryTimerId) {
clearTimeout(this.primaryTimerId);
this.primaryTimerId = null;
}
if (this.secondaryTimerId) {
clearTimeout(this.secondaryTimerId);
this.secondaryTimerId = null;
}
}
interruptPresentation({ preservePresentation = false, resetLoop = false } = {}) {
this.sequenceToken += 1;
this.clearTimers();
this.advanceQueued = false;
this.advanceInterrupt = false;
if (resetLoop) {
this.advanceLoopToken += 1;
this.advanceInFlight = false;
}
if (!preservePresentation) {
this.presentationVisible = false;
this.clearCurrent?.();
}
}
stop({ preservePresentation = false } = {}) {
this.interruptPresentation({ preservePresentation });
this.currentItemId = preservePresentation ? this.currentItemId : null;
this.currentIndex = preservePresentation ? this.currentIndex : -1;
this.queuedItemIds = [];
this.onStop?.({ preservePresentation });
}
createContext(token) {
return {
token,
isCurrent: () => token === this.sequenceToken && this.isActive(),
wait: (durationMs, { secondary = false } = {}) =>
new Promise((resolve) => {
const timerId = window.setTimeout(() => {
if (secondary) {
if (this.secondaryTimerId === timerId) this.secondaryTimerId = null;
} else if (this.primaryTimerId === timerId) {
this.primaryTimerId = null;
}
resolve(token === this.sequenceToken && this.isActive());
}, durationMs);
if (secondary) {
this.secondaryTimerId = timerId;
} else {
this.primaryTimerId = timerId;
}
}),
nextFrame: nextAnimationFrame,
setPresentationVisible: (visible) => {
if (token !== this.sequenceToken) return;
this.presentationVisible = Boolean(visible);
},
};
}
resolveNextItem(items) {
let targetItem = null;
while (this.queuedItemIds.length > 0 && !targetItem) {
const queuedId = this.queuedItemIds.shift();
targetItem = items.find((item) => this.getItemId(item) === queuedId) || null;
}
if (targetItem) return targetItem;
const nextIndex = this.currentIndex >= 0 ? (this.currentIndex + 1) % items.length : 0;
return items[nextIndex] || items[0] || null;
}
async performAdvance({ interrupt = false } = {}) {
if (!this.isActive()) return;
const items = this.getItems();
if (!Array.isArray(items) || items.length === 0) return;
const targetItem = this.resolveNextItem(items);
if (!targetItem) return;
const token = ++this.sequenceToken;
const context = this.createContext(token);
this.clearTimers();
this.presentationVisible = false;
this.clearCurrent?.();
this.currentItemId = this.getItemId(targetItem);
this.currentIndex = items.findIndex(
(item) => this.getItemId(item) === this.currentItemId,
);
await this.focusItem?.(targetItem, { interrupt, context });
if (!context.isCurrent()) {
this.presentationVisible = false;
return;
}
const presented = await this.presentItem?.(targetItem, { interrupt, context });
if (!presented || !context.isCurrent()) {
this.presentationVisible = false;
return;
}
this.presentationVisible = true;
const dwellCompleted = await context.wait(this.dwellMs);
if (!dwellCompleted || !context.isCurrent()) {
this.presentationVisible = false;
return;
}
await this.hideItem?.(targetItem, { context });
if (!context.isCurrent()) {
this.presentationVisible = false;
return;
}
this.presentationVisible = false;
const gapCompleted = await context.wait(this.transitionGapMs, { secondary: true });
if (!gapCompleted || !context.isCurrent()) {
return;
}
void this.advance();
}
async advance({ interrupt = false } = {}) {
if (!this.isActive()) return;
this.advanceQueued = true;
this.advanceInterrupt = this.advanceInterrupt || interrupt;
if (this.advanceInFlight) return;
const activeLoopToken = ++this.advanceLoopToken;
this.advanceInFlight = true;
try {
while (
this.advanceQueued &&
this.isActive() &&
this.advanceLoopToken === activeLoopToken
) {
const nextInterrupt = this.advanceInterrupt;
this.advanceQueued = false;
this.advanceInterrupt = false;
await this.performAdvance({ interrupt: nextInterrupt });
}
} finally {
if (this.advanceLoopToken === activeLoopToken) {
this.advanceInFlight = false;
}
}
}
}

View File

@@ -1,7 +1,7 @@
// earth.js - 3D Earth creation module // earth.js - 3D Earth creation module
import * as THREE from 'three'; 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'; import { latLonToVector3 } from './utils.js';
export let earth = null; export let earth = null;
@@ -11,6 +11,7 @@ export let terrain = null;
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null; let _earthMaterial = null;
let _earthShader = null; let _earthShader = null;
let _dayNightEnabled = true;
const _earthSunDirection = new THREE.Vector3( const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -33,6 +34,7 @@ function applyEarthDayNightShader(material) {
shader.uniforms.uTwilightColor = { value: twilightColor }; shader.uniforms.uTwilightColor = { value: twilightColor };
shader.uniforms.uNightTintColor = { value: nightTintColor }; shader.uniforms.uNightTintColor = { value: nightTintColor };
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity }; shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
shader.vertexShader = shader.vertexShader.replace( shader.vertexShader = shader.vertexShader.replace(
"#include <common>", "#include <common>",
@@ -55,7 +57,8 @@ uniform float uTwilightWidth;
uniform float uTwilightIntensity; uniform float uTwilightIntensity;
uniform vec3 uTwilightColor; uniform vec3 uTwilightColor;
uniform vec3 uNightTintColor; uniform vec3 uNightTintColor;
uniform float uNightTintIntensity;`, uniform float uNightTintIntensity;
uniform float uDayNightEnabled;`,
).replace( ).replace(
"#include <output_fragment>", "#include <output_fragment>",
` `
@@ -65,16 +68,27 @@ uniform float uNightTintIntensity;`,
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing); float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing)); float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
outgoingLight *= mix(uNightFloor, uDayBoost, daylight); // Camera-facing diffuse: vNormal and vViewPosition are both in view space.
outgoingLight += uTwilightColor * twilight * uTwilightIntensity; // N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity; float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
float cameraBoost = mix(0.62, 1.08, nDotV);
float dn = uDayNightEnabled;
vec3 dnLight = outgoingLight;
dnLight *= mix(uNightFloor, uDayBoost, daylight);
dnLight += uTwilightColor * twilight * uTwilightIntensity;
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
// dn=1: full day/night solar lighting
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
#include <output_fragment> #include <output_fragment>
`, `,
); );
}; };
material.customProgramCacheKey = () => "earth-day-night-v1"; material.customProgramCacheKey = () => "earth-day-night-v5";
material.needsUpdate = true; material.needsUpdate = true;
} }
@@ -212,34 +226,35 @@ export function createClouds(scene, earthObj) {
return clouds; return clouds;
} }
export function createTerrain(scene, earthObj, simplex) { export function createTerrain(earthObj) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128); const geometry = new THREE.SphereGeometry(
const positionAttribute = geometry.getAttribute('position'); CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
TERRAIN_CONFIG.geometryWidthSegments,
for (let i = 0; i < positionAttribute.count; i++) { TERRAIN_CONFIG.geometryHeightSegments,
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();
const material = new THREE.MeshPhongMaterial({ const material = new THREE.MeshPhongMaterial({
color: 0x00aa00, color: TERRAIN_CONFIG.color,
flatShading: true, emissive: TERRAIN_CONFIG.emissive,
specular: TERRAIN_CONFIG.specular,
shininess: TERRAIN_CONFIG.shininess,
vertexColors: true,
vertexAlphas: true,
transparent: 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 = new THREE.Mesh(geometry, material);
terrain.name = "earth-real-terrain";
terrain.visible = false; terrain.visible = false;
terrain.renderOrder = 0.5;
earthObj.add(terrain); earthObj.add(terrain);
return terrain; return terrain;
} }
@@ -347,6 +362,28 @@ export function setEarthSunDirection(direction) {
} }
} }
export function setDayNightEnabled(enabled) {
_dayNightEnabled = enabled;
if (_earthShader?.uniforms?.uDayNightEnabled) {
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
if (_earthMaterial) {
if (enabled) {
// Restore normal Phong lighting + custom day/night shader
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
_earthMaterial.emissiveMap = null;
} else {
// Full bright: zero diffuse so directional light has no effect;
// use original color as emissive map to show texture uniformly.
_earthMaterial.color.setRGB(0, 0, 0);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissiveMap = _earthMaterial.map;
}
_earthMaterial.needsUpdate = true;
}
}
export function loadEarthTexture() { export function loadEarthTexture() {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; } if (!_earthMaterial) { resolve(); return; }
@@ -367,6 +404,10 @@ export function loadEarthTexture() {
texture.minFilter = THREE.LinearMipmapLinearFilter; texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter;
_earthMaterial.map = texture; _earthMaterial.map = texture;
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
if (!_dayNightEnabled) {
_earthMaterial.emissiveMap = texture;
}
_earthMaterial.needsUpdate = true; _earthMaterial.needsUpdate = true;
resolve(); resolve();
}, },

View File

@@ -238,7 +238,7 @@ function mountCard() {
cardMounted = true; cardMounted = true;
} }
function positionPanel(panel, x, y) { function positionPanel(panel, x, y, options = {}) {
if (!panel) return; if (!panel) return;
const margin = 12; const margin = 12;
const offset = 14; const offset = 14;
@@ -251,6 +251,22 @@ function positionPanel(panel, x, y) {
const estW = Math.min(300 * scale, vpW - 32); const estW = Math.min(300 * scale, vpW - 32);
const estH = Math.min(420 * scale, vpH * 0.7); 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 left = x + offset;
let top = y + offset; let top = y + offset;
@@ -263,10 +279,10 @@ function positionPanel(panel, x, y) {
panel.style.bottom = 'auto'; panel.style.bottom = 'auto';
} }
function showPanel(x, y) { function showPanel(x, y, options = {}) {
const panel = getPanel(); const panel = getPanel();
if (!panel) return; 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'); panel.classList.add('is-visible');
} }
@@ -327,7 +343,7 @@ export function showInfoCard(type, data, options = {}) {
} }
content.innerHTML = html; content.innerHTML = html;
showPanel(options.x, options.y); showPanel(options.x, options.y, options);
} }
export function hideInfoCard() { export function hideInfoCard() {

View File

@@ -0,0 +1,46 @@
export function setButtonTooltip(button, text) {
if (button instanceof HTMLElement) {
button.title = text;
}
const tooltip = button?.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = text;
}
}
export function updateLayerButtonState(button, isActive) {
if (!button) return;
button.classList.toggle("active", isActive);
button.setAttribute("aria-checked", isActive ? "true" : "false");
const state = button.querySelector(".earth-layer-btn__state");
if (state) {
state.textContent = isActive ? "ON" : "OFF";
}
}
export function setLayerButtonState(button, options = {}) {
if (!(button instanceof HTMLButtonElement)) return;
const {
active = null,
loading = false,
tooltip = null,
statusText = null,
} = options;
button.classList.toggle("is-loading", loading);
button.toggleAttribute("aria-busy", loading);
button.disabled = loading;
if (typeof active === "boolean") {
updateLayerButtonState(button, active);
}
if (tooltip) {
setButtonTooltip(button, tooltip);
}
if (statusText) {
const statusTarget = button.dataset.statusTarget
? document.getElementById(button.dataset.statusTarget)
: null;
if (statusTarget) {
statusTarget.textContent = statusText;
}
}
}

View File

@@ -0,0 +1,174 @@
import {
loadGeoJSONFromPath,
loadLandingPoints,
getCableLegendItems,
toggleCables,
} from "./cables.js";
import {
clearSatelliteData,
getSatelliteLegendItems,
loadSatellites,
toggleSatellites,
} from "./satellites.js";
import {
loadBGPAnomalies,
toggleBGP,
} from "./bgp.js";
/**
* Layer startup task registry.
*
* This module is the startup-task counterpart to the layer registry in controls.js:
* - controls.js owns layer metadata such as startupPriority/startupMode/startupMessage
* - this file owns the executable startup task factory for each layer id
*
* A startup task is registered via registerLayerStartupTask(id, taskFactory).
* The taskFactory receives a startup context from main.js and must return an async
* function with the signature async (layerDefinition) => void.
*
* Put a task here only when a layer needs dedicated startup loading work:
* - preloading data at boot
* - staged loading with progress/loading messages
* - post-load UI refresh or warmup
*
* Do not put plain visibility toggles or persistent UI state here; those still belong
* to the layer registry/state flow in controls.js.
*/
const startupTaskRegistry = new Map();
export function resolveStartupMessage(layer, key, fallback) {
const message = layer?.startupMessage;
if (message && typeof message === "object" && key in message) {
return message[key];
}
if (typeof message === "string" && message.trim()) {
return message;
}
return fallback;
}
export function createLayerStartupTaskMap(context) {
return Object.fromEntries(
Array.from(startupTaskRegistry.entries()).map(([id, taskFactory]) => [
id,
taskFactory(context),
]),
);
}
export function registerLayerStartupTask(id, taskFactory) {
if (typeof id !== "string" || !id.trim()) {
throw new Error("registerLayerStartupTask 需要有效的图层 id");
}
if (typeof taskFactory !== "function") {
throw new Error("registerLayerStartupTask 需要可调用的任务工厂");
}
startupTaskRegistry.set(id, taskFactory);
}
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCableStartupTask();
registerSatelliteStartupTask();
registerBGPStartupTask();
}
function registerCableStartupTask() {
registerLayerStartupTask("cables", (context) => async (layer) => {
if (!context.isCablesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
);
await context.yieldFrame(12);
try {
await loadLandingPoints(context.scene, context.earth, { silent: true });
} catch (error) {
context.reportError("登陆点", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海缆..."),
);
await context.yieldFrame(12);
try {
await loadGeoJSONFromPath(context.scene, context.earth, { silent: true });
if (!context.isCancelled() && context.isCablesEnabled()) {
toggleCables(true);
context.updateCableToggleUi(true);
context.setLegendItems("cables", getCableLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "海缆", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerSatelliteStartupTask() {
registerLayerStartupTask("satellites", (context) => async (layer) => {
if (!context.isSatellitesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载卫星..."),
);
await context.yieldFrame(12);
try {
clearSatelliteData();
const loadResult = await loadSatellites({
limit: context.getInitialSatelliteLoadLimit(),
});
if (!context.isCancelled() && context.isSatellitesEnabled()) {
context.updateSatelliteToggleUi(true, loadResult.count);
context.setLegendItems("satellites", getSatelliteLegendItems());
context.refreshLegend();
context.scheduleSatellitePositionWarmup(() => {
if (!context.isCancelled() && context.isSatellitesEnabled()) {
toggleSatellites(true);
}
});
if (context.shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = context.nextSatelliteHydrationToken();
context.hydrateAllSatellitesInBackground(
() =>
hydrationToken === context.getSatelliteHydrationToken() &&
!context.isCancelled() &&
context.isSatellitesEnabled(),
);
}
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "卫星", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerBGPStartupTask() {
registerLayerStartupTask("bgp", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
);
await context.yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(context.scene, context.earth);
if (!context.isCancelled()) {
toggleBGP(context.getShowBGP());
context.updateBGPHud(bgpResult);
context.syncBGPKnownEventIds();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "BGP态势", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
registerBuiltinLayerStartupTasks();

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { latLonToVector3 } from "./utils.js";
let satellitePoints = null; let satellitePoints = null;
let satelliteBackdropPoints = null;
let satelliteTrails = null; let satelliteTrails = null;
let satelliteData = []; let satelliteData = [];
let showSatellites = false; let showSatellites = false;
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
let lockedDotSprite = null; let lockedDotSprite = null;
let predictedOrbitLine = null; let predictedOrbitLine = null;
let relatedSatelliteSprites = []; let relatedSatelliteSprites = [];
let highlightedSatelliteIndices = null;
let earthObjRef = null; let earthObjRef = null;
let sceneRef = null; let sceneRef = null;
let cameraRef = null; let cameraRef = null;
@@ -24,6 +26,7 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null; let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0; let positionUpdateAccumulator = 0;
let satelliteCapacity = 0; let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32; const DOT_TEXTURE_SIZE = 32;
@@ -117,6 +120,10 @@ export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16); breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
} }
function getBreathingPulse(phase) {
return 0.5 + 0.5 * Math.sin(phase);
}
export function getSatelliteLegendItems() { export function getSatelliteLegendItems() {
const presentKeys = new Set(); const presentKeys = new Set();
@@ -204,6 +211,37 @@ function createDotTexture() {
return texture; 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") { function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const size = DOT_TEXTURE_SIZE * 2; const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@@ -226,8 +264,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
export function createSatellites(scene, earthObj) { export function createSatellites(scene, earthObj) {
initSatelliteScene(scene, earthObj); initSatelliteScene(scene, earthObj);
const dotTexture = createDotTexture(); const dotTexture = createDotTexture();
const backdropTexture = createBackdropDotTexture();
const pointsGeometry = new THREE.BufferGeometry(); 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({ const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize, size: SATELLITE_CONFIG.dotSize,
@@ -237,29 +288,45 @@ export function createSatellites(scene, earthObj) {
opacity: 0.9, opacity: 0.9,
sizeAttenuation: false, sizeAttenuation: false,
alphaTest: 0.1, 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 = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false; satellitePoints.visible = false;
satellitePoints.userData = { type: "satellitePoints" }; satellitePoints.userData = { type: "satellitePoints" };
satellitePoints.renderOrder = 6;
const originalScale = { x: 1, y: 1, z: 1 }; const originalScale = { x: 1, y: 1, z: 1 };
satellitePoints.onBeforeRender = () => { const syncPointScale = () => {
if (earthObj && earthObj.scale.x !== 1) { if (earthObj && earthObj.scale.x !== 1) {
satellitePoints.scale.set( const scaleX = originalScale.x / earthObj.scale.x;
originalScale.x / earthObj.scale.x, const scaleY = originalScale.y / earthObj.scale.y;
originalScale.y / earthObj.scale.y, const scaleZ = originalScale.z / earthObj.scale.z;
originalScale.z / earthObj.scale.z, satellitePoints.scale.set(scaleX, scaleY, scaleZ);
); if (satelliteBackdropPoints) {
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
}
} else { } else {
satellitePoints.scale.set( satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
originalScale.x, if (satelliteBackdropPoints) {
originalScale.y, satelliteBackdropPoints.scale.set(
originalScale.z, originalScale.x,
); originalScale.y,
originalScale.z,
);
}
} }
}; };
satelliteBackdropPoints.onBeforeRender = syncPointScale;
satellitePoints.onBeforeRender = syncPointScale;
earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints); earthObj.add(satellitePoints);
const trailGeometry = new THREE.BufferGeometry(); const trailGeometry = new THREE.BufferGeometry();
@@ -281,7 +348,12 @@ export function createSatellites(scene, earthObj) {
return satellitePoints; 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; return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
} }
@@ -295,13 +367,50 @@ function createSatellitePositionState() {
} }
function ensureSatelliteCapacity(count) { function ensureSatelliteCapacity(count) {
if (!satellitePoints || !satelliteTrails) return; if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
const nextCapacity = Math.max(count, 0); const nextCapacity = Math.max(count, 0);
if (nextCapacity === satelliteCapacity) return; 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 positions = new Float32Array(nextCapacity * 3);
const backdropPositions = new Float32Array(nextCapacity * 3);
const colors = 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( satellitePoints.geometry.setAttribute(
"position", "position",
new THREE.BufferAttribute(positions, 3), new THREE.BufferAttribute(positions, 3),
@@ -310,10 +419,26 @@ function ensureSatelliteCapacity(count) {
"color", "color",
new THREE.BufferAttribute(colors, 3), 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 trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = 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( satelliteTrails.geometry.setAttribute(
"position", "position",
new THREE.BufferAttribute(trailPositions, 3), new THREE.BufferAttribute(trailPositions, 3),
@@ -323,10 +448,19 @@ function ensureSatelliteCapacity(count) {
new THREE.BufferAttribute(trailColors, 3), new THREE.BufferAttribute(trailColors, 3),
); );
satellitePositions = Array.from( satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
{ length: nextCapacity }, const previousState = previousSatellitePositions[index];
createSatellitePositionState, if (!previousState) {
); return createSatellitePositionState();
}
return {
current: previousState.current.clone(),
trail: previousState.trail.slice(),
trailIndex: previousState.trailIndex,
trailCount: previousState.trailCount,
};
});
satelliteCapacity = nextCapacity; satelliteCapacity = nextCapacity;
} }
@@ -337,7 +471,7 @@ function computeSatellitePosition(satellite, time) {
return null; return null;
} }
const satrec = buildSatrecFromProperties(props, time); const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) { if (!satrec || satrec.error) {
return null; return null;
} }
@@ -356,7 +490,8 @@ function computeSatellitePosition(satellite, time) {
} }
const r = Math.sqrt(x * x + y * y + z * z); const r = Math.sqrt(x * x + y * y + z * z);
const displayRadius = CONFIG.earthRadius * 1.05; const displayRadius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const scale = displayRadius / r; const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale); return new THREE.Vector3(x * scale, y * scale, z * scale);
@@ -382,6 +517,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
return twoline2satrec(tleLines.line1, tleLines.line2); 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) { function computeTleChecksum(line) {
let sum = 0; let sum = 0;
@@ -464,7 +638,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
} }
function generateFallbackPosition(satellite, index, total) { function generateFallbackPosition(satellite, index, total) {
const radius = CONFIG.earthRadius + 5; const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index; const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53; const inclination = satellite.properties?.inclination || 53;
@@ -491,8 +665,8 @@ function generateFallbackPosition(satellite, index, total) {
return new THREE.Vector3(x, y, z); return new THREE.Vector3(x, y, z);
} }
export async function loadSatellites() { export async function loadSatellites(options = {}) {
const limit = getRequestedSatelliteLimit(); const limit = getRequestedSatelliteLimit(options.limit);
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin); const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
if (limit !== null) { if (limit !== null) {
url.searchParams.set("limit", String(limit)); url.searchParams.set("limit", String(limit));
@@ -505,13 +679,17 @@ export async function loadSatellites() {
const data = await response.json(); const data = await response.json();
satelliteData = data.features || []; satelliteData = data.features || [];
satelliteSatrecCache = new Map();
ensureSatelliteCapacity(satelliteData.length); ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satelliteData.length; return {
count: satelliteData.length,
requestedLimit: limit,
};
} }
export function updateSatellitePositions(deltaTime = 0, force = false) { export function updateSatellitePositions(deltaTime = 0, force = false) {
if (!satellitePoints || satelliteData.length === 0) return; if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails = const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null; showSatellites || showTrails || lockedSatelliteIndex !== null;
@@ -528,6 +706,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positionUpdateAccumulator = 0; positionUpdateAccumulator = 0;
const positions = satellitePoints.geometry.attributes.position.array; const positions = satellitePoints.geometry.attributes.position.array;
const backdropPositions =
satelliteBackdropPoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array; const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array; const trailColors = satelliteTrails.geometry.attributes.color.array;
@@ -559,13 +739,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = pos.x; positions[i * 3] = pos.x;
positions[i * 3 + 1] = pos.y; positions[i * 3 + 1] = pos.y;
positions[i * 3 + 2] = pos.z; 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 rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule); const { r, g, b } = getSatelliteRuleColor(rule);
colors[i * 3] = r; if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) {
colors[i * 3 + 1] = g; const lum = r * 0.299 + g * 0.587 + b * 0.114;
colors[i * 3 + 2] = b; 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]; const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) { for (let j = 0; j < TRAIL_LENGTH; j++) {
@@ -601,6 +791,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = 0; positions[i * 3] = 0;
positions[i * 3 + 1] = 0; positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 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++) { for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3; const trailIdx = (i * TRAIL_LENGTH + j) * 3;
@@ -613,6 +806,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
satellitePoints.geometry.attributes.position.needsUpdate = true; satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true; satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count); 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.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true; satelliteTrails.geometry.attributes.color.needsUpdate = true;
@@ -631,6 +826,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
export function toggleSatellites(visible) { export function toggleSatellites(visible) {
showSatellites = visible; showSatellites = visible;
if (satelliteBackdropPoints) {
satelliteBackdropPoints.visible = visible;
}
if (satellitePoints) { if (satellitePoints) {
satellitePoints.visible = visible; satellitePoints.visible = visible;
} }
@@ -646,6 +844,10 @@ export function toggleTrails(visible) {
} }
} }
export function getShowTrails() {
return showTrails;
}
export function getShowSatellites() { export function getShowSatellites() {
return showSatellites; return showSatellites;
} }
@@ -705,7 +907,10 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
.subVectors(scratchWorldSatellitePosition, earthObjRef.position) .subVectors(scratchWorldSatellitePosition, earthObjRef.position)
.normalize(); .normalize();
return scratchToCamera.dot(scratchToSatellite) > 0; return (
scratchToCamera.dot(scratchToSatellite) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
} }
function createBrighterDotCanvas() { function createBrighterDotCanvas() {
@@ -751,6 +956,7 @@ function createRingSprite(position, isLocked = false) {
const sprite = new THREE.Sprite(spriteMaterial); const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position); sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1); sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite); earthObjRef.add(sprite);
return sprite; return sprite;
} }
@@ -770,6 +976,7 @@ function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
const sprite = new THREE.Sprite(spriteMaterial); const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position); sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1); sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite); earthObjRef.add(sprite);
return sprite; return sprite;
} }
@@ -792,6 +999,7 @@ export function showHoverRing(position, isLocked = false) {
lockedDotSprite = new THREE.Sprite(dotMaterial); lockedDotSprite = new THREE.Sprite(dotMaterial);
lockedDotSprite.position.copy(position); lockedDotSprite.position.copy(position);
lockedDotSprite.scale.set(4, 4, 1); lockedDotSprite.scale.set(4, 4, 1);
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
earthObjRef.add(lockedDotSprite); earthObjRef.add(lockedDotSprite);
return lockedRingSprite; return lockedRingSprite;
} }
@@ -821,10 +1029,15 @@ export function hideLockedRing() {
export function updateLockedRingPosition(position) { export function updateLockedRingPosition(position) {
if (!position) return; if (!position) return;
if (!lockedRingSprite || !lockedDotSprite) {
showHoverRing(position, true);
}
if (lockedRingSprite) { if (lockedRingSprite) {
lockedRingSprite.position.copy(position); lockedRingSprite.position.copy(position);
const ringPulse = getBreathingPulse(breathingPhase);
const breathScale = const breathScale =
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; 1 +
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
lockedRingSprite.scale.set( lockedRingSprite.scale.set(
SATELLITE_CONFIG.ringSize * breathScale, SATELLITE_CONFIG.ringSize * breathScale,
SATELLITE_CONFIG.ringSize * breathScale, SATELLITE_CONFIG.ringSize * breathScale,
@@ -832,20 +1045,21 @@ export function updateLockedRingPosition(position) {
); );
lockedRingSprite.material.opacity = lockedRingSprite.material.opacity =
SATELLITE_CONFIG.breathingOpacityMin + SATELLITE_CONFIG.breathingOpacityMin +
Math.sin(breathingPhase) * ringPulse *
(SATELLITE_CONFIG.breathingOpacityMax - (SATELLITE_CONFIG.breathingOpacityMax -
SATELLITE_CONFIG.breathingOpacityMin); SATELLITE_CONFIG.breathingOpacityMin);
} }
if (lockedDotSprite) { if (lockedDotSprite) {
lockedDotSprite.position.copy(position); lockedDotSprite.position.copy(position);
const dotPulse = getBreathingPulse(breathingPhase);
const dotBreathScale = const dotBreathScale =
1 + 1 +
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1); lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
lockedDotSprite.material.opacity = lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin + SATELLITE_CONFIG.dotOpacityMin +
Math.sin(breathingPhase) * dotPulse *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
} }
} }
@@ -881,6 +1095,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() { export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => { relatedSatelliteSprites.forEach((item) => {
if (item.sprite) { if (item.sprite) {
@@ -888,12 +1111,16 @@ export function clearRelatedSatelliteHighlights() {
} }
}); });
relatedSatelliteSprites = []; relatedSatelliteSprites = [];
highlightedSatelliteIndices = null;
applyDimMaterialState(false);
} }
export function highlightRelatedSatellites(indices, color = "#7dd3fc") { export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights(); clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return; if (!Array.isArray(indices) || indices.length === 0) return;
highlightedSatelliteIndices = new Set(indices);
applyDimMaterialState(true);
indices.forEach((index) => { indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current; const pos = satellitePositions?.[index]?.current;
if (!pos) return; if (!pos) return;
@@ -983,7 +1210,8 @@ function calculatePredictedOrbit(
if (points.length < samples * 0.5) { if (points.length < samples * 0.5) {
points.length = 0; points.length = 0;
const radius = CONFIG.earthRadius + 5; const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const inclination = satellite.properties?.inclination || 53; const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0; const raan = satellite.properties?.raan || 0;
@@ -1034,9 +1262,12 @@ export function showPredictedOrbit(satellite) {
transparent: true, transparent: true,
opacity: 0.8, opacity: 0.8,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
}); });
predictedOrbitLine = new THREE.Line(geometry, material); predictedOrbitLine = new THREE.Line(geometry, material);
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(predictedOrbitLine); earthObjRef.add(predictedOrbitLine);
} }
@@ -1049,10 +1280,12 @@ export function hidePredictedOrbit() {
export function clearSatelliteData() { export function clearSatelliteData() {
satelliteData = []; satelliteData = [];
satelliteSatrecCache = new Map();
selectedSatellite = null; selectedSatellite = null;
lockedSatelliteIndex = null; lockedSatelliteIndex = null;
hoveredSatelliteIndex = null; hoveredSatelliteIndex = null;
positionUpdateAccumulator = 0; positionUpdateAccumulator = 0;
breathingPhase = 0;
satellitePositions.forEach((position) => { satellitePositions.forEach((position) => {
position.current.set(0, 0, 0); position.current.set(0, 0, 0);
@@ -1075,6 +1308,16 @@ export function clearSatelliteData() {
satellitePoints.geometry.setDrawRange(0, 0); 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) { if (satelliteTrails) {
const trailPositionAttr = satelliteTrails.geometry.attributes.position; const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color; const trailColorAttr = satelliteTrails.geometry.attributes.color;
@@ -1097,6 +1340,11 @@ export function clearSatelliteData() {
export function resetSatelliteState() { export function resetSatelliteState() {
clearSatelliteData(); clearSatelliteData();
if (satelliteBackdropPoints) {
disposeObject3D(satelliteBackdropPoints);
satelliteBackdropPoints = null;
}
if (satellitePoints) { if (satellitePoints) {
disposeObject3D(satellitePoints); disposeObject3D(satellitePoints);
satellitePoints = null; satellitePoints = null;
@@ -1109,6 +1357,7 @@ export function resetSatelliteState() {
satellitePositions = []; satellitePositions = [];
satelliteCapacity = 0; satelliteCapacity = 0;
satelliteSatrecCache = new Map();
showSatellites = false; showSatellites = false;
showTrails = true; showTrails = true;
} }

View File

@@ -0,0 +1,318 @@
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 resolvedTileCache = 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,
);
const tileData = { data, width, height };
resolvedTileCache.set(cacheKey, tileData);
return tileData;
})();
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();
resolvedTileCache = 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();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity;
}
export function sampleElevationAt(lat, lon) {
if (!terrainReady) return null;
const z = TERRAIN_CONFIG.baseZoom;
const { tileX, tileY, pixelX, pixelY } = latLonToTileSample(lat, lon, z, TERRAIN_CONFIG.tileSize);
const tile = resolvedTileCache.get(`${z}/${tileX}/${tileY}`);
if (!tile) return null;
return Math.max(0, decodeTerrariumHeight(tile, pixelX, pixelY));
}
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;
}

View File

@@ -9,6 +9,11 @@ let statusQueue = [];
let statusBusy = false; let statusBusy = false;
let loadingActive = false; let loadingActive = false;
let loadingLockedWidth = 0; let loadingLockedWidth = 0;
let pendingLoadingMessage = "";
function createStatusEntry(message, type = "info") {
return { message, type };
}
function getElement(id) { function getElement(id) {
return document.getElementById(id); return document.getElementById(id);
@@ -67,6 +72,10 @@ function buildStatusContent(statusEl, message, type) {
statusEl.appendChild(text); statusEl.appendChild(text);
} }
function buildPersistentErrorContent(errorEl, message) {
buildStatusContent(errorEl, message, "error");
}
function hideStatusElement(statusEl, onHidden) { function hideStatusElement(statusEl, onHidden) {
statusEl.classList.remove("visible"); statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => { statusHideTimeoutId = setTimeout(() => {
@@ -113,7 +122,15 @@ function startTransientStatus(message, type = "info") {
// Show status message // Show status message
export function showStatusMessage(message, type = "info") { 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(); processStatusQueue();
} }
@@ -179,7 +196,12 @@ export function setLoading(loading) {
loadingActive = true; loadingActive = true;
statusBusy = false; statusBusy = false;
clearLoadingWidthLock(statusEl); clearLoadingWidthLock(statusEl);
buildStatusContent(statusEl, "正在加载...", "loading"); buildStatusContent(
statusEl,
pendingLoadingMessage || "正在加载...",
"loading",
);
pendingLoadingMessage = "";
statusEl.className = `${STATUS_BASE_CLASS} loading`; statusEl.className = `${STATUS_BASE_CLASS} loading`;
setElementDisplay(statusEl, true, "inline-flex"); setElementDisplay(statusEl, true, "inline-flex");
statusEl.offsetHeight; statusEl.offsetHeight;
@@ -188,6 +210,7 @@ export function setLoading(loading) {
updateLoadingWidthLock(statusEl); updateLoadingWidthLock(statusEl);
}); });
} else { } else {
pendingLoadingMessage = "";
if (!statusEl.classList.contains("loading")) { if (!statusEl.classList.contains("loading")) {
loadingActive = false; loadingActive = false;
clearLoadingWidthLock(statusEl); clearLoadingWidthLock(statusEl);
@@ -205,7 +228,10 @@ export function setLoading(loading) {
export function setLoadingMessage(title) { export function setLoadingMessage(title) {
const statusEl = getElement("status-message"); 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"); const textEl = statusEl.querySelector(".earth-status-text");
if (textEl) { if (textEl) {
textEl.textContent = title; textEl.textContent = title;
@@ -237,16 +263,21 @@ export function hideTooltip() {
export function showError(message) { export function showError(message) {
const errorEl = getElement("error-message"); const errorEl = getElement("error-message");
if (!errorEl) return; if (!errorEl) return;
errorEl.textContent = message; buildPersistentErrorContent(errorEl, message);
setElementDisplay(errorEl, true); errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
setElementDisplay(errorEl, true, "inline-flex");
errorEl.offsetHeight;
errorEl.classList.add("visible");
} }
// Hide error message // Hide error message
export function hideError() { export function hideError() {
const errorEl = getElement("error-message"); const errorEl = getElement("error-message");
if (errorEl) { if (errorEl) {
errorEl.classList.remove("visible");
setElementDisplay(errorEl, false); setElementDisplay(errorEl, false);
errorEl.textContent = ""; errorEl.className = "earth-error-message";
errorEl.innerHTML = "";
} }
} }
@@ -255,6 +286,7 @@ export function clearUiState() {
statusQueue = []; statusQueue = [];
statusBusy = false; statusBusy = false;
loadingActive = false; loadingActive = false;
pendingLoadingMessage = "";
const statusEl = getElement("status-message"); const statusEl = getElement("status-message");
if (statusEl) { if (statusEl) {

View File

@@ -1054,6 +1054,14 @@ start_ai_provider_service() {
exit 1 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() { ensure_database_services_healthy() {
local retry=1 local retry=1
@@ -1130,7 +1138,15 @@ start_backend_service() {
log_success "启动数据库已就绪" log_success "启动数据库已就绪"
sleep 3 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 if [ "$backend_port_requested" -eq 1 ]; then
kill_port_if_requested "$backend_port" "后端" kill_port_if_requested "$backend_port" "后端"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.29.2" version = "0.31.3"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [

2
uv.lock generated
View File

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