Compare commits

...

10 Commits

Author SHA1 Message Date
rayd1o
1cd2dab0ee release: bump version to 0.42.2 2026-04-28 04:35:13 +08:00
rayd1o
42d019af36 release: bump version to 0.42.1 2026-04-28 04:29:44 +08:00
rayd1o
b4e8afb272 release: bump version to 0.42.0 2026-04-28 04:27:18 +08:00
rayd1o
eeee788530 release: bump version to 0.41.2 2026-04-27 23:23:23 +08:00
linkong
655e2a7d2d release: bump version to 0.41.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:31:34 +08:00
linkong
3ea99a9529 release: bump version to 0.41.0 2026-04-27 13:58:29 +08:00
rayd1o
f9c1334365 release: bump version to 0.40.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 05:03:30 +08:00
rayd1o
5f47ec1659 release: bump version to 0.40.4 2026-04-26 01:41:29 +08:00
rayd1o
229be0bced release: bump version to 0.40.3 2026-04-25 23:02:22 +08:00
linkong
50a417ca83 release: bump version to 0.40.2 2026-04-24 17:50:43 +08:00
89 changed files with 10110 additions and 511 deletions

View File

@@ -18,7 +18,7 @@ Do not use this skill for ordinary commits that are not being released.
## Versioning Rules
- `feature` -> bump `+0.1.0`
- `feature` -> bump minor and reset patch to `0` (`x.y.z``x.(y+1).0`; for example `0.41.2``0.42.0`)
- `bugfix` -> bump `+0.0.1`
- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release
@@ -53,7 +53,9 @@ If unrelated uncommitted changes exist, list them and ask the user whether to in
- If the user provided an explicit type (`feature` / `bugfix`), use it
- Otherwise infer from `git diff HEAD` and recent `git log`
- Compute the next version (e.g. `0.26.2` → bugfix → `0.26.3`)
- Compute the next version:
- `feature`: increment minor and reset patch to `0` (e.g. `0.41.2``0.42.0`)
- `bugfix`: increment patch only (e.g. `0.26.2``0.26.3`)
- **Show the release plan before making any changes:**
```

View File

@@ -1 +1 @@
0.40.1
0.42.2

View File

@@ -1,6 +1,10 @@
FROM python:3.14-slim
ARG PYTHON_IMAGE=python:3.14-slim
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
FROM ${UV_IMAGE} AS uv
FROM ${PYTHON_IMAGE}
COPY --from=uv /uv /uvx /bin/
WORKDIR /app

View File

@@ -1,6 +1,10 @@
FROM python:3.14-slim
ARG PYTHON_IMAGE=python:3.14-slim
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
FROM ${UV_IMAGE} AS uv
FROM ${PYTHON_IMAGE}
COPY --from=uv /uv /uvx /bin/
WORKDIR /app

View File

@@ -1353,6 +1353,71 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/summary")
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"celestrak_tle",
"top500",
"epoch_ai_gpu",
],
)
cables = convert_cable_to_geojson(records_by_source.get("arcgis_cables", []))
landing_points = convert_landing_point_to_geojson(
records_by_source.get("arcgis_landing_points", []),
)
satellites = convert_satellite_to_geojson(
_filter_known_records(records_by_source.get("celestrak_tle", [])),
)
compute_centers = convert_compute_centers_to_geojson(
_filter_known_records(
records_by_source.get("top500", [])
+ records_by_source.get("epoch_ai_gpu", []),
),
)
compute_features = compute_centers.get("features", [])
active_incident_result = await db.execute(
select(func.count(BGPIncident.id)).where(BGPIncident.status == "active"),
)
active_anomaly_result = await db.execute(
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active"),
)
active_incident_count = int(active_incident_result.scalar() or 0)
active_anomaly_count = int(active_anomaly_result.scalar() or 0)
bgp_collectors = await build_bgp_collector_coverage(
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
)
return {
"generated_at": to_iso8601_utc(datetime.now(UTC)),
"stats": {
"cable_count": len(cables.get("features", [])),
"landing_point_count": len(landing_points.get("features", [])),
"satellite_count": len(satellites.get("features", [])),
"compute_center_count": len(compute_features),
"supercomputer_count": sum(
1 for feature in compute_features
if feature.get("properties", {}).get("site_type") == "supercomputer"
),
"gpu_cluster_count": sum(
1 for feature in compute_features
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
),
"bgp_event_count": active_incident_count or active_anomaly_count,
"bgp_incident_count": active_incident_count,
"bgp_anomaly_count": active_anomaly_count,
"bgp_collector_count": len([item for item in bgp_collectors if item.get("collector")]),
},
}
@router.get("/all")
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
"""获取所有可视化数据的统一端点

View File

@@ -215,3 +215,133 @@ async def test_compute_centers_geojson_endpoint_returns_stats():
assert data["features"][0]["properties"]["data_type"] == "compute_center"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_visualization_geo_summary_returns_counts(monkeypatch):
records = [
_build_record(
record_id=1,
source="arcgis_cables",
data_type="submarine_cable",
name="Test Cable",
country="",
city="",
latitude=0,
longitude=0,
metadata={
"route_coordinates": [[[0, 0], [1, 1]]],
"status": "active",
},
),
_build_record(
record_id=2,
source="arcgis_landing_points",
data_type="landing_point",
name="Test Landing",
country="United States",
city="New York",
latitude=40.7,
longitude=-74.0,
metadata={"city_id": 10},
),
_build_record(
record_id=3,
source="celestrak_tle",
data_type="satellite_tle",
name="TESTSAT",
country="",
city="",
latitude=0,
longitude=0,
metadata={
"norad_cat_id": 12345,
"tle_line1": "1 12345U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 9991",
"tle_line2": "2 12345 51.6000 100.0000 0001000 10.0000 20.0000 15.50000000 01",
},
),
_build_record(
record_id=4,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="Oak Ridge",
latitude=35.93,
longitude=-84.31,
metadata={"rank": 1, "rmax": 1102000.0},
),
_build_record(
record_id=5,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Colossus",
country="United States",
city="Memphis",
latitude=35.15,
longitude=-90.05,
metadata={"value": "20000", "unit": "TFlop/s"},
),
]
class _ScalarResult:
def __init__(self, rows=None, scalar_value=None):
self._rows = rows or []
self._scalar_value = scalar_value
def scalar(self):
return self._scalar_value
def scalars(self):
class _Scalars:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
return _Scalars(self._rows)
class _FakeSession:
async def execute(self, query):
query_text = str(query)
if "bgp_incidents" in query_text:
return _ScalarResult(scalar_value=2)
if "bgp_anomalies" in query_text:
return _ScalarResult(scalar_value=3)
return _ScalarResult(rows=records)
async def override_get_db():
yield _FakeSession()
async def _fake_build_bgp_collector_coverage(*_args, **_kwargs):
return [
{"collector": "rrc00"},
{"collector": "rrc01"},
]
monkeypatch.setattr(
"app.api.v1.visualization.build_bgp_collector_coverage",
_fake_build_bgp_collector_coverage,
)
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/visualization/geo/summary")
assert response.status_code == 200
stats = response.json()["stats"]
assert stats["cable_count"] == 1
assert stats["landing_point_count"] == 1
assert stats["satellite_count"] == 1
assert stats["compute_center_count"] == 2
assert stats["supercomputer_count"] == 1
assert stats["gpu_cluster_count"] == 1
assert stats["bgp_event_count"] == 2
assert stats["bgp_incident_count"] == 2
assert stats["bgp_anomaly_count"] == 3
assert stats["bgp_collector_count"] == 2
finally:
app.dependency_overrides.clear()

View File

@@ -18,6 +18,9 @@ services:
build:
context: .
dockerfile: aiprovider/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -5,6 +5,9 @@ services:
build:
context: .
dockerfile: aiprovider/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -5,6 +5,9 @@ services:
build:
context: .
dockerfile: aiprovider/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
env_file:
- ./aiprovider/.env
container_name: planet_aiprovider

View File

@@ -8,8 +8,118 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.42.2] — 2026-04-28
### 🐛 Fixes
- Docs 中文模式下补齐左侧分组、文档标题、页头分类与搜索结果分类翻译,并更新文档站品牌标题/副标题文案
---
## [0.42.1] — 2026-04-28
### 🐛 Fixes
- 修正 release skill 的 feature 版本计算规则minor 进位时 patch 必须重置为 `0`,例如 `0.41.2` 应发布为 `0.42.0`
---
## [0.42.0] — 2026-04-28
### ✨ Highlights
- 新增公开 `/docs` 文档站支持中英文技术文档、使用手册、Quickstart、搜索、目录锚点与浅色/深色/跟随系统主题
- Earth 在无高清材质时新增轻量 Fresnel 边缘提示,并调整卫星覆盖默认显示与地表材质可读性
### 🔧 Improvements
- 将技术文档整理为 `docs/technical/zh``docs/technical/en`,并补充控制台、`planet.sh`、Earth 与公共组件使用说明
- 新增 `SegmentedControl` 公共滑块组件,支持缩放参数,复用到 docs 语言与主题切换
- Markdown 渲染器接入自定义滚动条,表格与代码块在深色模式和 overflow 场景下保持可读
- Docs 搜索结果支持内部滚动、点击外部关闭、重新聚焦恢复上次搜索结果
- Earth 工具栏展开状态与设置持久化版本迁移继续收口,改善默认面板和快捷关闭行为
---
## [0.41.2] — 2026-04-27
### 🔧 Improvements
- `planet.sh` 启动链路新增 verbose 滚动输出窗口,并在后端端口占用时打印目标地址和监听进程诊断
- Docker 构建支持通过 build args 覆盖 Python 与 uv 镜像,方便 Docker Hub 不稳定时切换镜像源
### 🐛 Fixes
- Earth 海缆登陆点改为基于相机射线与地球遮挡判断可见性,修复旋转后 pin 可见性滞后一帧的问题
### 🔧 Improvements
- `docker-compose*.yml` 为 AI Provider 构建传入 `PYTHON_IMAGE` / `UV_IMAGE` 参数,默认仍使用官方镜像
- 后端启动失败遇到 `Address already in use` 时输出 `lsof``ss` 与 PID 命令行信息
- verbose 模式下 AI Provider build、后端与前端启动日志会在 spinner 下方保留最新 5 行滚动展示
---
## [0.41.1] — 2026-04-27
### 🐛 Fixes
- 修复新闻直播面板设置项持久化失效:`closeTransientMobileOverlays` 通过旁路路径隐藏面板导致下次 persist 快照到错误状态,改为不重新从 DOM 读取面板可见性
- 修复登陆点 pin 在地球侧面被半截遮挡改为在接近地平线前dot < 0.05)主动隐藏,避免深度测试切片
### 🔧 Improvements
- 将所有画布绘制的图标抽取为 SVG存入 `frontend/public/earth/assets/icons/`,新增图标规范到 `rules.md`
---
## [0.41.0] — 2026-04-27
### ✨ Highlights
- Earth 图层系统完成地表到天空的注册顺序与关注优先的面板顺序拆分支持基座海陆色块、国界、高清材质、云图、地形、算力、BGP、卫星、轨迹与海缆的稳定层级
- 国界层新增真实行政区轮廓交互与中国/台湾联动高亮修复高清材质、地形、footprint、卫星与经纬线之间的遮挡和 hover 竞争
### 🔧 Improvements
- 新增无轮廓基座地图,所有图层关闭时仍保留 `#010609` 海洋与 `#080f1b` 陆地色块
- 将大气云图抽象为独立图层并接入桌面/移动端图层开关、持久化状态与启动同步
- 高清材质改为独立纹理覆盖层,地形显示在高清材质上方,并在高清材质关闭/恢复时保持原地形开关意图
- 补充 Earth 渲染层级与图层样式文档,记录正式图层名、变量名、材质颜色、线宽与 renderOrder
---
## [0.40.5] — 2026-04-26
### 🔧 Improvements
- 卫星拖尾改用 Instanced screen-space ribbon单 draw call 渲染所有轨迹段,支持像素级宽度控制
- Iridium 地面覆盖重写为球面投影径向网格,修复填充光晕不可见问题;新增外圈 LineLoop
- 搜索面板打开时改用双 rAF 延迟聚焦输入框,确保 CSS 过渡完成后焦点可靠触发
- 代码清理:提取 `IRIDIUM_OVERLAY_COLOR``IRIDIUM_REFERENCE_ALTITUDE_KM` 常量,消除重复三角函数调用
---
## [0.39.0] — 2026-04-24
## [0.40.4] — 2026-04-26
### 🔧 Improvements
- 新增页面可见性恢复处理,页面从后台切回前台时主动刷新卫星位置,避免累积后台时间在下一帧一次性回放
- 抽出卫星轨迹状态与轨迹几何清理 helper统一后台恢复与清空数据时的轨迹重置路径
### 🐛 Fixes
- 修复页面在后台停留较久后恢复前台时,卫星轨迹因超大 `deltaTime` 突然跳变、拖尾异常拉长的问题
- 修复后台恢复后首帧仍沿用旧轨迹缓存,导致轨迹与当前卫星位置短时错位的问题
---
## [0.40.3] — 2026-04-25
### 🔧 Improvements
- 卫星点云升级为自定义 ShaderMaterial支持 per-point alpha 控制,锁定/悬停卫星从点云中精确隐藏
- 修复锁定环与自发光选中标记的 depthTest 错误false → true消除远端渲染穿透 artifact
- 新增锁定环悬停态缩放与线宽LOCKED_RING_HOVER_SCALE / LOCKED_RING_HOVER_LINE_WIDTH
- 修复 updateLockedDotWorldTransform / updateLockedHaloWorldTransform 未强制刷新 matrixWorld 导致的位置漂移
---
## [0.40.2] — 2026-04-24
### 🔧 Improvements
- 卫星点大小随镜头缩放动态调整,拉近变大、拉远变小,响应与相机距离线性对应
- 调小卫星点默认基础尺寸dotSize 2.8),缩放范围更合理
---
## [0.40.1] — 2026-04-24
### 🔧 Improvements

View File

@@ -19,10 +19,12 @@
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
- [earth-country-boundary-overlay-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-country-boundary-overlay-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)
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-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)

View File

@@ -0,0 +1,486 @@
# Frontend Public Docs Site Plan
## 目标
新增一个公开访问的 `/docs` 页面,作为 Planet 的开发设计文档与使用手册入口。
这个页面应类似常见开源软件文档站:
- 不需要登录即可访问
-`/earth` 和 admin 后台平级,但视觉和信息架构独立
- 直接整理并展示仓库内 `docs/technical` 的 Markdown 文档
- 支持搜索、分类导航、文档目录和内部跳转
-`docs/technical` 继续作为文档真源,避免页面内容和仓库文档漂移
## 非目标
本阶段不做:
- 后端全文搜索服务
- 数据库驱动的 CMS
- 独立文档构建系统,例如 Docusaurus / VitePress
- 每篇文档单独手写 React 页面
- 用户权限、编辑器、在线保存或评论功能
-`docs/plans``docs/deprecated` 全量公开为正式手册
后续可以再决定是否把 plans / deprecated 做成独立的“路线图 / 历史归档”分区。
## 技术路线
### 推荐方案Markdown 直接渲染
使用 Vite 在前端构建阶段直接加载 `docs/technical/**/*.md`
```ts
const modules = import.meta.glob('../../../docs/technical/**/*.md', {
query: '?raw',
import: 'default',
})
```
这样每篇 Markdown 文件仍然留在仓库文档目录中,`/docs` 页面只是读取、索引和渲染这些文档。
当前项目已经满足主要前提:
- 前端使用 Vite + React
- `frontend/vite.config.ts` 已配置 `server.fs.allow: ['..']`
- 已有 `MarkdownRenderer` 可作为基础
- `docs/technical` 文档数量较少,前端本地搜索足够
### 不推荐方案:每篇文档单独写 React
不建议把每篇文档重写成 `.tsx` 页面,因为:
- 文档会出现两份真源
- 修改技术文档时还要同步 UI 页面
- 计划文档、技术上下文、变量表这类内容天然适合 Markdown
- 后续新增文档的成本会变高
只有当某篇文档需要强交互演示、实时图表或复杂 UI 时,才考虑给该文档补充一个 React 组件扩展。
## 信息架构
### 公开路由
新增:
- `/docs`
- `/docs/:slug`
路由行为:
- `/docs` 默认打开 `docs/technical/README.md`,或打开人工指定的首页文档
- `/docs/:slug` 打开对应技术文档
- 未找到文档时显示 docs 专属 404而不是跳回 admin
- `/docs` 加入 `App.tsx` 的公开路由白名单
### 文档分类
`docs/technical` 中的现有文档整理进以下分组:
#### Overview
- `README.md`
#### Earth
- `earth-frontend-context.md`
- `earth-layer-style-reference.md`
- `earth-render-layer-order.md`
- `earth-satellite-footprint-policy.md`
- `earth-bgp-context.md`
- `earth-news-live-streams-collector-format.md`
#### Frontend
- `frontend-admin-frontend-context.md`
- `frontend-layout-guidelines.md`
#### Backend
- `backend-collectors.md`
- `backend-system-service-control.md`
#### Agents
- `agents-aiprovider.md`
#### Ops
- `ops-docker-compose-buildx-upgrade.md`
### 页面布局
桌面端:
- 顶部:产品名、搜索框、当前文档标题
- 左侧:文档分组导航
- 中间Markdown 正文
- 右侧:当前文档目录,也就是 h2 / h3 anchors
移动端:
- 顶部固定搜索入口
- 导航折叠为抽屉或下拉
- 正文单列显示
- 当前文档目录折叠为“本文目录”
视觉风格:
- 像开源软件 docs 页面,清晰、安静、可长时间阅读
- 不复用 admin 后台的重操作感布局
- 不做 Earth 的沉浸式深色 HUD 风格
- 优先阅读性、扫描效率和代码/表格可读性
## 前端实现设计
### 文件结构
建议新增:
```text
frontend/src/pages/Docs/
Docs.tsx
docs-content.ts
docs-search.ts
docs-slugs.ts
Docs.css
```
可选拆分:
```text
frontend/src/pages/Docs/components/
DocsSidebar.tsx
DocsSearch.tsx
DocsToc.tsx
DocsMarkdown.tsx
```
如果初版代码量不大,可以先保持在 `Docs.tsx` + 少量 helper 文件中,避免过度拆分。
### 文档注册表
创建一个 registry负责将 Markdown 文件路径映射为文档元信息:
```ts
interface DocsEntry {
slug: string
path: string
title: string
group: string
order: number
loader: () => Promise<string>
}
```
slug 规则:
- `docs/technical/README.md` -> `overview`
- `docs/technical/earth-layer-style-reference.md` -> `earth-layer-style-reference`
- 只暴露稳定 slug不暴露本机绝对路径
标题规则:
- 优先读取 Markdown 第一个 `# heading`
- 没有 h1 时用人工 registry title
- 再 fallback 到文件名转换标题
### Markdown 渲染
初版可以复用现有:
- [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx)
但建议增强或包装为 docs 专用渲染:
- heading 生成稳定 `id`
- 右侧 TOC 使用同一套 heading 解析结果
- 内部 Markdown 链接转换为 `/docs/:slug`
- 外部链接保留 `target="_blank" rel="noreferrer"`
- 表格横向滚动
- 代码块保留等宽字体和语言标记
- 支持 GitHub 风格的相对文档链接
内部链接转换示例:
- `earth-render-layer-order.md` -> `/docs/earth-render-layer-order`
- `./earth-layer-style-reference.md` -> `/docs/earth-layer-style-reference`
- `/home/ray/dev/linkong/planet/docs/technical/foo.md` -> `/docs/foo`
对非 `docs/technical` 的链接:
- 初版可保留原始链接文本
- 或显示为不可跳转的 repo path
- 后续再扩展为跨文档区导航
### 搜索
初版使用纯前端本地搜索。
索引字段:
- title
- slug
- group
- headings
- markdown 正文纯文本
搜索策略:
- 页面首次加载后异步加载所有 `docs/technical` Markdown
- 生成内存索引
- 用户输入时本地过滤
- 简单打分即可:
- 标题命中权重最高
- heading 命中其次
- 文件名 / slug 命中其次
- 正文命中最低
搜索结果展示:
- 文档标题
- 分组
- 命中的 heading 或正文摘要
- 点击跳转到文档
当前只有 13 篇文档,不需要 Lunr、Fuse 或后端搜索。后续文档数量显著增长时,再考虑引入轻量搜索库。
### 路由接入
修改:
- [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
新增 lazy import
```ts
const Docs = lazy(() => import('./pages/Docs/Docs'))
```
公开路由:
```ts
const publicPaths = new Set(['/', '/earth', '/docs'])
```
注意:`/docs/:slug` 不能只用精确匹配 `Set`
建议改为:
```ts
const isPublicRoute =
window.location.pathname === '/' ||
window.location.pathname === '/earth' ||
window.location.pathname === '/docs' ||
window.location.pathname.startsWith('/docs/')
```
新增 routes
```tsx
<Route path="/docs" element={<Docs />} />
<Route path="/docs/:slug" element={<Docs />} />
```
### 样式
建议独立 `Docs.css`,不依赖 admin 页面布局。
核心样式要求:
- 文档正文最大宽度控制在适合阅读的范围
- 表格横向滚动,不撑破布局
- 代码块横向滚动
- 左侧导航固定或 sticky
- 右侧 TOC sticky
- 移动端隐藏右侧 TOC导航折叠
- 搜索结果浮层或独立面板不遮挡正文阅读
注意:
- 不做营销 hero
- 不做卡片堆叠式首页
- 首页第一屏应直接是文档入口和内容,而不是宣传页
## 实施阶段
### Phase 1基础文档站
目标:
- `/docs` 可公开访问
- 能看到 `docs/technical` 文档列表
- 能打开每篇 Markdown
- 能基本渲染标题、段落、列表、代码块、表格
任务:
- 新增 `Docs` 页面
- 新增 docs registry
- 接入 Vite raw Markdown loading
- 接入 `/docs``/docs/:slug`
- 加入公开路由白名单
- 初版 CSS 布局
验收:
- 未登录访问 `/docs` 不跳转登录
- `/docs/earth-layer-style-reference` 可打开样式参考文档
- `/docs/backend-collectors` 可打开后端采集器文档
- 构建通过:`source ~/.zshrc && bun run build`
### Phase 2搜索与 TOC
目标:
- 支持本地搜索所有 technical 文档
- 当前文档右侧显示目录
- 搜索结果可跳转
任务:
- 实现 heading parser
- 实现 TOC 组件
- 实现 search index
- 搜索结果显示文档标题、分组和摘要
- 当前文档标题与 active nav 高亮
验收:
- 搜索 `Fresnel` 能找到 Earth 图层样式文档
- 搜索 `collector` 能找到 backend collectors
- 点击搜索结果进入对应文档
- 右侧 TOC 点击后滚动到对应 heading
### Phase 3链接清理与文档体验
目标:
- Markdown 内部链接在 docs 站内自然跳转
- 长表格、代码块、绝对路径链接的显示更友好
任务:
- 转换 `docs/technical/*.md` 相对链接
- 转换 repo 内 technical 文档绝对路径
- 外链新窗口打开
- 文件路径链接以代码样式显示
- 增强空状态和 404
验收:
-`docs/technical/README.md` 点击 technical 文档链接进入 `/docs/:slug`
- 不支持的 repo 内路径不会导致前端崩溃
- 外部链接行为正常
### Phase 4文档内容整理
目标:
- `docs/technical` 的首页适合作为公开手册入口
- 每篇文档标题、摘要和分类清晰
任务:
- 检查每篇文档是否有唯一 h1
- 给 README 补公开手册导览
- 必要时补文档摘要
- 保持文档内容仍然服务开发维护,不改成营销语气
验收:
- `/docs` 首页能说明各技术文档用途
- 左侧分类和 README 内容一致
- 没有明显重复、过期或找不到的主入口
## 需要改动的文件
预计新增:
- `frontend/src/pages/Docs/Docs.tsx`
- `frontend/src/pages/Docs/Docs.css`
- `frontend/src/pages/Docs/docs-content.ts`
- `frontend/src/pages/Docs/docs-search.ts`
预计修改:
- `frontend/src/App.tsx`
- `frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx` 或新增 docs 专用 wrapper
- `docs/technical/README.md`
可选修改:
- `frontend/src/index.css`,只放全局极少量 docs shell reset 时才需要
- `docs/CHANGELOG.md`,实施完成后记录
- `docs/version-history.md`,若进入版本发布流程再更新
## 风险与注意事项
### 构建路径风险
Vite 从 `frontend/src` 读取 `../../../docs/technical/**/*.md` 时,需要确认开发和生产构建都可解析。
缓解:
- 使用相对路径 glob
- 构建验证必须跑 `source ~/.zshrc && bun run build`
- 不使用运行时 `fetch('/docs/...')` 读取仓库文件,避免生产环境缺文件
### Markdown 能力不足
现有 `MarkdownRenderer` 是轻量实现,可能不完整支持所有 GitHub Markdown。
缓解:
- 初版优先覆盖当前 `docs/technical` 实际用到的语法
- 若后续需要脚注、嵌套列表、复杂代码高亮,再考虑引入 `react-markdown` 等依赖
### Bundle 体积
把所有 Markdown 打进前端 bundle 会增加体积。
当前文档数量少,风险可接受。
缓解:
- 使用 lazy page chunk
- Markdown loader 保持异步
- 搜索索引在 `/docs` 页面内初始化,不影响 `/earth` 和 admin 首屏
### 公开内容边界
`docs/technical` 会被公开展示,需要避免包含密钥、内部机器地址、临时方案或不应公开的操作细节。
缓解:
- 实施前快速审阅 `docs/technical`
- 暂不公开 `docs/plans``docs/deprecated`
- 以后如需公开更多文档,先建立 allowlist
## 验收清单
- `/docs` 未登录可访问
- `/docs/:slug` 未登录可访问
- `/docs` 不影响 `/earth`
- 未登录访问 admin 仍然跳登录
- 左侧导航包含所有 `docs/technical` 文档
- 文档按 Overview / Earth / Frontend / Backend / Agents / Ops 分类
- Markdown 表格正常显示并可横向滚动
- 代码块正常显示并可横向滚动
- 搜索可搜索标题、heading 和正文
- 搜索结果点击可跳转
- 当前文档 TOC 可跳转
- 不存在的 slug 显示 docs 404
- `source ~/.zshrc && bun run build` 通过
## 后续增强
- 给文档页面增加复制 heading 链接按钮
- 给代码块增加复制按钮
- 增加“上一页 / 下一页”导航
- 增加最近更新信息
- 从 git metadata 读取文档更新时间
- 引入轻量全文搜索库
- 支持 plans / deprecated 独立分区
- 增加页面内反馈入口

View File

@@ -0,0 +1,35 @@
# Technical Docs
This directory holds "current implementation and current structure" documentation, focusing on:
- How the code is organized right now
- Where the current entry points are
- How state and components work
- Which implementation boundaries future changes should follow
What belongs here:
- Quickstart and user manual
- Frontend context
- Earth frontend structure
- Earth satellite footprint policy
- Earth render layer order
- Earth layer style property index
- Backend runtime control
- Collector status
- Collection format conventions
## Entry Points
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
- [manual.md](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
What does not belong here:
- Incomplete roadmaps
- Future iteration plans
- Large-scale refactor proposals
Those belong in:
- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md)

View File

@@ -0,0 +1,264 @@
# Data Collectors
## I. System Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Data Collection Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ TOP500 │ │ Epoch AI │ │ HuggingFace │ │
│ │ Collector │ │ Collector │ │ Collector │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ BaseCollector │◄── Base class (unified) │
│ │ run() method │ │
│ └─────────┬───────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ fetch() │ │transform()│ │ _save_data│ │
│ │ raw data │ │ transform │ │ save to DB│ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ CollectedData table│◄── Unified storage │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Scheduler (APScheduler) │ │
│ │ Scheduled tasks: every 4h/6h/12h/1d auto-execute │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## II. Pipeline
```python
# 1. Scheduler triggers (scheduled or manual)
# ↓
# 2. run() executes the full pipeline
async def run(self, db):
# 2.1 Check if collector is enabled
if not collector_registry.is_active(self.name):
return {"status": "skipped"}
# 2.2 Record task start
task = CollectionTask(status="running")
db.add(task)
await db.commit()
# 2.3 FETCH — get raw data (implemented by subclass)
raw_data = await self.fetch()
# 2.4 TRANSFORM — convert to unified format
data = self.transform(raw_data)
# 2.5 SAVE — persist to database
records_count = await self._save_data(db, data)
# 2.6 Record task completion
task.status = "success"
task.records_processed = records_count
await db.commit()
```
**Core file**: `backend/app/services/collectors/base.py`
## III. Collector List
| Collector | Data type | Content | Frequency |
|-----------|-----------|---------|-----------|
| TOP500 | supercomputer | Global supercomputer rankings (compute, performance) | 4 hours |
| Epoch AI | gpu_cluster | GPU compute cluster info | 6 hours |
| HuggingFace Models | model | AI model information | 12 hours |
| HuggingFace Datasets | dataset | Dataset information | 12 hours |
| HuggingFace Spaces | space | Demo applications | 1 day |
| PeeringDB | ixp/network/facility | Internet exchange points / networks / facilities | 1-2 days |
| TeleGeography | submarine_cable | Submarine cable information | 7 days |
## IV. Data Format (stored in CollectedData table)
```python
# Each collector's parse_response() return format
{
"source_id": "top500_1", # Original system ID (required)
"name": "El Capitan", # Name (required)
"description": "System desc...", # Description
"country": "United States", # Country
"city": "Livermore, CA", # City
"latitude": "37.6819", # Latitude (string)
"longitude": "-121.7681", # Longitude (string)
"value": "1742.00", # Performance value (e.g. compute)
"unit": "PFlop/s", # Unit
"metadata": { # Extra data (JSON)
"rank": 1,
"r_peak": 2746.38,
"cores": 11039616
},
"reference_date": "2025-11-01" # Data reference date
}
```
## V. Database Schema
**CollectedData table** (`collected_data`)
| Field | Type | Description |
|-------|------|-------------|
| id | SERIAL | Primary key |
| source | VARCHAR(100) | Data source name (top500, huggingface, etc.) |
| source_id | VARCHAR(100) | Original data ID |
| data_type | VARCHAR(50) | Data type (supercomputer, model, etc.) |
| name | VARCHAR(500) | Name |
| title | VARCHAR(500) | Title |
| description | TEXT | Description |
| country | VARCHAR(100) | Country |
| city | VARCHAR(100) | City |
| latitude | VARCHAR(50) | Latitude |
| longitude | VARCHAR(50) | Longitude |
| value | VARCHAR(100) | Performance value |
| unit | VARCHAR(20) | Unit |
| metadata | JSONB | Extra metadata |
| collected_at | TIMESTAMP | Collection time |
| reference_date | TIMESTAMP | Data reference date |
| is_valid | INTEGER | Whether valid |
**Core file**: `backend/app/models/collected_data.py`
## VI. TOP500 Collector Example (full pipeline)
```python
# 1. fetch() — get HTML from the web
async def fetch(self):
url = "https://top500.org/lists/top500/list/2025/11/"
response = await client.get(url)
return response.text # returns HTML
# 2. parse_response() — parse HTML into unified format
def parse_response(self, html):
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
for row in table.find_all("tr")[1:]: # skip header
cells = row.find_all("td")
entry = {
"source_id": f"top500_{cells[0].text}",
"name": cells[1].text.strip(),
"country": cells[2].text.strip(),
"city": "",
"latitude": "",
"longitude": "",
"value": "1742.00",
"unit": "PFlop/s",
"metadata": {
"rank": 1,
"cores": "11340000"
},
"reference_date": "2025-11-01"
}
data.append(entry)
return data
# 3. run() automatically calls _save_data() to save to database
```
**Core file**: `backend/app/services/collectors/top500.py`
## VII. Scheduler
```python
# Register all collectors into scheduled tasks at startup
def start_scheduler():
for name, collector in collectors.items():
if collector_registry.is_active(name):
scheduler.add_job(
run_collector_task,
trigger=IntervalTrigger(hours=collector.frequency_hours),
id=name,
name=name
)
```
| Collector | Frequency |
|-----------|-----------|
| TOP500 | Every 4 hours |
| Epoch AI | Every 6 hours |
| HuggingFace | Every 12 hours |
| PeeringDB | Every 1-2 days |
| TeleGeography | Every 7 days |
**Core file**: `backend/app/services/scheduler.py`
## VIII. Code Files
```
backend/app/services/collectors/
├── base.py # Base class: run() pipeline, _save_data() persistence
├── registry.py # Collector registry
├── scheduler.py # Scheduled task dispatch (APScheduler)
├── top500.py # TOP500 collector
├── epoch_ai.py # Epoch AI collector
├── huggingface.py # HuggingFace collector
├── peeringdb.py # PeeringDB collector
└── telegeraphy.py # TeleGeography submarine cable collector
backend/app/models/
└── collected_data.py # Unified data model
```
## IX. Data Usage
Collected data ultimately:
1. **Visualization** — displays supercomputers, GPU clusters, and submarine cables' geographic positions
2. **Situational analysis** — global compute distribution statistics and growth trends
3. **Alert system** — detects changes to important nodes
## X. Collector Registration
Collectors are automatically registered at application startup:
```python
# backend/app/services/collectors/__init__.py
collector_registry.register(TOP500Collector())
collector_registry.register(EpochAIGPUCollector())
collector_registry.register(HuggingFaceModelCollector())
collector_registry.register(HuggingFaceDatasetCollector())
collector_registry.register(HuggingFaceSpacesCollector())
collector_registry.register(PeeringDBIXPCollector())
collector_registry.register(PeeringDBNetworkCollector())
collector_registry.register(PeeringDBFacilityCollector())
collector_registry.register(TeleGeographyCableCollector())
collector_registry.register(TeleGeographyLandingPointCollector())
collector_registry.register(TeleGeographyCableSystemCollector())
```
**Core file**: `backend/app/services/collectors/registry.py`
## XI. Triggering Collection
### Method 1: Scheduled
At startup, APScheduler automatically creates scheduled tasks based on each collector's `frequency_hours` setting.
### Method 2: Manual API trigger
```bash
# Trigger TOP500 collection
curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \
-H "Authorization: Bearer <token>"
```
**Core file**: `backend/app/api/v1/datasources.py`

View File

@@ -0,0 +1,252 @@
# Earth Frontend Context
This document describes the current real structure of the Earth display frontend. The focus is on helping future changes to the HUD, layers, media panel, real terrain, and BGP visualization avoid repeating past structural and state-sync pitfalls.
Related references:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
## Current Goal
The Earth frontend is not an ordinary admin page — it is an independent large-screen display frontend. Current product goals:
- Maintain the spatial depth and readability of the globe view
- Keep HUD, layers, media panel, BGP, satellites, cables, and similar elements in a unified interaction model
- Clearly represent states like loading, enabled, hidden, and locked
## Current Entry Point
React route entry:
- [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
The current approach is simple:
- The React page only provides a full-screen `iframe`
- The actual Earth application runs at:
- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
Earth frontend is essentially a standalone static application under `public/earth`.
## Current File Layers
### 1. Page Entry and Structure
- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
Responsibilities:
- Base HUD DOM
- Layer panel
- Media panel
- Toolbar
- Settings dialog
- Legacy element ID compatibility
### 2. Main Runtime
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
Responsibilities:
- Globe initialization
- Three.js scene assembly
- Data loading and refresh
- Layer module integration
- Earth-level state synchronization
### 3. Earth Control Layer
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
Responsibilities:
- Toolbar interaction
- Layer panel interaction
- Rotation / zoom / layout
- HUD panel drag
- Layer toggle state machine
- Earth settings read, persist, and reset
This is currently the most critical UI control entry point for the Earth frontend.
### 4. UI and Status Messages
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
Responsibilities:
- Loading panel
- Status message
- Tooltip / error / cleanup logic
### 5. Globe and Terrain
- [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)
Responsibilities:
- Globe sphere, cloud layer, atmosphere
- Real terrain mesh
- Terrain tile fetch, decode, displacement, and shading
### 6. Layer Modules
- [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)
- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js)
- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
Each module is responsible for its own:
- Data fetching
- Three.js mesh creation and update
- State tracking (loaded, visible, hover, locked)
- Self-cleanup (dispose on scene destroy)
### 7. HUD Panels and Search
- [hud-panels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/hud-panels.js)
- [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
- [search.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/search.js)
- [legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js)
### 8. Cruise Mode
- [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)
The cruise sequencer handles generic logic: current target, queue order, camera focus, and dwell / hide / switch. Business modules supply target queues and content — they should not contain camera control logic.
### 9. Constants
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
All material, layer, satellite, BGP, cable, terrain, celestial, and other style parameters are maintained here. Do not scatter magic numbers in module files.
## Current Style Layers
CSS files in `frontend/public/earth/css/` each correspond to a specific component scope. Do not write global Earth styles into `base.css` unless they genuinely apply to everything.
## Current Layer Toggle State Semantics
### `data-status-target`
Layer toggle buttons use `data-status-target` attributes to link button state to layer state. The state machine in `controls.js` handles:
- `loading`: showing the loading indicator
- `enabled`: layer is active
- `hidden`: layer is hidden
- `error`: layer failed to load
This is the canonical way to synchronize button visual state with actual layer state. Do not maintain separate boolean flags for button display.
## Current Settings Persistence
Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset.
Settings that affect visual layers (terrain opacity, day/night mode, satellite display style, etc.) are read during initialization and applied immediately.
## Current Terrain Pipeline
1. `terrain.js` creates a sphere geometry with enough segments
2. On load, fetches Terrarium-format elevation tiles from the backend
3. Decodes R/G/B into elevation values
4. Displaces vertex positions radially based on elevation
5. Applies a vertex alpha that fades terrain edges at coastlines
6. Terrain writes to the scene as a mesh above the HD texture layer
When HD texture is off, terrain is temporarily hidden and its state is remembered. When HD texture comes back on, terrain restores its prior visibility.
## Current High-Frequency Risk Points
### 1. Visual State and Business State Out of Sync
The most common class of Earth bugs:
- Button shows "loaded," but layer has no objects rendered
- Button shows "hidden," but objects are still visible
- Loading ended, but button still looks like it hasn't
All future changes must prioritize checking state sync.
### 2. HUD Layout: Check Structure First, Not CSS Patches
Earth HUD has repeatedly experienced:
- Panel compressed to a sliver
- Markdown content clipped
- Tabs/iframe content consumed by `overflow: hidden`
Inspection order:
1. Who is responsible for height
2. Who is responsible for scrolling
3. Which layer is doing the clipping
Do not immediately add `overflow: hidden` or extra wrapper layers.
### 3. Transitional Paths Must Be Closed Off
Earth has gone through multiple rounds of HUD, toolbar, and media panel refactoring, making it easy to accumulate:
- Old helpers
- Old classes
- Old fallback logic
- Deprecated variants
After each major feature is complete, do a cleanup pass.
### 4. Cruise Mode and Business Events Must Not Be Deeply Coupled
The correct boundary:
- The generic cruise layer only knows:
- Current target
- Queue order
- Camera focus
- Dwell / hide / switch
- Business modules only supply:
- Target queues
- Focus coordinates
- Card content
- Highlight / layer side effects
If future cable, satellite, or news cruise is added, do not copy a new set of `main.js` state variables. Instead reuse:
- [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)
- The business adapter pattern from [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
## Recommended Change Approach
For future Earth changes:
1. First identify what you're changing:
- Three.js rendering layer
- HUD structure layer
- Layer state layer
- Panel content layer
2. If involving layer buttons, connect to the unified state machine
3. If involving visibility toggle, check whether tooltip / legend / info-card / lock all close together
4. If involving panel layout, check structure before touching CSS
## Current Boundary with the Console Frontend
The Earth frontend and the console frontend are not the same UI system:
- Console frontend: React + Ant Design workbench
- Earth frontend: native HUD + Three.js display under `public/earth`
Therefore:
- Earth should not directly reuse Ant Table / AppLayout semantics
- The console should not copy Earth HUD animations and glass-layer design language
For console structure, see:
- [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)

View File

@@ -0,0 +1,224 @@
# Earth Layer Style Property Index
This document records the material, color, opacity, line width, radius offset, and `renderOrder` style properties of all Earth frontend layers. For layer ordering relationships, see [earth-render-layer-order.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md).
## Naming Conventions
| Category | Convention | Example |
| --- | --- | --- |
| Global config objects | `*_CONFIG` | `COUNTRY_BOUNDARY_CONFIG` |
| Layer radius offsets | `*AltitudeOffset` / `radiusOffset` | `lineAltitudeOffset`, `GRID_CONFIG.radiusOffset` |
| Opacity | `*Opacity` | `hoverLineOpacity` |
| Render order | `*RenderOrder` | `textureOverlayRenderOrder` |
| Color | `*Color`, hex number or CSS color value | `lineColor`, `colors.supercomputer` |
| Line width | `lineWidth` / `*LineWidth` | `GRID_CONFIG.lineWidth` |
## Earth Base and HD Texture
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Earth base radius | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
| Earth base color | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
| Earth base emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
| Earth base specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
| Earth base shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
| Earth base opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | Standalone HD texture sphere radius |
| HD texture opacity | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | HD texture `MeshPhongMaterial.opacity` |
| HD texture renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
| HD texture specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | Reduces specular highlight in direct-light areas to avoid blown-out texture |
| HD texture shininess | `EARTH_MATERIAL_CONFIG.textureOverlayShininess` | `4` | Reduces specular concentration |
| HD texture color multiplier | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
## Earth Occluder and Day/Night
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Occluder radius factor | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | Depth occluder sphere radius |
| Occluder segments | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | Occluder geometry segments |
| Occluder renderOrder | inline | `-1` | `occluder.renderOrder` |
| Day/night sun direction | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | Custom day/night shader |
| Night-side minimum brightness | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.24` | Shader uniform |
| Day-side boost | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `1.12` | Shader uniform |
| Twilight width | `EARTH_MATERIAL_CONFIG.dayNight.twilightWidth` | `0.2` | Shader uniform |
| Twilight intensity | `EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity` | `0.14` | Shader uniform |
| Twilight color | `EARTH_MATERIAL_CONFIG.dayNight.twilightColor` | `0x4ea0ff` | Shader uniform |
| Night tint color | `EARTH_MATERIAL_CONFIG.dayNight.nightTintColor` | `0x0b1830` | Shader uniform |
| Night tint intensity | `EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity` | `0.08` | Shader uniform |
## Atmospheric Glow and Clouds
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Inner atmosphere radius factor | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
| Inner atmosphere segments | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
| Inner atmosphere color | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | Shader RGB |
| Inner atmosphere rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | Shader rim falloff |
| Inner atmosphere intensity | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | Shader alpha multiplier |
| Outer atmosphere radius factor | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.016` | `atmosOuterGeo` |
| Outer atmosphere segments | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
| Outer atmosphere color | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | Shader RGB |
| Outer atmosphere rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `5.0` | Shader rim falloff |
| Outer atmosphere intensity | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.02` | Shader alpha multiplier |
| Atmosphere blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
| Atmosphere renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
| No-HD-texture rim glow color | `EARTH_MATERIAL_CONFIG.rimGlowColor` | `[0.35, 0.65, 1.0]` | Fresnel shell RGB when HD texture is hidden or unavailable |
| No-HD-texture rim glow power | `EARTH_MATERIAL_CONFIG.rimGlowPower` | `3.8` | Shader rim falloff; higher = narrower edge |
| No-HD-texture rim glow intensity | `EARTH_MATERIAL_CONFIG.rimGlowIntensity` | `0.28` | Shader alpha multiplier |
| No-HD-texture rim glow segments | `EARTH_MATERIAL_CONFIG.rimGlowSegments` | `64` | `earth-rim-glow` geometry segments |
| Cloud layer radius offset | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | Cloud sphere radius |
| Cloud layer segments | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | Cloud sphere geometry |
| Cloud layer opacity | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
| Cloud texture | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | Cloud texture map |
| Cloud blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
## Land/Ocean Base and Country Borders
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Country border data path | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON input |
| Ocean fill color | local `OCEAN_HEX` | `0x010609` | Land/ocean base canvas background |
| Land fill color | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | Land/ocean base canvas land |
| Land/ocean base opacity | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` radius |
| Land/ocean base renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
| Land/ocean mask size | `landMaskWidth / landMaskHeight` | `2048 / 1024` | Canvas / DataTexture size |
| Country tint color | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | Tint when HD texture is off |
| Country tint radius offset | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` radius |
| Country tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
| Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line |
| Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity |
| Border dimmed opacity on hover | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | Normal border opacity during hover |
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | Normal border line radius |
| Border line renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | Normal border line level |
| Border hover color | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | Neon red-orange |
| Border hover opacity | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | Hover line opacity |
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | Hover line radius |
| Border hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | Hover line level |
| Border hover glow opacity | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | Glow line opacity |
| Border hover glow line width | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | Glow `LineBasicMaterial.linewidth` |
| Border hover glow level offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | Glow renderOrder = `2.29` |
| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | Glow radius = hover radius + 0.04 |
## Real Terrain
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Terrain tile size | `TERRAIN_CONFIG.tileSize` | `256` | Terrarium tile read size |
| Terrain base zoom | `TERRAIN_CONFIG.baseZoom` | `4` | Terrain sampling zoom |
| Terrain geometry segments | `geometryWidthSegments / geometryHeightSegments` | `320 / 320` | Terrain sphere geometry |
| Terrain base radius offset | `TERRAIN_CONFIG.baseRadiusOffset` | `0.16` | Terrain overlays HD texture |
| Terrain exaggeration | `TERRAIN_CONFIG.exaggeration` | `34` | Elevation to world units |
| Terrain land fade height | `TERRAIN_CONFIG.landRevealFadeMeters` | `220` | Vertex alpha for coastline fade |
| Terrain opacity | `TERRAIN_CONFIG.opacity` | `0.68` | `MeshPhongMaterial.opacity` |
| Terrain color | `TERRAIN_CONFIG.color` | `0x8aa884` | `MeshPhongMaterial.color` |
| Terrain emissive | `TERRAIN_CONFIG.emissive` | `0x030704` | Reduces self-emission to preserve terrain shading |
| Terrain specular | `TERRAIN_CONFIG.specular` | `0x344438` | Gives terrain local sheen without boosting HD texture brightness |
| Terrain shininess | `TERRAIN_CONFIG.shininess` | `16` | Tightens terrain highlight |
| Terrain renderOrder | inline | `1.2` | `terrain.renderOrder` |
| Terrain polygonOffset | inline | `factor -1`, `units -1` | Reduces z-fighting near sphere surface |
## Grid Lines
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Grid radius offset | `GRID_CONFIG.radiusOffset` | `0.14` | Grid sphere radius |
| Grid color | `GRID_CONFIG.color` | `0xc0e0ff` | `LineBasicMaterial.color` |
| Grid opacity | `GRID_CONFIG.opacity` | `0.08` | `LineBasicMaterial.opacity` |
| Grid line width | `GRID_CONFIG.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| Grid renderOrder | `GRID_CONFIG.renderOrder` | `2.05` | Grid level |
| Latitude step | `GRID_CONFIG.latitudeStep` | `15` | Latitude line generation step |
| Longitude step | `GRID_CONFIG.longitudeStep` | `30` | Longitude line generation step |
| Segment sample step | `GRID_CONFIG.segmentStep` | `5` | Grid line sample step |
## Submarine Cables and Landing Points
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Default cable color | `CABLE_COLORS.default` | `0xffff44` | Used when no data color available |
| Cable radius offset | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | Cable line radius |
| Cable line width | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| Cable opacity | `CABLE_CONFIG.line.opacity` | `1.0` | Cable line opacity |
| Cable renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | Cable line level |
| Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | Aligns with compute center marker height |
| Landing point icon texture size | `CABLE_CONFIG.landingPoint.textureSize` | `256` | Canvas size for solid map-pin icon |
| Landing point icon aspect ratio | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` |
| Landing point icon anchor | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`, aligns pin tip to landing point lat/lon |
| Landing point base scale | `CABLE_CONFIG.landingPoint.baseScale` | `12` | Matches compute center sprite height |
| Landing point color | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| Landing point opacity | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| Landing point renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | Aligns with compute center surface level |
| Landing point dim brightness | `landingPointVisual.dimBrightness` | `0.62` | Dim state color multiplier |
| Dimmed landing point color | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | Dim state color; avoids dark base showing through as a dark hole |
| Dimmed landing point emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | Dim state weak amber self-emission |
| Dimmed landing point opacity | `landingPointVisual.dimmed.opacity` | `0.78` | Dim state opacity; no longer uses low alpha blending with dark base |
## Satellites, Trails, and Footprints
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Satellite display radius offset | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | Satellite point position |
| Satellite dot base pixel size | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | Point shader size |
| Satellite backdrop dot scale | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | Backdrop dot size |
| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | Breathing animation |
| Satellite dot breathing speed | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | Dot opacity animation |
| Satellite backdrop renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
| Satellite dot renderOrder | inline | `6` | `satellitePoints.renderOrder` |
| Satellite trail length | `SATELLITE_CONFIG.trailLength` | `10` | Trail buffer |
| Satellite trail line width | `SATELLITE_CONFIG.trailLineWidth` | `3` | Ribbon shader uniform |
| Selected ring size | `SATELLITE_CONFIG.ringSize` | `0.07` | Hover / locked ring sprite |
| Satellite overlay renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | Locked ring / halo / orbit |
| Footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Footprint fill |
## Compute Centers
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Compute center radius offset | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | Marker position |
| Compute center base opacity | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
| Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Supercomputer marker |
| GPU cluster marker scale | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
| Hover scale | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | Hover state |
| Locked scale | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | Locked state |
| Dimmed scale / opacity | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | Dim state |
| Supercomputer color | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | Marker texture |
| GPU cluster color | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | Marker texture |
| Linked color | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | Linked state |
| Compute center renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | Surface facility below satellites |
## BGP Observation
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `2.1` | Anomaly marker |
| BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | Collector marker |
| Event base scale | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | Anomaly sprite |
| Collector base scale | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | Collector plane |
| Hover / dim scale | `hoverScale / dimmedScale` | `1.16 / 0.92` | Interaction states |
| Normal event opacity | `BGP_CONFIG.opacity.normal` | `0.78` | Anomaly sprite |
| Hover opacity | `BGP_CONFIG.opacity.hover` | `1.0` | Hover state |
| Dimmed opacity | `BGP_CONFIG.opacity.dimmed` | `0.24` | Dim state |
| Collector opacity | `BGP_CONFIG.opacity.collector` | `0.62` | Collector state |
| Critical color | `BGP_CONFIG.severityColors.critical` | `0xff4d4f` | Critical event |
| High color | `BGP_CONFIG.severityColors.high` | `0xff9f43` | High-severity event |
| Medium color | `BGP_CONFIG.severityColors.medium` | `0xffd166` | Medium-severity event |
| Low color | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | Low-severity event |
| Collector base color | `BGP_CONFIG.collectorColor` | `0x6db7ff` | Default collector color |
| Region color | `BGP_CONFIG.regionColor` | `0x2dd4bf` | Region overlay |
## Celestial and Starfield
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Sky sphere radius | `CELESTIAL_CONFIG.skyRadius` | `2600` | Celestial background |
| Sky opacity | `CELESTIAL_CONFIG.skyOpacity` | `1` | Background material |
| Sun distance / scale | `sunDistance / sunScale` | `2150 / 78` | Sun sprite |
| Moon distance / scale | `moonDistance / moonScale` | `2050 / 38` | Moon sprite |
| Sun halo scale | `CELESTIAL_CONFIG.sunHaloScale` | `136` | Sun halo |
| Moon halo scale | `CELESTIAL_CONFIG.moonHaloScale` | `62` | Moon halo |
| Sun light color / intensity | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | Scene light |
| Back light color / intensity | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | Scene light |
| Star count | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
| Star radius range | `minRadius + radiusJitter` | `800 + 200` | Random distribution |
| Star color | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
| Star size | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |

View File

@@ -0,0 +1,175 @@
# News Live Streams Collector Format
The `news_live_streams` collector accepts a "channel directory JSON" as input rather than scraping web pages directly.
Goals:
- Allow the backend to stably ingest live news streams from around the world
- Ensure the Earth page TV module always consumes a consistent structure
- Make it easy to integrate channel directories like `worldmonitor` that mix YouTube / HLS / iframe sources
## Recommended JSON Structure
```json
{
"sources": [
{
"id": "bbc-world-news",
"name": "BBC World News",
"provider": "BBC",
"region": "UK",
"language": "en",
"source_type": "youtube",
"youtube_video_id": "dQw4w9WgXcQ",
"youtube_channel": "https://www.youtube.com/@BBCNews",
"embed_url": "",
"stream_url": "",
"homepage_url": "https://www.youtube.com/@BBCNews/live",
"poster_url": "",
"sort_order": 220,
"is_enabled": true,
"notes": "Primary English global news channel"
},
{
"id": "france24-en",
"name": "France 24 English",
"provider": "France 24",
"region": "France",
"language": "en",
"source_type": "hls",
"stream_url": "https://example.com/live.m3u8",
"homepage_url": "https://www.france24.com/en/live",
"sort_order": 230,
"is_enabled": true
},
{
"id": "cctv4-page",
"name": "CCTV-4 Chinese International",
"provider": "CCTV",
"region": "China",
"language": "zh-CN",
"source_type": "iframe",
"embed_url": "https://tv.cctv.com/live/cctv4/",
"homepage_url": "https://tv.cctv.com/live/cctv4/",
"sort_order": 10,
"is_enabled": true
}
]
}
```
## Field Conventions
- `id`: unique identifier, should be stable
- `name`: channel display name
- `provider`: provider name
- `region`: country or region
- `language`: language code
- `source_type`: `iframe` / `hls` / `video` / `external` / `youtube`
- `embed_url`: page suitable for iframe embedding
- `stream_url`: direct video stream URL
- `homepage_url`: official website or channel page
- `youtube_video_id`: YouTube live video ID
- `youtube_channel`: YouTube channel handle or channel URL
- `poster_url`: cover image, optional
- `sort_order`: sort value, smaller = higher in the list
- `is_enabled`: whether enabled
- `notes`: brief notes
## Panel Behavior Conventions
- `youtube`
- Prefers `youtube_video_id`
- When embedding is not possible, at least keep `youtube_channel` or `homepage_url` for external opening
- `hls` / `video`
- Prefers `stream_url`
- `iframe`
- Prefers `embed_url`
- `external`
- No embedding attempt; only keeps external open link
## Current Implementation Status
- The backend settings page supports manually maintaining channel directories
- The Earth TV module merges:
- Manually configured sources
- Sources collected by the `news_live_streams` collector
- The current default fallback source is CCTV-4 Chinese International
- When no override is configured, `news_live_streams` defaults to `iptv-org`:
- `channels.json`
- `streams.json`
- `logos.json`
and automatically filters for news-category channel directories
## Collector Configuration
`news_live_streams` does not need a separate new page; it reuses the existing data source configuration:
- `endpoint`
- Channel directory JSON API URL
- `auth_type`
- `none` / `bearer` / `api_key` / `basic`
- `headers`
- Additional request headers
- `config`
- Collector request and parsing behavior
### Supported `config` Fields
```json
{
"timeout": 30,
"method": "GET",
"params": {
"region": "global"
},
"body_type": "json",
"body": {
"include_disabled": false
},
"response_path": "payload.channels"
}
```
- `timeout`: request timeout in seconds
- `method`: `GET` or `POST`
- `params`: query parameter object
- `body_type`: `json` or `form`
- `body`: request body for `POST`
- `json_body`: explicit JSON request body, takes priority over `body`
- `form_body`: explicit form request body, takes priority over `body`
- `response_path`: path to the channel array in the response JSON, supports dot notation, e.g.:
- `payload.channels`
- `data.items`
- `result.streams`
### Authentication Details
- `bearer`: uses `Authorization: Bearer <token>`
- `api_key`: sent as request header by default; if `auth_config.in = "query"`, sent as query param
- `basic`: uses HTTP Basic Authorization
## Compatible Response Structures
The collector first tries to read:
- Top-level array
- Or an array under these common fields:
- `sources`
- `streams`
- `channels`
- `items`
- `results`
- `data`
It also accepts these field aliases:
- `id` / `source_id` / `slug` / `channel_id` / `code`
- `name` / `title` / `channel` / `display_name`
- `provider` / `publisher` / `network`
- `stream_url` / `stream` / `playback_url` / `hls_url` / `m3u8_url`
- `embed_url` / `embed` / `page_url`
- `homepage_url` / `source_url` / `website`
- `language` / `lang` / `locale`
- `youtube_video_id` / `video_id`
- `youtube_channel` / `channel_handle`

View File

@@ -0,0 +1,56 @@
# Earth Render Layer Order
This document records the current Earth renderer's layer order and the intent of each layer. When adjusting `renderOrder`, radius offsets, depth strategy, or pointer interaction, update this document accordingly.
Note: the layer control panel order and the registration / startup load order are two separate semantics.
| Order type | Current sequence | Notes |
| --- | --- | --- |
| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Borders → Grid | Controlled by `displayOrder`, sorted by operational relevance. |
| Registration / startup load order | Grid → Borders → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. |
## Surface Layer Stack
| Order | Layer | Source | Render / Radius Strategy | Depth / Interaction Strategy | Notes |
| --- | --- | --- | --- | --- | --- |
| -1000 | Celestial background mesh | `celestial.js` | Background sphere | Not part of surface picking | Behind all Earth content. |
| -1 | Earth occluder sphere | `earth.js` | Invisible inner sphere | Writes depth buffer | Occludes objects behind the Earth. |
| 0 | Earth base sphere | `earth.js` | `CONFIG.earthRadius` | Surface picking fallback target | Dark base; still visible when all optional map layers are off. |
| 0.2 | Country dark tint | `country-boundaries.js` | `tintAltitudeOffset` | Raycast disabled | Used when HD texture is off. |
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off. |
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset` | Surface picking target when visible | HD texture always overlays the land/ocean base fill. |
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
| 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. |
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
| 2.05 | Grid lines | `earth.js` | `CONFIG.earthRadius + 0.14` | Raycast disabled | Low-opacity lines over HD texture. |
| 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset` | Raycast disabled | Only needs to stay above HD texture. |
| 2.29 | Country border hover glow | `country-boundaries.js` | Hover radius + glow offset | `depthTest: false`, raycast disabled | Additive glow to reinforce border edge and terrain hover visibility. |
| 2.3 | Country border hover line | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`, raycast disabled | Neon red-orange hover line; China and Taiwan share the same highlight group. |
| 3 | Satellite footprint fill | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested, Group renderOrder stays 0 | Footprint above country borders, below compute centers and satellites. |
| 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. |
| 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. |
| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Below satellite dots. |
| 6 | Satellite dots | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets | Satellite overlay path | Used for selected/locked satellite emphasis. |
| 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. |
## Toggle Behavior
| Toggle | Behavior |
| --- | --- |
| HD texture off | Hides HD texture, enables country tint / base surface, disables terrain and day/night toggle interaction, and remembers terrain and day/night previous states. |
| HD texture on | Restores HD texture and the remembered terrain / day/night states. |
| Terrain on | Displayed above HD texture, but below country border hover, footprints, satellites, and other emphasis layers. |
| Cloud layer | Only controls cloud mesh visibility. |
| Country borders | Controls border line and hover line visibility; land/ocean base fill exists independently as the Earth base map. |
## Interaction Rules
| Interaction | Current Rule |
| --- | --- |
| Earth coordinate hover | When HD texture is visible, uses the HD texture overlay as the surface picking target; otherwise uses the Earth base sphere. |
| Country border hover | Converts surface pick coordinates to lat/lon, then uses GeoJSON point-in-polygon; the border hover line itself does not receive raycasts. |
| Country border hover visual | On hover, dims normal border lines and draws no-depth-test glow and solid lines. |
| China / Taiwan hover | `CHN` and `TWN` are grouped in the same hover highlight group; the tooltip still shows the actually-hit feature. |
| Terrain | Acts as a visual layer only; `terrain.raycast` is disabled. |
| Satellites | Uses screen-space satellite picking to prevent footprints or surface layers from blocking satellite clicks. |

View File

@@ -0,0 +1,198 @@
# Earth Satellite Footprint Policy
This document records the current product boundary, data rationale, and implemented behavior for `footprint` in the Earth satellite layer. The goal is to prevent the Starlink-specific ground coverage model from being misapplied to other constellations.
Related context:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
## Current Goal
- Define which non-Starlink satellites should not show a ground footprint
- Define which constellations may have their own footprint in the future but cannot reuse the Starlink bowtie / GSO-gap model
- Solidify this policy as an executable implementation boundary, not leave it scattered across visual parameters
## Current Local Categories
Current CelesTrak satellite groups in [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py) include:
- `starlink`
- `gps-ops`
- `galileo`
- `glonass`
- `beidou`
- `leo`
- `geo`
- `iridium-next`
Non-Starlink categories:
- `gps-ops`
- `galileo`
- `glonass`
- `beidou`
- `leo`
- `geo`
- `iridium-next`
## Research Conclusions
### 1. GNSS / RNSS: `gps-ops`, `galileo`, `glonass`, `beidou`
Do not draw a localized ground footprint by default.
Reason:
- Public sources emphasize `Earth-pointing`, `Earth coverage`, `continuous global coverage`
- The public semantic of these systems is global navigation / timing coverage, not the localized spot footprint associated with Starlink's end-user service
More appropriate representation:
- Default: show only the satellite body and orbit
- If future needs require showing "service reachability," only a weak global coverage semantic is appropriate — do not draw a localized ground spot
References:
- [GPS III EC Antenna Patterns](https://www.navcen.uscg.gov/sites/default/files/pdf/gps/GPS_ZIP/GPS_III_EC_Antenna_Patterns_SVN_74_75_76_77_78.pdf)
- [ESA Galileo satellites](https://www.esa.int/Applications/Satellite_navigation/Galileo/Galileo_satellites)
- [Navipedia Galileo General Introduction](https://gssc.esa.int/navipedia/index.php/Galileo_General_Introduction)
- [BeiDou official overview](https://www.beidou.gov.cn/xt/gfxz/201812/P020190117356387956569.pdf)
- [GPS.gov GNSS overview](https://www.gps.gov/systems/gnss/)
### 2. `iridium-next`
Can have a footprint, but cannot reuse Starlink's single bowtie footprint.
Reason:
- Iridium NEXT public documentation emphasizes a fixed multi-spot beam system
- Public examples commonly show `48 fixed spot beams in 4 tiers`
- This is not the same problem as Starlink's "single satellite, single primary footprint, with GSO gap" business visualization
More appropriate representation:
- Default: still do not draw a Starlink-style ground footprint
- Future implementation: connect an independent Iridium multi-beam adapter layer
- Visually closer to multi-beam clusters / honeycomb / layered beams, not a single bowtie spot
Reference:
- [Iridium Satellite Spot Beam Coverage on the US](https://www.mathworks.com/help/phased/ug/iridium-satellite-spot-beam-coverage-on-the-us-1.html)
### 3. `geo`
Do not draw a unified footprint by default.
Reason:
- GEO communication satellites may use global beam, zone beam, spot beam, or steerable spot beam
- Without operator / payload / beam contour metadata, drawing a unified footprint is very likely incorrect
More appropriate representation:
- Default: show only the GEO belt and satellite parking position semantics
- Only allow footprint drawing when beam contour / operator metadata is available
Reference:
- [ITU Handbook on Satellite](https://www.itu.int/dms_pub/itu-r/opb/hdb/R-HDB-42-2002-PDF-E.pdf)
### 4. `leo` (generic)
Do not draw a footprint by default.
Reason:
- The `leo` group is too mixed — it may include communication, remote sensing, experimental, and observation satellites
- Without mission / payload / antenna pattern metadata, there is no basis for a service-coverage visualization
More appropriate representation:
- Default: show only the satellite and orbit
- Future: if subdivided by operator / mission subtype, decide then whether to introduce an independent coverage mode
## Product Policy
Current unified policy:
- `Starlink`
- Keep the current dedicated `ground_footprint` logic
- `Iridium NEXT`
- Reserve an independent adapter layer
- Do not reuse Starlink footprint currently
- `GPS / Galileo / GLONASS / BeiDou`
- No ground footprint
- `GEO`
- No footprint without beam metadata
- `Generic LEO`
- No footprint without mission metadata
## Implemented Behavior
This implementation only does the minimum executable version and does not change existing Starlink visual parameters:
1. Backend passes constellation group and footprint policy hint to the frontend
- CelesTrak collector stores `GROUP` in `metadata.constellation_group`
- Visualization API outputs:
- `properties.constellation_group`
- `properties.footprint_policy`
Current policy values:
- `starlink_ground_footprint`
- `iridium_coverage_ring`
- `none`
Relevant code:
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
2. Frontend makes footprint a capability-gated renderer
- `ground_footprint` is only actually enabled when `footprint_policy === starlink_ground_footprint`
- `iridium-next` no longer falls back to a placeholder branch; it goes through an independent Iridium coverage ring adapter
- Other non-Starlink satellites automatically fall back to `self_glow` even if the user globally selects `ground_footprint`
Relevant code:
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [frontend/public/earth/js/iridium-footprint-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/iridium-footprint-adapter.js)
3. Satellite info card shows capability, not just orbital parameters
- Satellite details now clearly display:
- `Constellation / Group`
- `Coverage Capability`
- `Current Display`
- `Coverage Model`
- Users can directly see:
- Whether the current satellite supports footprint
- Whether the current display has been fallen back due to capability gating
- That Iridium and Starlink use different models
Relevant code:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
## Current Implementation Boundary
This boundary must be maintained:
- Starlink's footprint parameters and shader logic serve Starlink only
- Non-Starlink capability decisions belong to the "policy layer / adapter layer"
- Do not re-mix different constellations' coverage models into the same parameter set
- `iridium-next` has been separated into an independent adapter and should continue along this boundary rather than adding more if/else to the existing Starlink bowtie
## Recommended Next Steps
If continuing forward, the recommended order is:
1. Create a dedicated footprint adapter for `iridium-next`
2. Add a read-only indicator in the UI to tell users whether the current satellite supports footprint
3. If GEO beam contour / operator metadata becomes available, enable operator-specific footprint for GEO

View File

@@ -0,0 +1,293 @@
# Admin Frontend Context
This document describes the current real structure of the console frontend. The goal is to help future page development, table refactoring, layout governance, and state consolidation quickly find the right entry points.
Related references:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
## Current Goal
The console frontend is a backend workbench, not a display-style dashboard. Current constraints:
- Pages default to a single-screen work area
- Primary interaction happens through in-module scrolling, not relying on the whole page growing infinitely
- Lists, tables, and analysis pages prioritize keeping the main work area visible
- Common layout, scrollbar, and table scroll behavior should be reused across pages
## Current Route Entry Points
Main entry point:
- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
Current admin-related routes:
- `/admin`
- `/users`
- `/datasources`
- `/data`
- `/alerts/system`
- `/alerts/bgp`
- `/alerts/situational`
- `/bgp`
- `/playground`
- `/settings`
`/earth` is a standalone display page and is not part of the console shell.
## Current Page Shell
The console shared shell is at:
- [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx)
Responsibilities:
- Left-side navigation
- Collapse and expand
- Current account / version information
- Content area height closure
- Site-wide unified sidebar scrollbar
Current structure:
```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>
```
Future console pages should adapt to this shell rather than redefining full-page height semantics.
## Current Shared Components
### 1. `Scrollbar`
File:
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx)
Purpose:
- Ordinary content containers like the console sidebar
- Internally manages visibility, thumb size, drag, and dual-axis overflow detection
Current constraint:
- The scrollbar must be a floating overlay that does not participate in layout
- Should leave no visible trace when there is no overflow
- Real scrolling is still handled by the native container; only the visible layer and interaction layer are replaced
### 2. `ScrollbarOverlay`
File:
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx)
Purpose:
- Areas like Ant Table that already have an internal scroll container
- Does not take over scroll semantics; only adds a new scrollbar visible layer
Current usage:
- Data sources
- Collected data
- User management
- Settings page
- Alerts page
- BGP page
### 3. `TableScrollRegion`
File:
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/TableScrollRegion.tsx)
Purpose:
- Provides a unified wrapper for table scroll areas
- New table pages should reuse this rather than repeating the "table area + overlay scrollbar" boilerplate
### 4. `SegmentedControl`
Files:
- [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx)
- [SegmentedControl.css](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.css)
Purpose:
- Segmented controls for language, theme, mode, or other 2 to 3 option settings
- Settings that need the shared animated slider, active state, and compact button layout
- The `/docs` footer language switcher and theme switcher already reuse it
Interface semantics:
- `options`: each option contains `value` and `label`, with optional `icon` and `title`
- `value`: current active value
- `onChange`: called when the selected option changes
- `ariaLabel`: accessible name for the control
- `className`: page-level hook for size or local style overrides
Current constraints:
- The component owns slider count, position, and spring-like transition
- Feature pages should only pass options and state, not recreate private slider DOM
- Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components
- Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement
### 5. `MarkdownRenderer`
File:
- [MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx)
Purpose:
- Renders Markdown content for `/docs`
- Supports headings, lists, blockquotes, code blocks, tables, and basic inline formatting
- Code blocks and tables reuse `Scrollbar` so horizontal content does not blow out the docs page
Current constraints:
- It is not a full GitHub Markdown engine; it only covers the syntax currently needed by project docs
- Internal document links should be converted to `/docs/:slug` through `transformLink`
- Heading anchors are injected through `getHeadingId`, keeping route state outside the renderer
### 6. `TableActions`
File:
- [TableActions.tsx](/home/ray/dev/linkong/planet/frontend/src/components/TableActions/TableActions.tsx)
Purpose:
- Shared action entry for table operation columns
- Shows inline actions when expanded
- Uses a more-actions dropdown when collapsed
Companion export:
- `actionCellProps`: for action-column `onCell`, preventing action buttons from being ellipsized or wrapped
## Current State Sources
### 1. Auth State
File:
- [auth.ts](/home/ray/dev/linkong/planet/frontend/src/stores/auth.ts)
Responsibilities:
- Token
- Current user
- Login / logout
`App.tsx` uses it to decide whether to redirect to the login page.
### 2. Business Data Gateway
AI / situational awareness related services are currently in:
- [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)
Constraints:
- Pages must not scatter URL construction directly
- Define boundaries through port/types first
- Then implement via http/mock gateway
## Current Page Layer Recommendations
### 1. Dashboard and Summary Pages
Example:
- [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx)
Priority goals:
- Stable header
- Summary cards compact first
- Main work area occupies primary height
### 2. Table Pages
Examples:
- [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)
Constraints:
- Prefer internal scrolling
- Do not let tables blow out the full page
- New table areas should reuse `TableScrollRegion` / `ScrollbarOverlay`
### 3. Complex Workspace Pages
Examples:
- [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)
Constraints:
- Tab content must not share the same height logic
- Table tabs, Markdown tabs, and config tabs each need their own scroll responsibility
- AI result areas and long text areas should maintain a minimum readable height
## Current Layout Constraints
These principles have been repeatedly validated in the project:
1. Parent container height chain must close
2. `min-height: 0` must not be omitted
3. Overflow responsibility must be explicit
4. Do not use `overflow: hidden` to mask structural issues
5. Do not compress the main work area to make summary cards show completely
6. Custom scrollbars must be floating overlays; they must not squeeze content width
For detailed experience, see:
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
## Recommended Change Approach
For future console page changes:
1. Confirm whether the page is a summary page, table page, or complex workspace
2. Integrate into the existing shell and scroll semantics first
3. Reuse shared scroll components
4. Handle visual and detail interactions last
Do not write local CSS patches first, then retrofit the structure.
## Current Clear Boundary
The console frontend and the Earth frontend are not the same system:
- Console frontend: React + Ant Design workbench
- Earth frontend: independent native HUD system under `public/earth`
Therefore:
- Do not move Earth's HUD / animations / state machine directly into the console
- Do not force the console's table / scroll strategy onto the Earth HUD
For Earth-related structure, see:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)

View File

@@ -0,0 +1,309 @@
# Frontend Layout Guidelines
Admin pages in this project default to a "single-screen workspace" layout standard. The goal is not to prevent all overflow, but to ensure that under common desktop viewports:
- The main page structure is visible within one screen
- The user can simultaneously see the page header, summary area, and main workspace
- Overflow content scrolls within its module, rather than stretching the entire page vertically
Current recommended reference implementations:
- [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)
## Core Principles
### 1. Pages Should Prioritize a Single-Screen Workspace
Admin pages default to:
- Header: title, description, main actions
- Main workspace: stats cards, tables, charts, lists, tabs
Recommended structure:
```tsx
<AppLayout>
<div className="page-shell">
<div className="page-shell__header">...</div>
<div className="page-shell__body">...</div>
</div>
</AppLayout>
```
Total page height should be bounded within the `AppLayout` content area, not allowed to grow naturally downward without limit.
### 2. Scrolling Should Happen Inside Modules
If tables, logs, long lists, or chart details overflow their space:
- Let the card scroll internally
- Let the table scroll internally
- Let the tab content area scroll internally
Do not rely on full-page scrolling to "solve" the space problem.
### 3. The Main Workspace Must Get the Most Space
The most important module on a page must be the visual and spatial lead. Typically ensure:
- Header always visible
- Summary area height controlled
- Main table / chart / analysis area occupies more than 50% of visible height
If a page has multiple large modules, priority order is:
1. First compress the description and summary areas
2. Then move secondary modules into tabs or switch views
3. Only then consider adding more full-page scrolling
### 4. Small Screens and High Zoom Must Enter Compact Mode
When window height is low, width is narrow, or system zoom is high, actively switch to a compact layout:
- Reduce card padding
- Reduce header and cell spacing
- Convert summary area to a more compact single-row or horizontal-scroll layout
- Move secondary modules into tabs, drawers, or collapsed areas
Compact mode goal: maintain usability, not just shrink all text and controls.
### 5. Overflow Responsibility Must Be Explicit
Large content blocks on the page must explicitly define:
- Who is responsible for filling remaining height
- Who is responsible for clipping
- Who is responsible for scrolling
Common requirements:
- Parent container chain needs `min-height: 0`
- Workspace containers typically need `display: flex`
- The real scroll node must explicitly use `overflow: auto`
### 6. Cards Must Not Be Compressed to Unreadable
Historical problems have not been "missing scrollbars," but:
- Cards compressed by `flex` to only a tiny visible area
- Text can render but cannot be read completely
- Content exists but is cut off by `overflow: hidden`
Future constraints:
- First ensure cards have a readable minimum height
- If further compression affects readability, switch to internal scrolling
- Do not compress body text, tables, or description areas into unreadable strips just to "maintain one screen"
### 7. Tabs Are Not Inherently Safe Layout Containers
Historical regressions with Tabs include:
- Hidden tab panes reappearing due to custom `display: flex`
- All tabs having the same height/overflow rules forced on them
- Table tabs work, but markdown / help / diagnostics tabs get crushed
Constraints:
- Each type of content inside `Tabs` must define its own layout strategy
- Table tab: "fixed height + internal scrolling"
- Docs/Markdown tab: better as "tab pane self-scrolls + content normal document flow"
- If overriding component library styles, verify the hidden state still holds
### 8. Summary Areas Should Enter Compact Mode First, Not Compress Body
Historical experience shows the top summary cards are most often mishandled:
- They frequently get forcibly narrowed to "fit everything"
- Then the body, tables, and AI result areas all lose their main space
Unified constraint:
- On small screens or high zoom, summary cards should first:
- Reduce padding
- Switch to horizontal scrolling
- Switch to a more compact grid
- Do not sacrifice the main workspace's visible area first
### 9. Long-Document Content Should Prioritize Reading Experience
Content like the following cannot directly apply "table workspace" logic:
- AI briefs
- Runtime logs
- Raw JSON
- Help text
- Multi-paragraph descriptive text
These areas should prioritize:
- Stable title and meta information visibility
- Body has a clear minimum readable height
- Body scroll strategy defined separately
- Support for Markdown tables, dividers, quotes, code blocks
### 10. Height Critical Paths Should Use Fewer Wrapper Layers
Many scroll problems historically were not in the component itself, but came from an extra wrapper layer:
- Height chain broken
- `min-height: 0` not passed down
- `overflow` responsibility absorbed
Therefore:
- For height-critical areas, prefer the most direct DOM structure
- When using `Space`, extra wrapper `div`, or third-party layout containers, verify they don't change scroll and height semantics
- If an area shows "content is there but only a sliver is visible," first suspect an intermediate wrapper layer
## Historical Pitfalls
From Earth, Playground, BGP, DataSources page bugfixes, several high-frequency pitfall types:
### 1. Using `overflow: hidden` to Mask Layout Problems
Superficially the page looks "clean," but actually causes:
- Content getting clipped
- Tab content reduced to a sliver
- Panel renders successfully but users can't see it
Correct approach:
- Let the real content node scroll
- Don't let upper containers unconditionally clip all child content
### 2. Treating All Tabs as the Same Content Type
Tables, Markdown, help cards, and log streams have completely different space requirements.
Correct approach:
- Table: fixed workspace + internal scrolling
- Document: normal flow content + pane-level scrolling
- Side description: content-driven height, not forced to fill
### 3. Only Doing Visual Shrinking, Not Space Reallocation
This causes:
- Card text truncated
- Table shows only 1-2 rows
- Buttons and filters crammed together
Correct approach:
- Compact mode prioritizes re-layout
- Summary area horizontal scrolling
- Collapse / hide secondary modules
### 4. Incomplete Parent Container Height Chain
This is the most common cause of internal scrolling failing.
Inspection order:
1. Does the outer layer actually have a determined height?
2. Does the flex parent have `min-height: 0`?
3. Does the real scroll node explicitly use `overflow: auto`?
4. Have intermediate wrapper layers silently changed layout semantics?
### 5. UI State and Display State Out of Sync
Repeated in Earth-related changes:
- Layer hidden, but hover/lock still active
- Tooltip still showing stale object
- Legend not switching with the state
These constraints also apply to admin pages:
- Hidden, unmounted, or switched-out content should not retain active interaction state
## Recommended Implementation Patterns
### Page Shell
Reuse existing common structures in the project:
- `.dashboard-content-inner`
- `.page-shell`
- `.page-shell__header`
- `.page-shell__body`
- `.table-scroll-region`
Do not invent a completely different height and scroll semantics for each page.
### Table Workspace
Recommended pattern:
```tsx
<Card>
<div className="table-scroll-region" ref={tableRegionRef}>
<Table
pagination={false}
scroll={{ x: 1200, y: tableHeight }}
/>
</div>
</Card>
```
Requirements:
- Tables should scroll inside their card
- `scroll.y` should come from actual available height calculation, not a completely static magic number
- Parent container chain must ensure header, body, content overflow all close inside the table
### Multi-Module Pages
If a page has:
- Summary cards
- Table
- Anomaly details
- Recent events
Do not simply stack all modules vertically. Prefer:
- Top summary + single main workspace at bottom
- Tab-switch multiple secondary data views
- Left-right split with each column scrolling independently
## Discouraged Patterns
The following patterns are considered non-compliant with this project's page standard:
- Relying on full-page vertical scrolling to display the main workspace
- Stacking 3-4 large cards vertically on one page, each wanting to display fully
- Table without internal scrolling, causing only 1-2 rows visible after zoom
- Parent container missing `min-height: 0`, causing internal scrolling to fail
- Only doing visual shrinking without addressing real space allocation
## Page Acceptance Checklist
Before submitting, check at minimum:
- Can page header, summary area, and main workspace appear simultaneously?
- Does the main workspace get the most height on the page?
- When table or detail overflows, does the scrollbar appear inside the module?
- Is the card compressed to the point where text doesn't display completely? If so, has it switched to internal scrolling?
- Is it still usable at browser zoom `125%` / `150%`?
- In a low-height window, is there still a reasonable number of visible content rows?
- Are Tabs, Card, Table still operable when overflowing?
- Do non-table tabs (Markdown, help text, logs) have their own independent and reasonable scroll strategy?
## Implementation Order
When adding or refactoring admin pages, design in this order:
1. Define the main workspace first
2. Determine which modules must always be visible
3. Then handle styling and visual hierarchy
Simply put:
- First ensure correct space allocation
- Then handle scroll boundaries
- Finally handle aesthetics

489
docs/technical/en/manual.md Normal file
View File

@@ -0,0 +1,489 @@
# Planet Manual
This manual is for daily use, demos, development integration, and local operations. It covers four core entry points:
- `planet.sh`: local start, stop, restart, health check, and log access
- Earth: public 3D situational awareness page
- Console: admin backend (login required)
- Docs: public developer documentation and manual
For the shortest path to getting started, see [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
## Entry Overview
After a default startup, the common URLs are:
| Name | URL | Login Required | Description |
| --- | --- | --- | --- |
| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness |
| Docs | `http://localhost:3000/docs` | No | Developer docs, technical reference, usage manual |
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
| AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging |
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
## planet.sh
`planet.sh` is the main control script for local development and demos. Use it to manage services rather than manually starting frontend, backend, database, and AI Provider separately.
### Start
```bash
./planet.sh start
```
Default behavior:
- Starts PostgreSQL and Redis
- Starts AI Provider
- Starts the backend API
- Starts the frontend Vite dev server
- Outputs Earth, console, Playground, and backend API doc URLs
Specify custom ports:
```bash
./planet.sh start -b 8001 -f 3001 -a 8101
```
Parameters:
| Flag | Meaning |
| --- | --- |
| `-b <port>` | Backend port |
| `-f <port>` | Frontend port |
| `-a <port>` | AI Provider port |
| `--allow-lan` | Enable LAN access |
| `--verbose` | Show more command output during execution |
### Stop
```bash
./planet.sh stop
```
Stops:
- Backend
- AI Provider
- Frontend
- PostgreSQL
- Redis
### Restart
Full restart:
```bash
./planet.sh restart
```
Per-module restart:
```bash
./planet.sh restart -b
./planet.sh restart -f
./planet.sh restart -a
./planet.sh restart -d
```
| Flag | Effect |
| --- | --- |
| `-b` | Backend only |
| `-f` | Frontend only |
| `-a` | AI Provider only |
| `-d` | Database only |
Per-module restarts are preferred during development — they avoid interrupting unrelated services.
### Create User
```bash
./planet.sh createuser
```
Used to create a console login account before first use. The script interactively prompts for username, password, and role.
### Health Check
```bash
./planet.sh health
```
Checks:
- `planet_*` container status
- Backend `/health`
- AI Provider `/health`
- Frontend reachability
If something shows offline, check the corresponding logs first.
### Logs
Recent logs:
```bash
./planet.sh log
```
Follow logs:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
| Flag | Log source |
| --- | --- |
| `-f` / `--frontend` | `/tmp/planet_frontend.log` |
| `-b` / `--backend` | `/tmp/planet_backend.log` |
| `-a` / `--ai-provider` | `planet_aiprovider` container logs |
### LAN Access
```bash
./planet.sh start --allow-lan
```
Useful for:
- Starting in WSL, accessing from Windows browser
- Demos on phone or tablet
- Another machine on the same LAN accessing the same dev instance
After starting, check your firewall and WSL network forwarding if access fails.
## Earth
Earth is the public 3D situational awareness page, accessed at:
```text
http://localhost:3000/earth
```
It is a standalone frontend. The actual page lives at:
- `frontend/public/earth/index.html`
- `frontend/public/earth/js/`
- `frontend/public/earth/css/`
The React route `/earth` simply hosts it in an iframe.
### Main Uses
Earth is used to observe in a single globe view:
- BGP events, anomalies, and situational posture
- Satellites and orbital trails
- Submarine cables and landing points
- Compute centers
- Country borders, grid lines, HD texture, cloud layer, terrain
- Live news streams and situational news
- Search and focused object details
### Layer Control
The right-side layer panel toggles visualization layers on or off.
Common layers include:
- Grid lines
- Country borders
- HD texture
- Atmospheric cloud layer
- Submarine cables
- Compute centers
- BGP observation
- Satellites
- Orbital trails
- Terrain
Some layers have dependencies:
- Terrain requires HD texture
- Trails require Satellites
- When HD texture is off, the globe shows the base map and edge glow effect
### Search
Earth search finds current globe objects, such as:
- Submarine cables
- Landing points
- Satellites
- Compute centers
- BGP events
- BGP collectors
Search results can be used to quickly locate objects and open their details.
### Settings
The settings panel contains:
- Rotation mode / cruise mode
- Cruise modules: BGP, News
- Satellite display style: self-glow, real ground footprint
- Day/night mode
- Panel visibility toggles
- Globe default size
- Terrain opacity
- Reset settings
These settings are stored in browser local storage. They revert to defaults if you switch browsers or clear site data.
### Cruise Mode
Cruise mode makes Earth automatically cycle through focus targets.
Current cruise modules:
- BGP
- News
Suitable for demos, monitoring displays, or unattended presentations.
### Mobile
Earth has a mobile drawer layout. On small screens:
- Layer controls open in a mobile drawer
- Search, settings, and details use mobile panels
- Main interactions remain centered on globe object clicks, search, and layer toggles
### Common Issues
#### Earth Won't Open
Check whether the frontend is online:
```bash
./planet.sh health
./planet.sh log -f
```
If the frontend port is not `3000`, use the actual port shown at startup.
#### Layer Has No Data
Check the backend and data sources:
```bash
./planet.sh health
./planet.sh log -b
```
Then open the console and check:
- `/datasources`
- `/data`
- `/bgp`
#### Satellites, BGP, or Cables Load Slowly
These layers may depend on backend APIs, external data sources, or first-run collection tasks. Wait for startup tasks to finish before checking logs and console data source status.
## Console
Console entry point:
```text
http://localhost:3000/admin
```
The console requires login. Create a user first if this is your first time:
```bash
./planet.sh createuser
```
### Page Structure
The console uses React + Ant Design, with a left-side menu organized by work domain.
Common pages:
| Page | Route | Purpose |
| --- | --- | --- |
| Dashboard | `/admin` | System overview |
| Earth | `/earth` | Opens the public Earth page |
| Data Sources | `/datasources` | Manage data sources and trigger collection |
| Collected Data | `/data` | View collected data |
| BGP Observation | `/bgp` | BGP situational data |
| System Alerts | `/alerts/system` | System-level alerts |
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
| Situational Alerts | `/alerts/situational` | Situational assessment alerts |
| AI Playground | `/playground` | AI Provider debugging |
| System Logs | `/logs` | View system logs (typically super admin only) |
| Users | `/users` | User management |
| Settings | `/settings` | System config and TV live stream sources |
### Data Sources
`/datasources` shows and manages collection sources.
Common operations:
- View data source status
- Trigger collection
- View recent collection tasks
- Adjust configuration
If a category of objects is missing on Earth, start here to confirm the data source is available.
### Collected Data
`/data` shows the collected data table.
Useful for diagnosing:
- Whether data has entered the system
- Whether data update times match expectations
- Whether a data source produced valid records
### BGP Observation
`/bgp` is the BGP-focused page.
It complements the BGP layer on Earth:
- Earth emphasizes spatial posture and visual focus
- The console BGP page emphasizes lists, status, details, and assessment
### Alerts
Alert entry points:
- `/alerts/system`
- `/alerts/bgp`
- `/alerts/situational`
Used to view system, network, and situational alerts.
### System Settings
`/settings` manages system-level configuration.
Current common uses:
- System settings
- TV live stream source configuration
- Data source configuration entry points
Available configuration depends on the current user's role.
### System Logs
`/logs` views system logs. If the menu item is not visible, the current user likely lacks the required role.
Common troubleshooting sequence:
```bash
./planet.sh health
./planet.sh log
```
Then open `/logs` for more structured runtime information.
## Docs
Public documentation site:
```text
http://localhost:3000/docs
```
Current public content comes from:
```text
docs/technical/zh/ (Chinese)
docs/technical/en/ (English)
```
Docs supports:
- Category navigation
- Markdown rendering
- Tables and code blocks
- In-document table of contents
- Local search
- Internal links between technical documents
When adding a new technical document, check:
- Does it have a clear top-level heading
- Does it need to be added to the `/docs` manual category and ordering
- Does it contain information that should not be publicly displayed
## Development Command Conventions
Frontend commands must use Bun:
```bash
cd frontend
bun install
bun run dev
bun run build
```
Do not use `npm run ...`. The project uses Bun in WSL / Windows mixed environments to avoid Node/npm path compatibility issues.
Verify the frontend build:
```bash
source ~/.zshrc && bun run build
```
## Troubleshooting Order
When something goes wrong, follow this sequence:
1. Check service status:
```bash
./planet.sh health
```
2. Check recent logs:
```bash
./planet.sh log
```
3. Check per-module logs:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
4. Restart only the affected module:
```bash
./planet.sh restart -f
./planet.sh restart -b
./planet.sh restart -a
```
5. If database or cache is abnormal, restart the database:
```bash
./planet.sh restart -d
```
6. If still unrecovered, do a full restart:
```bash
./planet.sh restart
```
## Related Docs
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md)
- [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [earth-layer-style-reference.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
- [backend-system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)

View File

@@ -0,0 +1,105 @@
# Docker + Compose + Buildx Upgrade Guide
Process: remove old version → install new version → verify
---
# 1. Remove Old Version
## Remove apt-installed packages
```bash
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc
```
---
## Remove system `docker-compose` (V1)
```bash
sudo rm -f "$(which docker-compose 2>/dev/null)"
```
---
## Find and remove manually installed Buildx plugin
```bash
docker info | sed -n '/Plugins:/,/^ Server:/p' | grep -A2 buildx
```
Get the `Path` from the output, then run:
```bash
rm -f <path to docker-buildx file>
```
---
## Clean up unused dependencies
```bash
sudo apt autoremove -y
```
---
# 2. Install Official Docker
Includes Docker Engine, Docker Compose plugin, and Docker Buildx plugin.
## Install dependencies
```bash
sudo apt update
sudo apt install -y ca-certificates curl gnupg
```
---
## Add Docker GPG key
```bash
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
```
---
## Add official repository
```bash
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
```
---
## Install Docker + Compose + Buildx
```bash
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
---
# 3. Verify Installation
```bash
docker --version
docker compose version
docker buildx version
```
---
# 4. Common Commands
```bash
docker compose up -d
docker compose down
docker buildx build .
```

View File

@@ -0,0 +1,193 @@
# Quickstart
This guide is for developers or demo operators starting Planet for the first time. The goal is to get services running via the shortest path and know which URLs to open.
## Prerequisites
Recommended: run in a WSL / Linux shell.
You need:
- Docker / Docker Compose available
- `uv` and `bun` accessible in the current shell
- Repository cloned locally
On a new machine, run the bootstrap script first:
```bash
./scripts/bootstrap-dev.sh
```
This script checks and syncs common dependencies, and generates if missing:
- `backend/.env`
- `aiprovider/.env`
- `frontend/.env.local`
## 1. Start Services
From the repository root:
```bash
./planet.sh start
```
After startup, the key URLs are:
| Entry | Default URL | Purpose |
| --- | --- | --- |
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
| Console | `http://localhost:3000/admin` | Admin console (login required) |
| Docs | `http://localhost:3000/docs` | Public developer docs and manual |
| AI Playground | `http://localhost:3000/playground` | AI debugging (login required) |
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
If the default ports are taken, specify custom ports:
```bash
./planet.sh start -f 3001 -b 8001 -a 8101
```
## 2. Create a Login User
The console requires login. For first-time use:
```bash
./planet.sh createuser
```
Follow the prompts to enter username, password, and role.
## 3. Open Earth
Visit:
```text
http://localhost:3000/earth
```
Earth is a public page — no login required.
Once in, verify:
- The globe renders correctly
- The right-side layer panel can toggle layers on/off
- Search can find cables, satellites, compute centers, BGP events
- Settings panel can switch cruise mode, day/night mode, satellite display style
## 4. Open the Console
Visit:
```text
http://localhost:3000/admin
```
The console manages data sources, collected data, situational observation, alerts, system logs, and configuration.
First-time inspection checklist:
- `/datasources`: data source configuration and collection status
- `/data`: collected data
- `/bgp`: BGP situational view
- `/alerts/system`: system alerts
- `/settings`: system configuration
## 5. Check Service Health
```bash
./planet.sh health
```
This shows container status and checks:
- Backend
- AI Provider
- Frontend
## 6. View Logs
Recent logs:
```bash
./planet.sh log
```
Follow a specific service:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
Flags:
- `-f`: frontend logs
- `-b`: backend logs
- `-a`: AI Provider logs
## 7. Common Restarts
Frontend only:
```bash
./planet.sh restart -f
```
Backend only:
```bash
./planet.sh restart -b
```
AI Provider only:
```bash
./planet.sh restart -a
```
Database only:
```bash
./planet.sh restart -d
```
Full restart:
```bash
./planet.sh restart
```
## 8. LAN Access
To allow a Windows browser, phone, or another device on the same network:
```bash
./planet.sh start --allow-lan
```
This makes the frontend and backend listen on a LAN-accessible address.
If access fails, check from the shell running Planet:
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
## 9. Stop Services
```bash
./planet.sh stop
```
This shuts down the frontend, backend, AI Provider, PostgreSQL, and Redis.
## Next Steps
- Full usage guide: [manual.md](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
- Console structure: [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- Earth structure: [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- Backend collectors: [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)

View File

@@ -9,13 +9,21 @@
适合放入这里的内容:
- Quickstart 和使用手册
- 前端上下文
- Earth 前端结构
- Earth 卫星 footprint 策略
- Earth 渲染图层顺序
- Earth 图层样式属性索引
- 后端运行控制
- collector 现状
- 采集格式约定
## 使用入口
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/quickstart.md):从零启动 Planet 的最短路径
- [manual.md](/home/ray/dev/linkong/planet/docs/technical/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
不适合放入这里的内容:
- 尚未完成的 roadmap

View File

@@ -0,0 +1,333 @@
# AI Provider Guide
## Overview
`aiprovider` is the model-adapter service for Planet.
It isolates model-vendor details from the main backend so the rest of the system can call a stable business API:
- Caller service -> `planet backend`
- `planet backend` -> `aiprovider`
- `aiprovider` -> concrete model provider
The recommended default is:
- External and cross-service callers use `planet backend`
- Only infrastructure-grade internal jobs call `aiprovider` directly
## Responsibilities
`backend` is responsible for:
- authentication and authorization
- business-level request shaping
- stable `/api/v1/ai/...` endpoints
- internal service-to-service authentication toward `aiprovider`
`aiprovider` is responsible for:
- model protocol adaptation
- provider selection by `.env`
- timeout and lightweight retry
- request tracing via `X-Request-ID`
This now follows an OpenClaw-like seam:
- `AI_PROVIDER` identifies the vendor or logical provider
- `AI_PROVIDER_API` identifies the wire adapter
That split makes MiniMax, Claude-compatible gateways, and self-hosted OpenAI-compatible services easier to model without overloading one config field.
## Supported Providers
`aiprovider` currently supports these provider identities:
- `openai`
- `anthropic`
- `minimax`
- `ollama`
Supported request adapters:
- `openai-completions`
- `anthropic-messages`
- `ollama-generate`
Backward-compatible aliases still accepted:
- `openai_compatible`
- `anthropic_compatible`
- `claude_compatible`
Provider mapping:
- `vLLM`, `LM Studio`, `One API`: `AI_PROVIDER=openai`, `AI_PROVIDER_API=openai-completions`
- `MiniMax`: `AI_PROVIDER=minimax`, `AI_PROVIDER_API=anthropic-messages`
- Claude-compatible gateways: `AI_PROVIDER=anthropic`, `AI_PROVIDER_API=anthropic-messages`
- `Ollama`: `AI_PROVIDER=ollama`, `AI_PROVIDER_API=ollama-generate`
## API Surfaces
### Main backend API
Preferred stable entrypoints:
- `GET /api/v1/ai/provider/status`
- `POST /api/v1/ai/situational-awareness/analyze`
Authentication:
- `Authorization: Bearer <jwt>`
Optional tracing header:
- `X-Request-ID: <caller-generated-id>`
The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response.
### AI provider internal API
Internal-only endpoints:
- `GET /v1/provider/status`
- `POST /v1/analyze`
Authentication:
- `X-Provider-Token: <shared-secret>`
Optional tracing header:
- `X-Request-ID: <caller-generated-id>`
## Request Example
### Call through backend
```bash
curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \
-H "Authorization: Bearer <access_token>" \
-H "X-Request-ID: bgp-incident-20260407-001" \
-H "Content-Type: application/json" \
-d '{
"title": "BGP异常研判",
"objective": "总结当前风险并给出处置建议",
"observations": [
"collector A 在 5 分钟内出现多次 origin 变更",
"异常集中在同一地区前缀"
],
"constraints": [
"不要编造不存在的数据",
"区分事实和推断"
],
"context": {
"source": "bgp-monitor",
"severity": "high"
}
}'
```
### Call `aiprovider` directly
```bash
curl -X POST http://localhost:8010/v1/analyze \
-H "X-Provider-Token: change_me" \
-H "X-Request-ID: ai-batch-job-001" \
-H "Content-Type: application/json" \
-d '{
"title": "链路波动分析",
"objective": "给出简要态势摘要和下一步建议",
"observations": [
"多个节点出现延迟上升"
],
"constraints": [
"不要假设根因已经确认"
],
"context": {
"region": "APAC"
}
}'
```
## Response Shape
Both backend and `aiprovider` return the same payload shape:
```json
{
"provider": "minimax",
"api": "anthropic-messages",
"model": "MiniMax-M2.7",
"content": "1) 态势摘要 ...",
"content_blocks": [],
"text_blocks": [],
"thinking_blocks": [],
"raw_response": {}
}
```
Both services also return:
- `X-Request-ID: <id>`
## Configuration
### Backend
Recommended backend `.env`:
```env
AI_PROVIDER_SERVICE_URL=http://localhost:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
```
Reference file:
- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example)
### AI Provider
Reference file:
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
Frontend local reference:
- [frontend/.env.example](/home/ray/dev/linkong/planet/frontend/.env.example)
Common settings:
```env
SERVICE_NAME=planet-ai-provider
SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
### OpenAI-compatible example
```env
AI_PROVIDER=openai
AI_PROVIDER_API=openai-completions
AI_BASE_URL=http://127.0.0.1:8001/v1
AI_API_KEY=local-key
AI_MODEL=your-local-model
```
### MiniMax CN example
```env
AI_PROVIDER=minimax
AI_PROVIDER_API=anthropic-messages
AI_BASE_URL=https://api.minimaxi.com/anthropic
AI_API_KEY=sk-cp-xxxxx
AI_MODEL=MiniMax-M2.7
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
```
MiniMax note:
- This follows the same Anthropic Messages request shape as the official MiniMax examples.
- For MiniMax, `aiprovider` now disables `thinking` by default unless the caller explicitly passes a `thinking` object.
- This mirrors OpenClaw's caution around MiniMax Anthropic-compatible behavior.
### Anthropic-compatible example
```env
AI_PROVIDER=anthropic
AI_PROVIDER_API=anthropic-messages
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com/anthropic
AI_API_KEY=your_api_key
AI_MODEL=your-model
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
```
### Ollama example
```env
AI_PROVIDER=ollama
AI_PROVIDER_API=ollama-generate
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MODEL=qwen2.5:7b
```
## Deployment Modes
### Single machine
Recommended local flow:
- `backend` on `localhost:8000`
- `aiprovider` on `localhost:8010`
- local model gateway on `localhost:11434` or another local port
Helpers already included:
- [planet.sh](/home/ray/dev/linkong/planet/planet.sh)
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
### Multi-machine
Example topology:
- app machine: `backend`
- AI gateway machine: `aiprovider`
- model machine: local model service or cloud proxy
In that case, this becomes service-to-service HTTP RPC:
- caller -> backend
- backend -> `http://10.0.0.12:8010`
- `aiprovider` -> model endpoint
Recommended cross-machine backend config:
```env
AI_PROVIDER_SERVICE_URL=http://10.0.0.12:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
```
Recommended operating rules:
- keep `aiprovider` on a private network
- protect it with `X-Provider-Token` at minimum
- always send `X-Request-ID`
- keep callers on the backend API unless they are infrastructure jobs
## Retry And Failure Behavior
`backend -> aiprovider`:
- retries lightweight network / 5xx failures
- returns `502` when the provider service is unavailable
`aiprovider -> model provider`:
- retries lightweight network / 5xx failures
- returns `502` when the model provider is unavailable
This is intentionally conservative. It avoids masking persistent errors while still absorbing short hiccups.
## Operational Notes
- `./planet.sh start` now starts `aiprovider` automatically
- `./planet.sh restart -a` restarts only `aiprovider`
- `./planet.sh log -a` tails `aiprovider` logs
- `./planet.sh health` reports `aiprovider` health
## Recommended Calling Policy
- Frontend and application services: call `backend`
- Scheduled infra jobs and diagnostics: optionally call `aiprovider`
- Do not let multiple business services integrate model vendors independently
That keeps provider switching centralized and avoids model-specific drift across the system.

View File

@@ -0,0 +1,347 @@
# System Service Control
This document defines the fixed mapping between admin control-plane actions and
the existing `planet.sh` service-management commands.
The goal is to reuse the current operational script semantics without exposing
arbitrary shell execution to the frontend or API callers.
## Scope
- This mapping is for admin-side operational controls only.
- The control plane must submit a fixed action name, not a raw shell command.
- The backend is responsible for translating an allowed action into a fixed
`planet.sh` invocation.
## Design Rules
- Only whitelist actions may be executed.
- The frontend must never send arbitrary shell strings.
- The backend must build command arguments from a fixed mapping table.
- High-risk actions should be restricted to `super_admin`.
- Prefer partial restarts over full-stack restarts when UI continuity matters.
## Action Mapping
| Action name | Intended use | `planet.sh` command | Notes |
| --- | --- | --- | --- |
| `restart-backend` | Restart backend API only | `./planet.sh restart -b` | Recommended first implementation for UI-triggered restart flows. |
| `restart-database` | Restart PostgreSQL and Redis containers | `./planet.sh restart -d` | Useful when database/cache services need a controlled bounce without restarting the UI. |
| `restart-system` | Restart the whole application stack | `./planet.sh restart` | Frontend continuity breaks briefly; UI should switch to guided recovery mode. |
| `restart-frontend` | Restart frontend dev server only | `./planet.sh restart -f` | Use with caution; UI continuity is weaker than backend-only restart. |
| `restart-backend-port` | Restart backend on a specific port | `./planet.sh restart -b <port>` | Port must be backend-validated before execution. |
| `restart-frontend-port` | Restart frontend on a specific port | `./planet.sh restart -f <port>` | Port must be backend-validated before execution. |
| `health-check` | Read current service health | `./planet.sh health` | Safe read-only operational action. |
| `show-logs-backend` | Inspect backend logs | `./planet.sh log -b` | Best used for CLI/operator tooling, not normal Web UI streaming. |
| `show-logs-frontend` | Inspect frontend logs | `./planet.sh log -f` | Best used for CLI/operator tooling, not normal Web UI streaming. |
## Not Exposed In UI By Default
The following existing script capabilities should not be exposed directly in the
Web UI unless there is an explicit product need and an additional safety review:
- `./planet.sh restart`
- `./planet.sh start`
- `./planet.sh stop`
- `./planet.sh createuser`
- any future raw shell passthrough
Reason:
- full restart can break the current control session;
- stop/start have larger blast radius;
- user creation is not a service-control operation;
- raw shell passthrough creates unnecessary privilege risk.
## Recommended First-Phase UI Contract
### Frontend action payload
```json
{
"action": "restart-backend"
}
```
### Backend command resolution
```text
restart-backend -> ["./planet.sh", "restart", "-b"]
restart-database -> ["./planet.sh", "restart", "-d"]
restart-system -> ["./planet.sh", "restart"]
restart-frontend -> ["./planet.sh", "restart", "-f"]
health-check -> ["./planet.sh", "health"]
```
## API Draft
### Primary Endpoint
- `POST /api/v1/system/restart-tasks`
Purpose:
- create a controlled restart task;
- resolve a whitelist action into a fixed `planet.sh` command;
- hand execution off to an external runner or detached subprocess.
### Request Body
```json
{
"action": "restart-backend"
}
```
Optional future shape:
```json
{
"action": "restart-backend-port",
"port": 8000
}
```
### Response
```json
{
"task_id": "restart_20260331_153000_ab12cd",
"action": "restart-backend",
"status": "queued",
"stage": "accepted",
"message": "Restart task accepted"
}
```
### Task Query Endpoint
- `GET /api/v1/system/restart-tasks/{task_id}`
Response shape:
```json
{
"task_id": "restart_20260331_153000_ab12cd",
"action": "restart-backend",
"status": "queued",
"stage": "accepted",
"message": "Waiting for execution",
"requested_by": {
"id": 1,
"username": "admin"
},
"created_at": "2026-03-31T15:30:00+08:00",
"updated_at": "2026-03-31T15:30:02+08:00"
}
```
### Optional Log Endpoint
- `GET /api/v1/system/restart-tasks/{task_id}/logs`
Suggested response:
```json
{
"task_id": "restart_20260331_153000_ab12cd",
"lines": [
"accepted restart-backend request",
"spawning restart command",
"waiting for backend shutdown",
"waiting for backend health recovery"
]
}
```
This log endpoint is optional for phase one. The first version can work with
task state plus `/health` polling alone.
## Task State Model
### Status
- `queued`
- `running`
- `succeeded`
- `failed`
- `timeout`
### Stage
- `accepted`
- `spawning`
- `stopping`
- `starting`
- `waiting_for_health`
- `healthy`
- `failed`
### Interpretation
- `status` is the high-level terminal or non-terminal state.
- `stage` is the operator-facing execution phase for the UI.
- `message` is the short human-readable line shown in the modal or full-screen
overlay.
## Permission Model
- `restart-backend` should require `super_admin`.
- Permission checks should follow the same role pattern already used in
[users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py).
- Frontend visibility may hide controls for non-`super_admin`, but backend must
still enforce authorization.
## Storage Model
Recommended first implementation:
- store restart task state in Redis;
- keep task lifetime short;
- keep recent logs as a bounded list.
Suggested keys:
- `system:restart_task:{task_id}`
- `system:restart_task:{task_id}:logs`
Suggested stored fields:
- `task_id`
- `action`
- `status`
- `stage`
- `message`
- `requested_by_id`
- `requested_by_username`
- `created_at`
- `updated_at`
## Execution Model
The request-handling API process should not depend on itself surviving long
enough to stream the whole restart output.
Recommended execution flow:
1. validate caller and action
2. create task state in Redis
3. resolve action to fixed `planet.sh` argv
4. spawn detached executor
5. return `task_id`
6. executor updates task state while restart is in progress
7. frontend polls health and/or task state until recovery
Recommended command resolution examples:
```text
restart-backend -> ["./planet.sh", "restart", "-b"]
restart-frontend -> ["./planet.sh", "restart", "-f"]
restart-backend-port -> ["./planet.sh", "restart", "-b", "<port>"]
health-check -> ["./planet.sh", "health"]
```
## Frontend Polling Flow
Recommended first-phase UX:
1. user clicks `重启后端`
2. confirmation modal explains temporary unavailability
3. frontend calls `POST /api/v1/system/restart-tasks`
4. UI enters blocking restart state
5. frontend polls `/health` every `1-2s`
6. temporary request failures are treated as expected
7. after `2-3` consecutive successful health checks, frontend reloads page
Optional richer polling:
1. poll task status endpoint while backend is still reachable
2. switch to `/health` recovery polling after disconnect begins
3. refresh page after health recovery
## Frontend State Machine
- `idle`
- `confirming`
- `submitting`
- `waiting_for_shutdown`
- `waiting_for_recovery`
- `recovered`
- `failed`
- `timeout`
Suggested UI messages:
- `已发送重启指令`
- `正在停止后端服务`
- `正在等待服务恢复`
- `服务已恢复,正在刷新页面`
- `恢复超时,请手动检查服务状态`
## Phase-One Recommendation
Implement only the following in phase one:
- `restart-backend`
- `super_admin` permission gate
- task creation endpoint
- Redis-backed task state
- frontend confirmation modal
- frontend `/health` polling
- automatic page reload after recovery
Do not implement in phase one:
- full `./planet.sh restart`
- raw shell command passthrough
- arbitrary service control
- full terminal stdout streaming
- multi-action concurrent restart queueing
## Implementation Checklist
### Backend
1. add a dedicated system-control API module under `backend/app/api/v1/`
2. add a whitelist-based action resolver for `planet.sh`
3. store restart task state in Redis
4. add detached restart-runner script execution
5. expose:
- `POST /api/v1/system/restart-tasks`
- `GET /api/v1/system/restart-tasks/{task_id}`
- optional task log endpoint
6. enforce `super_admin` permission on all restart-task endpoints
### Frontend
1. add a `重启后端` control on the dashboard for `super_admin`
2. show a confirmation modal before dispatch
3. after submission, switch modal into blocking restart state
4. poll `/health` until backend recovery is confirmed
5. auto-refresh page after consecutive successful health checks
6. show short stage-oriented logs instead of raw terminal streaming
### Operational Notes
1. phase one should target backend-only restart
2. frontend restart should remain out of scope initially
3. command execution must always originate from repository root
4. only fixed action names may cross the API boundary
## Validation Requirements
- Reject any action not present in the whitelist.
- If a port-bearing action is added, validate the port as an integer in
`1..65535`.
- Resolve commands from the repository root so `planet.sh` runs with a stable
working directory.
- Record the requested action, operator identity, execution start time, and
result.
## Implementation Guidance
- For UI-triggered restart flows, prefer `restart-backend` first.
- Do not rely on the current API request process to stream full restart output
after it triggers its own restart.
- Use a task record plus polling/health-check recovery flow instead of raw
terminal streaming as the primary UX.

View File

@@ -0,0 +1,355 @@
# BGP Context
## Current Goal
The BGP module is being evolved from an anomaly-only demo into a layered observability pipeline:
`raw observations -> enrichment -> detectors -> incidents -> console/Earth visualization`
The practical product goal is no longer just to "show incidents on the globe". The current product objective is:
1. keep BGP visually present on Earth even when incident density is low
2. make incidents clearly feel like a higher-confidence layer than anomalies
3. show that the observation network is still active even when there are no active incidents
In practice, that means Earth should behave like an observability surface, not only an incident map:
- `collectors` show that observation is happening
- `activity` shows where routing state is currently active or noisy
- `incidents` become the highest-confidence focus layer
## Current Backend Architecture
### Data Layers
1. `BGPObservation`
- File: `backend/app/models/bgp_observation.py`
- Purpose: store normalized raw routing observations from live/history sources.
- Typical fields:
- `source`
- `collector`
- `peer_asn`
- `peer_ip`
- `prefix`
- `event_type`
- `as_path`
- `origin_asn`
- `next_hop`
- `communities`
- `observed_at`
- `raw_payload`
- `collector_geo`
- `ingest_batch_id`
2. `BGPAnomaly`
- File: `backend/app/models/bgp_anomaly.py`
- Purpose: hold atomic detector outputs.
- Current detector output types include:
- `origin_change`
- `more_specific_burst`
- `mass_withdrawal`
3. `BGPIncident`
- File: `backend/app/models/bgp_incident.py`
- Purpose: aggregate atomic anomalies into incident-level objects for humans and the UI.
### Pipeline
Main flow is currently anchored in:
- `backend/app/services/collectors/bgp_common.py`
- `backend/app/services/bgp_enrichment.py`
- `backend/app/services/bgp_detectors.py`
- `backend/app/services/bgp_incidents.py`
Operational flow:
1. collectors fetch raw BGP data
2. `normalize_bgp_event()` standardizes payloads
3. observations are persisted to `bgp_observations`
4. enrichment augments events with analysis context
5. detectors create `bgp_anomalies`
6. incident aggregation rolls anomalies up into `bgp_incidents`
### Current Ingest Sources
1. `RIPE RIS Live`
- Collector file: `backend/app/services/collectors/ris_live.py`
- Used for realtime observation flow.
2. `CAIDA BGPStream Backfill`
- Collector file: `backend/app/services/collectors/bgpstream.py`
- Used as history/backfill entry point.
## Current Enrichment Status
Implemented enrichment skeleton in:
- `backend/app/services/bgp_enrichment.py`
Current enrichments:
- prefix family / prefix length
- supernet / more-specific derivation
- deduplicated AS path
- path prepending hints
- collector region info
- prefix baseline hints
- new-origin detection
- ASN organization profile from PeeringDB where available
- prefix scope / impacted region hints
- prefix geography source priority:
- `OpenGeoFeed` (override/high confidence)
- `IPtoASN` (country-range baseline)
- `NRO delegated stats` (registry-allocation fallback)
Current limitation:
- `RPKI` is still placeholder-only and returns `unknown`
- no real ROA validation source is integrated yet
- `inetnum` / `inet6num` whois fallback is still pending
## Current API Surface
Primary API file:
- `backend/app/api/v1/bgp.py`
Available endpoints:
- `/api/v1/bgp/events`
- `/api/v1/bgp/events/summary`
- `/api/v1/bgp/events/{id}`
- `/api/v1/bgp/anomalies`
- `/api/v1/bgp/anomalies/summary`
- `/api/v1/bgp/anomalies/{id}`
- `/api/v1/bgp/incidents`
- `/api/v1/bgp/incidents/summary`
- `/api/v1/bgp/incidents/{id}`
Visualization GeoJSON endpoints:
- `backend/app/api/v1/visualization.py`
- `/api/v1/visualization/geo/bgp-collectors`
- `/api/v1/visualization/geo/bgp-anomalies`
- `/api/v1/visualization/geo/bgp-incidents`
## Current Earth Behavior
Relevant files:
- `frontend/public/earth/js/bgp.js`
- `frontend/public/earth/js/main.js`
- `frontend/public/earth/js/info-card.js`
- `frontend/public/earth/js/constants.js`
- `frontend/public/earth/index.html`
Current design:
1. Collectors are always shown when BGP is enabled.
2. Incident markers are now the primary Earth BGP markers.
3. If there are no incidents, Earth falls back to anomaly markers.
4. If there are no anomalies either, collectors still provide presence.
5. A dedicated `activity layer` now adds:
- per-collector recent 15-minute activity halos
- clustered regional activity hints derived from active collectors
6. Incident markers now use:
- symbol-driven event cores
- outward ring pulses
- reduced diffuse glow compared with older Earth builds
5. The right-side stats now show:
- BGP events
- collector count
- BGP status summary
This is directionally correct, but still incomplete for low-event-density periods. Right now Earth can still feel too quiet when incidents are sparse because the system lacks a dedicated `activity layer` between raw observation and incident focus.
Current BGP status strategy:
- incidents present: show active incident count
- no incidents but anomalies present: show active anomaly count, plus active observation regions when available
- no incidents/anomalies but activity present: show `观测网络运行中`
- no incidents/anomalies but collectors present: show `观测网络运行中 · 当前未发现聚合级事件`
- no BGP data at all: show `暂无观测数据`
Earth info-card strategy:
- `bgp` card is now incident-centric in wording
- `bgp_collector` card shows collector location and current event count
## Current Product Gap
The main product gap is not architecture correctness. It is low-density visualization strategy.
Current reality:
- incident count is naturally much lower than anomaly count
- 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
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:
`event map -> observability map`
That means Earth needs three simultaneously readable layers:
1. `observation layer`
- collectors
- recent collector activity
- baseline coverage
2. `activity layer`
- recent event density
- anomaly/noise hotspots
- regional activity scoring
- incident presence bonus
3. `incident layer`
- sparse but highly legible, high-confidence event objects
- symbol-driven markers
- outward ring pulse instead of broad diffuse glow
## Incident Visual Direction
The Earth `incident` layer should not read like a large glowing patch. It should read like a compact, high-confidence event focus.
Design principles:
1. `incident` markers should use a strong primary symbol
- the symbol shape should carry type meaning where possible
- examples:
- `origin_change`: triangle-like warning marker
- `mass_withdrawal`: alert/exclamation-style marker
- `more_specific_burst`: split/radiating marker
2. emphasis should come from outward ring pulses, not area flooding
- use a compact hot core
- use one or more expanding ring pulses
- avoid broad luminous blobs that make the event center feel vague
3. `collector` and `incident` must stay visually distinct
- collectors are observation infrastructure
- incidents are extracted event focus
- collector activity should stay quieter than incident pulse language
4. calm periods still need observability presence
- collectors and activity layers should keep the map alive
- once incidents appear, they should clearly dominate nearby BGP visuals
5. incident geography should become `prefix-centric`
- collectors should remain evidence sources, not the primary event location
- preferred geography priority:
- `prefix_geography`
- `prefix_scope`
- `ASN organization region`
- `collector centroid` as final fallback
- `prefix_scope` should remain an observation-derived scope hint
- a new `prefix_geography` layer should be introduced for actual prefix-centric placement
Reference inspiration:
- `World Monitor`
- sparse event symbols
- compact centers
- ring-like outward pulses
- stronger incident legibility than diffuse glow
## Current Console Behavior
Relevant page:
- `frontend/src/pages/BGP/BGP.tsx`
Current BGP console page has three levels:
1. observation summary
- total events
- collector count
- prefix count
2. incident summary and incident table
3. anomaly detail table plus recent observation events
This means the BGP page still has useful signal even when there are zero anomalies.
## Known Product/Engineering Boundaries
1. The current system is still closer to an event board than a full BGP sensing platform.
2. RIS coverage still needs to expand beyond narrow subscription scope.
3. BGPStream history is still not full MRT-to-prefix decoded analytics.
4. Collector geography still depends heavily on static RIPE RIS mappings.
5. Incident-to-cable/IXP/region association is still weak and early-stage.
6. Earth currently visualizes logical observation/impact structure, not true physical traffic paths.
## Test Status
BGP-specific tests live in:
- `backend/tests/test_bgp.py`
Verified status at this point:
- `25 passed` for `backend/tests/test_bgp.py`
- `62 passed` for `backend/tests`
Covered areas include:
- normalization
- observation serialization
- enrichment
- detectors, including route leak candidate and path flap
- incident aggregation
- batch anomaly creation
- BGP events/incidents API
- summary endpoints
## Most Relevant Files
Backend:
- `backend/app/models/bgp_observation.py`
- `backend/app/models/bgp_anomaly.py`
- `backend/app/models/bgp_incident.py`
- `backend/app/services/collectors/bgp_common.py`
- `backend/app/services/bgp_enrichment.py`
- `backend/app/services/bgp_detectors.py`
- `backend/app/services/bgp_incidents.py`
- `backend/app/api/v1/bgp.py`
- `backend/app/api/v1/visualization.py`
Frontend:
- `frontend/src/pages/BGP/BGP.tsx`
- `frontend/public/earth/js/bgp.js`
- `frontend/public/earth/js/main.js`
- `frontend/public/earth/js/info-card.js`
- `frontend/public/earth/js/constants.js`
- `frontend/public/earth/index.html`
## Recommended Next Steps
### Next Backend / Detection Priority
1. Integrate real RPKI validation data.
2. Expand realtime collector coverage and include withdrawals more broadly.
3. Continue refining route leak and path instability detectors with stronger heuristics.
### Next Correlation / Storytelling Priority
4. Strengthen incident aggregation semantics and titles.
5. Add weak correlation from incidents to:
- cable corridors
- landing points
- IXPs
- other traffic anomaly sources
6. Refine Earth hover/click handoff between collectors and incidents.
### Next Visualization Priority
7. Refine regional activity scoring so the activity layer is informative without becoming noisy.
8. Add more incident symbol types as new detectors land.
9. Add a real prefix geography source:
- `IPtoASN / IPtoCountry` as the first practical dataset
- `OpenGeoFeed` as a higher-quality override layer
- registry/whois only as fallback

View File

@@ -0,0 +1,244 @@
# Earth 图层样式属性索引
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
`renderOrder` 等样式属性。层级关系请配合
[earth-render-layer-order.md](/home/ray/dev/linkong/planet/docs/technical/earth-render-layer-order.md)
查看。
## 命名约定
| 类别 | 约定 | 示例 |
| --- | --- | --- |
| 全局配置对象 | `*_CONFIG` | `COUNTRY_BOUNDARY_CONFIG` |
| 图层半径偏移 | `*AltitudeOffset` / `radiusOffset` | `lineAltitudeOffset`, `GRID_CONFIG.radiusOffset` |
| 透明度 | `*Opacity` | `hoverLineOpacity` |
| 渲染顺序 | `*RenderOrder` | `textureOverlayRenderOrder` |
| 颜色 | `*Color`,十六进制数字或 CSS 色值 | `lineColor`, `colors.supercomputer` |
| 线宽 | `lineWidth` / `*LineWidth` | `GRID_CONFIG.lineWidth` |
## Earth 基座与高清材质
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| Earth 基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
| Earth 基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
| Earth 基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
| Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
| Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
| Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | 独立高清材质球半径 |
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
| 高清材质 specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | 降低直射区域镜面高光,避免贴图死白 |
| 高清材质 shininess | `EARTH_MATERIAL_CONFIG.textureOverlayShininess` | `4` | 降低高光集中度 |
| 高清材质颜色乘色 | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
## Earth 遮挡与昼夜
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 遮挡球半径系数 | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | 深度遮挡球半径 |
| 遮挡球分段 | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | 遮挡球几何分段 |
| 遮挡球 renderOrder | inline | `-1` | `occluder.renderOrder` |
| 昼夜太阳方向 | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | 自定义 day/night shader |
| 夜侧最低亮度 | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.24` | shader uniform |
| 日侧增强 | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `1.12` | shader uniform |
| 暮光宽度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightWidth` | `0.2` | shader uniform |
| 暮光强度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity` | `0.14` | shader uniform |
| 暮光颜色 | `EARTH_MATERIAL_CONFIG.dayNight.twilightColor` | `0x4ea0ff` | shader uniform |
| 夜侧 tint 颜色 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintColor` | `0x0b1830` | shader uniform |
| 夜侧 tint 强度 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity` | `0.08` | shader uniform |
## 大气辉光与云图
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 内层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
| 内层大气分段 | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
| 内层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | shader RGB |
| 内层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | shader rim |
| 内层大气强度 | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | shader alpha multiplier |
| 外层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.0025` | `atmosOuterGeo` |
| 外层大气分段 | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
| 外层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | shader RGB |
| 外层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `9.0` | shader rim |
| 外层大气强度 | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.0025` | shader alpha multiplier |
| 大气辉光 blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
| 大气辉光 renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
| 无高清材质边缘光颜色 | `EARTH_MATERIAL_CONFIG.rimGlowColor` | `[0.42, 0.72, 1.0]` | 高清材质隐藏或不可用时的 Fresnel shell RGB |
| 无高清材质边缘光半径系数 | `EARTH_MATERIAL_CONFIG.rimGlowRadiusFactor` | `1.0035` | `earth-rim-glow` 外扩球壳半径 |
| 无高清材质边缘光 rim power | `EARTH_MATERIAL_CONFIG.rimGlowPower` | `3.4` | shader rim 衰减;值越大边缘越窄 |
| 无高清材质边缘光强度 | `EARTH_MATERIAL_CONFIG.rimGlowIntensity` | `0.24` | shader alpha multiplier |
| 无高清材质边缘光分段 | `EARTH_MATERIAL_CONFIG.rimGlowSegments` | `96` | `earth-rim-glow` 几何分段 |
| 无高清材质边缘光 renderOrder | `EARTH_MATERIAL_CONFIG.rimGlowRenderOrder` | `1.08` | `_earthRimGlow.renderOrder` |
| 无高清材质边缘光 depthTest | inline | `false` | 避免被海陆基座或地表填充遮住 |
| 云图半径偏移 | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | 云层球半径 |
| 云图分段 | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | 云层球几何分段 |
| 云图透明度 | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
| 云图贴图 | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | 云层贴图 |
| 云图 blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
## 海陆基座与国界
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
| 海洋填充色 | local `OCEAN_HEX` | `0x010609` | 海陆基座 canvas 背景 |
| 陆地填充色 | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | 海陆基座 canvas 陆地 |
| 海陆基座透明度 | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` 半径 |
| 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
| 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 |
| 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint |
| 国界 tint 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` 半径 |
| 国界 tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
| 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 |
| 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity |
| 国界线 hover 时压暗透明度 | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | hover 时普通国界线 opacity |
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | 普通国界线半径 |
| 国界线 renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | 普通国界线层级 |
| 国界 hover 颜色 | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | 霓虹红橘 |
| 国界 hover 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | hover 实线 opacity |
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | hover 实线半径 |
| 国界 hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | hover 实线层级 |
| 国界 hover glow 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | glow 线 opacity |
| 国界 hover glow 线宽 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | glow `LineBasicMaterial.linewidth` |
| 国界 hover glow 层级偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | glow renderOrder = `2.29` |
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | glow 半径 = hover 半径 + 0.04 |
## 真实地形
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 地形 tile size | `TERRAIN_CONFIG.tileSize` | `256` | Terrarium tile 读取 |
| 地形 base zoom | `TERRAIN_CONFIG.baseZoom` | `4` | 地形采样 zoom |
| 地形几何分段 | `geometryWidthSegments / geometryHeightSegments` | `320 / 320` | 地形球几何 |
| 地形基准半径偏移 | `TERRAIN_CONFIG.baseRadiusOffset` | `0.16` | 地形压过高清材质 |
| 地形夸张系数 | `TERRAIN_CONFIG.exaggeration` | `34` | 海拔转世界单位 |
| 地形陆地淡入高度 | `TERRAIN_CONFIG.landRevealFadeMeters` | `220` | 顶点 alpha |
| 地形透明度 | `TERRAIN_CONFIG.opacity` | `0.68` | `MeshPhongMaterial.opacity` |
| 地形颜色 | `TERRAIN_CONFIG.color` | `0x8aa884` | `MeshPhongMaterial.color` |
| 地形 emissive | `TERRAIN_CONFIG.emissive` | `0x030704` | 降低自发光,恢复地形明暗层次 |
| 地形 specular | `TERRAIN_CONFIG.specular` | `0x344438` | 给地形局部光泽,不抬高高清贴图直射亮度 |
| 地形 shininess | `TERRAIN_CONFIG.shininess` | `16` | 收紧地形高光,增强起伏辨识 |
| 地形 renderOrder | inline | `1.2` | `terrain.renderOrder` |
| 地形 polygonOffset | inline | `factor -1`, `units -1` | 降低贴近球面时的闪烁 |
## 经纬线
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 经纬线半径偏移 | `GRID_CONFIG.radiusOffset` | `0.14` | 经纬线球面半径 |
| 经纬线颜色 | `GRID_CONFIG.color` | `0xc0e0ff` | `LineBasicMaterial.color` |
| 经纬线透明度 | `GRID_CONFIG.opacity` | `0.08` | `LineBasicMaterial.opacity` |
| 经纬线线宽 | `GRID_CONFIG.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| 经纬线 renderOrder | `GRID_CONFIG.renderOrder` | `2.05` | 经纬线层级 |
| 纬线间隔 | `GRID_CONFIG.latitudeStep` | `15` | 纬线生成步长 |
| 经线间隔 | `GRID_CONFIG.longitudeStep` | `30` | 经线生成步长 |
| 线段采样步长 | `GRID_CONFIG.segmentStep` | `5` | 经纬线采样步长 |
## 海缆与登陆点
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |
| 海缆半径偏移 | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | 海缆线半径 |
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | 对齐算力中心贴地表 marker 高度 |
| 登陆点 icon 贴图尺寸 | `CABLE_CONFIG.landingPoint.textureSize` | `256` | canvas 渲染 EPS 参考图的实心 map-pin中间圆孔透明镂空 |
| 登陆点 icon 宽高比 | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` |
| 登陆点 icon 锚点 | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`,将 pin 下端点对齐登陆点经纬度 |
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `12` | 对齐算力中心等地表 icon 的 sprite 高度 |
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | 兼容旧球体材质sprite 不使用 |
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | 兼容旧球体材质sprite 不使用 |
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | 对齐算力中心地表设施层级 |
| 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 |
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
| 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 |
| 非相关登陆点 emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | dim 状态弱琥珀自发光 |
| 非相关登陆点 emissive 强度 | `landingPointVisual.dimmed.emissiveIntensity` | `0.18` | dim 状态弱发光强度 |
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 |
## 卫星、轨迹和 footprint
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 卫星显示半径偏移 | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | 卫星点位置 |
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size |
| 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 |
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | 呼吸动画 |
| 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 |
| 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor |
| 卫星背景点透明度 | inline | `0.42` | backdrop point opacity |
| 卫星点透明度 | inline | `0.9` | point material opacity |
| 卫星背景点 renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
| 卫星点 renderOrder | inline | `6` | `satellitePoints.renderOrder` |
| 卫星轨迹长度 | `SATELLITE_CONFIG.trailLength` | `10` | trail buffer |
| 卫星轨迹线宽 | `SATELLITE_CONFIG.trailLineWidth` | `3` | ribbon shader uniform |
| 选中 ring 大小 | `SATELLITE_CONFIG.ringSize` | `0.07` | hover / locked ring sprite |
| 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit |
| 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` |
| 自发光选中点透明度 | inline | `0.96` | locked dot material |
| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | footprint fill |
| footprint group renderOrder | inline | `0` | 避免 Group 排序盖过卫星点 |
## 算力中心
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 |
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker |
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 |
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 |
| dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 |
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture |
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture |
| 关联颜色 | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | 关联态 |
| 算力中心 renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | 地表设施低于卫星点 |
## BGP 观测
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `2.1` | anomaly marker |
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker |
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite |
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane |
| hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 |
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite |
| hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 |
| dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 |
| collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 |
| critical 颜色 | `BGP_CONFIG.severityColors.critical` | `0xff4d4f` | 严重事件 |
| high 颜色 | `BGP_CONFIG.severityColors.high` | `0xff9f43` | 高危事件 |
| medium 颜色 | `BGP_CONFIG.severityColors.medium` | `0xffd166` | 中危事件 |
| low 颜色 | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | 低危事件 |
| collector 基础色 | `BGP_CONFIG.collectorColor` | `0x6db7ff` | collector 默认色 |
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
| BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring |
| BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring |
| collector marker renderOrder | inline | `3` | `marker.renderOrder` |
| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` |
## 天体与星空
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 天球半径 | `CELESTIAL_CONFIG.skyRadius` | `2600` | 天体背景 |
| 天球透明度 | `CELESTIAL_CONFIG.skyOpacity` | `1` | 背景材质 |
| 太阳距离 / 缩放 | `sunDistance / sunScale` | `2150 / 78` | 太阳 sprite |
| 月亮距离 / 缩放 | `moonDistance / moonScale` | `2050 / 38` | 月亮 sprite |
| 太阳 halo 缩放 | `CELESTIAL_CONFIG.sunHaloScale` | `136` | 太阳 halo |
| 月亮 halo 缩放 | `CELESTIAL_CONFIG.moonHaloScale` | `62` | 月亮 halo |
| 太阳光颜色 / 强度 | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | scene light |
| 背光颜色 / 强度 | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | scene light |
| 星空点数量 | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
| 星空半径范围 | `minRadius + radiusJitter` | `800 + 200` | 随机分布 |
| 星空点颜色 | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
| 星空点大小 | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |

View File

@@ -0,0 +1,57 @@
# Earth 渲染图层顺序
本文记录当前 Earth 渲染器的图层顺序和每层意图。后续调整
`renderOrder`、半径偏移、深度策略或指针交互时,需要同步更新这里。
注意:图层控制面板顺序和注册 / 启动加载顺序是两套语义。
| 顺序类型 | 当前顺序 | 说明 |
| --- | --- | --- |
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → BGP → 地形 → 高清材质 → 大气云图 → 国界 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
| 注册 / 启动加载顺序 | 经纬线 → 国界 → 高清材质 → 大气云图 → 海缆 → 算力中心 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
## 地表图层栈
| 顺序 | 图层 | 来源 | 渲染 / 半径策略 | 深度 / 交互策略 | 备注 |
| --- | --- | --- | --- | --- | --- |
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有 Earth 内容之后。 |
| -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
| 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
| 1 | 海缆 | `cables.js` | `CABLE_CONFIG.line.renderOrder` | 海缆拾取路径 | 保持现有海缆层级。 |
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset` | 禁用 raycast | 只保证压过高清材质。 |
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-testedGroup renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
| 3-5 | BGP 标记和覆盖层 | `bgp.js` | 各 marker 自身 renderOrder | BGP 拾取路径 | 保持现有 BGP 视觉层级。 |
| 4.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 |
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
## 开关联动
| 开关 | 行为 |
| --- | --- |
| 高清材质 off | 隐藏高清材质,启用国界 tint / 基座表面,禁用地形和昼夜开关交互,并记住地形和昼夜之前状态。 |
| 高清材质 on | 恢复高清材质,并恢复记住的地形 / 昼夜状态。 |
| 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 |
| 大气云图 | 只控制云图 mesh 显隐。 |
| 国界 | 控制国界线和 hover 线显隐;海陆基座填充独立存在,作为 Earth 基座地图使用。 |
## 交互规则
| 交互 | 当前规则 |
| --- | --- |
| Earth 坐标 hover | 高清材质可见时使用高清材质 overlay 作为地表拾取目标,否则使用 Earth 基座球。 |
| 国界 hover | 先把地表拾取坐标转成经纬度,再用 GeoJSON 点面判断;国界 hover 线本身不接收 raycast。 |
| 国界 hover 视觉 | hover 时压暗普通国界线,并绘制无深度测试的光晕和实线。 |
| 中国 / 台湾 hover | `CHN``TWN` 被归到同一个 hover 高亮组tooltip 仍显示鼠标实际命中的 feature。 |
| 地形 | 只作为视觉层参与,`terrain.raycast` 已禁用。 |
| 卫星 | 使用屏幕空间卫星拾取,避免 footprint 或地表层挡住卫星点击。 |

View File

@@ -116,11 +116,68 @@
- 为表格滚动区提供统一包裹层
- 后续新表格页优先复用,不要重复写“表格区域 + overlay scrollbar”样板
### 4. 其他共享组件
### 4. `SegmentedControl`
文件:
- [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx)
- [SegmentedControl.css](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.css)
用途:
- 语言切换、主题切换、模式切换这类 2 到 3 项的分段控制器
- 需要保留滑块动画、激活态和紧凑按钮布局的设置项
- 当前 `/docs` 页底部语言切换与主题切换已经复用它
接口语义:
- `options`:每个选项包含 `value``label`,可选 `icon``title`
- `value`:当前激活值
- `onChange`:切换选项时回调
- `ariaLabel`:控制器可访问名称
- `className`:业务页面用于覆盖尺寸或局部样式
当前约束:
- 组件自身负责滑块数量、位置和弹性动画
- 业务页面只传选项和状态,不要重复写私有 slider DOM
- 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色
- 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉
### 5. `MarkdownRenderer`
文件:
- [MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx)
用途:
- 渲染 `/docs` 的 Markdown 正文
- 支持标题、列表、引用、代码块、表格和基础行内格式
- 代码块和表格内部复用 `Scrollbar`,避免横向内容撑爆文档页
当前约束:
- 它不是完整 GitHub Markdown 引擎,只覆盖项目文档当前需要的语法
- 文档内部链接应通过 `transformLink` 转成 `/docs/:slug`
- 标题锚点由 `getHeadingId` 注入,避免渲染器自己理解路由状态
### 6. `TableActions`
文件:
- [TableActions.tsx](/home/ray/dev/linkong/planet/frontend/src/components/TableActions/TableActions.tsx)
用途:
- 表格操作列的统一操作入口
- 展开状态下直接展示按钮
- 收起状态下用更多菜单承载操作
配套导出:
- `actionCellProps`:用于操作列 `onCell`,防止操作按钮被省略号截断或换行
## 当前状态来源
### 1. 认证状态

488
docs/technical/zh/manual.md Normal file
View File

@@ -0,0 +1,488 @@
# Planet 使用手册
这份手册面向日常使用、演示、开发联调和本地运维。它覆盖四个核心入口:
- `planet.sh`:本地启动、停止、重启、健康检查和日志入口
- Earth公开 3D 地球态势页面
- 控制台:登录后的管理后台
- Docs公开开发文档与使用手册
快速启动路径见 [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/quickstart.md)。
## 入口总览
默认启动后,常用地址如下:
| 名称 | 地址 | 是否需要登录 | 说明 |
| --- | --- | --- | --- |
| Earth | `http://localhost:3000/earth` | 否 | 3D 地球、图层、BGP、卫星、海缆、新闻态势 |
| Docs | `http://localhost:3000/docs` | 否 | 开发文档、技术说明、使用手册 |
| 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 |
| AI Playground | `http://localhost:3000/playground` | 是 | AI Provider 状态和调试 |
| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 |
## planet.sh
`planet.sh` 是本地开发和演示的主控脚本。优先使用它管理服务,而不是手动分别启动前端、后端、数据库和 AI Provider。
### 启动
```bash
./planet.sh start
```
默认行为:
- 启动 PostgreSQL 和 Redis
- 启动 AI Provider
- 启动后端 API
- 启动前端 Vite dev server
- 输出 Earth、控制台、Playground 和后端 API 文档入口
可指定端口:
```bash
./planet.sh start -b 8001 -f 3001 -a 8101
```
参数含义:
| 参数 | 含义 |
| --- | --- |
| `-b <port>` | 后端端口 |
| `-f <port>` | 前端端口 |
| `-a <port>` | AI Provider 端口 |
| `--allow-lan` | 允许局域网访问 |
| `--verbose` | 在执行过程中显示更多命令输出 |
### 停止
```bash
./planet.sh stop
```
会停止:
- 后端
- AI Provider
- 前端
- PostgreSQL
- Redis
### 重启
全量重启:
```bash
./planet.sh restart
```
按模块重启:
```bash
./planet.sh restart -b
./planet.sh restart -f
./planet.sh restart -a
./planet.sh restart -d
```
| 参数 | 作用 |
| --- | --- |
| `-b` | 只重启后端 |
| `-f` | 只重启前端 |
| `-a` | 只重启 AI Provider |
| `-d` | 只重启数据库 |
按模块重启适合日常开发,能避免无关服务被打断。
### 创建用户
```bash
./planet.sh createuser
```
用于首次进入控制台前创建登录账号。脚本会交互式提示用户名、密码和角色。
### 健康检查
```bash
./planet.sh health
```
会检查:
- `planet_*` 容器状态
- 后端 `/health`
- AI Provider `/health`
- 前端页面可达性
如果某项显示 offline优先查看对应日志。
### 日志
最近日志:
```bash
./planet.sh log
```
持续跟随日志:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
| 参数 | 日志来源 |
| --- | --- |
| `-f` / `--frontend` | `/tmp/planet_frontend.log` |
| `-b` / `--backend` | `/tmp/planet_backend.log` |
| `-a` / `--ai-provider` | `planet_aiprovider` 容器日志 |
### 局域网访问
```bash
./planet.sh start --allow-lan
```
适合:
- WSL 中启动Windows 浏览器访问
- 手机或平板演示 Earth
- 局域网其他机器访问同一个开发实例
启动后注意检查防火墙和 WSL 网络转发。
## Earth
Earth 是公开的 3D 态势页面,入口:
```text
http://localhost:3000/earth
```
它是独立前端,实际页面位于:
- `frontend/public/earth/index.html`
- `frontend/public/earth/js/`
- `frontend/public/earth/css/`
React 路由中的 `/earth` 只是用 iframe 承载它。
### 主要用途
Earth 用于在一个地球视图中观察:
- BGP 事件、异常和观测态势
- 卫星和轨迹
- 海缆与登陆点
- 算力中心
- 国界、经纬线、高清材质、云图、地形
- 新闻直播和态势新闻
- 搜索和聚焦对象详情
### 图层控制
右侧图层面板用于打开或关闭可视图层。
常见图层包括:
- 经纬线
- 国界
- 高清材质
- 大气云图
- 海缆
- 算力中心
- BGP 观测
- 卫星
- 轨迹
- 地形
部分图层存在依赖关系:
- 地形依赖高清材质
- 轨迹依赖卫星
- 高清材质关闭时,地球会显示基座地图和边缘识别效果
### 搜索
Earth 搜索支持查找当前地球对象,例如:
- 海缆
- 登陆点
- 卫星
- 算力中心
- BGP 事件
- BGP 观测站
搜索结果可以用于快速定位对象,并打开对应详情。
### 设置
设置面板包含:
- 旋转模式 / 巡航模式
- 巡航模块BGP、新闻
- 卫星显示风格:自身发光、真实地表覆盖
- 日夜模式
- 面板显示开关
- 地球默认大小
- 地形透明度
- 重置设置
这些设置会保存在浏览器本地存储中。换浏览器或清理站点数据后会恢复默认值。
### 巡航模式
巡航模式会让 Earth 自动轮播聚焦目标。
当前巡航模块包括:
- BGP
- 新闻
适合演示、监控大屏或无人值守展示。
### 移动端
Earth 有移动端抽屉布局。小屏下:
- 图层控制进入移动抽屉
- 搜索、设置、详情会使用移动端面板
- 主要交互仍围绕地球对象点击、搜索和图层开关
### 常见问题
#### Earth 打不开
先检查前端是否在线:
```bash
./planet.sh health
./planet.sh log -f
```
如果前端端口不是 `3000`,使用启动时输出的实际端口。
#### 图层没有数据
检查后端和数据源:
```bash
./planet.sh health
./planet.sh log -b
```
然后进入控制台查看:
- `/datasources`
- `/data`
- `/bgp`
#### 卫星、BGP 或海缆加载慢
这些图层可能依赖后端接口、外部数据源或首次加载任务。先等待启动任务完成,再查看日志和控制台数据源状态。
## 控制台
控制台入口:
```text
http://localhost:3000/admin
```
控制台需要登录。首次使用先创建用户:
```bash
./planet.sh createuser
```
### 页面结构
控制台使用 React + Ant Design左侧菜单按工作域组织。
常见入口:
| 页面 | 路由 | 用途 |
| --- | --- | --- |
| 仪表盘 | `/admin` | 系统概览 |
| Earth | `/earth` | 打开公开 Earth 页面 |
| 数据源 | `/datasources` | 管理数据源和触发采集 |
| 采集数据 | `/data` | 查看采集后的数据 |
| BGP 观测 | `/bgp` | 查看 BGP 专题数据 |
| 系统告警 | `/alerts/system` | 系统级告警 |
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
| 态势告警 | `/alerts/situational` | 态势研判告警 |
| AI Playground | `/playground` | AI Provider 调试 |
| 系统日志 | `/logs` | 查看系统日志,通常仅 super admin 可见 |
| 用户管理 | `/users` | 管理用户 |
| 系统配置 | `/settings` | 系统配置和电视直播源等设置 |
### 数据源
`/datasources` 用于查看和管理采集来源。
常见操作:
- 查看数据源状态
- 触发采集
- 查看最近采集任务
- 调整配置项
如果 Earth 上某类对象缺失,通常先到这里确认数据源是否可用。
### 采集数据
`/data` 用于查看采集后的数据表。
适合排查:
- 数据是否已经进入系统
- 数据更新时间是否符合预期
- 某个数据源是否产出了有效记录
### BGP 观测
`/bgp` 是 BGP 专题页面。
它和 Earth 的 BGP 图层互补:
- Earth 强调空间态势和可视聚焦
- 控制台 BGP 页面强调列表、状态、详情和研判
### 告警
告警入口包括:
- `/alerts/system`
- `/alerts/bgp`
- `/alerts/situational`
用于查看系统、网络和态势相关告警。
### 系统配置
`/settings` 用于管理系统级配置。
当前常见用途包括:
- 系统设置
- 电视直播源配置
- 数据源相关配置入口
具体可用配置取决于当前登录用户权限。
### 系统日志
`/logs` 用于查看系统日志。若菜单中不可见,通常是当前用户角色没有权限。
排查问题时常用组合:
```bash
./planet.sh health
./planet.sh log
```
再进入 `/logs` 查看更结构化的运行信息。
## Docs
公开文档站入口:
```text
http://localhost:3000/docs
```
当前公开内容来自:
```text
docs/technical/*.md
```
Docs 支持:
- 分类导航
- Markdown 渲染
- 表格和代码块
- 文档内目录
- 本地搜索
- technical 文档之间的内部链接跳转
如果新增 technical 文档,应同步检查:
- 是否有清晰的一级标题
- 是否需要加入 `/docs` 的人工分类和排序
- 是否包含不适合公开展示的信息
## 开发命令约定
前端命令必须使用 Bun
```bash
cd frontend
bun install
bun run dev
bun run build
```
不要使用 `npm run ...`。项目在 WSL / Windows 混合环境中优先依赖 Bun避免 Node/npm 路径差异带来的兼容问题。
验证前端构建:
```bash
source ~/.zshrc && bun run build
```
## 故障排查顺序
遇到问题时,建议按这个顺序排查:
1. 看服务状态:
```bash
./planet.sh health
```
2. 看最近日志:
```bash
./planet.sh log
```
3. 按模块查看日志:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
4. 只重启有问题的模块:
```bash
./planet.sh restart -f
./planet.sh restart -b
./planet.sh restart -a
```
5. 如果数据库或缓存异常,再重启数据库:
```bash
./planet.sh restart -d
```
6. 仍无法恢复时,执行全量重启:
```bash
./planet.sh restart
```
## 相关文档
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/quickstart.md)
- [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/frontend-admin-frontend-context.md)
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
- [earth-layer-style-reference.md](/home/ray/dev/linkong/planet/docs/technical/earth-layer-style-reference.md)
- [backend-system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/backend-collectors.md)

View File

@@ -0,0 +1,193 @@
# Quickstart
这份 Quickstart 面向第一次启动 Planet 的开发者或演示操作者。目标是用最短路径把服务跑起来,并知道应该打开哪些入口。
## 前置条件
推荐在 WSL / Linux shell 中运行。
需要具备:
- Docker / Docker Compose 可用
- 当前 shell 能访问 `uv``bun`
- 仓库已 clone 到本机
如果是新机器,优先执行仓库自带初始化脚本:
```bash
./scripts/bootstrap-dev.sh
```
这个脚本会检查并同步常用依赖,并在缺少时生成:
- `backend/.env`
- `aiprovider/.env`
- `frontend/.env.local`
## 1. 启动服务
在仓库根目录执行:
```bash
./planet.sh start
```
启动完成后,常用入口是:
| 入口 | 默认地址 | 用途 |
| --- | --- | --- |
| Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 |
| 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 |
| 文档站 | `http://localhost:3000/docs` | 公开开发文档和使用手册 |
| AI Playground | `http://localhost:3000/playground` | 登录后的 AI 调试入口 |
| 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 |
如果默认端口被占用,可以指定端口:
```bash
./planet.sh start -f 3001 -b 8001 -a 8101
```
## 2. 创建登录用户
控制台需要登录。首次使用可以执行:
```bash
./planet.sh createuser
```
按提示输入用户名、密码和角色。
## 3. 打开 Earth
访问:
```text
http://localhost:3000/earth
```
Earth 是公开页面,不需要登录。
进入后可以先确认:
- 地球正常显示
- 右侧图层控制可打开/关闭图层
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
- 设置面板可以切换巡航模式、日夜模式、卫星显示风格
## 4. 打开控制台
访问:
```text
http://localhost:3000/admin
```
控制台用于数据源、采集数据、专题观测、告警、系统日志和配置管理。
首次排查建议查看:
- `/datasources`:数据源配置和采集状态
- `/data`:已采集数据
- `/bgp`BGP 专题观测
- `/alerts/system`:系统告警
- `/settings`:系统配置
## 5. 查看运行状态
```bash
./planet.sh health
```
这个命令会显示容器状态,并检查:
- 后端
- AI Provider
- 前端
## 6. 查看日志
最近日志:
```bash
./planet.sh log
```
持续查看某个服务:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
含义:
- `-f`:前端日志
- `-b`:后端日志
- `-a`AI Provider 日志
## 7. 常用重启
只重启前端:
```bash
./planet.sh restart -f
```
只重启后端:
```bash
./planet.sh restart -b
```
只重启 AI Provider
```bash
./planet.sh restart -a
```
只重启数据库:
```bash
./planet.sh restart -d
```
全量重启:
```bash
./planet.sh restart
```
## 8. 局域网访问
如果希望 Windows 浏览器、手机或同一局域网的其他设备访问:
```bash
./planet.sh start --allow-lan
```
这会让前端和后端监听局域网可访问地址。
如果访问失败,先在运行 Planet 的 shell 中检查:
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
## 9. 停止服务
```bash
./planet.sh stop
```
停止后会关闭前端、后端、AI Provider、PostgreSQL 和 Redis。
## 下一步
- 完整操作说明见 [manual.md](/home/ray/dev/linkong/planet/docs/technical/manual.md)
- 控制台结构见 [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/frontend-admin-frontend-context.md)
- Earth 结构见 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
- 后端采集器见 [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/backend-collectors.md)

View File

@@ -16,12 +16,22 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.40.1`
- `dev` 当前开发分支历史推导到:`0.42.2`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.42.2` | bugfix | `dev` | `pending` | Docs 中文模式补齐分组和文档标题翻译,并更新文档站品牌文案 |
| `0.42.1` | bugfix | `dev` | `pending` | 修正 release skill 的 feature 版本计算规则minor 进位时重置 patch 为 0 |
| `0.42.0` | feature | `dev` | `pending` | 新增公开 `/docs` 文档站、中英文技术/使用文档、搜索与主题切换,并补充公共组件复用和 Earth 无高清材质边缘提示 |
| `0.41.2` | improvement | `dev` | `pending` | 启动脚本新增 verbose 滚动日志与端口占用诊断Docker 构建支持镜像源覆盖,并修复 Earth 登陆点遮挡判断 |
| `0.41.1` | improvement | `dev` | `pending` | 修复新闻直播持久化失效、pin 边缘遮挡;图标抽取为 SVG 并建立规范 |
| `0.41.0` | feature | `dev` | `pending` | Earth 图层顺序拆分、基座海陆色块、国界交互、高清材质/云图/地形层级与样式文档落地 |
| `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 |
| `0.40.4` | bugfix | `dev` | `pending` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 |
| `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial修复锁定环 depthTest 与位置漂移,新增悬停态缩放 |
| `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 |
| `0.40.1` | improvement | `dev` | `pending` | 卫星选中标记配色跟随图例,修复 footprint 遮蔽卫星渲染问题,修复选中海缆误触发卫星高亮 |
| `0.40.0` | feature | `dev` | `pending` | Earth 卫星 footprint 按星座能力分层Iridium 独立 coverage ring 落地,卫星详情卡补齐覆盖能力与当前显示说明 |
| `0.39.0` | feature | `dev` | `pending` | 后端统一结构化日志地基落地,系统日志页重构为紧凑日志工作台,并修复 Earth 移动端态势抽屉与新闻详情同步问题 |

View File

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

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP route collector marker. Outer ring + Material access_point icon. -->
<!-- States: idle opacity=0.74, hover/locked use brighter blend (color controlled externally) -->
<!-- Outer ring -->
<circle cx="64" cy="64" r="22" fill="none" stroke="rgba(111,160,197,0.34)" stroke-width="1.2"/>
<!-- access_point icon: 24x24 path scaled 4x and offset to (16,16) in 128x128 canvas space -->
<g transform="translate(16 16) scale(4 4)"
fill="rgba(214,224,233,0.88)"
stroke="rgba(64,106,136,0.74)"
stroke-width="0.9"
stroke-linejoin="round"
stroke-linecap="round">
<path d="M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"/>
<!-- Center dot override -->
<circle cx="12" cy="12" r="0.85" fill="rgba(222,231,239,0.72)" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP specific/burst anomaly symbol: 6 radial spokes (r 26→48) + center dot (r=16). -->
<!-- Spoke endpoints calculated as: inner=64+cos(angle)*26, outer=64+cos(angle)*48 for 6 angles -->
<g stroke="currentColor" stroke-width="10" stroke-linecap="round">
<line x1="90" y1="64" x2="112" y2="64"/>
<line x1="77" y1="86.5" x2="88" y2="105.6"/>
<line x1="51" y1="86.5" x2="40" y2="105.6"/>
<line x1="38" y1="64" x2="16" y2="64"/>
<line x1="51" y1="41.5" x2="40" y2="22.4"/>
<line x1="77" y1="41.5" x2="88" y2="22.4"/>
</g>
<circle cx="64" cy="64" r="16" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP generic event symbol: filled circle. -->
<circle cx="64" cy="64" r="28" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP withdraw event symbol: exclamation mark (rounded bar + dot). -->
<rect x="52" y="22" width="24" height="62" rx="12" fill="currentColor"/>
<circle cx="64" cy="102" r="10" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP route leak symbol: two nested open triangular outlines (outer + inner chevron). -->
<g stroke="currentColor" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none">
<polyline points="28,96 64,28 100,96"/>
<polyline points="40,82 64,54 88,82"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Ring overlay for BGP event markers. Stroked circle, no fill. -->
<circle cx="64" cy="64" r="44" fill="none" stroke="rgba(255,255,255,0.98)" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP origin anomaly symbol: upward triangle. -->
<polygon points="64,18 110,106 18,106" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP flap event symbol: zigzag/wave (filled W shape, closed). -->
<polygon points="14,100 38,26 64,100 90,26 114,100" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP collector base glow: radial gradient dot. Inner r=8 fully opaque, fades to transparent at r=56. -->
<defs>
<radialGradient id="bgp-glow" cx="64" cy="64" r="56" fx="64" fy="64" fr="8" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="white" stop-opacity="1"/>
<stop offset="24%" stop-color="white" stop-opacity="0.92"/>
<stop offset="58%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="64" cy="64" r="56" fill="url(#bgp-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- GPU cluster marker: database/cylinder stack icon. -->
<!-- Color: #2dd4bf (teal) per COMPUTE_CENTER_CONFIG.colors.gpu_cluster -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<!-- Outer cylinder: top ellipse cap + side rect + bottom half-ellipse -->
<!-- Inner groove ring: smaller cylinder shape overlaid at same color (subtle shape layering) -->
<g fill="#2dd4bf">
<rect x="46" y="46" width="36" height="28"/>
<ellipse cx="64" cy="46" rx="18" ry="8"/>
<path d="M 82,74 A 18,8 0 0,1 46,74 Z"/>
<rect x="52" y="58" width="24" height="6"/>
<ellipse cx="64" cy="58" rx="12" ry="4.5"/>
<path d="M 76,64 A 12,4.5 0 0,1 52,64 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 787 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Supercomputer marker: flat-screen monitor with neck and base stand. -->
<!-- Color: #38bdf8 (sky-blue) per COMPUTE_CENTER_CONFIG.colors.supercomputer -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<g fill="#38bdf8">
<rect x="40" y="42" width="48" height="30" rx="7"/>
<rect x="58" y="74" width="12" height="8" rx="3"/>
<rect x="50" y="84" width="28" height="5" rx="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 50 600 680">
<!-- Location pin with circular cutout. Coordinates in 1000-unit space (canvas scale: size/1000). -->
<!-- States: color via fill attribute — default white for 3D canvas, orange (#ffaa00) for normal, white for hover/locked -->
<path
fill-rule="evenodd"
fill="currentColor"
d="M400 704 C386 704 375 697 367 684 L173 378 C117 290 144 173 229 111 C278 75 337 57 400 57 C463 57 522 75 571 111 C656 173 683 290 627 378 L433 684 C425 697 414 704 400 704 Z
M400 320 m-86 0 a86 86 0 1 0 172 0 a86 86 0 1 0 -172 0"
/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@@ -827,6 +827,12 @@
rgba(255, 255, 255, 0.04);
}
.earth-mobile-layer-card.is-disabled,
.earth-mobile-layer-card:disabled {
cursor: not-allowed;
opacity: 0.46;
}
.earth-mobile-layer-card-icon {
font-size: 22px;
color: var(--hud-accent-strong);
@@ -1401,6 +1407,12 @@
transform: translateX(16px);
}
label.is-disabled.earth-mobile-settings-card {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
.earth-mobile-settings-slider-row {
display: flex;
align-items: center;
@@ -2478,6 +2490,12 @@
transform: translateX(calc(16px * var(--hud-scale)));
}
.earth-settings-item.is-disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
@media (max-width: 960px) {
.earth-settings-sheet {
top: 24px;

View File

@@ -271,6 +271,16 @@
opacity: 1;
}
.layer-row-toggle.is-disabled {
cursor: not-allowed;
opacity: 0.35;
}
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-label,
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-icon {
opacity: 0.4;
}
/* Thumb */
.layer-row-toggle-track::after {
content: "";

File diff suppressed because one or more lines are too long

View File

@@ -108,33 +108,13 @@
<!-- Layer rows -->
<div class="layer-panel-list" id="layer-panel-list">
<div class="layer-row" data-layer-name="地形 terrain">
<span class="material-symbols-rounded layer-row-icon">landscape</span>
<div class="layer-row" data-layer-name="海缆 subsea cables">
<span class="material-symbols-rounded layer-row-icon">cable</span>
<div class="layer-row-copy">
<span class="layer-row-label">地形</span>
<span class="layer-row-meta">Terrain</span>
<span class="layer-row-label">海缆</span>
<span class="layer-row-meta">Subsea Cables</span>
</div>
<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>
</button>
</div>
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
<div class="layer-row-copy">
<span class="layer-row-label">经纬线</span>
<span class="layer-row-meta">Graticule</span>
</div>
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="卫星 satellites">
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
<div class="layer-row-copy">
<span class="layer-row-label">卫星</span>
<span class="layer-row-meta">Satellites</span>
</div>
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
@@ -148,13 +128,13 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="海缆 subsea cables">
<span class="material-symbols-rounded layer-row-icon">cable</span>
<div class="layer-row" data-layer-name="卫星 satellites">
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
<div class="layer-row-copy">
<span class="layer-row-label">海缆</span>
<span class="layer-row-meta">Subsea Cables</span>
<span class="layer-row-label">卫星</span>
<span class="layer-row-meta">Satellites</span>
</div>
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
@@ -178,6 +158,56 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="地形 terrain">
<span class="material-symbols-rounded layer-row-icon">landscape</span>
<div class="layer-row-copy">
<span class="layer-row-label">地形</span>
<span class="layer-row-meta">Terrain</span>
</div>
<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>
</button>
</div>
<div class="layer-row" data-layer-name="高清材质 纹理 texture hd earth">
<span class="material-symbols-rounded layer-row-icon">globe</span>
<div class="layer-row-copy">
<span class="layer-row-label">高清材质</span>
<span class="layer-row-meta">High-Res Texture</span>
</div>
<button id="toggle-earth-high-res-texture" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换高清材质显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="大气 云图 云层 clouds atmosphere">
<span class="material-symbols-rounded layer-row-icon">cloud</span>
<div class="layer-row-copy">
<span class="layer-row-label">大气云图</span>
<span class="layer-row-meta">Cloud Layer</span>
</div>
<button id="toggle-atmosphere-clouds" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换大气云图显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="国界 国家 borders countries boundary">
<span class="material-symbols-rounded layer-row-icon">public</span>
<div class="layer-row-copy">
<span class="layer-row-label">国界</span>
<span class="layer-row-meta">Country Borders</span>
</div>
<button id="toggle-country-boundaries" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换国界显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
<div class="layer-row-copy">
<span class="layer-row-label">经纬线</span>
<span class="layer-row-meta">Graticule</span>
</div>
<button id="toggle-grid-lines" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换经纬线显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
</div>
<!-- Empty search state -->
@@ -695,8 +725,8 @@
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="self_glow" aria-pressed="true">自身发光</button>
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="ground_footprint" aria-pressed="false">真实地表覆盖</button>
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="self_glow" aria-pressed="false">自身发光</button>
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
</div>
</div>
</div>
@@ -896,17 +926,17 @@
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
<button
type="button"
class="earth-settings-segmented-btn is-active"
class="earth-settings-segmented-btn"
data-satellite-display-style="self_glow"
aria-pressed="true"
aria-pressed="false"
>
自身发光
</button>
<button
type="button"
class="earth-settings-segmented-btn"
class="earth-settings-segmented-btn is-active"
data-satellite-display-style="ground_footprint"
aria-pressed="false"
aria-pressed="true"
>
真实地表覆盖
</button>

View File

@@ -20,7 +20,60 @@ export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
let landingPointGeometry = null;
let landingPointTexture = null;
const _lpEarthWorldPos = new THREE.Vector3();
const _lpWorldPos = new THREE.Vector3();
const _lpCameraRel = new THREE.Vector3();
const _lpPointRel = new THREE.Vector3();
const _lpCameraToPoint = new THREE.Vector3();
function createLandingPointTexture() {
const size = CABLE_CONFIG.landingPoint.textureSize;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const iconPath = new Path2D(
[
"M400 704",
"C386 704 375 697 367 684",
"L173 378",
"C117 290 144 173 229 111",
"C278 75 337 57 400 57",
"C463 57 522 75 571 111",
"C656 173 683 290 627 378",
"L433 684",
"C425 697 414 704 400 704",
"Z",
].join(" "),
);
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.translate(size * 0.12, size * 0.02);
ctx.scale(size / 1000, size / 1000);
ctx.fillStyle = "#ffffff";
ctx.fill(iconPath);
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(400, 320, 86, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function getLandingPointTexture() {
if (!landingPointTexture) {
landingPointTexture = createLandingPointTexture();
}
return landingPointTexture;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
@@ -49,7 +102,7 @@ function disposeMaterial(material) {
return;
}
if (material.map) {
if (material.map && !material.userData?.sharedMap) {
material.map.dispose();
}
material.dispose();
@@ -69,6 +122,22 @@ function disposeObject(object, parent) {
}
}
function setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
point.material.color.set(color);
point.material.opacity = opacity;
if (point.material.emissive && emissive !== undefined) {
point.material.emissive.setHex(emissive);
}
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
point.material.emissiveIntensity = emissiveIntensity;
}
}
function setLandingPointScale(point, heightScale) {
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
point.scale.set(heightScale * aspect, heightScale, 1);
}
function getCableColor(properties) {
if (properties.color) {
if (
@@ -357,13 +426,6 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
clearLandingPoints(earthObj);
if (!landingPointGeometry) {
landingPointGeometry = new THREE.SphereGeometry(
CABLE_CONFIG.landingPoint.radius,
CABLE_CONFIG.landingPoint.widthSegments,
CABLE_CONFIG.landingPoint.heightSegments,
);
}
let validCount = 0;
for (const feature of data.features) {
@@ -396,29 +458,35 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
continue;
}
const sphere = new THREE.Mesh(
landingPointGeometry,
new THREE.MeshStandardMaterial({
const marker = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getLandingPointTexture(),
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
depthTest: false,
depthWrite: false,
}),
);
sphere.position.copy(position);
sphere.userData = {
marker.material.userData.sharedMap = true;
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
marker.center.set(
CABLE_CONFIG.landingPoint.anchorX,
CABLE_CONFIG.landingPoint.anchorY,
);
marker.position.copy(position);
marker.userData = {
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale,
sharedGeometry: true,
};
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale);
earthObj.add(sphere);
landingPoints.push(sphere);
earthObj.add(marker);
landingPoints.push(marker);
validCount++;
}
@@ -533,6 +601,42 @@ export function getAllLandingPoints() {
return landingPoints;
}
function isFacingCamera(lp, camera) {
lp.getWorldPosition(_lpWorldPos);
if (lp.parent) {
lp.parent.getWorldPosition(_lpEarthWorldPos);
} else {
_lpEarthWorldPos.set(0, 0, 0);
}
_lpCameraRel.copy(camera.position).sub(_lpEarthWorldPos);
_lpPointRel.copy(_lpWorldPos).sub(_lpEarthWorldPos);
_lpCameraToPoint.subVectors(_lpPointRel, _lpCameraRel);
const distanceSq = _lpCameraToPoint.lengthSq();
if (distanceSq <= 0) return true;
const distance = Math.sqrt(distanceSq);
_lpCameraToPoint.multiplyScalar(1 / distance);
// The pin sprite is rendered without depth testing so its full shape does
// not get sliced by the globe. Instead, hide it when the camera-to-anchor
// segment is occluded by a slightly inflated globe, matching the behavior of
// the BGP and compute-center markers near the limb.
const occlusionRadius =
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45;
const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint);
const cameraRadiusSq = _lpCameraRel.lengthSq();
const discriminant =
cameraProjection * cameraProjection -
(cameraRadiusSq - occlusionRadius * occlusionRadius);
if (discriminant < 0) return true;
const nearestIntersection = -cameraProjection - Math.sqrt(discriminant);
return nearestIntersection <= 0 || nearestIntersection >= distance;
}
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
const pulse =
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
@@ -544,58 +648,65 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
: [];
landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isRelated =
!dimAll &&
Array.isArray(lp.userData.cableNames) &&
lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) {
lp.material.color.setHex(0xffd27a);
lp.material.emissive.setHex(0x7a4a00);
lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
lp.material.opacity =
Math.max(
setLandingPointMaterialState(lp, {
color: 0xffd27a,
emissive: 0x7a4a00,
emissiveIntensity:
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
opacity: Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
);
),
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(
setLandingPointScale(
lp,
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
baseScale *
distanceScale,
distanceScale,
);
} else {
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
const r = dimColor.r * brightness;
const g = dimColor.g * brightness;
const b = dimColor.b * brightness;
lp.material.color.setRGB(r / 255, g / 255, b / 255);
lp.material.emissive.setHex(CABLE_CONFIG.landingPointVisual.dimmed.emissive);
lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity;
lp.material.opacity = CABLE_CONFIG.landingPointVisual.dimmed.opacity;
setLandingPointMaterialState(lp, {
color: new THREE.Color(r / 255, g / 255, b / 255),
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(baseScale * distanceScale);
setLandingPointScale(lp, baseScale * distanceScale);
}
});
}
export function resetLandingPointVisualState(camera = null) {
landingPoints.forEach((lp) => {
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
lp.material.emissiveIntensity = CABLE_CONFIG.landingPoint.emissiveIntensity;
lp.material.opacity = CABLE_CONFIG.landingPoint.opacity;
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
setLandingPointMaterialState(lp, {
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
opacity: CABLE_CONFIG.landingPoint.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(baseScale * distanceScale);
setLandingPointScale(lp, baseScale * distanceScale);
});
}

View File

@@ -5,6 +5,7 @@ import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const textureCache = new Map();
let showComputeCenters = true;
let supercomputerCount = 0;
@@ -196,7 +197,7 @@ function createComputeCenterMarker(markerData) {
),
);
marker.scale.setScalar(baseScale);
marker.renderOrder = 8;
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
marker.visible = showComputeCenters;
marker.userData = {
...markerData,

View File

@@ -31,7 +31,7 @@ export const SATELLITE_DISPLAY_STYLES = {
};
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
SATELLITE_DISPLAY_STYLES.SELF_GLOW;
SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT;
export const CRUISE_CONFIG = {
dwellMs: 7_000,
@@ -155,19 +155,45 @@ export const TERRAIN_CONFIG = {
baseZoom: 4,
geometryWidthSegments: 320,
geometryHeightSegments: 320,
baseRadiusOffset: 0.04,
baseRadiusOffset: 0.16,
exaggeration: 34,
landRevealFadeMeters: 220,
maxConcurrentRequests: 10,
opacity: 0.62,
color: 0x7f9d7f,
emissive: 0x061008,
specular: 0x233126,
shininess: 10,
opacity: 0.68,
color: 0x8aa884,
emissive: 0x030704,
specular: 0x344438,
shininess: 16,
urlTemplate:
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
};
export const COUNTRY_BOUNDARY_CONFIG = {
dataPath: "/earth/data/countries-admin0.min.geojson",
lineAltitudeOffset: 0.24,
hoverAltitudeOffset: 0.32,
lineColor: 0x7fc7ff,
lineOpacity: 0.58,
lineRenderOrder: 2.2,
dimmedLineOpacity: 0.18,
hoverLineColor: 0xff3b1f,
hoverLineOpacity: 1.0,
hoverLineRenderOrder: 2.3,
hoverGlowOpacity: 0.38,
hoverGlowLineWidth: 3,
hoverGlowRenderOrderOffset: 0.01,
hoverGlowRadiusOffset: 0.04,
tintAltitudeOffset: 0.04,
tintColor: 0x0b1830,
tintRenderOrder: 0.2,
landColor: 0x080f1b,
landOpacity: 1.0,
landAltitudeOffset: 0.08,
landRenderOrder: 0.86,
landMaskWidth: 2048,
landMaskHeight: 1024,
};
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
@@ -175,6 +201,7 @@ export const PATHS = {
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
earthSummaryApi: '/api/v1/visualization/geo/summary',
earthClientLogsApi: '/api/v1/system/logs/earth-client',
};
@@ -233,15 +260,17 @@ export const CABLE_CONFIG = {
renderOrder: 1,
},
landingPoint: {
altitudeOffset: 0.1,
radius: 0.4,
widthSegments: 16,
heightSegments: 16,
baseScale: 2.5,
altitudeOffset: 0.48,
textureSize: 256,
iconAspectRatio: 0.82,
anchorX: 0.52,
anchorY: 0.276,
baseScale: 12,
color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5,
opacity: 1.0,
renderOrder: 4.5,
},
landingPointSizeStabilization: {
enabled: true,
@@ -251,7 +280,7 @@ export const CABLE_CONFIG = {
},
landingPointVisual: {
pulseSpeed: 0.003,
dimBrightness: 0.3,
dimBrightness: 0.62,
related: {
emissiveIntensityBase: 0.5,
emissiveIntensityPulse: 0.5,
@@ -261,10 +290,10 @@ export const CABLE_CONFIG = {
scalePulse: 0.3,
},
dimmed: {
colorRGB: { r: 255, g: 170, b: 0 },
emissive: 0x000000,
emissiveIntensity: 0,
opacity: 0.3,
colorRGB: { r: 180, g: 116, b: 28 },
emissive: 0x3a2200,
emissiveIntensity: 0.18,
opacity: 0.78,
},
},
};
@@ -277,13 +306,16 @@ export const CABLE_STATE = {
export const SATELLITE_CONFIG = {
maxCount: -1,
initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true,
initialLoadCount: null,
hydrateFullAfterInitialLoad: false,
trailLength: 10,
trailLineWidth: 3,
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotSize: 4,
dotBaseSize: 2.8,
dotBackdropScale: 1.28,
dotZoomScalePower: 1,
ringSize: 0.07,
apiPath: '/api/v1/visualization/geo/satellites',
breathingSpeed: 0.08,
@@ -390,19 +422,45 @@ export const PREDICTED_ORBIT_CONFIG = {
};
export const GRID_CONFIG = {
latitudeStep: 10,
radiusOffset: 0.14,
color: 0xc0e0ff,
opacity: 0.08,
lineWidth: 1,
renderOrder: 2.05,
latitudeStep: 15,
longitudeStep: 30,
gridStep: 5
segmentStep: 5,
};
export const CLOUD_LAYER_CONFIG = {
radiusOffset: 3,
widthSegments: 64,
heightSegments: 64,
opacity: 0.15,
textureUrl: "./assets/earth_clouds_1024.png",
};
export const STARFIELD_CONFIG = {
count: 8000,
minRadius: 800,
radiusJitter: 200,
color: 0xffffff,
size: 0.5,
};
export const EARTH_MATERIAL_CONFIG = {
// Diffuse color multiplies with texture — pure white = full saturation,
// slightly grey-blue pulls perceived saturation down without a custom shader.
color: 0xcdd8e6,
// Base sphere sits below the country fill and high-res texture overlays.
// Keep it dark so a delayed overlay never flashes or reads as a white layer.
color: 0x010609,
specular: 0x1a2d45,
shininess: 12,
emissive: 0x050a12,
opacity: 0.96,
emissive: 0x010609,
opacity: 1,
textureOverlayAltitudeOffset: 0.1,
textureOverlayOpacity: 0.88,
textureOverlayRenderOrder: 0.96,
textureOverlaySpecular: 0x05080d,
textureOverlayShininess: 4,
// Depth-mask occluder keeps far-side objects hidden behind the earth
occluderRadiusFactor: 0.999,
@@ -416,11 +474,19 @@ export const EARTH_MATERIAL_CONFIG = {
atmosInnerIntensity: 0.18,
// Fresnel atmosphere glow — outer corona
atmosOuterRadiusFactor: 1.016,
atmosOuterRadiusFactor: 1.0025,
atmosOuterSegments: 48,
atmosOuterColor: [0.18, 0.45, 0.9],
atmosOuterRimPower: 5.0,
atmosOuterIntensity: 0.02,
atmosOuterRimPower: 9.0,
atmosOuterIntensity: 0.0025,
// Subtle Fresnel edge cue shown when the high-res texture is hidden or unavailable.
rimGlowColor: [0.42, 0.72, 1.0],
rimGlowRadiusFactor: 1.0035,
rimGlowPower: 3.4,
rimGlowIntensity: 0.24,
rimGlowSegments: 96,
rimGlowRenderOrder: 1.08,
// Texture candidates — tried in order, first success wins
textureUrls: [
@@ -432,12 +498,12 @@ export const EARTH_MATERIAL_CONFIG = {
dayNight: {
enabled: true,
sunDirection: { x: 1, y: 0.2, z: 0.4 },
nightFloor: 0.32,
dayBoost: 0.94,
twilightWidth: 0.24,
nightFloor: 0.24,
dayBoost: 1.12,
twilightWidth: 0.2,
twilightIntensity: 0.14,
twilightColor: 0x4ea0ff,
nightTintColor: 0x0b1830,
nightTintIntensity: 0.05,
nightTintIntensity: 0.08,
},
};

View File

@@ -29,6 +29,11 @@ import {
clearLockedObject,
clearLockedObjectAndInfo,
setCablesEnabled,
setCountryBoundariesEnabled,
setHighResTextureEnabled,
getHighResTextureEnabled,
setAtmosphereCloudsEnabled,
getAtmosphereCloudsEnabled,
setSatellitesEnabled,
getSatellitesEnabled,
} from "./main.js";
@@ -41,6 +46,7 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries } from "./country-boundaries.js";
import {
toggleComputeCenters,
getShowComputeCenters,
@@ -73,6 +79,7 @@ export let rotationMode = ROTATION_MODE.ROTATE;
let dayNightEnabled = true;
let defaultEarthZoom = CONFIG.defaultViewZoom;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
let earthObj = null;
let listeners = [];
@@ -103,6 +110,10 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 5;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
@@ -119,6 +130,7 @@ let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
let toolbarHubController = null;
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
@@ -331,9 +343,31 @@ function getMobileLayerButtons(layerId) {
).filter((button) => button instanceof HTMLButtonElement);
}
function getLayerDisabledState(layerId) {
if (layerId === "trails" && !getSatellitesEnabled()) {
return {
disabled: true,
statusText: "不可用",
tooltip: "卫星关闭时不可用",
};
}
if (layerId === "terrain" && !getHighResTextureEnabled()) {
return {
disabled: true,
statusText: "不可用",
tooltip: "高清材质关闭时不可用",
};
}
return {
disabled: false,
statusText: null,
tooltip: null,
};
}
function syncMobileLayerCards() {
const summary = document.getElementById("mobile-layer-summary");
const definitions = getSortedLayerDefinitions();
const definitions = getDisplayLayerDefinitions();
let activeCount = 0;
definitions.forEach((definition) => {
@@ -342,11 +376,19 @@ function syncMobileLayerCards() {
activeCount += 1;
}
getMobileLayerButtons(definition.id).forEach((button) => {
const disabledState = getLayerDisabledState(definition.id);
button.classList.toggle("is-active", visible);
button.classList.toggle("is-disabled", disabledState.disabled);
button.disabled = disabledState.disabled;
button.setAttribute("aria-checked", visible ? "true" : "false");
if (disabledState.tooltip) {
button.title = disabledState.tooltip;
} else {
button.removeAttribute("title");
}
const status = button.querySelector("[data-mobile-layer-status]");
if (status) {
status.textContent = visible ? "开启" : "关闭";
status.textContent = disabledState.statusText || (visible ? "开启" : "关闭");
}
});
});
@@ -360,7 +402,7 @@ function renderMobileLayerCards() {
const list = document.getElementById("mobile-layer-list");
if (!(list instanceof HTMLElement)) return;
const definitions = getSortedLayerDefinitions();
const definitions = getDisplayLayerDefinitions();
list.innerHTML = definitions
.map((definition) => `
<button
@@ -384,6 +426,7 @@ function renderMobileLayerCards() {
bindListener(button, "click", async (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
if (target.disabled || target.classList.contains("is-disabled")) return;
const layerId = target.dataset.mobileLayerButton;
const definition = layerId ? getLayerDefinition(layerId) : null;
if (!definition) return;
@@ -594,6 +637,21 @@ function getSortedLayerDefinitions({ includeUnprioritized = true } = {}) {
.sort(compareLayerDefinitionsByStartupPriority);
}
function getDisplayLayerDefinitions() {
return Array.from(layerRegistry.values()).sort((left, right) => {
const leftOrder = Number.isFinite(left?.displayOrder)
? left.displayOrder
: Number.POSITIVE_INFINITY;
const rightOrder = Number.isFinite(right?.displayOrder)
? right.displayOrder
: Number.POSITIVE_INFINITY;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left?.id || "").localeCompare(String(right?.id || ""));
});
}
function shouldIncludeLayerInStartupLoad(definition) {
if (!Number.isFinite(definition?.startupPriority)) {
return false;
@@ -658,13 +716,22 @@ function getCurrentSharedSettingsSnapshot() {
};
}
function getDefaultLayerVisibilitySnapshot() {
return Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.defaultActive)]),
);
}
function captureEarthSettingsDefaults() {
if (!earthSettingsDefaults) {
const panelVisibility = getCurrentPanelVisibilitySnapshot();
const shared = getCurrentSharedSettingsSnapshot();
earthSettingsDefaults = {
version: 2,
shared,
version: EARTH_SETTINGS_VERSION,
shared: {
...shared,
layerVisibility: getDefaultLayerVisibilitySnapshot(),
},
views: {
desktop: {
panelVisibility: { ...panelVisibility },
@@ -680,7 +747,7 @@ function captureEarthSettingsDefaults() {
function cloneEarthSettings(settings) {
return {
version: 2,
version: EARTH_SETTINGS_VERSION,
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
@@ -753,6 +820,13 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
});
if ((rawSettings?.version || 0) < GRID_LINES_DEFAULT_VERSION && inputLayerVisibility.gridLines === true) {
normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines;
}
if ((rawSettings?.version || 0) < MEDIA_PANEL_DEFAULT_VERSION) {
normalizedDesktopPanelVisibility["media-panel"] = true;
}
const nextRotationMode =
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
@@ -765,11 +839,17 @@ function normalizeEarthSettings(rawSettings, defaults) {
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
),
);
const nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
let nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
sharedSettings?.satelliteDisplayStyle,
)
? sharedSettings.satelliteDisplayStyle
: defaults.shared.satelliteDisplayStyle;
if (
(rawSettings?.version || 0) < SATELLITE_DISPLAY_DEFAULT_VERSION &&
nextSatelliteDisplayStyle === SATELLITE_DISPLAY_STYLES.SELF_GLOW
) {
nextSatelliteDisplayStyle = defaults.shared.satelliteDisplayStyle;
}
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? sharedSettings.dayNightEnabled
@@ -779,7 +859,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
);
return {
version: 2,
version: EARTH_SETTINGS_VERSION,
shared: {
rotationMode: nextRotationMode,
cruiseModules: nextCruiseModules.length > 0
@@ -805,7 +885,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
function getPersistedLayers() {
return getSortedLayerDefinitions().filter((layer) => layer.persist !== false);
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
}
function getLayerDefinition(layerId) {
@@ -849,7 +929,10 @@ function syncEarthSettingsStateFromRuntime() {
const scope = getSettingsViewportScope();
nextSettings.shared = getCurrentSharedSettingsSnapshot();
nextSettings.views[scope].panelVisibility = getCurrentPanelVisibilitySnapshot();
// panelVisibility is maintained in earthSettingsState via setHudPanelVisibility.
// Do not re-snapshot from DOM here: transient hides (e.g. closeTransientMobileOverlays)
// change the DOM without going through setHudPanelVisibility and would corrupt the
// user's persisted preference.
earthSettingsState = nextSettings;
return nextSettings;
}
@@ -1149,6 +1232,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
} else if (enabled) {
setEarthStatValue("satellite-count", `${getSatelliteCount()}`);
}
syncTrailsAvailability();
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
@@ -1179,6 +1263,61 @@ function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = fa
return enabled;
}
async function setCountryBoundariesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
try {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "国界加载中...",
});
}
await setCountryBoundariesEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏国界" : "显示国界",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换国界显示失败:", error);
setLayerButtonState(button, {
active: false,
loading: false,
tooltip: "显示国界",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
}
function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
setHighResTextureEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏高清材质" : "显示高清材质",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
}
function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏大气云图" : "显示大气云图",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
}
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
toggleBGP(enabled);
@@ -1216,9 +1355,11 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleTrails(enabled);
const disabledState = getLayerDisabledState("trails");
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
disabled: disabledState.disabled,
tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"),
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
@@ -1228,6 +1369,17 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
return enabled;
}
function syncTrailsAvailability() {
const trailsEnabled = getShowTrails();
const disabledState = getLayerDisabledState("trails");
setLayerButtonState(getLayerButton("trails"), {
active: trailsEnabled,
disabled: disabledState.disabled,
tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"),
});
syncMobileLayerCards();
}
async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
@@ -1253,23 +1405,6 @@ async function applyLayerVisibilitySettings(layerVisibility = {}, options = {})
function getBuiltinLayerDefinitions() {
return [
{
id: "terrain",
buttonId: "toggle-terrain",
icon: "landscape",
label: "地形",
meta: "Terrain",
keywords: "地形 terrain",
defaultActive: false,
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
{
id: "gridLines",
buttonId: "toggle-grid-lines",
@@ -1277,8 +1412,9 @@ function getBuiltinLayerDefinitions() {
label: "经纬线",
meta: "Graticule",
keywords: "经纬线 graticule 经纬 latitude longitude",
defaultActive: true,
startupPriority: null,
defaultActive: false,
displayOrder: 100,
startupPriority: 10,
startupMode: "visible",
startupLabel: "经纬线",
startupMessage: "",
@@ -1287,36 +1423,55 @@ function getBuiltinLayerDefinitions() {
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
icon: "satellite_alt",
label: "卫星",
meta: "Satellites",
keywords: "卫星 satellites",
defaultActive: false,
startupPriority: 30,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
getVisible: () => getSatellitesEnabled(),
id: "countryBoundaries",
buttonId: "toggle-country-boundaries",
icon: "public",
label: "国界",
meta: "Country Borders",
keywords: "国界 国家 borders countries boundary",
defaultActive: true,
displayOrder: 90,
startupPriority: 20,
startupMode: "preload",
startupLabel: "海陆基座",
startupMessage: "正在加载海陆基座...",
getVisible: () => getShowCountryBoundaries(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
},
{
id: "trails",
buttonId: "toggle-trails",
icon: "timeline",
label: "轨迹",
meta: "Trails",
keywords: "轨迹 trails",
id: "earthHighResTexture",
buttonId: "toggle-earth-high-res-texture",
icon: "globe",
label: "高清材质",
meta: "High-Res Texture",
keywords: "高清 材质 纹理 texture hd 地表 earth",
defaultActive: true,
startupPriority: null,
displayOrder: 70,
startupPriority: 30,
startupMode: "visible",
startupLabel: "轨迹",
startupMessage: "",
getVisible: () => getShowTrails(),
startupLabel: "高清材质",
startupMessage: "正在启用高清材质...",
getVisible: () => getHighResTextureEnabled(),
setVisible: (visible, options = {}) =>
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
},
{
id: "atmosphereClouds",
buttonId: "toggle-atmosphere-clouds",
icon: "cloud",
label: "大气云图",
meta: "Cloud Layer",
keywords: "大气 云图 云层 clouds atmosphere",
defaultActive: true,
displayOrder: 80,
startupPriority: 40,
startupMode: "visible",
startupLabel: "大气云图",
startupMessage: "",
getVisible: () => getAtmosphereCloudsEnabled(),
setVisible: (visible, options = {}) =>
setAtmosphereCloudsLayerEnabled(getLayerButton("atmosphereClouds"), visible, options),
},
{
id: "cables",
@@ -1326,7 +1481,8 @@ function getBuiltinLayerDefinitions() {
meta: "Subsea Cables",
keywords: "海缆 subsea cables",
defaultActive: true,
startupPriority: 20,
displayOrder: 10,
startupPriority: 50,
startupMode: "visible",
startupLabel: "海缆",
startupMessage: {
@@ -1345,7 +1501,8 @@ function getBuiltinLayerDefinitions() {
meta: "Compute Centers",
keywords: "算力中心 compute centers gpu 超算",
defaultActive: true,
startupPriority: 35,
displayOrder: 40,
startupPriority: 60,
startupMode: "preload",
startupLabel: "算力中心",
startupMessage: "正在加载算力中心...",
@@ -1361,7 +1518,8 @@ function getBuiltinLayerDefinitions() {
meta: "Routing Signals",
keywords: "bgp观测 routing signals",
defaultActive: true,
startupPriority: 40,
displayOrder: 50,
startupPriority: 70,
startupMode: "preload",
startupLabel: "BGP态势",
startupMessage: "正在加载BGP态势...",
@@ -1369,6 +1527,58 @@ function getBuiltinLayerDefinitions() {
setVisible: (visible, options = {}) =>
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
icon: "satellite_alt",
label: "卫星",
meta: "Satellites",
keywords: "卫星 satellites",
defaultActive: false,
displayOrder: 30,
startupPriority: 80,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
getVisible: () => getSatellitesEnabled(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
},
{
id: "trails",
buttonId: "toggle-trails",
icon: "timeline",
label: "轨迹",
meta: "Trails",
keywords: "轨迹 trails",
defaultActive: true,
displayOrder: 20,
startupPriority: null,
startupMode: "visible",
startupLabel: "轨迹",
startupMessage: "",
getVisible: () => getShowTrails(),
setVisible: (visible, options = {}) =>
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
},
{
id: "terrain",
buttonId: "toggle-terrain",
icon: "landscape",
label: "地形",
meta: "Terrain",
keywords: "地形 terrain",
defaultActive: false,
displayOrder: 60,
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
];
}
@@ -1430,6 +1640,7 @@ function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) {
function registerLayerDefinition(definition, options = {}) {
const normalizedDefinition = {
persist: true,
displayOrder: null,
startupPriority: null,
startupMode: "visible",
startupLabel: "",
@@ -1818,6 +2029,31 @@ function applyDayNightEnabled(enabled, { persist = true } = {}) {
if (persist) persistEarthSettings();
}
export function setDayNightEnabledExternal(enabled, { persist = true } = {}) {
applyDayNightEnabled(enabled, { persist });
}
export function getDayNightEnabled() {
return dayNightEnabled;
}
export function setTerrainLayerInteractable(enabled) {
const button = getLayerButton("terrain");
setLayerButtonState(button, {
disabled: !enabled,
tooltip: enabled ? null : "高清材质关闭时不可用",
});
syncMobileLayerCards();
}
export function setDayNightInteractable(enabled) {
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
input.disabled = !enabled;
const label = input.closest("label");
if (label) label.classList.toggle("is-disabled", !enabled);
});
}
function setupSettingsControls() {
const settingsTrigger = document.getElementById("settings-trigger");
const settingsClose = document.getElementById("settings-close");
@@ -1960,7 +2196,7 @@ function setupSettingsControls() {
});
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
settingsApplyPromise = applyEarthSettings(loadEarthSettings());
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
@@ -2283,7 +2519,7 @@ function resetCleanup() {
listeners = [];
}
export function setupControls(camera, renderer, scene, earth) {
export async function setupControls(camera, renderer, scene, earth) {
resetCleanup();
activeCamera = camera;
earthObj = earth;
@@ -2292,6 +2528,8 @@ export function setupControls(camera, renderer, scene, earth) {
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
await settingsApplyPromise;
syncTrailsAvailability();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
@@ -2622,7 +2860,11 @@ function bindLayerButton(row, definition) {
if (button.dataset.layerBound === "true") return;
bindListener(button, "click", async function () {
if (this.classList.contains("is-loading")) {
if (
this.disabled ||
this.classList.contains("is-loading") ||
this.classList.contains("is-disabled")
) {
return;
}
await definition.setVisible(!definition.getVisible());
@@ -2638,6 +2880,7 @@ export function registerLayer({
keywords = "",
defaultActive = false,
persist = true,
displayOrder = null,
startupPriority = null,
startupMode = "visible",
startupLabel = "",
@@ -2658,6 +2901,7 @@ export function registerLayer({
keywords,
defaultActive,
persist,
displayOrder,
startupPriority,
startupMode,
startupLabel,
@@ -2821,6 +3065,11 @@ function setupKeyboardControls() {
return;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
return;
}
clearLockedObjectAndInfo();
});
}
@@ -3011,6 +3260,12 @@ function setupToolbarHubCluster() {
scheduleExpandedToolbarBoundsRefresh();
};
const closePinnedToolbar = () => {
hubPinnedOpen = false;
cancelCollapse();
setExpanded(false);
};
const scheduleCollapse = () => {
if (hubPinnedOpen) return;
if (collapseTimer) clearTimeout(collapseTimer);
@@ -3033,6 +3288,9 @@ function setupToolbarHubCluster() {
cancelAnimationFrame(refreshBoundsFrameId);
refreshBoundsFrameId = 0;
}
if (toolbarHubController?.cluster === cluster) {
toolbarHubController = null;
}
});
// Start collapsed — hub acts as the hover target to reveal the arc
@@ -3055,13 +3313,12 @@ function setupToolbarHubCluster() {
event.preventDefault();
event.stopPropagation();
cancelCollapse();
if (isMobileLayout()) {
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
return;
if (hubPinnedOpen) {
closePinnedToolbar();
} else {
hubPinnedOpen = true;
setExpanded(true);
}
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
});
const HOVER_PADDING_PX = 12;
@@ -3174,9 +3431,14 @@ function setupToolbarHubCluster() {
if (!hubPinnedOpen) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest("#toolbar-cluster")) return;
hubPinnedOpen = false;
setExpanded(false);
closePinnedToolbar();
});
toolbarHubController = {
cluster,
isOpen: () => hubPinnedOpen || cluster.classList.contains("is-expanded"),
close: closePinnedToolbar,
};
}
export function teardownControls() {

View File

@@ -0,0 +1,500 @@
import * as THREE from "three";
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
// ─── Module state ──────────────────────────────────────────────────────────────
let _earthObj = null;
let _features = [];
let _landMesh = null;
let _tintMesh = null;
let _boundaryLines = null;
let _hoverGlowLines = null;
let _hoverLines = null;
let _hoveredFeature = null;
let _hoveredGroupKey = null;
let _visible = false;
let _landFillEnabled = true;
let _landFillSuppressed = false;
let _tintEnabled = false;
let _loaded = false;
let _loadPromise = null;
const OCEAN_HEX = 0x010609;
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
function hexToStyle(hex) {
return `#${hex.toString(16).padStart(6, "0")}`;
}
function hexToRgb(hex) {
return [
(hex >> 16) & 255,
(hex >> 8) & 255,
hex & 255,
];
}
function buildLandTexture(features) {
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
const oceanRgb = hexToRgb(OCEAN_HEX);
if (!ctx) {
const oceanData = new Uint8Array(width * height * 4);
for (let i = 0; i < oceanData.length; i += 4) {
oceanData[i] = oceanRgb[0];
oceanData[i + 1] = oceanRgb[1];
oceanData[i + 2] = oceanRgb[2];
oceanData[i + 3] = 255;
}
const fallbackTexture = new THREE.DataTexture(
oceanData,
width,
height,
THREE.RGBAFormat,
);
fallbackTexture.needsUpdate = true;
return fallbackTexture;
}
// Ocean background
ctx.fillStyle = hexToStyle(OCEAN_HEX);
ctx.fillRect(0, 0, width, height);
// Land polygons using evenodd fill rule so holes (lakes, islands) work correctly
ctx.fillStyle = hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor);
for (const feat of features) {
const geom = feat.geometry;
if (!geom) continue;
const polys =
geom.type === "Polygon" ? [geom.coordinates] :
geom.type === "MultiPolygon" ? geom.coordinates : null;
if (!polys) continue;
for (const rings of polys) {
ctx.beginPath();
for (const ring of rings) {
for (let i = 0; i < ring.length; i++) {
// equirectangular: x = (lon+180)/360*width, y = (90-lat)/180*height
const px = ((ring[i][0] + 180) / 360) * width;
const py = ((90 - ring[i][1]) / 180) * height;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath();
}
ctx.fill("evenodd");
}
}
const imageData = ctx.getImageData(0, 0, width, height);
const tex = new THREE.DataTexture(
new Uint8Array(imageData.data),
width,
height,
THREE.RGBAFormat,
);
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.generateMipmaps = false;
tex.flipY = true;
tex.needsUpdate = true;
return tex;
}
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
function makeLandMesh(tex) {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
const geo = new THREE.SphereGeometry(r, 128, 64);
const mat = new THREE.MeshBasicMaterial({
color: 0xffffff,
map: tex,
transparent: COUNTRY_BOUNDARY_CONFIG.landOpacity < 1,
opacity: COUNTRY_BOUNDARY_CONFIG.landOpacity,
depthTest: true,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.name = "country-land-ocean";
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
mesh.visible = false;
mesh.raycast = () => {};
return mesh;
}
function makeTintMesh() {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
const geo = new THREE.SphereGeometry(r, 64, 32);
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
const mesh = new THREE.Mesh(geo, mat);
mesh.name = "country-tint";
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.tintRenderOrder;
mesh.visible = false;
mesh.raycast = () => {};
return mesh;
}
// ─── Boundary line geometry ────────────────────────────────────────────────────
function ringToSegments(ring, radius, out) {
const n = ring.length;
if (n < 2) return;
for (let i = 0; i < n - 1; i++) {
out.push(latLonToVector3(ring[i][1], ring[i][0], radius));
out.push(latLonToVector3(ring[i+1][1], ring[i+1][0], radius));
}
}
function featureToSegments(geom, radius) {
const pts = [];
if (!geom) return pts;
if (geom.type === "Polygon") {
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
} else if (geom.type === "MultiPolygon") {
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
}
return pts;
}
function buildBoundaryLines(features) {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
transparent: true,
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
depthTest: true,
depthWrite: false,
});
const all = [];
for (const feat of features) {
const pts = featureToSegments(feat.geometry, r);
all.push(...pts);
}
const geo = all.length > 0
? new THREE.BufferGeometry().setFromPoints(all)
: new THREE.BufferGeometry();
const lines = new THREE.LineSegments(geo, mat);
lines.name = "country-boundary-all";
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function buildHoverLines() {
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
transparent: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity < 1,
opacity: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity,
depthTest: false,
depthWrite: false,
});
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
lines.name = "country-hover";
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function buildHoverGlowLines() {
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
transparent: true,
opacity: COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity,
depthTest: false,
depthWrite: false,
blending: THREE.AdditiveBlending,
linewidth: COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth,
});
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
lines.name = "country-hover-glow";
lines.renderOrder =
COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder -
COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function setBoundaryLinesDimmed(dimmed) {
if (!_boundaryLines?.material) return;
_boundaryLines.material.opacity = dimmed
? COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity
: COUNTRY_BOUNDARY_CONFIG.lineOpacity;
_boundaryLines.material.needsUpdate = true;
}
function clearHoverLineGeometries() {
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
}
function featureListToSegments(features, radius) {
return features.flatMap(f => featureToSegments(f.geometry, radius));
}
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
function pointInRing(lat, lon, ring) {
let inside = false;
const n = ring.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if ((yi > lat) !== (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
function featureContains(lat, lon, feat) {
const geom = feat.geometry;
if (!geom) return false;
if (geom.type === "Polygon") {
if (!pointInRing(lat, lon, geom.coordinates[0])) return false;
return geom.coordinates.slice(1).every(h => !pointInRing(lat, lon, h));
}
if (geom.type === "MultiPolygon") {
return geom.coordinates.some(poly =>
pointInRing(lat, lon, poly[0]) &&
poly.slice(1).every(h => !pointInRing(lat, lon, h))
);
}
return false;
}
function makeCountryInfo(feat) {
if (!feat) return null;
const p = feat.properties || {};
return {
name: p.NAME_EN || p.NAME || p.ADMIN || "",
nameZh: p.NAME_ZH || null,
isoA3: p.ISO_A3 || p.ADM0_A3 || null,
isoA2: p.ISO_A2 || null,
continent: p.CONTINENT || null,
};
}
function getCountryHighlightGroupKey(feat) {
const p = feat?.properties || {};
const isoA3 = p.ISO_A3 || p.ADM0_A3 || "";
if (isoA3 === "CHN" || isoA3 === "TWN") {
return "CHN_TWN";
}
return isoA3 || p.ISO_A2 || p.NAME_EN || p.NAME || p.ADMIN || null;
}
function getHighlightFeatures(feat) {
const groupKey = getCountryHighlightGroupKey(feat);
if (!groupKey) return feat ? [feat] : [];
return _features.filter(f => getCountryHighlightGroupKey(f) === groupKey);
}
// ─── Public API ────────────────────────────────────────────────────────────────
/** Called during init (before data load). Creates the placeholder tint sphere. */
export function createCountryBoundaryLayer(earthObj) {
_earthObj = earthObj;
_tintMesh = makeTintMesh();
_earthObj.add(_tintMesh);
}
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
export async function loadCountryBoundaries() {
if (_loaded) return _features.length;
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
const geojson = await resp.json();
_features = (geojson.features || []).filter(f => f.geometry);
const tex = buildLandTexture(_features);
_landMesh = makeLandMesh(tex);
_earthObj.add(_landMesh);
_boundaryLines = buildBoundaryLines(_features);
_earthObj.add(_boundaryLines);
_hoverGlowLines = buildHoverGlowLines();
_earthObj.add(_hoverGlowLines);
_hoverLines = buildHoverLines();
_earthObj.add(_hoverLines);
_loaded = true;
return _features.length;
})();
return _loadPromise;
}
/** Load if not yet loaded, then return feature count. */
export async function ensureCountryBoundariesReady() {
if (!_loaded) await loadCountryBoundaries();
return _features.length;
}
/**
* Show or hide the country boundary lines.
* The land/ocean fill is the base earth map and stays independent from this
* line visibility switch.
* @param {boolean} visible
* @param {{ showTint?: boolean, showLandFill?: boolean, suppressLandFill?: boolean }} [opts]
* showLandFill whether to show the base land/ocean fill.
* Defaults to the current stored value so callers that only
* care about visibility don't need to repeat it.
* suppressLandFill temporarily keep the fill below the high-res texture
* without changing the layer's own fill state.
*/
export function toggleCountryBoundaries(
visible,
{ showTint = false, showLandFill = null, suppressLandFill = null } = {},
) {
_visible = Boolean(visible);
if (showLandFill !== null) _landFillEnabled = Boolean(showLandFill);
if (suppressLandFill !== null) _landFillSuppressed = Boolean(suppressLandFill);
if (_landMesh) {
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
if (_boundaryLines) _boundaryLines.visible = _visible;
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
if (_hoverLines) _hoverLines.visible = _visible;
if (!_visible) {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
}
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
}
/**
* Show or hide the land/ocean canvas fill independently of boundary lines.
*/
export function setLandFillEnabled(enabled) {
_landFillEnabled = Boolean(enabled);
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
export function setLandFillSuppressed(enabled) {
_landFillSuppressed = Boolean(enabled);
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
/**
* Enable / disable the solid dark tint overlay (used when high-res texture is off).
*/
export function setSurfaceTintEnabled(enabled) {
_tintEnabled = Boolean(enabled);
if (_tintMesh) _tintMesh.visible = _visible && _tintEnabled;
}
export function getShowCountryBoundaries() {
return _visible;
}
/** Clear the hover highlight without hiding the full layer. */
export function clearCountryBoundaryHover() {
if (!_hoveredFeature) return;
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
}
/**
* Update hover highlight for the given lat/lon coords.
* Returns a country-info object when hovering over land, or null over ocean.
*/
export function updateCountryBoundaryHover(coords) {
if (!_loaded || !_visible) return null;
const { lat, lon } = coords;
const found = _features.find(f => featureContains(lat, lon, f)) || null;
const groupKey = getCountryHighlightGroupKey(found);
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
_hoveredFeature = found;
_hoveredGroupKey = groupKey;
if (_hoverLines) {
if (!found) {
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
} else {
setBoundaryLinesDimmed(true);
const highlightFeatures = getHighlightFeatures(found);
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
if (_hoverGlowLines) {
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
_hoverGlowLines.geometry.setFromPoints(glowPts);
}
const corePts = featureListToSegments(highlightFeatures, coreRadius);
_hoverLines.geometry.setFromPoints(corePts);
}
}
}
return found ? makeCountryInfo(found) : null;
}
/** Dispose all Three.js objects and reset state. */
export function clearCountryBoundaryData() {
_hoveredFeature = null;
_hoveredGroupKey = null;
function disposeObj(obj) {
if (!obj) return;
if (_earthObj) _earthObj.remove(obj);
obj.geometry?.dispose();
if (obj.material) {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
}
}
disposeObj(_hoverLines);
disposeObj(_hoverGlowLines);
disposeObj(_boundaryLines);
disposeObj(_landMesh);
disposeObj(_tintMesh);
_hoverLines = null;
_hoverGlowLines = null;
_boundaryLines = null;
_landMesh = null;
_tintMesh = null;
_features = [];
_loaded = false;
_loadPromise = null;
_visible = false;
_landFillEnabled = true;
_landFillSuppressed = false;
_tintEnabled = false;
}
export function getCountryBoundaryLegendItems() {
return [
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
];
}

View File

@@ -1,18 +1,32 @@
// earth.js - 3D Earth creation module
import * as THREE from 'three';
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
import {
CLOUD_LAYER_CONFIG,
CONFIG,
EARTH_CONFIG,
EARTH_MATERIAL_CONFIG,
GRID_CONFIG,
STARFIELD_CONFIG,
TERRAIN_CONFIG,
} from './constants.js';
import { latLonToVector3 } from './utils.js';
export let earth = null;
export let clouds = null;
export let terrain = null;
let showGridLines = true;
let showGridLines = false;
let showClouds = true;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
let _earthShader = null;
let _earthTextureOverlay = null;
let _earthTextureOverlayMaterial = null;
let _earthShaders = [];
let _dayNightEnabled = true;
let _loadedTexture = null;
let _textureVisible = true;
let _earthRimGlow = null;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -26,7 +40,7 @@ function applyEarthDayNightShader(material) {
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
material.onBeforeCompile = (shader) => {
_earthShader = shader;
_earthShaders.push(shader);
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
@@ -79,6 +93,7 @@ uniform float uDayNightEnabled;`,
dnLight *= mix(uNightFloor, uDayBoost, daylight);
dnLight += uTwilightColor * twilight * uTwilightIntensity;
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
dnLight = dnLight / (vec3(1.0) + max(dnLight - vec3(0.68), vec3(0.0)) * 0.86);
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
// dn=1: full day/night solar lighting
@@ -94,6 +109,7 @@ uniform float uDayNightEnabled;`,
}
export function createEarth(scene) {
_earthShaders = [];
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
const C = EARTH_MATERIAL_CONFIG;
@@ -103,7 +119,7 @@ export function createEarth(scene) {
specular: C.specular,
shininess: C.shininess,
emissive: C.emissive,
transparent: true,
transparent: C.opacity < 1,
opacity: C.opacity,
side: THREE.FrontSide,
depthWrite: true,
@@ -117,6 +133,31 @@ export function createEarth(scene) {
earth.rotation.x = EARTH_CONFIG.tiltRad;
scene.add(earth);
const textureOverlayGeometry = new THREE.SphereGeometry(
CONFIG.earthRadius + C.textureOverlayAltitudeOffset,
128,
128,
);
_earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
specular: C.textureOverlaySpecular,
shininess: C.textureOverlayShininess,
transparent: true,
opacity: C.textureOverlayOpacity,
side: THREE.FrontSide,
depthWrite: false,
depthTest: true,
});
applyEarthDayNightShader(_earthTextureOverlayMaterial);
_earthTextureOverlay = new THREE.Mesh(
textureOverlayGeometry,
_earthTextureOverlayMaterial,
);
_earthTextureOverlay.name = "earth-high-res-texture-overlay";
_earthTextureOverlay.renderOrder = C.textureOverlayRenderOrder;
_earthTextureOverlay.visible = false;
earth.add(_earthTextureOverlay);
// Depth-mask occluder — invisible sphere slightly inside the earth,
// writes to the depth buffer so far-side cables/satellites are occluded.
const occluderGeometry = new THREE.SphereGeometry(
@@ -132,7 +173,9 @@ export function createEarth(scene) {
occluder.renderOrder = -1;
earth.add(occluder);
// Shared Fresnel vertex shader for both atmosphere layers
// Keep the original atmosphere shells on the legacy camera-facing shader so
// they stay as a soft edge cue instead of becoming a visible transparent hull
// at close zoom levels.
const ATMOS_VERTEX_SHADER = `
varying vec3 vNormal;
void main() {
@@ -141,6 +184,17 @@ export function createEarth(scene) {
}
`;
const RIM_VERTEX_SHADER = `
varying vec3 vNormal;
varying vec3 vViewDirection;
void main() {
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewDirection = normalize(-mvPosition.xyz);
gl_Position = projectionMatrix * mvPosition;
}
`;
// Fresnel atmosphere — inner rim
const [ir, ig, ib] = C.atmosInnerColor;
const atmosInnerGeo = new THREE.SphereGeometry(
@@ -193,15 +247,52 @@ export function createEarth(scene) {
atmosOuter.renderOrder = 1;
earth.add(atmosOuter);
// Fresnel rim cue: an outer shell keeps the edge tied to the globe while bypassing
// the darker fill layers that can otherwise hide a same-radius glow. Unlike the
// legacy atmosphere shells, this one uses the real view direction so its highlight
// stays attached to the visible globe edge while zooming.
const [rr, rg, rb] = C.rimGlowColor;
const rimGlowGeo = new THREE.SphereGeometry(
CONFIG.earthRadius * C.rimGlowRadiusFactor,
C.rimGlowSegments,
C.rimGlowSegments,
);
const rimGlowMat = new THREE.ShaderMaterial({
vertexShader: RIM_VERTEX_SHADER,
fragmentShader: `
varying vec3 vNormal;
varying vec3 vViewDirection;
void main() {
float viewFacing = max(dot(normalize(vNormal), normalize(vViewDirection)), 0.0);
float rim = 1.0 - viewFacing;
float alpha = pow(rim, ${C.rimGlowPower.toFixed(1)}) * ${C.rimGlowIntensity.toFixed(2)};
gl_FragColor = vec4(${rr.toFixed(2)}, ${rg.toFixed(2)}, ${rb.toFixed(2)}, alpha);
}
`,
blending: THREE.AdditiveBlending,
side: THREE.FrontSide,
transparent: true,
depthTest: false,
depthWrite: false,
});
_earthRimGlow = new THREE.Mesh(rimGlowGeo, rimGlowMat);
_earthRimGlow.name = "earth-rim-glow";
_earthRimGlow.renderOrder = C.rimGlowRenderOrder;
earth.add(_earthRimGlow);
// Texture is loaded separately via loadEarthTexture() for staged loading
return earth;
}
export function createClouds(scene, earthObj) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
const geometry = new THREE.SphereGeometry(
CONFIG.earthRadius + CLOUD_LAYER_CONFIG.radiusOffset,
CLOUD_LAYER_CONFIG.widthSegments,
CLOUD_LAYER_CONFIG.heightSegments,
);
const material = new THREE.MeshPhongMaterial({
transparent: true,
opacity: 0.15,
opacity: CLOUD_LAYER_CONFIG.opacity,
depthTest: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
@@ -209,10 +300,12 @@ export function createClouds(scene, earthObj) {
});
clouds = new THREE.Mesh(geometry, material);
clouds.name = "earth-atmosphere-clouds";
clouds.visible = showClouds;
earthObj.add(clouds);
textureLoader.load(
'./assets/earth_clouds_1024.png',
CLOUD_LAYER_CONFIG.textureUrl,
function(texture) {
material.map = texture;
material.needsUpdate = true;
@@ -226,6 +319,17 @@ export function createClouds(scene, earthObj) {
return clouds;
}
export function toggleClouds(visible) {
showClouds = Boolean(visible);
if (clouds) {
clouds.visible = showClouds;
}
}
export function getShowClouds() {
return showClouds;
}
export function createTerrain(earthObj) {
const geometry = new THREE.SphereGeometry(
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
@@ -251,7 +355,8 @@ export function createTerrain(earthObj) {
terrain = new THREE.Mesh(geometry, material);
terrain.name = "earth-real-terrain";
terrain.visible = false;
terrain.renderOrder = 0.5;
terrain.renderOrder = 1.2;
terrain.raycast = () => {};
earthObj.add(terrain);
return terrain;
@@ -265,11 +370,11 @@ export function toggleTerrain(visible) {
export function createStars(scene) {
const starGeometry = new THREE.BufferGeometry();
const starCount = 8000;
const starCount = STARFIELD_CONFIG.count;
const starPositions = new Float32Array(starCount * 3);
for (let i = 0; i < starCount * 3; i += 3) {
const r = 800 + Math.random() * 200;
const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
@@ -281,8 +386,8 @@ export function createStars(scene) {
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMaterial = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.5,
color: STARFIELD_CONFIG.color,
size: STARFIELD_CONFIG.size,
transparent: true,
blending: THREE.AdditiveBlending
});
@@ -302,17 +407,19 @@ export function createGridLines(scene, earthObj) {
latitudeLines = [];
longitudeLines = [];
const earthRadius = 100.1;
const earthRadius = CONFIG.earthRadius + GRID_CONFIG.radiusOffset;
const gridMaterial = new THREE.LineBasicMaterial({
color: 0x44aaff,
color: GRID_CONFIG.color,
transparent: true,
opacity: 0.2,
linewidth: 1
opacity: GRID_CONFIG.opacity,
linewidth: GRID_CONFIG.lineWidth,
depthTest: true,
depthWrite: false,
});
for (let lat = -75; lat <= 75; lat += 15) {
for (let lat = -75; lat <= 75; lat += GRID_CONFIG.latitudeStep) {
const points = [];
for (let lon = -180; lon <= 180; lon += 5) {
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.segmentStep) {
const point = latLonToVector3(lat, lon, earthRadius);
points.push(point);
}
@@ -320,14 +427,15 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'latitude', value: lat };
line.renderOrder = GRID_CONFIG.renderOrder;
line.visible = showGridLines;
earthObj.add(line);
latitudeLines.push(line);
}
for (let lon = -180; lon <= 180; lon += 30) {
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.longitudeStep) {
const points = [];
for (let lat = -90; lat <= 90; lat += 5) {
for (let lat = -90; lat <= 90; lat += GRID_CONFIG.segmentStep) {
const point = latLonToVector3(lat, lon, earthRadius);
points.push(point);
}
@@ -335,6 +443,7 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'longitude', value: lon };
line.renderOrder = GRID_CONFIG.renderOrder;
line.visible = showGridLines;
earthObj.add(line);
longitudeLines.push(line);
@@ -359,29 +468,43 @@ export function getEarth() {
return earth;
}
export function getEarthSurfacePickTarget() {
return _earthTextureOverlay?.visible ? _earthTextureOverlay : earth;
}
export function getClouds() {
return clouds;
}
export function clearEarthTexture() {
if (!_earthMaterial) return;
_earthMaterial.map = null;
_earthMaterial.needsUpdate = true;
_loadedTexture = null;
if (_earthTextureOverlayMaterial) {
_earthTextureOverlayMaterial.map = null;
_earthTextureOverlayMaterial.needsUpdate = true;
}
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = false;
}
if (_earthRimGlow) {
_earthRimGlow.visible = true;
}
}
export function setEarthSunDirection(direction) {
if (!direction) return;
_earthSunDirection.copy(direction).normalize();
if (_earthShader?.uniforms?.uSunDirectionWorld) {
_earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection);
}
_earthShaders.forEach((shader) => {
shader?.uniforms?.uSunDirectionWorld?.value?.copy(_earthSunDirection);
});
}
export function setDayNightEnabled(enabled) {
_dayNightEnabled = enabled;
if (_earthShader?.uniforms?.uDayNightEnabled) {
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
_earthShaders.forEach((shader) => {
if (shader?.uniforms?.uDayNightEnabled) {
shader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
});
if (_earthMaterial) {
if (enabled) {
// Restore normal Phong lighting + custom day/night shader
@@ -390,10 +513,9 @@ export function setDayNightEnabled(enabled) {
_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.emissiveMap = null;
}
_earthMaterial.needsUpdate = true;
}
@@ -401,7 +523,7 @@ export function setDayNightEnabled(enabled) {
export function loadEarthTexture() {
return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; }
if (!_earthTextureOverlayMaterial) { resolve(); return; }
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
const tryLoad = (index) => {
@@ -418,12 +540,15 @@ export function loadEarthTexture() {
texture.anisotropy = 16;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
_earthMaterial.map = texture;
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
if (!_dayNightEnabled) {
_earthMaterial.emissiveMap = texture;
_loadedTexture = texture;
_earthTextureOverlayMaterial.map = texture;
_earthTextureOverlayMaterial.needsUpdate = true;
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = _textureVisible;
}
if (_earthRimGlow) {
_earthRimGlow.visible = !_textureVisible;
}
_earthMaterial.needsUpdate = true;
resolve();
},
null,
@@ -433,3 +558,22 @@ export function loadEarthTexture() {
tryLoad(0);
});
}
export function setEarthTextureVisible(visible) {
_textureVisible = Boolean(visible);
const textureShowing = _textureVisible && Boolean(_loadedTexture);
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = textureShowing;
}
if (_earthTextureOverlayMaterial) {
_earthTextureOverlayMaterial.map = _loadedTexture || null;
_earthTextureOverlayMaterial.needsUpdate = true;
}
if (_earthRimGlow) {
_earthRimGlow.visible = !textureShowing;
}
}
export function getEarthTextureVisible() {
return _textureVisible;
}

View File

@@ -5,7 +5,11 @@ const SURFACE_SCALE = 1.003;
const SURFACE_OFFSET = 0.72;
const CLUSTER_DIAMETER_KM_APPROX = 4500;
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
const SURFACE_AXIS = new THREE.Vector3(0, 0, 1);
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
const FILL_RINGS = 12;
const FILL_SEGMENTS = 48;
const RING_SEGMENTS = 72;
function disposeMaterial(material) {
if (!material) return;
@@ -19,34 +23,27 @@ function disposeMaterial(material) {
function disposeObjectTree(object) {
if (!object) return;
object.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
disposeMaterial(child.material);
}
if (child.geometry) child.geometry.dispose();
if (child.material) disposeMaterial(child.material);
});
}
function createIridiumClusterMaterial() {
function createIridiumFillMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3,
blending: THREE.AdditiveBlending,
uniforms: {
uColor: { value: new THREE.Color(0x5faeff) },
uOpacity: { value: 0.24 },
uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) },
uOpacity: { value: 0.55 },
},
vertexShader: `
attribute vec2 aUv;
varying vec2 vUv;
void main() {
vUv = uv;
vUv = aUv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
@@ -54,12 +51,10 @@ function createIridiumClusterMaterial() {
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
void main() {
vec2 p = vUv * 2.0 - 1.0;
float ellipseMetric = p.x * p.x * 0.82 + p.y * p.y * 1.06;
float alpha = exp(-ellipseMetric * 1.05) * (1.0 - smoothstep(0.86, 1.24, ellipseMetric));
alpha *= uOpacity;
float r2 = dot(vUv, vUv);
float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2));
float alpha = glow * uOpacity;
if (alpha <= 0.001) discard;
gl_FragColor = vec4(uColor, alpha);
}
@@ -67,6 +62,17 @@ function createIridiumClusterMaterial() {
});
}
function createIridiumRingMaterial() {
return new THREE.LineBasicMaterial({
color: new THREE.Color(IRIDIUM_OVERLAY_COLOR),
transparent: true,
opacity: 0.75,
blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
});
}
function projectOffsetToSurface(
centerNormal,
alongTrack,
@@ -88,13 +94,55 @@ function projectOffsetToSurface(
function computeClusterRadiusKm(altitudeKm) {
const altitudeScale = THREE.MathUtils.clamp(
(Number(altitudeKm) || 780) / 780,
(Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM,
0.88,
1.18,
);
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
}
function buildFillGeometry() {
// Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each.
// Positions are updated in world space each frame; indices are static.
const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS;
const positions = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
// Center vertex: uv = (0,0)
// Edge vertices: uv on unit circle, r = ring/FILL_RINGS
const indices = [];
// Center to first ring: triangle fan
for (let s = 0; s < FILL_SEGMENTS; s++) {
const a = 1 + s;
const b = 1 + (s + 1) % FILL_SEGMENTS;
indices.push(0, a, b);
}
// Ring to ring
for (let r = 0; r < FILL_RINGS - 1; r++) {
const ringBase = 1 + r * FILL_SEGMENTS;
const nextBase = ringBase + FILL_SEGMENTS;
for (let s = 0; s < FILL_SEGMENTS; s++) {
const s1 = (s + 1) % FILL_SEGMENTS;
indices.push(ringBase + s, nextBase + s, ringBase + s1);
indices.push(nextBase + s, nextBase + s1, ringBase + s1);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(indices);
return geometry;
}
function buildRingGeometry() {
const positions = new Float32Array(RING_SEGMENTS * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
return geometry;
}
export function createIridiumFootprintAdapter({
earthObj,
earthRadiusWorld,
@@ -105,19 +153,21 @@ export function createIridiumFootprintAdapter({
const group = new THREE.Group();
group.name = "iridium-footprint-overlay";
group.renderOrder = renderOrder;
group.userData = {
earthRadiusWorld,
clusterGlow: null,
};
group.userData = { earthRadiusWorld, fill: null, outerRing: null };
const clusterGlow = new THREE.Mesh(
new THREE.CircleGeometry(1, 72),
createIridiumClusterMaterial(),
);
clusterGlow.name = "iridium-cluster-glow";
clusterGlow.renderOrder = renderOrder - 1;
group.add(clusterGlow);
group.userData.clusterGlow = clusterGlow;
const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial());
fill.name = "iridium-cluster-fill";
fill.renderOrder = renderOrder;
fill.frustumCulled = false;
group.add(fill);
group.userData.fill = fill;
const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial());
outerRing.name = "iridium-outer-ring";
outerRing.renderOrder = renderOrder;
outerRing.frustumCulled = false;
group.add(outerRing);
group.userData.outerRing = outerRing;
earthObj.add(group);
return group;
@@ -129,30 +179,65 @@ export function updateIridiumFootprintAdapter(
) {
if (!group || !position || !alongTrack || !crossTrack) return;
const earthRadiusWorld =
group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
const earthRadiusWorld = group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
const centerNormal = position.clone().normalize();
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
const clusterGlow = group.userData?.clusterGlow || null;
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
const alongRadiusKm = clusterRadiusKm * 1.18;
const crossRadiusKm = clusterRadiusKm * 0.96;
if (clusterGlow) {
const clusterCenter = projectOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
0,
0,
earthRadiusWorld,
);
const clusterNormal = clusterCenter.clone().normalize();
clusterGlow.position.copy(clusterCenter);
clusterGlow.quaternion.setFromUnitVectors(SURFACE_AXIS, clusterNormal);
clusterGlow.scale.set(
clusterRadiusKm * worldUnitsPerKm * 1.18,
clusterRadiusKm * worldUnitsPerKm * 0.96,
1,
const fill = group.userData?.fill;
if (fill) {
const posAttr = fill.geometry.attributes.position;
const uvAttr = fill.geometry.attributes.aUv;
// Center vertex
const center = projectOffsetToSurface(
centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld,
);
posAttr.setXYZ(0, center.x, center.y, center.z);
uvAttr.setXY(0, 0, 0);
// Ring vertices
for (let r = 1; r <= FILL_RINGS; r++) {
const t = r / FILL_RINGS;
const aKm = alongRadiusKm * t;
const cKm = crossRadiusKm * t;
for (let s = 0; s < FILL_SEGMENTS; s++) {
const angle = (s / FILL_SEGMENTS) * Math.PI * 2;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const pt = projectOffsetToSurface(
centerNormal, alongTrack, crossTrack,
aKm * cosA,
cKm * sinA,
earthRadiusWorld,
);
const vi = 1 + (r - 1) * FILL_SEGMENTS + s;
posAttr.setXYZ(vi, pt.x, pt.y, pt.z);
uvAttr.setXY(vi, t * cosA, t * sinA);
}
}
posAttr.needsUpdate = true;
uvAttr.needsUpdate = true;
fill.geometry.computeBoundingSphere();
}
const outerRing = group.userData?.outerRing;
if (outerRing) {
const posAttr = outerRing.geometry.attributes.position;
for (let k = 0; k < RING_SEGMENTS; k++) {
const angle = (k / RING_SEGMENTS) * Math.PI * 2;
const pt = projectOffsetToSurface(
centerNormal, alongTrack, crossTrack,
alongRadiusKm * Math.cos(angle),
crossRadiusKm * Math.sin(angle),
earthRadiusWorld,
);
posAttr.setXYZ(k, pt.x, pt.y, pt.z);
}
posAttr.needsUpdate = true;
outerRing.geometry.computeBoundingSphere();
}
}

View File

@@ -23,12 +23,14 @@ export function setLayerButtonState(button, options = {}) {
const {
active = null,
loading = false,
disabled = null,
tooltip = null,
statusText = null,
} = options;
button.classList.toggle("is-loading", loading);
button.toggleAttribute("aria-busy", loading);
button.disabled = loading;
button.disabled = loading || (disabled === true);
button.classList.toggle("is-disabled", disabled === true);
if (typeof active === "boolean") {
updateLayerButtonState(button, active);
}

View File

@@ -18,6 +18,11 @@ import {
loadComputeCenters,
toggleComputeCenters,
} from "./compute-centers.js";
import {
getCountryBoundaryLegendItems,
loadCountryBoundaries,
toggleCountryBoundaries,
} from "./country-boundaries.js";
/**
* Layer startup task registry.
@@ -72,10 +77,11 @@ export function registerLayerStartupTask(id, taskFactory) {
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCountryBoundaryStartupTask();
registerCableStartupTask();
registerSatelliteStartupTask();
registerComputeCenterStartupTask();
registerBGPStartupTask();
registerSatelliteStartupTask();
}
function registerCableStartupTask() {
@@ -176,6 +182,32 @@ function registerBGPStartupTask() {
});
}
function registerCountryBoundaryStartupTask() {
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
);
await context.yieldFrame(12);
try {
await loadCountryBoundaries();
if (!context.isCancelled()) {
const textureOn = context.isEarthTextureVisible();
toggleCountryBoundaries(context.getShowCountryBoundaries(), {
showTint: !textureOn,
showLandFill: true,
suppressLandFill: false,
});
context.setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "国界", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerComputeCenterStartupTask() {
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
context.setLoadingMessage(

View File

@@ -3,6 +3,7 @@ import { createHUDPanel } from "./hud-panels.js";
const LEGEND_MODES = {
cables: { title: "海缆" },
satellites: { title: "卫星" },
countryBoundaries: { title: "国界" },
computeCenters: { title: "算力" },
bgp: { title: "BGP" },
};
@@ -12,6 +13,7 @@ let legendPanel = null;
let legendItemsByMode = {
cables: [],
satellites: [],
countryBoundaries: [],
computeCenters: [],
bgp: [],
};

View File

@@ -33,11 +33,17 @@ import {
createEarth,
createClouds,
createTerrain,
getShowClouds,
createGridLines,
getEarth,
getEarthSurfacePickTarget,
loadEarthTexture,
clearEarthTexture,
setEarthSunDirection,
setEarthTextureVisible,
getEarthTextureVisible,
toggleClouds,
toggleTerrain,
} from "./earth.js";
import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js";
import {
@@ -50,6 +56,19 @@ import {
setCelestialFollow,
setCelestialDayNightEnabled,
} from "./celestial.js";
import {
clearCountryBoundaryData,
clearCountryBoundaryHover,
createCountryBoundaryLayer,
ensureCountryBoundariesReady,
getCountryBoundaryLegendItems,
getShowCountryBoundaries,
setLandFillEnabled,
setLandFillSuppressed,
setSurfaceTintEnabled,
toggleCountryBoundaries,
updateCountryBoundaryHover,
} from "./country-boundaries.js";
import {
loadGeoJSONFromPath,
loadLandingPoints,
@@ -92,6 +111,7 @@ import {
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
updateSatellitePointSize,
isSatelliteFrontFacing,
setSatelliteCamera,
setSatelliteSunDirection,
@@ -161,6 +181,10 @@ import {
getZoomLevel,
setZoomLevel,
teardownControls,
getDayNightEnabled,
setDayNightEnabledExternal,
setTerrainLayerInteractable,
setDayNightInteractable,
} from "./controls.js";
import {
createLayerStartupTaskMap,
@@ -226,7 +250,7 @@ let destroyed = false;
let isDataLoading = false;
let currentLoadToken = 0;
let cablesEnabled = true;
let satellitesEnabled = true;
let satellitesEnabled = false;
let cableToggleToken = 0;
let satelliteToggleToken = 0;
let satelliteHydrationToken = 0;
@@ -236,11 +260,13 @@ let calloutConnector = null;
let cruiseBGPAdapter = null;
let cruiseNewsAdapter = null;
let cruiseSequencer = null;
let earthStatsSummary = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
let pinchGesture = null;
let pointerDragDistance = 0;
let suppressNextClick = false;
let shouldRefreshSatellitesAfterVisibilityResume = false;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
@@ -252,6 +278,8 @@ const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const scratchComputeCenterDirection = new THREE.Vector3();
const scratchComputeCenterWorldPosition = new THREE.Vector3();
const scratchSatelliteWorldPosition = new THREE.Vector3();
const scratchSatelliteScreenPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = [];
@@ -470,6 +498,7 @@ function resetTransientComputeCenterStates() {
function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
clearCountryBoundaryHover();
hoveredBGP = null;
hoveredComputeCenter = null;
@@ -646,6 +675,13 @@ function getComputeCenterBriefHtml(marker) {
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
}
function getCountryBoundaryBriefHtml(country) {
const name = country?.nameZh || country?.name || "未知国家";
const code = country?.isoA3 || country?.isoA2 || "-";
const continent = country?.continent || "-";
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
}
function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
@@ -1211,14 +1247,69 @@ function getBGPStatusText(bgpResult) {
return "当前无活跃事件";
}
function toCount(value) {
const count = Number(value);
return Number.isFinite(count) ? count : 0;
}
function formatBGPStatusFromSummary(summary) {
if (!summary) return "-";
if (summary.bgpIncidentCount > 0) {
return `${summary.bgpIncidentCount} 起活跃事件`;
}
if (summary.bgpAnomalyCount > 0) {
return `${summary.bgpAnomalyCount} 条活跃异常`;
}
return "当前无活跃事件";
}
function applyEarthStatsSummary(summary) {
if (!summary) return;
updateEarthStats({
cableCount: `${summary.cableCount}`,
landingPointCount: `${summary.landingPointCount}`,
satelliteCount: `${summary.satelliteCount}`,
computeCenterCount: `${summary.computeCenterCount}`,
bgpAnomalyCount: `${summary.bgpEventCount}`,
bgpCollectorCount: `${summary.bgpCollectorCount}`,
bgpStatusSummary: formatBGPStatusFromSummary(summary),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
}
async function loadEarthStatsSummary() {
try {
const response = await fetch(PATHS.earthSummaryApi);
if (!response.ok) {
throw new Error(`Earth summary HTTP ${response.status}`);
}
const payload = await response.json();
const stats = payload?.stats || {};
earthStatsSummary = {
cableCount: toCount(stats.cable_count),
landingPointCount: toCount(stats.landing_point_count),
satelliteCount: toCount(stats.satellite_count),
computeCenterCount: toCount(stats.compute_center_count),
bgpEventCount: toCount(stats.bgp_event_count),
bgpIncidentCount: toCount(stats.bgp_incident_count),
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
bgpCollectorCount: toCount(stats.bgp_collector_count),
};
applyEarthStatsSummary(earthStatsSummary);
} catch (error) {
console.warn("全球态势聚合统计加载失败:", error);
}
}
function updateComputeCenterHud(computeCenterResult) {
const computeBtn = document.getElementById("toggle-compute-centers");
if (computeBtn) {
computeBtn.classList.add("active");
const tooltip = computeBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = "隐藏算力中心";
}
setLayerButtonState(computeBtn, {
active: getShowComputeCenters(),
loading: false,
tooltip: getShowComputeCenters() ? "隐藏算力中心" : "显示算力中心",
});
}
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount}`);
@@ -1227,11 +1318,11 @@ function updateComputeCenterHud(computeCenterResult) {
function updateBGPHud(bgpResult) {
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
bgpBtn.classList.add("active");
const tooltip = bgpBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = ACTIVE_BGP_TOOLTIP_TEXT;
}
setLayerButtonState(bgpBtn, {
active: getShowBGP(),
loading: false,
tooltip: getShowBGP() ? ACTIVE_BGP_TOOLTIP_TEXT : "显示BGP观测",
});
}
setEarthStatValue("bgp-anomaly-count", `${bgpResult.totalCount}`);
@@ -1778,6 +1869,58 @@ function updatePointerFromEvent(event) {
interactionRaycaster.setFromCamera(interactionMouse, camera);
}
function getSatellitePointerIntersections(event) {
if (!renderer || !camera || !getShowSatellites()) return [];
const satPoints = getSatellitePoints();
const satPositions = getSatellitePositions();
if (!satPoints?.visible || !Array.isArray(satPositions) || satPositions.length === 0) {
return [];
}
const rect = renderer.domElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const shaderSize = satPoints.material?.uniforms?.size?.value;
const dotSizePx = Number.isFinite(shaderSize)
? shaderSize / dpr
: SATELLITE_CONFIG.dotBaseSize;
const pickRadiusPx = Math.max(12, Math.min(30, dotSizePx * 4));
const pickRadiusSq = pickRadiusPx * pickRadiusPx;
const hits = [];
satPoints.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
for (let index = 0; index < satelliteCount; index++) {
const position = satPositions[index]?.current;
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
if (
scratchSatelliteScreenPosition.z < -1 ||
scratchSatelliteScreenPosition.z > 1
) {
continue;
}
const screenX =
rect.left + (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * rect.width;
const screenY =
rect.top + (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * rect.height;
const dx = screenX - event.clientX;
const dy = screenY - event.clientY;
const distanceSq = dx * dx + dy * dy;
if (distanceSq <= pickRadiusSq) {
hits.push({ index, distanceSq });
}
}
hits.sort((a, b) => a.distanceSq - b.distanceSq);
return hits;
}
function buildLoadErrorMessage(errors) {
if (errors.length === 0) return "";
return errors
@@ -1798,7 +1941,8 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
});
}
setEarthStatValue("satellite-count", `${satelliteCount}`);
const resolvedCount = satelliteCount || earthStatsSummary?.satelliteCount || 0;
setEarthStatValue("satellite-count", `${resolvedCount}`);
}
function updateCableToggleUi(enabled) {
@@ -1811,8 +1955,11 @@ function updateCableToggleUi(enabled) {
});
}
setEarthStatValue("cable-count", `${getCableLines().length}`);
setEarthStatValue("landing-point-count", `${getLandingPoints().length}`);
const cableCount = getCableLines().length || earthStatsSummary?.cableCount || 0;
const landingPointCount =
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
setEarthStatValue("cable-count", `${cableCount}`);
setEarthStatValue("landing-point-count", `${landingPointCount}`);
}
async function ensureCablesEnabled() {
@@ -1920,6 +2067,7 @@ function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
satelliteHydrationToken += 1;
toggleSatellites(false);
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
@@ -1927,13 +2075,25 @@ function disableSatellites() {
}
function updateStatsSummary() {
const cableCount = getCableLines().length || earthStatsSummary?.cableCount || 0;
const landingPointCount =
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
const satelliteCount = getSatelliteCount() || earthStatsSummary?.satelliteCount || 0;
const computeCenterCount =
getComputeCenterCount() || earthStatsSummary?.computeCenterCount || 0;
const bgpEventCount = getBGPCount() || earthStatsSummary?.bgpEventCount || 0;
const bgpCollectorCount =
getBGPCollectorCount() || earthStatsSummary?.bgpCollectorCount || 0;
updateEarthStats({
cableCount: getCableLines().length,
landingPointCount: getLandingPoints().length,
computeCenterCount: `${getComputeCenterCount()} `,
bgpAnomalyCount: `${getBGPCount()} `,
bgpCollectorCount: `${getBGPCollectorCount()} `,
bgpStatusSummary: getBGPStatusSummary(),
cableCount: `${cableCount}`,
landingPointCount: `${landingPointCount}`,
satelliteCount: `${satelliteCount} `,
computeCenterCount: `${computeCenterCount} `,
bgpAnomalyCount: `${bgpEventCount} `,
bgpCollectorCount: `${bgpCollectorCount}`,
bgpStatusSummary: getBGPCount()
? getBGPStatusSummary()
: formatBGPStatusFromSummary(earthStatsSummary),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
@@ -2000,6 +2160,7 @@ export function init() {
initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
@@ -2021,15 +2182,28 @@ export function init() {
});
setCelestialDayNightEnabled(true);
createGridLines(scene, earthObj);
createCountryBoundaryLayer(earthObj);
createSatellites(scene, earthObj);
setupControls(camera, renderer, scene, earthObj);
setupEventListeners();
clock.start();
loadData();
animate();
registerGlobalApi();
setupControls(camera, renderer, scene, earthObj)
.catch((error) => {
console.error("初始化 Earth 控制项失败:", error);
void reportEarthClientLog({
level: "error",
category: "init",
module: "controls",
message: `初始化 Earth 控制项失败: ${error?.message || String(error)}`,
detail: error,
});
})
.finally(() => {
if (destroyed) return;
setupEventListeners();
clock.start();
loadData();
animate();
registerGlobalApi();
});
}
function registerGlobalApi() {
@@ -2160,12 +2334,18 @@ async function loadData() {
clearCableData(earth);
clearComputeCenterData(earth);
clearSatelliteData();
clearCountryBoundaryHover();
setLoadingMessage("正在初始化...");
setLoading(true);
await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
setLoadingMessage("正在读取全球态势统计...");
await loadEarthStatsSummary();
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(12);
const errors = [];
// Step 1 — Earth texture
@@ -2192,7 +2372,9 @@ async function loadData() {
updateComputeCenterHud,
updateBGPHud,
getShowComputeCenters,
getShowCountryBoundaries,
getShowBGP,
isEarthTextureVisible: () => getEarthTextureVisible(),
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
@@ -2245,6 +2427,7 @@ async function loadData() {
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
@@ -2336,6 +2519,102 @@ export async function setCablesEnabled(
}
}
export async function setCountryBoundariesEnabled(
enabled,
{ suppressStatus = false } = {},
) {
if (!enabled) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
if (!suppressStatus) {
showStatusMessage("国界已隐藏", "info");
}
return 0;
}
try {
const countryCount = await ensureCountryBoundariesReady();
const textureOn = getEarthTextureVisible();
toggleCountryBoundaries(true, {
showTint: !textureOn,
showLandFill: true,
suppressLandFill: false,
});
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
refreshLegend();
if (!suppressStatus) {
showStatusMessage("国界已显示", "info");
}
return countryCount;
} catch (error) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
const message = `国界加载失败: ${error?.message || String(error)}`;
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "country-boundaries",
message,
detail: error,
});
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
}
}
let _dayNightBeforeTextureOff = null;
let _terrainBeforeTextureOff = null;
export function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) {
setEarthTextureVisible(enabled);
setSurfaceTintEnabled(!enabled);
setLandFillEnabled(true);
setLandFillSuppressed(false);
setTerrainLayerInteractable(enabled);
setDayNightInteractable(enabled);
if (!enabled) {
if (_terrainBeforeTextureOff === null) {
_terrainBeforeTextureOff = getShowTerrain();
}
toggleTerrain(false);
if (_dayNightBeforeTextureOff === null) {
_dayNightBeforeTextureOff = getDayNightEnabled();
}
setDayNightEnabledExternal(false, { persist: false });
} else {
if (_terrainBeforeTextureOff !== null) {
toggleTerrain(_terrainBeforeTextureOff);
_terrainBeforeTextureOff = null;
}
if (_dayNightBeforeTextureOff !== null) {
setDayNightEnabledExternal(_dayNightBeforeTextureOff, { persist: false });
_dayNightBeforeTextureOff = null;
}
}
if (!suppressStatus) {
showStatusMessage(enabled ? "高清材质已启用" : "高清材质已隐藏", "info");
}
return enabled;
}
export function getHighResTextureEnabled() {
return getEarthTextureVisible();
}
export function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) {
toggleClouds(enabled);
if (!suppressStatus) {
showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info");
}
return enabled;
}
export function getAtmosphereCloudsEnabled() {
return getShowClouds();
}
export async function setSatellitesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
@@ -2391,6 +2670,7 @@ export async function setSatellitesEnabled(
function setupEventListeners() {
const handleResize = () => onWindowResize();
const handleVisibilityChange = () => onVisibilityChange();
const handlePointerMove = (event) => onPointerMove(event);
const handlePointerDown = (event) => onPointerDown(event);
const handlePointerUp = (event) => onPointerUp(event);
@@ -2402,6 +2682,7 @@ function setupEventListeners() {
const handleInfoCardDrag = () => repositionCruiseConnector();
bindListener(window, "resize", handleResize);
bindListener(document, "visibilitychange", handleVisibilityChange);
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
@@ -2424,6 +2705,20 @@ function setupEventListeners() {
}
}
function onVisibilityChange() {
if (document.hidden) {
shouldRefreshSatellitesAfterVisibilityResume = true;
return;
}
if (!shouldRefreshSatellitesAfterVisibilityResume) return;
shouldRefreshSatellitesAfterVisibilityResume = false;
// Drop the elapsed background time so the next RAF does not replay it.
clock.getDelta();
updateSatellitePositions(0, true, { resetTrails: true });
}
function updateHudScale() {
const widthScale = window.innerWidth / HUD_CONFIG.scaleReferenceWidth;
const heightScale = window.innerHeight / HUD_CONFIG.scaleReferenceHeight;
@@ -2543,6 +2838,7 @@ function onMouseMove(event) {
inertialVelocity.y = rotationDeltaY;
inertialVelocity.x = rotationDeltaX;
previousMousePosition = { x: event.clientX, y: event.clientY };
clearCountryBoundaryHover();
hideTooltip();
return;
}
@@ -2572,18 +2868,11 @@ function onMouseMove(event) {
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
if (getShowSatellites()) {
const satPoints = getSatellitePoints();
if (satPoints) {
const satIntersects = interactionRaycaster.intersectObject(satPoints);
if (satIntersects.length > 0) {
const satIndex = satIntersects[0].index;
if (isSatelliteFrontFacing(satIndex, camera)) {
hoveredSatIndexFromIntersect = satIndex;
hoveredSat = selectSatellite(satIndex);
}
}
}
const satIntersects = getSatellitePointerIntersections(event);
if (satIntersects.length > 0) {
const satIndex = satIntersects[0].index;
hoveredSatIndexFromIntersect = satIndex;
hoveredSat = selectSatellite(satIndex);
}
const hoveredBGPMarker = getPrimaryBGPHoverTarget(
@@ -2688,7 +2977,7 @@ function onMouseMove(event) {
event.clientX,
event.clientY,
camera,
earth,
getEarthSurfacePickTarget() || earth,
document.body,
interactionRaycaster,
interactionMouse,
@@ -2696,6 +2985,18 @@ function onMouseMove(event) {
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
const hoveredCountry = getShowCountryBoundaries()
? updateCountryBoundaryHover(coords)
: null;
if (hoveredCountry) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getCountryBoundaryBriefHtml(hoveredCountry),
);
return;
}
clearCountryBoundaryHover();
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
@@ -2708,8 +3009,11 @@ function onMouseMove(event) {
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
);
} else {
clearCountryBoundaryHover();
hideTooltip();
}
} else {
clearCountryBoundaryHover();
}
}
@@ -2846,6 +3150,7 @@ function onPointerUp(event) {
}
function onMouseLeave() {
clearCountryBoundaryHover();
hideTooltip();
}
@@ -2880,9 +3185,7 @@ function onClick(event) {
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
)
: [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
const satIntersects = getSatellitePointerIntersections(event);
const clickedBGPMarker = getShowBGP()
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
@@ -2978,9 +3281,7 @@ function onClick(event) {
const clickX = event.clientX;
const clickY = event.clientY;
const frontFacingSats = satIntersects.filter((sat) =>
isSatelliteFrontFacing(sat.index, camera),
);
const frontFacingSats = satIntersects;
if (frontFacingSats.length === 0) return;
let selectedIndex = frontFacingSats[0].index;
@@ -3098,6 +3399,12 @@ function animate() {
}
}
// Force matrixWorld to be current before any isFacingCamera checks below.
// Without this, getWorldPosition() on LP sprites reads the previous frame's
// transform (earth rotation is updated above but scene.updateMatrixWorld()
// only runs inside renderer.render(), which hasn't been called yet).
earth?.updateMatrixWorld(true);
applyCableVisualState();
const activeCruiseMarker =
isCruiseModeActive() && isCruisePresentationPinned()
@@ -3125,6 +3432,7 @@ function animate() {
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateSatellitePointSize();
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
const currentSunDirection = getSunDirection();
@@ -3172,6 +3480,7 @@ export function destroy() {
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());
clearCountryBoundaryData();
resetSatelliteState();
clearUiState();
disposeCelestialLayer();

View File

@@ -42,6 +42,8 @@ let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
const SATELLITE_FOOTPRINT_POLICIES = Object.freeze({
NONE: "none",
STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
@@ -66,8 +68,64 @@ const SATELLITE_CONSTELLATION_LABELS = Object.freeze({
});
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const TRAIL_RIBBON_VERTEX_SHADER = /* glsl */ `
attribute vec3 instanceStart;
attribute vec3 instanceEnd;
attribute vec3 instanceColorStart;
attribute vec3 instanceColorEnd;
uniform vec2 resolution;
uniform float lineWidth;
varying vec3 vColor;
void main() {
float t = position.x;
float side = position.y;
vec4 clipStart = projectionMatrix * modelViewMatrix * vec4(instanceStart, 1.0);
vec4 clipEnd = projectionMatrix * modelViewMatrix * vec4(instanceEnd, 1.0);
vec2 screenStart = (clipStart.xy / clipStart.w * 0.5 + 0.5) * resolution;
vec2 screenEnd = (clipEnd.xy / clipEnd.w * 0.5 + 0.5) * resolution;
vec2 dir = screenEnd - screenStart;
float segLen = length(dir);
vec4 clipPos = mix(clipStart, clipEnd, t);
if (segLen > 0.001) {
dir /= segLen;
vec2 normal = vec2(-dir.y, dir.x);
clipPos.xy += normal * side * lineWidth * 0.5 / resolution * 2.0 * clipPos.w;
vColor = mix(instanceColorStart, instanceColorEnd, t);
} else {
vColor = vec3(0.0);
}
gl_Position = clipPos;
}
`;
const TRAIL_RIBBON_FRAGMENT_SHADER = /* glsl */ `
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`;
const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [
"instanceStart",
"instanceEnd",
"instanceColorStart",
"instanceColorEnd",
];
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
const FALLBACK_MIN_MEAN_MOTION = 12;
const FALLBACK_MEAN_MOTION_SPREAD = 4;
const FALLBACK_TRAIL_TIP_LENGTH = 0.004;
const FALLBACK_TRAIL_ALPHA_START = 0.2;
const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
@@ -81,6 +139,9 @@ const LOCKED_HALO_CORE_PIXEL_RADIUS = 8;
const LOCKED_HALO_PIXEL_RADIUS = 24;
const FILLED_TEXTURE_RADIUS_RATIO = 0.28;
const LOCKED_RING_IDLE_SCALE = 0.68;
const LOCKED_RING_HOVER_SCALE = 1.32;
const LOCKED_RING_HOVER_LINE_WIDTH = 5;
const HOVER_RING_LINE_WIDTH = 3;
const LOCKED_RING_IDLE_OPACITY = 0.92;
const EARTH_RADIUS_KM = 6378.137;
const GROUND_FOOTPRINT_MIN_ELEVATION_DEG = 25;
@@ -111,6 +172,71 @@ const satelliteSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
export let breathingPhase = 0;
function getPointPixelRatio() {
return typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
}
function getSatelliteDotBaseSize() {
return SATELLITE_CONFIG.dotBaseSize;
}
function getSatelliteDotBackdropSize() {
return getSatelliteDotBaseSize() * SATELLITE_CONFIG.dotBackdropScale;
}
function getSatelliteDotZoomScale(cameraDistance) {
return Math.pow(CONFIG.defaultCameraZ / Math.max(cameraDistance, 1), SATELLITE_CONFIG.dotZoomScalePower);
}
function createSatellitePointMaterial({
texture,
size,
opacity = 1,
useVertexColor = true,
baseColor = 0xffffff,
alphaTest = 0.04,
}) {
return new THREE.ShaderMaterial({
uniforms: {
pointTexture: { value: texture },
size: { value: size * getPointPixelRatio() },
opacity: { value: opacity },
baseColor: { value: new THREE.Color(baseColor) },
},
transparent: true,
depthTest: true,
depthWrite: false,
vertexShader: `
uniform float size;
uniform vec3 baseColor;
attribute float alpha;
${useVertexColor ? "attribute vec3 color;" : ""}
varying float vAlpha;
varying vec3 vColor;
void main() {
vAlpha = alpha;
vColor = ${useVertexColor ? "color" : "baseColor"};
gl_PointSize = size;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D pointTexture;
uniform float opacity;
varying float vAlpha;
varying vec3 vColor;
void main() {
vec4 texel = texture2D(pointTexture, gl_PointCoord);
float alphaValue = texel.a * opacity * vAlpha;
if (alphaValue < ${alphaTest.toFixed(3)}) discard;
gl_FragColor = vec4(vColor * texel.rgb, alphaValue);
}
`,
});
}
const SATELLITE_LEGEND_RULES = [
{
key: "equatorial",
@@ -193,6 +319,27 @@ export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
export function updateSatellitePointSize() {
if (!satellitePoints || !cameraRef) return;
const camDist = cameraRef.position.length();
const zoomScale = getSatelliteDotZoomScale(camDist);
const pointPixelRatio = getPointPixelRatio();
const dotSize = getSatelliteDotBaseSize() * zoomScale;
const backdropSize = getSatelliteDotBackdropSize() * zoomScale;
const shaderDotSize = dotSize * pointPixelRatio;
const shaderBackdropSize = backdropSize * pointPixelRatio;
if (satellitePoints.material.uniforms?.size) {
satellitePoints.material.uniforms.size.value = shaderDotSize;
} else {
satellitePoints.material.size = dotSize;
}
if (satelliteBackdropPoints.material.uniforms?.size) {
satelliteBackdropPoints.material.uniforms.size.value = shaderBackdropSize;
} else {
satelliteBackdropPoints.material.size = backdropSize;
}
}
function getBreathingPulse(phase) {
return 0.5 + 0.5 * Math.sin(phase);
}
@@ -329,7 +476,7 @@ function createBackdropDotTexture() {
return texture;
}
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
function createRingTexture(innerRadius, outerRadius, color = "#ffffff", lineWidth = 3) {
const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement("canvas");
canvas.width = size;
@@ -338,7 +485,7 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const center = size / 2;
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2);
ctx.stroke();
@@ -375,26 +522,21 @@ export function createSatellites(scene, earthObj) {
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,
const backdropMaterial = createSatellitePointMaterial({
size: getSatelliteDotBackdropSize(),
texture: backdropTexture,
useVertexColor: false,
baseColor: 0x0b1626,
opacity: 0.42,
sizeAttenuation: false,
alphaTest: 0.04,
depthWrite: false,
});
const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize,
map: dotTexture,
vertexColors: true,
transparent: true,
const pointsMaterial = createSatellitePointMaterial({
size: getSatelliteDotBaseSize(),
texture: dotTexture,
useVertexColor: true,
opacity: 0.9,
sizeAttenuation: false,
alphaTest: 0.1,
depthWrite: false,
});
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
@@ -435,16 +577,32 @@ export function createSatellites(scene, earthObj) {
earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints);
const trailGeometry = new THREE.BufferGeometry();
// Instanced screen-space ribbon: one quad instance per trail segment.
// Single mesh / single draw call for all satellite trails.
const ribbonGeometry = new THREE.InstancedBufferGeometry();
// Base quad: position.x = t (0=seg-start, 1=seg-end), position.y = side (-1/+1)
ribbonGeometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3),
);
ribbonGeometry.setIndex(new THREE.BufferAttribute(new Uint16Array([0, 2, 1, 2, 3, 1]), 1));
const trailMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
const trailResolution = new THREE.Vector2(window.innerWidth, window.innerHeight);
const trailMaterial = new THREE.ShaderMaterial({
uniforms: {
lineWidth: { value: SATELLITE_CONFIG.trailLineWidth },
resolution: { value: trailResolution },
},
vertexShader: TRAIL_RIBBON_VERTEX_SHADER,
fragmentShader: TRAIL_RIBBON_FRAGMENT_SHADER,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
satelliteTrails = new THREE.Mesh(ribbonGeometry, trailMaterial);
satelliteTrails.onBeforeRender = (renderer) => renderer.getSize(trailResolution);
satelliteTrails.frustumCulled = false;
satelliteTrails.visible = false;
satelliteTrails.userData = { type: "satelliteTrails" };
earthObj.add(satelliteTrails);
@@ -472,6 +630,31 @@ function createSatellitePositionState() {
};
}
function resetSatelliteTrailState() {
satellitePositions.forEach((position) => {
position.trail = [];
position.trailIndex = 0;
position.trailCount = 0;
});
}
function clearSatelliteTrailGeometry() {
if (!satelliteTrails) return;
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
const attr = satelliteTrails.geometry.attributes[name];
if (attr?.array) {
attr.array.fill(0);
attr.needsUpdate = true;
}
}
}
function clearSatelliteTrails() {
resetSatelliteTrailState();
clearSatelliteTrailGeometry();
}
function ensureSatelliteCapacity(count) {
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
@@ -483,16 +666,27 @@ function ensureSatelliteCapacity(count) {
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 previousPointAlphas = satellitePoints.geometry.attributes.alpha?.array || null;
const previousBackdropAlphas =
satelliteBackdropPoints.geometry.attributes.alpha?.array || null;
const previousInstanceStarts =
satelliteTrails.geometry.attributes.instanceStart?.array || null;
const previousInstanceEnds =
satelliteTrails.geometry.attributes.instanceEnd?.array || null;
const previousInstanceColorStarts =
satelliteTrails.geometry.attributes.instanceColorStart?.array || null;
const previousInstanceColorEnds =
satelliteTrails.geometry.attributes.instanceColorEnd?.array || null;
const previousSatellitePositions = satellitePositions;
const previousCapacity = satelliteCapacity;
const positions = new Float32Array(nextCapacity * 3);
const backdropPositions = new Float32Array(nextCapacity * 3);
const colors = new Float32Array(nextCapacity * 3);
const pointAlphas = new Float32Array(nextCapacity);
const backdropAlphas = new Float32Array(nextCapacity);
pointAlphas.fill(1);
backdropAlphas.fill(1);
if (previousPointPositions) {
positions.set(
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
@@ -509,10 +703,27 @@ function ensureSatelliteCapacity(count) {
if (previousColors) {
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
}
if (previousPointAlphas) {
pointAlphas.set(
previousPointAlphas.subarray(0, Math.min(previousPointAlphas.length, pointAlphas.length)),
);
}
if (previousBackdropAlphas) {
backdropAlphas.set(
previousBackdropAlphas.subarray(
0,
Math.min(previousBackdropAlphas.length, backdropAlphas.length),
),
);
}
satelliteBackdropPoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(backdropPositions, 3),
);
satelliteBackdropPoints.geometry.setAttribute(
"alpha",
new THREE.BufferAttribute(backdropAlphas, 1),
);
satelliteBackdropPoints.geometry.setDrawRange(
0,
Math.min(previousCapacity, nextCapacity),
@@ -525,34 +736,54 @@ function ensureSatelliteCapacity(count) {
"color",
new THREE.BufferAttribute(colors, 3),
);
satellitePoints.geometry.setAttribute(
"alpha",
new THREE.BufferAttribute(pointAlphas, 1),
);
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
if (previousTrailPositions) {
trailPositions.set(
previousTrailPositions.subarray(
0,
Math.min(previousTrailPositions.length, trailPositions.length),
),
const segCount = nextCapacity * (TRAIL_LENGTH - 1);
const instanceStarts = new Float32Array(segCount * 3);
const instanceEnds = new Float32Array(segCount * 3);
const instanceColorStarts = new Float32Array(segCount * 3);
const instanceColorEnds = new Float32Array(segCount * 3);
if (previousInstanceStarts) {
instanceStarts.set(
previousInstanceStarts.subarray(0, Math.min(previousInstanceStarts.length, instanceStarts.length)),
);
}
if (previousTrailColors) {
trailColors.set(
previousTrailColors.subarray(
0,
Math.min(previousTrailColors.length, trailColors.length),
),
if (previousInstanceEnds) {
instanceEnds.set(
previousInstanceEnds.subarray(0, Math.min(previousInstanceEnds.length, instanceEnds.length)),
);
}
if (previousInstanceColorStarts) {
instanceColorStarts.set(
previousInstanceColorStarts.subarray(0, Math.min(previousInstanceColorStarts.length, instanceColorStarts.length)),
);
}
if (previousInstanceColorEnds) {
instanceColorEnds.set(
previousInstanceColorEnds.subarray(0, Math.min(previousInstanceColorEnds.length, instanceColorEnds.length)),
);
}
satelliteTrails.geometry.setAttribute(
"position",
new THREE.BufferAttribute(trailPositions, 3),
"instanceStart",
new THREE.InstancedBufferAttribute(instanceStarts, 3),
);
satelliteTrails.geometry.setAttribute(
"color",
new THREE.BufferAttribute(trailColors, 3),
"instanceEnd",
new THREE.InstancedBufferAttribute(instanceEnds, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceColorStart",
new THREE.InstancedBufferAttribute(instanceColorStarts, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceColorEnd",
new THREE.InstancedBufferAttribute(instanceColorEnds, 3),
);
satelliteTrails.geometry.instanceCount = segCount;
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
const previousState = previousSatellitePositions[index];
@@ -570,6 +801,25 @@ function ensureSatelliteCapacity(count) {
satelliteCapacity = nextCapacity;
}
function shouldHideSatellitePoint(index) {
return index === hoveredSatelliteIndex || index === lockedSatelliteIndex;
}
function updateSatellitePointVisibilityAttributes(count = satelliteData.length) {
const pointAlphaAttr = satellitePoints?.geometry?.attributes?.alpha;
const backdropAlphaAttr = satelliteBackdropPoints?.geometry?.attributes?.alpha;
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
const visibleCount = Math.min(count, pointAlphaAttr.array.length, backdropAlphaAttr.array.length);
for (let i = 0; i < visibleCount; i++) {
const alpha = shouldHideSatellitePoint(i) ? 0 : 1;
pointAlphaAttr.array[i] = alpha;
backdropAlphaAttr.array[i] = alpha;
}
pointAlphaAttr.needsUpdate = true;
backdropAlphaAttr.needsUpdate = true;
}
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
@@ -743,7 +993,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
};
}
function generateFallbackPosition(satellite, index, total) {
function generateFallbackPosition(satellite, index, total, time = new Date()) {
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
@@ -755,9 +1005,21 @@ function generateFallbackPosition(satellite, index, total) {
.split("")
.reduce((a, b) => a + b.charCodeAt(0), 0);
const randomOffset = (hash % 1000) / 1000;
const rawMeanMotion = Number(satellite.properties?.mean_motion);
const meanMotion =
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
? rawMeanMotion
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
const normalizedIndex = index / total;
const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180;
const elapsedDays = Number.isFinite(time?.getTime?.())
? time.getTime() / FALLBACK_ORBIT_DAY_MS
: Date.now() / FALLBACK_ORBIT_DAY_MS;
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
const theta =
normalizedIndex * Math.PI * 2 * 10 +
(raan * Math.PI) / 180 +
fallbackPhase;
const phi =
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
@@ -786,6 +1048,7 @@ export async function loadSatellites(options = {}) {
const data = await response.json();
satelliteData = data.features || [];
satelliteSatrecCache = new Map();
resetSatelliteTrailState();
ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return {
@@ -794,31 +1057,49 @@ export async function loadSatellites(options = {}) {
};
}
export function updateSatellitePositions(deltaTime = 0, force = false) {
export function updateSatellitePositions(deltaTime = 0, force = false, options = {}) {
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null;
positionUpdateAccumulator += deltaTime;
showSatellites ||
showTrails ||
lockedSatelliteIndex !== null;
const shouldResetTrails =
options.resetTrails ||
(!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS);
if (shouldResetTrails) {
clearSatelliteTrails();
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
} else {
positionUpdateAccumulator += deltaTime;
}
if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) {
return;
}
const elapsedMs = Math.max(
positionUpdateAccumulator,
POSITION_UPDATE_INTERVAL_MS,
);
const elapsedMs = shouldResetTrails
? 0
: Math.max(
positionUpdateAccumulator,
POSITION_UPDATE_INTERVAL_MS,
);
positionUpdateAccumulator = 0;
const positions = satellitePoints.geometry.attributes.position.array;
const backdropPositions =
satelliteBackdropPoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array;
const pointAlphas = satellitePoints.geometry.attributes.alpha.array;
const backdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha.array;
const instanceStarts = satelliteTrails.geometry.attributes.instanceStart.array;
const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array;
const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array;
const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array;
const baseTime = new Date(Date.now() + elapsedMs);
const count = Math.min(satelliteData.length, satelliteCapacity);
let trailSegmentCount = 0;
for (let i = 0; i < count; i++) {
const satellite = satelliteData[i];
@@ -830,16 +1111,30 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
let pos = computeSatellitePosition(satellite, adjustedTime);
if (!pos) {
pos = generateFallbackPosition(satellite, i, count);
pos = generateFallbackPosition(satellite, i, count, adjustedTime);
}
satellitePositions[i].current.copy(pos);
if (shouldUpdateTrails && i !== lockedSatelliteIndex) {
if (shouldUpdateTrails) {
const satPos = satellitePositions[i];
satPos.trail[satPos.trailIndex] = pos.clone();
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
if (satPos.trailCount === 0 && TRAIL_LENGTH > 1) {
for (let k = 0; k < TRAIL_LENGTH; k++) {
const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS;
const pastTime = new Date(adjustedTime.getTime() - offsetMs);
let pastPos = computeSatellitePosition(satellite, pastTime);
if (!pastPos) {
pastPos = generateFallbackPosition(satellite, i, count, pastTime);
}
satPos.trail[satPos.trailIndex] = pastPos;
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
}
satPos.trailCount = TRAIL_LENGTH;
} else {
satPos.trail[satPos.trailIndex] = pos.clone();
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
}
}
positions[i * 3] = pos.x;
@@ -862,34 +1157,64 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
colors[i * 3] = r * pointBrightness;
colors[i * 3 + 1] = g * pointBrightness;
colors[i * 3 + 2] = b * pointBrightness;
const pointAlpha = shouldHideSatellitePoint(i) ? 0 : 1;
pointAlphas[i] = pointAlpha;
backdropAlphas[i] = pointAlpha;
const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
if (j < satPosition.trailCount) {
const idx =
(satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) %
TRAIL_LENGTH;
const trailPoint = satPosition.trail[idx];
if (trailPoint) {
trailPositions[trailIdx] = trailPoint.x;
trailPositions[trailIdx + 1] = trailPoint.y;
trailPositions[trailIdx + 2] = trailPoint.z;
const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha * trailBrightness;
trailColors[trailIdx + 1] = g * alpha * trailBrightness;
trailColors[trailIdx + 2] = b * alpha * trailBrightness;
continue;
const tc = satPosition.trailCount;
let hasVisibleTrail = false;
for (let j = 0; j < TRAIL_LENGTH - 1; j++) {
if (j + 1 < tc) {
const idxA =
(satPosition.trailIndex - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH;
const idxB =
(satPosition.trailIndex - tc + j + 1 + TRAIL_LENGTH) % TRAIL_LENGTH;
const ptA = satPosition.trail[idxA];
const ptB = satPosition.trail[idxB];
if (ptA && ptB && ptA.distanceToSquared(ptB) > 1e-8) {
const base = trailSegmentCount * 3;
instanceStarts[base] = ptA.x;
instanceStarts[base + 1] = ptA.y;
instanceStarts[base + 2] = ptA.z;
instanceEnds[base] = ptB.x;
instanceEnds[base + 1] = ptB.y;
instanceEnds[base + 2] = ptB.z;
const alphaA = (j + 1) / tc;
const alphaB = (j + 2) / tc;
instanceColorStarts[base] = r * alphaA * trailBrightness;
instanceColorStarts[base + 1] = g * alphaA * trailBrightness;
instanceColorStarts[base + 2] = b * alphaA * trailBrightness;
instanceColorEnds[base] = r * alphaB * trailBrightness;
instanceColorEnds[base + 1] = g * alphaB * trailBrightness;
instanceColorEnds[base + 2] = b * alphaB * trailBrightness;
hasVisibleTrail = true;
trailSegmentCount++;
}
}
trailPositions[trailIdx] = pos.x;
trailPositions[trailIdx + 1] = pos.y;
trailPositions[trailIdx + 2] = pos.z;
trailColors[trailIdx] = 0;
trailColors[trailIdx + 1] = 0;
trailColors[trailIdx + 2] = 0;
}
if (!hasVisibleTrail) {
const base = trailSegmentCount * 3;
const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z) || 1;
const nx = pos.x / dist;
const ny = pos.y / dist;
const nz = pos.z / dist;
const tip = FALLBACK_TRAIL_TIP_LENGTH;
instanceStarts[base] = pos.x + nx * tip;
instanceStarts[base + 1] = pos.y + ny * tip;
instanceStarts[base + 2] = pos.z + nz * tip;
instanceEnds[base] = pos.x;
instanceEnds[base + 1] = pos.y;
instanceEnds[base + 2] = pos.z;
const fallbackAlphaStart = FALLBACK_TRAIL_ALPHA_START * trailBrightness;
const fallbackAlphaEnd = FALLBACK_TRAIL_ALPHA_END * trailBrightness;
instanceColorStarts[base] = r * fallbackAlphaStart;
instanceColorStarts[base + 1] = g * fallbackAlphaStart;
instanceColorStarts[base + 2] = b * fallbackAlphaStart;
instanceColorEnds[base] = r * fallbackAlphaEnd;
instanceColorEnds[base + 1] = g * fallbackAlphaEnd;
instanceColorEnds[base + 2] = b * fallbackAlphaEnd;
trailSegmentCount++;
}
}
@@ -900,23 +1225,40 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
backdropPositions[i * 3] = 0;
backdropPositions[i * 3 + 1] = 0;
backdropPositions[i * 3 + 2] = 0;
pointAlphas[i] = 0;
backdropAlphas[i] = 0;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
trailPositions[trailIdx] = 0;
trailPositions[trailIdx + 1] = 0;
trailPositions[trailIdx + 2] = 0;
}
}
const trailArrayLength = instanceStarts.length / 3;
for (let i = trailSegmentCount; i < trailArrayLength; i++) {
const base = i * 3;
instanceStarts[base] = 0;
instanceStarts[base + 1] = 0;
instanceStarts[base + 2] = 0;
instanceEnds[base] = 0;
instanceEnds[base + 1] = 0;
instanceEnds[base + 2] = 0;
instanceColorStarts[base] = 0;
instanceColorStarts[base + 1] = 0;
instanceColorStarts[base + 2] = 0;
instanceColorEnds[base] = 0;
instanceColorEnds[base + 1] = 0;
instanceColorEnds[base + 2] = 0;
}
satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.attributes.alpha.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count);
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
satelliteBackdropPoints.geometry.attributes.alpha.needsUpdate = true;
satelliteBackdropPoints.geometry.setDrawRange(0, count);
satelliteTrails.geometry.attributes.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true;
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
satelliteTrails.geometry.attributes[name].needsUpdate = true;
}
satelliteTrails.geometry.instanceCount = trailSegmentCount;
// Keep the hover ring synced with the propagated satellite position even
// when the pointer stays still and no new hover event is emitted.
@@ -1005,10 +1347,12 @@ export function setSatelliteSunDirection(direction) {
export function setLockedSatelliteIndex(index) {
lockedSatelliteIndex = index;
updateSatellitePointVisibilityAttributes();
}
export function setHoveredSatelliteIndex(index) {
hoveredSatelliteIndex = index;
updateSatellitePointVisibilityAttributes();
}
function normalizeSatelliteDisplayStyle(nextStyle) {
@@ -1181,7 +1525,7 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
function createLockedHaloMaterial(color = "#ffbf47") {
return new THREE.ShaderMaterial({
transparent: true,
depthTest: false,
depthTest: true,
depthWrite: false,
side: THREE.DoubleSide,
uniforms: {
@@ -1218,7 +1562,7 @@ function createGroundFootprintMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: false,
depthTest: true,
depthWrite: false,
uniforms: {
uColor: { value: new THREE.Color(0xffffff) },
@@ -1388,6 +1732,7 @@ function clearLockedSatelliteStyleVisuals() {
function updateLockedDotWorldTransform(position) {
if (!lockedDotSprite || !position || !earthObjRef) return;
earthObjRef.updateMatrixWorld(true);
const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
lockedDotSprite.position.copy(worldPosition);
if (cameraRef) {
@@ -1408,6 +1753,7 @@ function updateLockedDotWorldTransform(position) {
function updateLockedHaloWorldTransform(position) {
if (!position || !earthObjRef || !lockedHaloMesh) return;
earthObjRef.updateMatrixWorld(true);
const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
const viewDirection = cameraRef
? scratchToCamera.subVectors(cameraRef.position, worldPosition).normalize()
@@ -1755,7 +2101,7 @@ function showSelfGlowStyle(position, color = "#ffd25a") {
color: new THREE.Color(color),
transparent: true,
opacity: 0.96,
depthTest: false,
depthTest: true,
depthWrite: false,
side: THREE.DoubleSide,
});
@@ -1782,6 +2128,7 @@ function showGroundFootprintStyle(position) {
createGroundFootprintMaterial(),
);
fill.name = "footprint-fill";
fill.renderOrder = GROUND_FOOTPRINT_RENDER_ORDER;
lockedGroundFootprintMesh.add(fill);
earthObjRef.add(lockedGroundFootprintMesh);
updateGroundFootprintTransform(position);
@@ -1815,6 +2162,7 @@ function createRingSprite(position, isLocked = false, color = "#ffcc00") {
8,
12,
isLocked ? color : "#ffffff",
isLocked ? LOCKED_RING_HOVER_LINE_WIDTH : HOVER_RING_LINE_WIDTH,
);
const filledTexture = isLocked
? createFilledCircleTexture(color)
@@ -1823,7 +2171,9 @@ function createRingSprite(position, isLocked = false, color = "#ffcc00") {
map: ringTexture,
transparent: true,
opacity: 0.8,
depthTest: false,
depthTest: true,
depthWrite: false,
alphaTest: 0.01,
sizeAttenuation: false,
});
@@ -1857,24 +2207,8 @@ function updateLockedMarkerVisual(isHovered) {
}
}
function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
if (!earthObjRef) return null;
const ringTexture = createRingTexture(7, 11, color);
const spriteMaterial = new THREE.SpriteMaterial({
map: ringTexture,
transparent: true,
opacity: 0.55,
depthTest: false,
sizeAttenuation: false,
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
function createRelatedSatelliteSprite(position) {
return createRingSprite(position, false);
}
export function showHoverRing(position, isLocked = false) {
@@ -1953,7 +2287,7 @@ export function updateLockedRingPosition(position) {
1 +
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
const markerSize = isHovered
? SATELLITE_CONFIG.ringSize
? SATELLITE_CONFIG.ringSize * LOCKED_RING_HOVER_SCALE
: SATELLITE_CONFIG.ringSize * LOCKED_RING_IDLE_SCALE;
lockedRingSprite.scale.set(
markerSize * breathScale,
@@ -2017,26 +2351,38 @@ export function setSatelliteRingState(index, state, position) {
hoveredSatelliteIndex = index;
hideHoverRings();
showHoverRing(position, false);
updateSatellitePointVisibilityAttributes();
break;
case "locked":
hoveredSatelliteIndex = null;
hideHoverRings();
showHoverRing(position, true);
updateSatellitePointVisibilityAttributes();
break;
case "none":
hoveredSatelliteIndex = null;
hideHoverRings();
hideLockedRing();
updateSatellitePointVisibilityAttributes();
break;
}
}
function applyDimMaterialState(isDimmed) {
if (satellitePoints) {
satellitePoints.material.opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
const opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
if (satellitePoints.material.uniforms?.opacity) {
satellitePoints.material.uniforms.opacity.value = opacity;
} else {
satellitePoints.material.opacity = opacity;
}
}
if (satelliteBackdropPoints) {
satelliteBackdropPoints.material.opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
const opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
if (satelliteBackdropPoints.material.uniforms?.opacity) {
satelliteBackdropPoints.material.uniforms.opacity.value = opacity;
} else {
satelliteBackdropPoints.material.opacity = opacity;
}
}
}
@@ -2060,7 +2406,7 @@ export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
const sprite = createRelatedSatelliteSprite(pos, color);
const sprite = createRelatedSatelliteSprite(pos);
if (!sprite) return;
relatedSatelliteSprites.push({ index, sprite, color });
});
@@ -2225,14 +2571,13 @@ export function clearSatelliteData() {
satellitePositions.forEach((position) => {
position.current.set(0, 0, 0);
position.trail = [];
position.trailIndex = 0;
position.trailCount = 0;
});
resetSatelliteTrailState();
if (satellitePoints) {
const positionAttr = satellitePoints.geometry.attributes.position;
const colorAttr = satellitePoints.geometry.attributes.color;
const alphaAttr = satellitePoints.geometry.attributes.alpha;
if (positionAttr?.array) {
positionAttr.array.fill(0);
positionAttr.needsUpdate = true;
@@ -2241,31 +2586,30 @@ export function clearSatelliteData() {
colorAttr.array.fill(0);
colorAttr.needsUpdate = true;
}
if (alphaAttr?.array) {
alphaAttr.array.fill(0);
alphaAttr.needsUpdate = true;
}
satellitePoints.geometry.setDrawRange(0, 0);
}
if (satelliteBackdropPoints) {
const backdropPositionAttr =
satelliteBackdropPoints.geometry.attributes.position;
const backdropAlphaAttr =
satelliteBackdropPoints.geometry.attributes.alpha;
if (backdropPositionAttr?.array) {
backdropPositionAttr.array.fill(0);
backdropPositionAttr.needsUpdate = true;
}
if (backdropAlphaAttr?.array) {
backdropAlphaAttr.array.fill(0);
backdropAlphaAttr.needsUpdate = true;
}
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
}
if (satelliteTrails) {
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color;
if (trailPositionAttr?.array) {
trailPositionAttr.array.fill(0);
trailPositionAttr.needsUpdate = true;
}
if (trailColorAttr?.array) {
trailColorAttr.array.fill(0);
trailColorAttr.needsUpdate = true;
}
}
clearSatelliteTrailGeometry();
hideHoverRings();
hideLockedRing();

View File

@@ -242,13 +242,15 @@ export function openSearchPanel() {
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
);
window.setTimeout(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
});
});
}, 16);
});
}
export function focusSearchInput({ select = false } = {}) {

View File

@@ -185,14 +185,22 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
setEarthStatValue("cable-count", String(stats.cableCount || 0));
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
const has = (key) => Object.prototype.hasOwnProperty.call(stats, key);
if (has("cableCount")) setEarthStatValue("cable-count", String(stats.cableCount || 0));
if (has("landingPointCount")) {
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
}
if (has("satelliteCount")) setEarthStatValue("satellite-count", String(stats.satelliteCount || 0));
if (has("computeCenterCount")) {
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
}
if (has("bgpAnomalyCount")) setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
if (has("bgpCollectorCount")) {
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
}
if (has("bgpStatusSummary")) setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
if (has("terrainOn")) setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
if (has("textureQuality")) setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
}
// Show/hide loading via status message

View File

@@ -18,11 +18,22 @@ const Settings = lazy(() => import('./pages/Settings/Settings'))
const BGP = lazy(() => import('./pages/BGP/BGP'))
const Playground = lazy(() => import('./pages/Playground/Playground'))
const Logs = lazy(() => import('./pages/Logs/Logs'))
const Docs = lazy(() => import('./pages/Docs/Docs'))
const ROOT_ROUTE = '/'
const EARTH_ROUTE = '/earth'
const DOCS_ROUTE = '/docs'
const DOCS_ROUTE_PATTERN = '/docs/:slug'
const DOCS_ROUTE_PREFIX = `${DOCS_ROUTE}/`
const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE])
function isPublicPath(pathname: string) {
return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX)
}
function App() {
const { token } = useAuthStore()
const publicPaths = new Set(['/', '/earth'])
const isPublicRoute = publicPaths.has(window.location.pathname)
const isPublicRoute = isPublicPath(window.location.pathname)
if (!token && !isPublicRoute) {
return <Login />
@@ -38,8 +49,10 @@ function App() {
>
<Routes>
<Route path="/admin" element={<Dashboard />} />
<Route path="/" element={<Navigate to="/earth" replace />} />
<Route path="/earth" element={<Earth />} />
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
<Route path={EARTH_ROUTE} element={<Earth />} />
<Route path={DOCS_ROUTE} element={<Docs />} />
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />

View File

@@ -1,11 +1,21 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import Scrollbar from '../Scrollbar/Scrollbar'
interface MarkdownRendererProps {
markdown: string
className?: string
getHeadingId?: () => (text: string, level: number) => string | undefined
transformLink?: (href: string) => MarkdownLink | null
}
function renderInlineMarkdown(text: string): ReactNode[] {
interface MarkdownLink {
href: string
external?: boolean
}
function renderInlineMarkdown(text: string, transformLink?: MarkdownRendererProps['transformLink']): ReactNode[] {
const result: ReactNode[] = []
const pattern = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g
let lastIndex = 0
@@ -22,8 +32,17 @@ function renderInlineMarkdown(text: string): ReactNode[] {
if (matchedText.startsWith('[')) {
const linkMatch = matchedText.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (linkMatch) {
const resolvedLink = transformLink?.(linkMatch[2])
const href = resolvedLink?.href || linkMatch[2]
const isExternal = resolvedLink?.external ?? true
result.push(
<a key={`inline-${key}`} href={linkMatch[2]} target="_blank" rel="noreferrer">
<a
key={`inline-${key}`}
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noreferrer' : undefined}
>
{linkMatch[1]}
</a>,
)
@@ -62,7 +81,13 @@ function renderInlineMarkdown(text: string): ReactNode[] {
return result
}
export default function MarkdownRenderer({ markdown, className }: MarkdownRendererProps) {
function MarkdownRenderer({
markdown,
className,
getHeadingId,
transformLink,
}: MarkdownRendererProps) {
const resolveHeadingId = getHeadingId?.()
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
const nodes: ReactNode[] = []
let index = 0
@@ -92,9 +117,11 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
index += 1
}
nodes.push(
<pre key={`block-${index}`}>
<code>{codeLines.join('\n')}</code>
</pre>,
<Scrollbar key={`block-${index}`} className="markdown-renderer__code-scroll">
<pre>
<code>{codeLines.join('\n')}</code>
</pre>
</Scrollbar>,
)
continue
}
@@ -108,11 +135,13 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
const content = renderInlineMarkdown(headingMatch[2])
if (level === 1) nodes.push(<h1 key={`block-${index}`}>{content}</h1>)
else if (level === 2) nodes.push(<h2 key={`block-${index}`}>{content}</h2>)
else if (level === 3) nodes.push(<h3 key={`block-${index}`}>{content}</h3>)
else if (level === 4) nodes.push(<h4 key={`block-${index}`}>{content}</h4>)
const headingText = headingMatch[2]
const content = renderInlineMarkdown(headingText, transformLink)
const headingId = resolveHeadingId?.(headingText, level)
if (level === 1) nodes.push(<h1 key={`block-${index}`} id={headingId}>{content}</h1>)
else if (level === 2) nodes.push(<h2 key={`block-${index}`} id={headingId}>{content}</h2>)
else if (level === 3) nodes.push(<h3 key={`block-${index}`} id={headingId}>{content}</h3>)
else if (level === 4) nodes.push(<h4 key={`block-${index}`} id={headingId}>{content}</h4>)
else nodes.push(<p key={`block-${index}`} className="markdown-renderer__heading-fallback">{content}</p>)
index += 1
continue
@@ -140,7 +169,7 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
nodes.push(
<ul key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item, transformLink)}</li>
))}
</ul>,
)
@@ -159,7 +188,7 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
nodes.push(
<ol key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item)}</li>
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item, transformLink)}</li>
))}
</ol>,
)
@@ -186,12 +215,12 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
}
nodes.push(
<div key={`block-${index}`} className="markdown-renderer__table-wrap">
<Scrollbar key={`block-${index}`} className="markdown-renderer__table-wrap">
<table className="markdown-renderer__table">
<thead>
<tr>
{tableHeaderCells.map((cell, cellIndex) => (
<th key={`head-${cellIndex}`}>{renderInlineMarkdown(cell)}</th>
<th key={`head-${cellIndex}`}>{renderInlineMarkdown(cell, transformLink)}</th>
))}
</tr>
</thead>
@@ -199,13 +228,13 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
{bodyRows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex}`}>{renderInlineMarkdown(cell)}</td>
<td key={`cell-${rowIndex}-${cellIndex}`}>{renderInlineMarkdown(cell, transformLink)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
</Scrollbar>,
)
continue
}
@@ -215,12 +244,14 @@ export default function MarkdownRenderer({ markdown, className }: MarkdownRender
paragraphLines.push(lines[index].trim())
index += 1
}
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '))}</p>)
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '), transformLink)}</p>)
}
return <div className={className ? `markdown-renderer ${className}` : 'markdown-renderer'}>{nodes}</div>
}
export default memo(MarkdownRenderer)
function parseTableRow(line: string): string[] | null {
if (!line.includes('|')) {
return null

View File

@@ -1,6 +1,7 @@
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useRef,
@@ -25,6 +26,7 @@ interface ScrollbarProps {
children: ReactNode
className?: string
minThumbSize?: number
viewportRef?: RefObject<HTMLDivElement>
}
interface DragState {
@@ -49,8 +51,10 @@ function Scrollbar({
children,
className = '',
minThumbSize = 28,
viewportRef: externalViewportRef,
}: ScrollbarProps) {
const viewportRef = useRef<HTMLDivElement | null>(null)
const internalViewportRef = useRef<HTMLDivElement | null>(null)
const viewportRef = externalViewportRef ?? internalViewportRef
const trackXRef = useRef<HTMLDivElement | null>(null)
const trackYRef = useRef<HTMLDivElement | null>(null)
const dragStateRef = useRef<DragState | null>(null)

View File

@@ -0,0 +1,91 @@
.segmented-control {
position: relative;
display: flex;
align-items: center;
min-width: 0;
height: calc(42px * var(--segmented-control-scale, 1));
padding: calc(4px * var(--segmented-control-scale, 1));
border: 1px solid var(--segmented-control-border, var(--d-border, #d9e1ec));
border-radius: var(--segmented-control-radius, calc(14px * var(--segmented-control-scale, 1)));
background: var(--segmented-control-bg, var(--d-segment-bg, #eef3f9));
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06);
}
.segmented-control__slider {
position: absolute;
top: calc(4px * var(--segmented-control-scale, 1));
left: calc(4px * var(--segmented-control-scale, 1));
z-index: 1;
width: calc((100% - (8px * var(--segmented-control-scale, 1))) / var(--segmented-control-items, 2));
height: calc(100% - (8px * var(--segmented-control-scale, 1)));
border-radius: var(--segmented-control-slider-radius, calc(10px * var(--segmented-control-scale, 1)));
background: var(--segmented-control-slider-bg, var(--d-segment-slider, #ffffff));
box-shadow: var(--segmented-control-slider-shadow, var(--d-segment-shadow, 0 2px 8px rgba(15, 23, 42, 0.12)));
transform: translateX(calc(var(--segmented-control-index, 0) * 100%));
transition:
transform 0.46s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.22s ease,
box-shadow 0.22s ease;
}
.segmented-control__button {
position: relative;
z-index: 2;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--segmented-control-button-gap, calc(2px * var(--segmented-control-scale, 1)));
border: 0;
border-radius: var(--segmented-control-button-radius, calc(10px * var(--segmented-control-scale, 1)));
background: none;
color: var(--segmented-control-color, var(--d-lang-btn, #4a5568));
font: inherit;
font-size: var(--segmented-control-font-size, calc(10px * var(--segmented-control-scale, 1)));
font-weight: var(--segmented-control-font-weight, 800);
letter-spacing: var(--segmented-control-letter-spacing, 0.04em);
cursor: pointer;
transition: color 0.18s ease, transform 0.18s ease;
}
.segmented-control__button:hover {
color: var(--segmented-control-hover, var(--d-nav-hover, #0d4f9f));
}
.segmented-control__button:active .segmented-control__icon {
transform: scale(0.86);
}
.segmented-control__button--active {
color: var(--segmented-control-active, var(--d-nav-active, #0b5fc1));
}
.segmented-control__icon {
width: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
height: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.segmented-control__icon svg {
width: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
height: var(--segmented-control-icon-size, calc(15px * var(--segmented-control-scale, 1)));
stroke-width: 2.1;
}
.segmented-control__button--active .segmented-control__icon svg {
stroke-width: 2.45;
}
.segmented-control__label {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,69 @@
import type { CSSProperties, ReactNode } from 'react'
import './SegmentedControl.css'
export interface SegmentedControlOption<T extends string> {
value: T
label: ReactNode
icon?: ReactNode
title?: string
}
interface SegmentedControlProps<T extends string> {
ariaLabel: string
className?: string
options: SegmentedControlOption<T>[]
scale?: number
value: T
onChange: (value: T) => void
}
function SegmentedControl<T extends string>({
ariaLabel,
className = '',
options,
scale = 1,
value,
onChange,
}: SegmentedControlProps<T>) {
const activeIndex = Math.max(0, options.findIndex((option) => option.value === value))
const style = {
'--segmented-control-items': options.length,
'--segmented-control-index': activeIndex,
'--segmented-control-scale': scale,
} as CSSProperties
return (
<div
className={`segmented-control ${className}`.trim()}
role="group"
aria-label={ariaLabel}
style={style}
>
<span className="segmented-control__slider" aria-hidden="true" />
{options.map((option) => {
const isActive = option.value === value
return (
<button
key={option.value}
type="button"
className={isActive ? 'segmented-control__button segmented-control__button--active' : 'segmented-control__button'}
data-value={option.value}
onClick={() => onChange(option.value)}
title={option.title}
aria-pressed={isActive}
>
{option.icon ? (
<span className="segmented-control__icon" aria-hidden="true">
{option.icon}
</span>
) : null}
<span className="segmented-control__label">{option.label}</span>
</button>
)
})}
</div>
)
}
export default SegmentedControl

View File

@@ -2104,6 +2104,7 @@ body {
.markdown-renderer ol,
.markdown-renderer blockquote,
.markdown-renderer pre,
.markdown-renderer__code-scroll,
.markdown-renderer hr,
.markdown-renderer__table-wrap {
margin: 0 0 0.9em;
@@ -2127,20 +2128,35 @@ body {
}
.markdown-renderer pre {
overflow: auto;
overflow: visible;
padding: 12px 14px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
}
.markdown-renderer__code-scroll {
max-width: 100%;
border-radius: 10px;
}
.markdown-renderer__code-scroll > .scrollbar__viewport,
.markdown-renderer__table-wrap > .scrollbar__viewport {
padding: 0;
}
.markdown-renderer__code-scroll pre {
min-width: max-content;
margin: 0;
}
.markdown-renderer hr {
border: 0;
border-top: 1px solid rgba(15, 23, 42, 0.12);
}
.markdown-renderer__table-wrap {
overflow-x: auto;
max-width: 100%;
}
.markdown-renderer__table {

View File

@@ -0,0 +1,810 @@
/* ─── Design tokens ─────────────────────────────────────────────────────────── */
.docs-page {
/* Light theme (default) */
--d-bg: #f6f8fb;
--d-text: #172033;
--d-border: #d9e1ec;
--d-sidebar-bg: #ffffff;
--d-brand-border: #e5ebf3;
--d-muted: #6b778c;
--d-nav-group: #7a8699;
--d-nav-link: #3a4658;
--d-nav-hover-bg: #edf3fb;
--d-nav-hover: #0d4f9f;
--d-nav-active-bg: #e5f0ff;
--d-nav-active: #0b5fc1;
--d-header-bg: rgba(255, 255, 255, 0.96);
--d-heading: #121a2a;
--d-input-border: #c8d3e2;
--d-input-focus: #2f80ed;
--d-input-focus-shadow: rgba(47, 128, 237, 0.14);
--d-dropdown-border: #d8e0eb;
--d-dropdown-shadow: 0 20px 48px rgba(35, 47, 68, 0.18);
--d-result-hover: #f0f5fc;
--d-excerpt: #69778b;
--d-toc-border: #d8e0eb;
--d-toc-text: #59667a;
--d-toc-empty: #8792a2;
--d-toc-active: #0b5fc1;
--d-link: #0b5fc1;
--d-state-border: #d9e1ec;
--d-state-bg: #ffffff;
--d-state-text: #59667a;
--d-brand-mark-bg: #172033;
--d-brand-mark-text: #ffffff;
--d-code-bg: #f0f4f9;
--d-code-border: #dbe4ef;
--d-code-text: #172033;
--d-table-head-bg: #f0f4f9;
--d-table-border: #dde4ef;
--d-sublink: #536070;
--d-sublink-hover: #0b5fc1;
--d-sublink-active: #0b5fc1;
--d-sublink-active-bg: #eef5ff;
--d-footer-bg: #f0f4f9;
--d-footer-border: #dde4ef;
--d-lang-btn: #4a5568;
--d-lang-btn-active-bg: #172033;
--d-lang-btn-active-text: #ffffff;
--d-theme-btn: #4a5568;
--d-segment-bg: #eef3f9;
--d-segment-slider: #ffffff;
--d-segment-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
--d-status-bg: #e8eef6;
}
.docs-page[data-theme="dark"] {
--d-bg: #0c0f16;
--d-text: #dce4f0;
--d-border: #1e2535;
--d-sidebar-bg: #111622;
--d-brand-border: #1a2030;
--d-muted: #7a8a9f;
--d-nav-group: #6e7e95;
--d-nav-link: #b0c0d4;
--d-nav-hover-bg: #1a2436;
--d-nav-hover: #4d9cff;
--d-nav-active-bg: #152040;
--d-nav-active: #5ba5ff;
--d-header-bg: rgba(13, 18, 28, 0.96);
--d-heading: #e8eef8;
--d-input-border: #263040;
--d-input-focus: #4d9cff;
--d-input-focus-shadow: rgba(77, 156, 255, 0.18);
--d-dropdown-border: #1e2a3c;
--d-dropdown-shadow: 0 20px 48px rgba(0, 0, 0, 0.5);
--d-result-hover: #162030;
--d-excerpt: #7a8a9f;
--d-toc-border: #1e2a3c;
--d-toc-text: #8a9bb8;
--d-toc-empty: #5e6e84;
--d-toc-active: #5ba5ff;
--d-link: #5ba5ff;
--d-state-border: #1e2535;
--d-state-bg: #111622;
--d-state-text: #8a9bb8;
--d-brand-mark-bg: #dce4f0;
--d-brand-mark-text: #111622;
--d-code-bg: #141a26;
--d-code-border: #1e2a3c;
--d-code-text: #c8d8f0;
--d-table-head-bg: #141a26;
--d-table-border: #1e2535;
--d-sublink: #7a8a9f;
--d-sublink-hover: #5ba5ff;
--d-sublink-active: #5ba5ff;
--d-sublink-active-bg: #0f1e38;
--d-footer-bg: #0e1420;
--d-footer-border: #1e2535;
--d-lang-btn: #8a9bb8;
--d-lang-btn-active-bg: #dce4f0;
--d-lang-btn-active-text: #111622;
--d-theme-btn: #8a9bb8;
--d-segment-bg: rgba(0, 0, 0, 0.28);
--d-segment-slider: #202938;
--d-segment-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
--d-status-bg: rgba(32, 41, 56, 0.72);
}
/* ─── Scrollbar theme overrides ──────────────────────────────────────────────── */
.docs-page .scrollbar__thumb {
background: rgba(160, 175, 200, 0.38);
}
.docs-page .scrollbar__thumb:hover,
.docs-page .scrollbar__thumb:focus-visible {
background: rgba(110, 130, 165, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.docs-page .scrollbar__track--dragging .scrollbar__thumb {
background: rgba(85, 110, 150, 0.88);
}
.docs-page[data-theme="dark"] .scrollbar__thumb {
background: rgba(70, 95, 140, 0.52);
}
.docs-page[data-theme="dark"] .scrollbar__thumb:hover,
.docs-page[data-theme="dark"] .scrollbar__thumb:focus-visible {
background: rgba(100, 135, 195, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.docs-page[data-theme="dark"] .scrollbar__track--dragging .scrollbar__thumb {
background: rgba(120, 160, 220, 0.88);
}
/* ─── Page shell ─────────────────────────────────────────────────────────────── */
.docs-page {
height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
background: var(--d-bg);
color: var(--d-text);
overflow: hidden;
}
/* ─── Sidebar ────────────────────────────────────────────────────────────────── */
.docs-sidebar {
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--d-border);
background: var(--d-sidebar-bg);
}
.docs-brand {
display: flex;
align-items: center;
gap: 12px;
min-height: 76px;
padding: 18px 22px;
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--d-brand-border);
}
.docs-brand__mark {
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--d-brand-mark-bg);
color: var(--d-brand-mark-text);
font-weight: 700;
}
.docs-brand__title,
.docs-brand__subtitle {
display: block;
}
.docs-brand__title {
font-size: 15px;
font-weight: 700;
}
.docs-brand__subtitle {
margin-top: 2px;
color: var(--d-muted);
font-size: 12px;
}
.docs-nav {
/* flex: 1 1 auto; min-height: 0; overflow: hidden — inherited from .scrollbar */
}
.docs-nav > .scrollbar__viewport {
padding: 18px 14px 12px;
}
.docs-nav__group + .docs-nav__group {
margin-top: 22px;
}
.docs-nav__heading {
margin: 0 0 8px;
padding: 0 8px;
color: var(--d-nav-group);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.docs-nav__item {
display: contents;
}
.docs-nav__link {
display: block;
padding: 8px 10px;
border-radius: 7px;
color: var(--d-nav-link);
font-size: 13px;
line-height: 1.35;
text-decoration: none;
}
.docs-nav__link:hover {
background: var(--d-nav-hover-bg);
color: var(--d-nav-hover);
}
.docs-nav__link--active {
background: var(--d-nav-active-bg);
color: var(--d-nav-active);
font-weight: 650;
}
/* Sidebar sub-items (Manual section headings) */
.docs-nav__subitems {
display: grid;
margin: 2px 0 6px;
padding-left: 10px;
border-left: 2px solid var(--d-border);
margin-left: 10px;
}
.docs-nav__sublink {
display: block;
padding: 5px 8px 5px 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--d-sublink);
font: inherit;
font-size: 12px;
line-height: 1.4;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-nav__sublink:hover {
color: var(--d-sublink-hover);
background: var(--d-nav-hover-bg);
}
.docs-nav__sublink--active {
color: var(--d-sublink-active);
background: var(--d-sublink-active-bg);
font-weight: 600;
}
/* Sidebar footer: language + theme toggles */
.docs-sidebar-footer {
display: grid;
grid-template-columns: minmax(76px, 0.76fr) minmax(126px, 1.08fr);
align-items: end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--d-footer-border);
background: var(--d-footer-bg);
}
.docs-footer-row {
display: grid;
}
.docs-theme-toggle {
--segmented-control-icon-size: calc(18px * var(--segmented-control-scale, 1));
}
.docs-theme-toggle .segmented-control__button {
flex-direction: row;
gap: 0;
}
.docs-theme-toggle .segmented-control__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.docs-theme-toggle .segmented-control__button[data-value="light"] {
color: #f59e0b;
}
.docs-theme-toggle .segmented-control__button[data-value="system"] {
color: #3b82f6;
}
.docs-theme-toggle .segmented-control__button[data-value="dark"] {
color: #8b5cf6;
}
.docs-theme-toggle .segmented-control__button:not(.segmented-control__button--active) {
opacity: 0.62;
}
.docs-theme-toggle .segmented-control__button--active {
opacity: 1;
}
/* ─── Main shell ─────────────────────────────────────────────────────────────── */
.docs-shell {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── Header ─────────────────────────────────────────────────────────────────── */
.docs-header {
position: relative;
z-index: 5;
min-height: 88px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
gap: 24px;
align-items: center;
padding: 18px 32px;
border-bottom: 1px solid var(--d-border);
background: var(--d-header-bg);
backdrop-filter: blur(4px);
}
.docs-header__eyebrow {
margin: 0 0 4px;
color: var(--d-muted);
font-size: 12px;
font-weight: 650;
}
.docs-header__title {
margin: 0;
color: var(--d-heading);
font-size: 24px;
line-height: 1.22;
}
/* ─── Search ─────────────────────────────────────────────────────────────────── */
.docs-search {
position: relative;
}
.docs-search__label {
display: block;
margin-bottom: 6px;
color: var(--d-muted);
font-size: 12px;
font-weight: 650;
}
.docs-search__input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--d-input-border);
border-radius: 7px;
background: var(--d-state-bg);
color: var(--d-text);
font: inherit;
outline: none;
}
.docs-search__input:focus {
border-color: var(--d-input-focus);
box-shadow: 0 0 0 3px var(--d-input-focus-shadow);
}
.docs-search__results {
--docs-search-results-max-height: 420px;
--docs-search-results-padding: 8px;
--docs-search-results-scroll-max-height: calc(
var(--docs-search-results-max-height) - (var(--docs-search-results-padding) * 2)
);
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(520px, 76vw);
max-height: var(--docs-search-results-max-height);
overflow: hidden;
display: flex;
flex-direction: column;
padding: var(--docs-search-results-padding);
border: 1px solid var(--d-dropdown-border);
border-radius: 8px;
background: var(--d-state-bg);
box-shadow: var(--d-dropdown-shadow);
z-index: 20;
}
.docs-search__results > .scrollbar {
flex: 0 1 auto;
min-height: 0;
}
.docs-search__results-scroll {
max-height: var(--docs-search-results-scroll-max-height);
}
.docs-search__results-scroll > .scrollbar__viewport {
height: auto;
max-height: var(--docs-search-results-scroll-max-height);
}
.docs-search__result {
width: 100%;
display: grid;
gap: 3px;
padding: 10px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.docs-search__result:hover,
.docs-search__result:focus-visible {
background: var(--d-result-hover);
outline: none;
}
.docs-search__result-title {
color: var(--d-heading);
font-size: 13px;
font-weight: 700;
}
.docs-search__result-meta {
color: var(--d-nav-active);
font-size: 12px;
font-weight: 650;
}
.docs-search__result-excerpt,
.docs-search__empty {
color: var(--d-excerpt);
font-size: 12px;
line-height: 1.45;
}
.docs-search__empty {
padding: 12px;
}
/* ─── Content layout ─────────────────────────────────────────────────────────── */
.docs-content-layout {
flex: 1 1 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
overflow: hidden;
}
.docs-article {
min-width: 0;
/* overflow: hidden — from .scrollbar */
}
.docs-article > .scrollbar__viewport {
padding: 36px clamp(28px, 5vw, 72px) 72px;
}
/* ─── Markdown content ───────────────────────────────────────────────────────── */
.docs-markdown {
max-width: 920px;
font-size: 15px;
line-height: 1.78;
color: var(--d-text);
}
.docs-markdown.markdown-renderer h1 {
margin-bottom: 20px;
font-size: 34px;
letter-spacing: 0;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h2 {
margin-top: 42px;
padding-top: 8px;
font-size: 24px;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h3 {
margin-top: 30px;
font-size: 18px;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h1,
.docs-markdown.markdown-renderer h2,
.docs-markdown.markdown-renderer h3 {
scroll-margin-top: 24px;
}
.docs-markdown.markdown-renderer a {
color: var(--d-link);
}
.docs-markdown.markdown-renderer code {
padding: 1px 5px;
border: 1px solid var(--d-code-border);
border-radius: 4px;
background: var(--d-code-bg);
color: var(--d-code-text);
font-size: 0.88em;
}
.docs-markdown.markdown-renderer pre {
border: 1px solid var(--d-code-border);
border-radius: 8px;
background: var(--d-code-bg);
overflow: visible;
}
.docs-markdown .markdown-renderer__code-scroll {
max-width: 100%;
margin: 0 0 0.9em;
border-radius: 8px;
}
.docs-markdown .markdown-renderer__code-scroll > .scrollbar__viewport,
.docs-markdown .markdown-renderer__table-wrap > .scrollbar__viewport {
padding: 0;
}
.docs-markdown .markdown-renderer__code-scroll pre {
min-width: max-content;
margin: 0;
}
.docs-markdown.markdown-renderer pre code {
border: 0;
border-radius: 0;
background: transparent;
font-size: 0.88em;
}
.docs-markdown.markdown-renderer blockquote {
border-left: 3px solid var(--d-border);
background: var(--d-code-bg);
border-radius: 0 8px 8px 0;
color: var(--d-toc-text);
}
.docs-markdown.markdown-renderer hr {
border: 0;
border-top: 1px solid var(--d-border);
}
.docs-markdown .markdown-renderer__table {
min-width: 680px;
border-collapse: collapse;
background: var(--d-state-bg);
color: var(--d-text);
}
.docs-markdown .markdown-renderer__table-wrap {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--d-table-border);
}
.docs-markdown .markdown-renderer__table th {
background: var(--d-table-head-bg);
color: var(--d-heading);
font-weight: 650;
}
.docs-markdown .markdown-renderer__table th,
.docs-markdown .markdown-renderer__table td {
padding: 8px 12px;
border: 1px solid var(--d-table-border);
font-size: 13px;
line-height: 1.5;
}
.docs-markdown .markdown-renderer__table td {
background: var(--d-state-bg);
color: var(--d-text);
}
/* ─── TOC ────────────────────────────────────────────────────────────────────── */
.docs-toc {
overflow: hidden;
display: flex;
flex-direction: column;
}
.docs-toc__inner {
/* flex: 1 1 auto; min-height: 0; overflow: hidden — from .scrollbar */
}
.docs-toc__inner .scrollbar__viewport {
padding: 34px 22px 40px 0;
}
.docs-toc__title {
margin: 0 0 12px;
color: var(--d-toc-empty);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.docs-toc__nav {
display: grid;
gap: 2px;
border-left: 1px solid var(--d-toc-border);
}
.docs-toc__link {
display: block;
padding: 6px 8px 6px 14px;
border: 0;
background: transparent;
color: var(--d-toc-text);
font: inherit;
font-size: 12px;
line-height: 1.35;
text-align: left;
cursor: pointer;
border-radius: 0 5px 5px 0;
transition: color 0.1s, background 0.1s;
}
.docs-toc__link:hover,
.docs-toc__link:focus-visible {
color: var(--d-toc-active);
outline: none;
}
.docs-toc__link--active {
color: var(--d-toc-active);
font-weight: 650;
background: var(--d-nav-active-bg);
}
.docs-toc__link--level-3 {
padding-left: 26px;
}
.docs-toc__empty {
margin: 0;
color: var(--d-toc-empty);
font-size: 12px;
}
/* ─── State blocks ───────────────────────────────────────────────────────────── */
.docs-state,
.docs-not-found {
max-width: 720px;
padding: 28px;
border: 1px solid var(--d-state-border);
border-radius: 8px;
background: var(--d-state-bg);
color: var(--d-state-text);
}
.docs-not-found h2 {
margin: 0 0 8px;
color: var(--d-heading);
}
.docs-not-found p {
margin: 0 0 16px;
}
.docs-not-found a {
color: var(--d-link);
font-weight: 650;
text-decoration: none;
}
/* ─── Responsive ─────────────────────────────────────────────────────────────── */
@media (max-width: 1120px) {
.docs-page {
grid-template-columns: 240px minmax(0, 1fr);
}
.docs-content-layout {
grid-template-columns: minmax(0, 1fr);
}
.docs-toc {
display: none;
}
}
@media (max-width: 820px) {
.docs-page {
display: flex;
flex-direction: column;
overflow: hidden;
}
.docs-sidebar {
min-height: auto;
border-right: 0;
border-bottom: 1px solid var(--d-border);
}
.docs-brand {
min-height: 64px;
}
.docs-nav {
flex: 0 0 auto;
max-height: 220px;
}
.docs-nav > .scrollbar__viewport {
display: flex;
gap: 16px;
padding: 12px 14px;
}
.docs-nav__group {
min-width: 190px;
}
.docs-nav__group + .docs-nav__group {
margin-top: 0;
}
.docs-nav__subitems {
display: none;
}
.docs-shell {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.docs-header {
grid-template-columns: 1fr;
gap: 14px;
padding: 18px;
}
.docs-content-layout {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.docs-article {
min-height: 0;
}
.docs-article > .scrollbar__viewport {
padding: 24px 18px 56px;
}
.docs-markdown.markdown-renderer h1 {
font-size: 28px;
}
}

View File

@@ -0,0 +1,446 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
import {
createHeadingIdResolver,
defaultDocsSlug,
extractHeadings,
getDocsEntries,
getDocsEntry,
getDocsGroupLabel,
groupDocsEntries,
slugFromDocsHref,
} from './docs-content'
import type { DocsHeading, DocsLang } from './docs-content'
import { buildDocsSearchRecords, searchDocs } from './docs-search'
import type { DocsSearchRecord } from './docs-search'
import './Docs.css'
type DocsThemeMode = 'system' | 'light' | 'dark'
const MIN_TOC_HEADING_LEVEL = 2
const MAX_TOC_HEADING_LEVEL = 3
const ACTIVE_HEADING_TOP_OFFSET_PX = 72
const TOC_SCROLL_OFFSET_PX = 24
const FOOTER_CONTROL_SCALE = 0.86
function getHashFromHref(href: string): string {
const hashIndex = href.indexOf('#')
return hashIndex >= 0 ? href.slice(hashIndex) : ''
}
function readStoredLang(): DocsLang {
const stored = localStorage.getItem('docs-lang')
return stored === 'en' ? 'en' : 'zh'
}
function readStoredThemeMode(): DocsThemeMode {
const stored = localStorage.getItem('docs-theme')
if (stored === 'system' || stored === 'light' || stored === 'dark') {
return stored
}
return 'system'
}
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return 'light'
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export default function Docs() {
const { slug } = useParams()
const navigate = useNavigate()
const [lang, setLang] = useState<DocsLang>(readStoredLang)
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
const [markdown, setMarkdown] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [searchRecords, setSearchRecords] = useState<DocsSearchRecord[]>([])
const [activeHeadingId, setActiveHeadingId] = useState<string>('')
const articleRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const docsEntries = useMemo(() => getDocsEntries(lang), [lang])
const activeSlug = slug || defaultDocsSlug
const activeEntry = useMemo(() => getDocsEntry(activeSlug, lang), [activeSlug, lang])
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
const langOptions = useMemo(() => [
{ value: 'zh' as const, label: '中文' },
{ value: 'en' as const, label: 'EN' },
], [])
const themeOptions = useMemo(() => [
{
value: 'light' as const,
label: '浅色',
title: '浅色',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
),
},
{
value: 'system' as const,
label: '系统',
title: '跟随系统',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<path d="M8 21h8M12 17v4" />
</svg>
),
},
{
value: 'dark' as const,
label: '深色',
title: '深色',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
),
},
], [])
const handleLangChange = useCallback((newLang: DocsLang) => {
setLang(newLang)
localStorage.setItem('docs-lang', newLang)
}, [])
const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => {
setThemeMode(nextMode)
localStorage.setItem('docs-theme', nextMode)
}, [])
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
setSystemTheme(mediaQuery.matches ? 'dark' : 'light')
}
handleChange()
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
let isCancelled = false
if (!activeEntry) {
setMarkdown('')
return
}
setIsLoading(true)
activeEntry.loader()
.then((content) => {
if (!isCancelled) setMarkdown(content)
})
.finally(() => {
if (!isCancelled) setIsLoading(false)
})
return () => { isCancelled = true }
}, [activeEntry])
useEffect(() => {
if (!markdown || !window.location.hash) return
window.requestAnimationFrame(() => {
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView({ block: 'start' })
})
}, [markdown])
useEffect(() => {
let isCancelled = false
buildDocsSearchRecords(docsEntries).then((records) => {
if (!isCancelled) setSearchRecords(records)
})
return () => { isCancelled = true }
}, [docsEntries])
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!searchRef.current?.contains(event.target as Node)) {
setIsSearchOpen(false)
}
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [])
const headings = useMemo<DocsHeading[]>(() => {
return markdown
? extractHeadings(markdown).filter((h) =>
h.level >= MIN_TOC_HEADING_LEVEL && h.level <= MAX_TOC_HEADING_LEVEL
)
: []
}, [markdown])
const makeHeadingIdResolver = useMemo(() => {
return markdown ? createHeadingIdResolver(markdown) : undefined
}, [markdown])
// Scroll spy: track which heading is currently at the top of the article
useEffect(() => {
const article = articleRef.current
if (!article || headings.length === 0) {
setActiveHeadingId('')
return
}
const handleScroll = () => {
const articleTop = article.getBoundingClientRect().top
let active = headings[0]?.id || ''
for (const { id } of headings) {
const el = document.getElementById(id)
if (el && el.getBoundingClientRect().top - articleTop < ACTIVE_HEADING_TOP_OFFSET_PX) {
active = id
}
}
setActiveHeadingId(active)
}
article.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => article.removeEventListener('scroll', handleScroll)
}, [headings])
const searchResults = useMemo(() => searchDocs(searchRecords, searchQuery), [searchQuery, searchRecords])
const shouldShowSearchResults = isSearchOpen && Boolean(searchQuery.trim())
const transformLink = useCallback((href: string) => {
const docsSlug = slugFromDocsHref(href)
if (docsSlug) return { href: `/docs/${docsSlug}${getHashFromHref(href)}`, external: false }
if (href.startsWith('#')) return { href, external: false }
return { href, external: true }
}, [])
const handleSearchSelect = useCallback((resultSlug: string) => {
setSearchQuery('')
setIsSearchOpen(false)
navigate(`/docs/${resultSlug}`)
}, [navigate])
const handleTocClick = useCallback((headingId: string) => {
const el = document.getElementById(headingId)
if (el && articleRef.current) {
const articleTop = articleRef.current.getBoundingClientRect().top
const elTop = el.getBoundingClientRect().top
articleRef.current.scrollBy({ top: elTop - articleTop - TOC_SCROLL_OFFSET_PX, behavior: 'smooth' })
}
}, [])
// Sidebar H2 sub-items for active Manual doc
const sidebarSubHeadings = useMemo(() => {
if (activeEntry?.group !== 'Manual' || headings.length === 0) return []
return headings.filter((h) => h.level === 2)
}, [activeEntry, headings])
return (
<main className="docs-page" data-theme={effectiveTheme}>
<aside className="docs-sidebar" aria-label="Documentation navigation">
<Link className="docs-brand" to="/docs">
<span className="docs-brand__mark">P</span>
<span>
<span className="docs-brand__title">
{lang === 'zh' ? '星球计划文档' : 'Planet Docs'}
</span>
<span className="docs-brand__subtitle">
{lang === 'zh' ? '开发者和用户手册' : 'Developer & User Guide'}
</span>
</span>
</Link>
<Scrollbar className="docs-nav">
{groupedEntries.map((group) => (
<section key={group.group} className="docs-nav__group">
<h2 className="docs-nav__heading">{getDocsGroupLabel(group.group, lang)}</h2>
{group.entries.map((entry) => {
const isActive = entry.slug === activeSlug
return (
<div key={entry.slug} className="docs-nav__item">
<Link
className={isActive ? 'docs-nav__link docs-nav__link--active' : 'docs-nav__link'}
to={`/docs/${entry.slug}`}
>
{entry.title}
</Link>
{isActive && sidebarSubHeadings.length > 0 && (
<div className="docs-nav__subitems">
{sidebarSubHeadings.map((h) => (
<button
key={h.id}
type="button"
className={h.id === activeHeadingId ? 'docs-nav__sublink docs-nav__sublink--active' : 'docs-nav__sublink'}
onClick={() => handleTocClick(h.id)}
>
{h.text}
</button>
))}
</div>
)}
</div>
)
})}
</section>
))}
</Scrollbar>
<footer className="docs-sidebar-footer">
<div className="docs-footer-row docs-footer-row--language">
<SegmentedControl
ariaLabel="Language"
className="docs-lang-toggle"
options={langOptions}
scale={FOOTER_CONTROL_SCALE}
value={lang}
onChange={handleLangChange}
/>
</div>
<div className="docs-footer-row">
<SegmentedControl
ariaLabel="Theme"
className="docs-theme-toggle"
options={themeOptions}
scale={FOOTER_CONTROL_SCALE}
value={themeMode}
onChange={handleThemeModeChange}
/>
</div>
</footer>
</aside>
<section className="docs-shell">
<header className="docs-header">
<div>
<p className="docs-header__eyebrow">
{activeEntry ? getDocsGroupLabel(activeEntry.group, lang) : 'Docs'}
</p>
<h1 className="docs-header__title">{activeEntry?.title || 'Document not found'}</h1>
</div>
<div className="docs-search" ref={searchRef}>
<label className="docs-search__label" htmlFor="docs-search-input">
{lang === 'zh' ? '搜索文档' : 'Search docs'}
</label>
<input
id="docs-search-input"
className="docs-search__input"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
setIsSearchOpen(Boolean(event.target.value.trim()))
}}
onFocus={() => {
if (searchQuery.trim()) {
setIsSearchOpen(true)
}
}}
placeholder={lang === 'zh' ? '搜索文档...' : 'Search guides, APIs, layers...'}
type="search"
/>
{shouldShowSearchResults && (
<div className="docs-search__results" role="listbox">
<Scrollbar className="docs-search__results-scroll">
{searchResults.length > 0 ? (
searchResults.map((result) => (
<button
key={result.entry.slug}
className="docs-search__result"
type="button"
onClick={() => handleSearchSelect(result.entry.slug)}
>
<span className="docs-search__result-title">{result.entry.title}</span>
<span className="docs-search__result-meta">
{getDocsGroupLabel(result.entry.group, lang)}
</span>
<span className="docs-search__result-excerpt">{result.excerpt}</span>
</button>
))
) : (
<div className="docs-search__empty">
{lang === 'zh' ? '未找到匹配文档' : 'No matching docs'}
</div>
)}
</Scrollbar>
</div>
)}
</div>
</header>
<div className="docs-content-layout">
<Scrollbar className="docs-article" viewportRef={articleRef}>
{activeEntry ? (
isLoading ? (
<div className="docs-state">
{lang === 'zh' ? '加载中...' : 'Loading document...'}
</div>
) : (
<MarkdownRenderer
markdown={markdown}
className="docs-markdown"
getHeadingId={makeHeadingIdResolver}
transformLink={transformLink}
/>
)
) : (
<div className="docs-not-found">
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
<p>
{lang === 'zh'
? '请求的文档不在公开文档集中。'
: 'The requested guide is not part of the public technical documentation set.'}
</p>
<Link to="/docs">
{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}
</Link>
</div>
)}
</Scrollbar>
<aside className="docs-toc" aria-label="Document table of contents">
<Scrollbar className="docs-toc__inner">
<h2 className="docs-toc__title">
{lang === 'zh' ? '本页目录' : 'On this page'}
</h2>
{headings.length > 0 ? (
<nav className="docs-toc__nav">
{headings.map((heading) => (
<button
key={heading.id}
className={[
'docs-toc__link',
`docs-toc__link--level-${heading.level}`,
heading.id === activeHeadingId ? 'docs-toc__link--active' : '',
].filter(Boolean).join(' ')}
type="button"
onClick={() => handleTocClick(heading.id)}
>
{heading.text}
</button>
))}
</nav>
) : (
<p className="docs-toc__empty">
{lang === 'zh' ? '暂无章节' : 'No sections'}
</p>
)}
</Scrollbar>
</aside>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,241 @@
export type DocsGroup = 'Overview' | 'Manual' | 'Earth' | 'Frontend' | 'Backend' | 'Agents' | 'Ops' | 'Other'
export type DocsLang = 'zh' | 'en'
export interface DocsEntry {
slug: string
filename: string
title: string
group: DocsGroup
order: number
loader: () => Promise<string>
}
export interface DocsHeading {
id: string
level: number
text: string
}
interface DocsMetadataEntry {
zh: { title: string; group: DocsGroup; order: number }
en: { title: string; group: DocsGroup; order: number }
}
const DOCS_GROUP_LABELS: Record<DocsLang, Record<DocsGroup, string>> = {
zh: {
Overview: '概览',
Manual: '使用手册',
Earth: '地球可视化',
Frontend: '前端',
Backend: '后端',
Agents: '智能体',
Ops: '运维',
Other: '其他',
},
en: {
Overview: 'Overview',
Manual: 'Manual',
Earth: 'Earth',
Frontend: 'Frontend',
Backend: 'Backend',
Agents: 'Agents',
Ops: 'Ops',
Other: 'Other',
},
}
const DOCS_README_FILENAME = 'README.md'
const FALLBACK_DOCS_ORDER = 999
const MAX_HEADING_ID_LENGTH = 80
export const defaultDocsSlug = 'overview'
const zhModules = import.meta.glob('../../../../docs/technical/zh/*.md', {
query: '?raw',
import: 'default',
})
const enModules = import.meta.glob('../../../../docs/technical/en/*.md', {
query: '?raw',
import: 'default',
})
const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
[DOCS_README_FILENAME]: {
zh: { title: '技术文档', group: 'Overview', order: 0 },
en: { title: 'Technical Docs', group: 'Overview', order: 0 },
},
'quickstart.md': {
zh: { title: '快速开始', group: 'Manual', order: 1 },
en: { title: 'Quickstart', group: 'Manual', order: 1 },
},
'manual.md': {
zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 },
en: { title: 'Planet Manual', group: 'Manual', order: 2 },
},
'earth-frontend-context.md': {
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },
en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 },
},
'earth-layer-style-reference.md': {
zh: { title: 'Earth 图层样式属性索引', group: 'Earth', order: 11 },
en: { title: 'Earth Layer Style Reference', group: 'Earth', order: 11 },
},
'earth-render-layer-order.md': {
zh: { title: 'Earth 渲染图层顺序', group: 'Earth', order: 12 },
en: { title: 'Earth Render Layer Order', group: 'Earth', order: 12 },
},
'earth-satellite-footprint-policy.md': {
zh: { title: 'Earth 卫星覆盖策略', group: 'Earth', order: 13 },
en: { title: 'Earth Satellite Footprint Policy', group: 'Earth', order: 13 },
},
'earth-bgp-context.md': {
zh: { title: 'BGP 态势上下文', group: 'Earth', order: 14 },
en: { title: 'BGP Context', group: 'Earth', order: 14 },
},
'earth-news-live-streams-collector-format.md': {
zh: { title: '新闻直播采集格式', group: 'Earth', order: 15 },
en: { title: 'News Live Streams Collector Format', group: 'Earth', order: 15 },
},
'frontend-admin-frontend-context.md': {
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
},
'frontend-layout-guidelines.md': {
zh: { title: '前端布局指南', group: 'Frontend', order: 21 },
en: { title: 'Frontend Layout Guidelines', group: 'Frontend', order: 21 },
},
'backend-collectors.md': {
zh: { title: '数据采集系统', group: 'Backend', order: 30 },
en: { title: 'Data Collectors', group: 'Backend', order: 30 },
},
'backend-system-service-control.md': {
zh: { title: '系统服务控制', group: 'Backend', order: 31 },
en: { title: 'System Service Control', group: 'Backend', order: 31 },
},
'agents-aiprovider.md': {
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
},
'ops-docker-compose-buildx-upgrade.md': {
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },
},
}
const GROUP_ORDER: DocsGroup[] = ['Overview', 'Manual', 'Earth', 'Frontend', 'Backend', 'Agents', 'Ops', 'Other']
const ALL_KNOWN_SLUGS = new Set(
Object.keys(DOCS_METADATA).map((filename) =>
filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
)
)
function filenameFromPath(path: string): string {
return path.split('/').pop() || path
}
export function slugFromFilename(filename: string): string {
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
}
function fallbackTitleFromFilename(filename: string): string {
return filename
.replace(/\.md$/, '')
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export function getDocsEntries(lang: DocsLang): DocsEntry[] {
const modules = lang === 'zh' ? zhModules : enModules
return Object.entries(modules)
.map(([path, loader]) => {
const filename = filenameFromPath(path)
const meta = DOCS_METADATA[filename]
const langMeta = meta?.[lang]
return {
slug: slugFromFilename(filename),
filename,
title: langMeta?.title || fallbackTitleFromFilename(filename),
group: (langMeta?.group || 'Other') as DocsGroup,
order: langMeta?.order ?? FALLBACK_DOCS_ORDER,
loader: loader as () => Promise<string>,
}
})
.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
}
export function getDocsEntry(slug: string | undefined, lang: DocsLang): DocsEntry | undefined {
const normalizedSlug = slug || defaultDocsSlug
return getDocsEntries(lang).find((entry) => entry.slug === normalizedSlug)
}
export function groupDocsEntries(entries: DocsEntry[]): Array<{ group: DocsGroup; entries: DocsEntry[] }> {
return GROUP_ORDER.map((group) => ({
group,
entries: entries.filter((entry) => entry.group === group),
})).filter((group) => group.entries.length > 0)
}
export function getDocsGroupLabel(group: DocsGroup, lang: DocsLang): string {
return DOCS_GROUP_LABELS[lang][group]
}
export function createHeadingId(text: string, usedIds: Map<string, number>): string {
const base = text
.toLowerCase()
.replace(/`([^`]+)`/g, '$1')
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, MAX_HEADING_ID_LENGTH) || 'section'
const count = usedIds.get(base) || 0
usedIds.set(base, count + 1)
return count === 0 ? base : `${base}-${count + 1}`
}
export function extractHeadings(markdown: string): DocsHeading[] {
const usedIds = new Map<string, number>()
return markdown
.split(/\r?\n/)
.map((line) => line.trim().match(/^(#{1,3})\s+(.+)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map((match) => {
const text = match[2].trim()
return {
id: createHeadingId(text, usedIds),
level: match[1].length,
text,
}
})
}
// Returns a factory — call factory() inside MarkdownRenderer to get a fresh resolver
// per render. This is necessary because StrictMode double-invokes renders, which
// would exhaust a shared stateful closure and cause heading IDs to become undefined.
export function createHeadingIdResolver(markdown: string): () => (text: string, level: number) => string | undefined {
const headings = extractHeadings(markdown)
return () => {
const indexByKey = new Map<string, number>()
return (text: string, level: number) => {
const key = `${level}:${text}`
const currentIndex = indexByKey.get(key) || 0
indexByKey.set(key, currentIndex + 1)
const matchingHeadings = headings.filter((heading) => heading.level === level && heading.text === text)
return matchingHeadings[currentIndex]?.id
}
}
}
export function slugFromDocsHref(href: string): string | null {
const normalized = decodeURIComponent(href).split('#')[0].replace(/\\/g, '/')
const filename = normalized.split('/').pop()
if (!filename?.endsWith('.md')) {
return null
}
const slug = slugFromFilename(filename)
return ALL_KNOWN_SLUGS.has(slug) ? slug : null
}

View File

@@ -0,0 +1,102 @@
import type { DocsEntry, DocsHeading } from './docs-content'
import { extractHeadings } from './docs-content'
export interface DocsSearchRecord {
entry: DocsEntry
markdown: string
headings: DocsHeading[]
plainText: string
}
export interface DocsSearchResult {
entry: DocsEntry
score: number
excerpt: string
}
const DEFAULT_EXCERPT_LENGTH = 160
const EXCERPT_CONTEXT_BEFORE = 56
const EXCERPT_CONTEXT_AFTER = 104
const MAX_SEARCH_RESULTS = 12
const SEARCH_SCORE = {
title: 80,
slug: 36,
group: 24,
headings: 32,
body: 8,
}
function stripMarkdown(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[#>*_\-|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function createExcerpt(text: string, query: string): string {
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const matchIndex = lowerText.indexOf(lowerQuery)
if (matchIndex < 0) {
return text.slice(0, DEFAULT_EXCERPT_LENGTH)
}
const start = Math.max(0, matchIndex - EXCERPT_CONTEXT_BEFORE)
const end = Math.min(text.length, matchIndex + query.length + EXCERPT_CONTEXT_AFTER)
const prefix = start > 0 ? '...' : ''
const suffix = end < text.length ? '...' : ''
return `${prefix}${text.slice(start, end)}${suffix}`
}
export async function buildDocsSearchRecords(entries: DocsEntry[]): Promise<DocsSearchRecord[]> {
const records = await Promise.all(
entries.map(async (entry) => {
const markdown = await entry.loader()
return {
entry,
markdown,
headings: extractHeadings(markdown),
plainText: stripMarkdown(markdown),
}
}),
)
return records
}
export function searchDocs(records: DocsSearchRecord[], rawQuery: string): DocsSearchResult[] {
const query = rawQuery.trim().toLowerCase()
if (!query) {
return []
}
return records
.map((record) => {
const title = record.entry.title.toLowerCase()
const slug = record.entry.slug.toLowerCase()
const group = record.entry.group.toLowerCase()
const headings = record.headings.map((heading) => heading.text).join(' ').toLowerCase()
const body = record.plainText.toLowerCase()
let score = 0
if (title.includes(query)) score += SEARCH_SCORE.title
if (slug.includes(query)) score += SEARCH_SCORE.slug
if (group.includes(query)) score += SEARCH_SCORE.group
if (headings.includes(query)) score += SEARCH_SCORE.headings
if (body.includes(query)) score += SEARCH_SCORE.body
return {
entry: record.entry,
score,
excerpt: createExcerpt(record.plainText, rawQuery.trim()),
}
})
.filter((result) => result.score > 0)
.sort((a, b) => b.score - a.score || a.entry.order - b.entry.order)
.slice(0, MAX_SEARCH_RESULTS)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

186
planet.sh
View File

@@ -35,6 +35,10 @@ WAIT_SPINNER_TICKS_PER_SECOND=8
WAIT_SPINNER_DETAIL=""
WAIT_SPINNER_MESSAGE=""
WAIT_SESSION_ACTIVE=0
VERBOSE=0
WAIT_VERBOSE_LOG_FILE=""
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}"
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}"
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
@@ -186,7 +190,36 @@ render_wait_spinner() {
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
local detail="${WAIT_SPINNER_DETAIL:- }"
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K\033[1A\r" "$frame" "$message" "$detail" >&2
local verbose_line=""
local verbose_lines=()
local line_count=0
local cursor_up=1
if [ "$VERBOSE" -eq 1 ] && [ -n "$WAIT_VERBOSE_LOG_FILE" ] && [ -f "$WAIT_VERBOSE_LOG_FILE" ]; then
while IFS= read -r verbose_line; do
verbose_line="$(sanitize_wait_detail "$verbose_line")"
[ -n "$verbose_line" ] || continue
verbose_lines+=("$verbose_line")
done < <(tail -n "$WAIT_VERBOSE_TAIL_LINE_COUNT" "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null)
while [ "${#verbose_lines[@]}" -gt "$WAIT_VERBOSE_LINE_COUNT" ]; do
verbose_lines=("${verbose_lines[@]:1}")
done
fi
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2
if [ "$VERBOSE" -eq 1 ]; then
line_count=1
while [ "$line_count" -le "$WAIT_VERBOSE_LINE_COUNT" ]; do
verbose_line="${verbose_lines[$line_count]:- }"
printf "\n${DIM} %s${NC}\033[K" "$verbose_line" >&2
line_count=$((line_count + 1))
done
cursor_up=$((WAIT_VERBOSE_LINE_COUNT + 1))
fi
printf "\033[%sA\r" "$cursor_up" >&2
}
animate_wait_spinner() {
@@ -208,7 +241,29 @@ animate_wait_spinner() {
}
clear_wait_spinner() {
printf "\r\033[K\n\033[K\033[1A\r" >&2
local line_count=0
local clear_verbose_lines=0
if [ "$VERBOSE" -eq 1 ] && { [ "$WAIT_SESSION_ACTIVE" -eq 1 ] || [ -n "$WAIT_VERBOSE_LOG_FILE" ]; }; then
clear_verbose_lines=1
fi
printf "\r\033[K" >&2
line_count=0
while [ "$line_count" -lt 1 ]; do
printf "\n\033[K" >&2
line_count=$((line_count + 1))
done
if [ "$clear_verbose_lines" -eq 1 ]; then
line_count=0
while [ "$line_count" -lt "$WAIT_VERBOSE_LINE_COUNT" ]; do
printf "\n\033[K" >&2
line_count=$((line_count + 1))
done
fi
printf "\033[%sA\r" "$((clear_verbose_lines == 1 ? WAIT_VERBOSE_LINE_COUNT + 1 : 1))" >&2
}
start_wait_session() {
@@ -247,6 +302,7 @@ close_wait_session_context() {
reset_wait_spinner_state() {
WAIT_SPINNER_DETAIL=""
WAIT_SPINNER_MESSAGE=""
WAIT_VERBOSE_LOG_FILE=""
}
sanitize_wait_detail() {
@@ -274,12 +330,19 @@ set_wait_detail() {
run_command_with_spinner() {
local message="$1"
shift
local verbose_log_file=""
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
set_wait_detail "$message"
fi
"$@" &
if [ "$VERBOSE" -eq 1 ]; then
verbose_log_file="$(mktemp /tmp/planet_verbose.XXXXXX.log)"
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
"$@" > "$verbose_log_file" 2>&1 &
else
"$@" &
fi
local command_pid=$!
local exit_code=0
@@ -294,6 +357,9 @@ run_command_with_spinner() {
done
wait "$command_pid" || exit_code=$?
if [ "$VERBOSE" -eq 1 ] && [ -n "$verbose_log_file" ]; then
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
fi
return "$exit_code"
}
@@ -619,7 +685,7 @@ ensure_ai_provider_image_current() {
build_ai_provider_image_with_fallback() {
if compose_available; then
set_wait_detail "使用 docker compose 构建 AI Provider 镜像"
if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then
if run_ai_provider_build_command "docker compose"; then
return 0
fi
if compose_v1_available; then
@@ -632,7 +698,7 @@ build_ai_provider_image_with_fallback() {
if compose_v1_available; then
set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像"
if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker-compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then
if run_ai_provider_build_command "docker-compose"; then
return 0
fi
return 1
@@ -641,6 +707,17 @@ build_ai_provider_image_with_fallback() {
report_missing_compose
}
run_ai_provider_build_command() {
local compose_command="$1"
if [ "$VERBOSE" -eq 1 ]; then
run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider 2>&1 | tee \"$AI_PROVIDER_BUILD_LOG_FILE\""
return $?
fi
run_command_with_spinner "构建 AI Provider 镜像" sh -c "${compose_command} build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"
}
install_uv_if_needed() {
if command -v uv >/dev/null 2>&1; then
return 0
@@ -909,6 +986,9 @@ wait_for_frontend_ready() {
else
set_wait_detail "启动 Vite 开发服务器"
fi
if [ "$VERBOSE" -eq 1 ]; then
WAIT_VERBOSE_LOG_FILE="$log_file"
fi
while [ "$tick" -lt "$max_ticks" ]; do
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
@@ -1045,17 +1125,25 @@ start_backend_with_retry() {
: > /tmp/planet_backend.log
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
BACKEND_PID=$!
if [ "$VERBOSE" -eq 1 ]; then
WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log"
fi
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
return 0
fi
kill "$BACKEND_PID" 2>/dev/null || true
if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log"
return 1
fi
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
retry=$((retry + 1))
done
clear_wait_spinner
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" || true
return 1
}
@@ -1387,6 +1475,66 @@ frontend_log_indicates_port_conflict() {
grep -q "Port .* is already in use" "$log_file" 2>/dev/null
}
backend_log_indicates_port_conflict() {
local log_file="$1"
[ -f "$log_file" ] || return 1
grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null
}
print_port_listener_details() {
local port="$1"
local found=0
local pid=""
local command_line=""
local lsof_output=""
local ss_output=""
local line=""
if command -v lsof >/dev/null 2>&1; then
lsof_output="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
if [ -n "$lsof_output" ]; then
printf "%s\n" "$lsof_output" | awk '{print " " $0}'
found=1
fi
fi
if command -v ss >/dev/null 2>&1; then
ss_output="$(ss -ltnpH "( sport = :${port} )" 2>/dev/null || true)"
while IFS= read -r line; do
[ -n "$line" ] || continue
printf "${DIM} ss: %s${NC}\n" "$line"
found=1
done <<EOF
$ss_output
EOF
fi
for pid in $(collect_port_pids "$port" || true); do
command_line="$(ps -p "$pid" -o args= 2>/dev/null | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
[ -n "$command_line" ] || command_line="未知命令"
printf "${DIM} pid %s: %s${NC}\n" "$pid" "$command_line"
found=1
done
if [ "$found" -eq 0 ]; then
log_note "未能在当前环境内定位端口 ${port} 的监听进程,可能被宿主机或外部网络命名空间占用。"
fi
}
report_backend_port_conflict_if_needed() {
local backend_port="$1"
local log_file="$2"
if backend_log_indicates_port_conflict "$log_file" || ! can_bind_port "$backend_port"; then
log_error "后端地址已被占用: 0.0.0.0:${backend_port} / 127.0.0.1:${backend_port} / [::1]:${backend_port}"
print_port_listener_details "$backend_port"
return 0
fi
return 1
}
# Frontend lifecycle helpers
cleanup_frontend_processes() {
local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}"
@@ -1527,6 +1675,10 @@ parse_service_args() {
FRONTEND_LAN_ENABLED=1
shift 1
;;
-v|--verbose)
VERBOSE=1
shift 1
;;
*)
log_error "未知参数: $1"
exit 1
@@ -1831,6 +1983,25 @@ log() {
esac
}
parse_global_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
-v|--verbose)
VERBOSE=1
shift 1
;;
*)
break
;;
esac
done
GLOBAL_ARG_REMAINDER=("$@")
}
parse_global_args "$@"
set -- "${GLOBAL_ARG_REMAINDER[@]}"
case "$1" in
start)
shift
@@ -1854,9 +2025,10 @@ case "$1" in
;;
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan --verbose"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan --verbose"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"

View File

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

View File

@@ -280,6 +280,18 @@ class BaseCollector:
---
## Country Data Validation - MANDATORY
- **ALL** data sources that carry a country, region, or territory field (API responses, GeoJSON, CSVs, scraped data, third-party enrichment) **MUST** have their country values validated against the project's canonical country dictionary at `backend/app/core/countries.py` before being stored or displayed
- Use `normalize_country(value)` from `countries.py` as the single gate. If it returns `None`, the value is unrecognized and must be logged and rejected or flagged — **NEVER** silently pass it through
- The dictionary encodes official political positions (e.g., Taiwan → 中国(台湾), Kosovo → 塞尔维亚, Gaza → 巴勒斯坦). Do **NOT** override these with raw source data labels
- When integrating a new data source, run a pre-flight check: extract all distinct country values from the source and verify each one resolves via `normalize_country`. Fix unresolved values before wiring up the collector
- Geographic boundary data (GeoJSON, shapefiles, tilesets) must be post-processed to align feature names and hover labels with the dictionary. The Natural Earth `ne_110m_admin_0_countries` dataset downloaded from GitHub was used as the base for the frontend boundary layer; political corrections were applied manually
- If a new country alias needs to be added to the dictionary, add it to `COUNTRY_ENTRIES` in `countries.py`**NEVER** scatter aliases across individual collectors or API handlers
- Frontend hover tooltips and info cards that display country names must source the name from the canonical dictionary (via `NAME_ZH` after normalization), not raw source strings
---
## Frontend Layout - MANDATORY
- Backend/admin pages must be designed as a `single-screen workspace` first, not as a long vertically stacked document
@@ -308,3 +320,62 @@ class BaseCollector:
- verify layouts under browser zoom `125%` and `150%`
- Avoid using wrapper components with implicit layout behavior, such as `Space`, for height-critical scroll regions unless their generated DOM is fully accounted for
- Any UI state that hides data or a layer must also reconcile related hover/lock/tooltip/selection state so hidden content is not still “active” in the UI
---
## Icon System - MANDATORY
All canvas-drawn marker icons for the 3D earth visualization **MUST** have a canonical SVG in:
```
frontend/public/earth/assets/icons/
```
This directory is the **single source of truth** for icon shapes. The canvas/Three.js drawing code may use inline `Path2D` strings or `<canvas>` draw calls derived from these SVGs, but the geometry must originate here.
### Naming convention
`{module}-{description}.svg` in kebab-case.
| Module prefix | Context |
|---------------|---------|
| `marker-` | Surface map markers (landing points, etc.) |
| `bgp-` | BGP/routing layer icons and event symbols |
| `compute-` | Compute center markers |
Examples: `marker-landing-point.svg`, `bgp-event-triangle.svg`, `compute-gpu-cluster.svg`
### Existing icons
| File | Used in | Description |
|------|---------|-------------|
| `marker-landing-point.svg` | `cables.js` | Cable landing point pin (with circular cutout) |
| `bgp-collector.svg` | `bgp.js` | BGP collector marker (access_point icon + outer ring) |
| `bgp-glow-dot.svg` | `bgp.js` | Base radial glow dot under BGP collector |
| `bgp-event-ring.svg` | `bgp.js` | Ring overlay on event markers |
| `bgp-event-triangle.svg` | `bgp.js` | Origin anomaly |
| `bgp-event-exclamation.svg` | `bgp.js` | Withdraw event |
| `bgp-event-wave.svg` | `bgp.js` | Flap event |
| `bgp-event-burst.svg` | `bgp.js` | Specific/burst anomaly |
| `bgp-event-leak.svg` | `bgp.js` | Route leak |
| `bgp-event-dot.svg` | `bgp.js` | Generic event |
| `compute-supercomputer.svg` | `compute-centers.js` | Supercomputer (#38bdf8) |
| `compute-gpu-cluster.svg` | `compute-centers.js` | GPU cluster (#2dd4bf) |
### Color rules
- Use `fill=”currentColor”` for single-color icons so the caller controls the color (event symbols, landing point)
- Hardcode brand colors only when the color is part of the icon identity (compute center types)
- State variants (hover, locked, dimmed) are handled by the calling canvas code via color/opacity — **do not create separate SVG files per state**
### Coordinate system
- Use the native canvas coordinate space as the `viewBox` (typically `0 0 128 128`)
- Exception: `marker-landing-point.svg` uses a `viewBox` cropped from 1000-unit path space
- SVG must visually match the canvas output at the same scale
### When adding a new icon
1. Create the SVG in `assets/icons/` following naming rules above
2. Add a row to the table in this section
3. Reference the SVG path/geometry in the canvas drawing code — do not invent new shapes directly in JS

2
uv.lock generated
View File

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