Compare commits

...

10 Commits

Author SHA1 Message Date
linkong
195a8bf71c release: bump version to 0.37.2 2026-04-23 11:12:49 +08:00
linkong
987c378f99 release: bump version to 0.37.1 2026-04-23 11:05:30 +08:00
rayd1o
67f82dc41c release: bump version to 0.37.0 2026-04-23 07:56:10 +08:00
rayd1o
abe04030fb release: bump version to 0.36.0 2026-04-22 23:42:10 +08:00
linkong
6a5f9f7ad4 release: bump version to 0.35.1 2026-04-22 18:04:54 +08:00
linkong
439a512148 docs: add earth mobile drawer UI plan and Claude Code/Codex toolchain
- Add earth-mobile-drawer-ui-plan documenting mobile drawer UX decisions
- Add goal-driven.md Claude Code command for autonomous task execution
- Add .codex/ config with OpenAI model definitions and goal-driven agent
- Add SKILL.md, openai.yaml, and prompt-template for Codex integration
2026-04-22 17:37:00 +08:00
linkong
f73fa1ea6d release: bump version to 0.35.0 2026-04-22 17:29:24 +08:00
linkong
5b623a6385 release: bump version to 0.34.0 2026-04-22 12:49:37 +08:00
rayd1o
0082cf3fbd release: bump version to 0.33.0 2026-04-22 05:28:54 +08:00
rayd1o
3ae4acdff8 release: bump version to 0.32.0 2026-04-22 04:41:39 +08:00
61 changed files with 8793 additions and 538 deletions

View File

@@ -0,0 +1,91 @@
---
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
argument-hint: 建议填写任务目标;若同时给出成功标准更好
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
---
# /goal-driven — 目标驱动执行模式
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
适用场景:
- 长周期实现任务
- 高复杂度工程任务
- 可被明确验收的研究、实现、迁移、验证类工作
不适用场景:
- 纯脑暴
- 无法定义成功标准的模糊任务
- 很小的一次性修改
## 输入要求
`$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
启动时先输出:
```md
Goal
- ...
Criteria for success
- ...
Plan
1. ...
2. ...
3. ...
Verification
- ...
```
## 执行规则
1. 先把任务固化为两个核心块:
- `Goal`
- `Criteria for success`
2. 成功标准必须尽量客观,可验证,可落地。
优先写成:
- 需要交付什么
- 需要通过哪些测试或验证
- 如何判断结果真的完成
3. 进入持续执行循环:
- 完成一个阶段
- 检查当前结果是否满足成功标准
- 若未满足,明确剩余差距并继续推进
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
5. 如果验证失败:
- 明确指出哪条成功标准没满足
- 继续工作,不要把阶段性进展误判为完成
6. 只有在以下情况之一才能停止:
- 成功标准已满足
- 用户明确要求停止
## 执行风格
- 重证据,轻口头判断
- 重验收,轻自我感觉
- 优先用测试、日志、产物、对比结果来证明完成
- 对长期任务保持“未达标就继续”的节奏
## 简版模板
```md
Goal: [[[[[在此填写最终目标]]]]]
Criteria for success: [[[[[在此填写成功标准]]]]]
循环执行:
1. 推进任务
2. 检查是否满足成功标准
3. 若未满足,继续工作
4. 直到满足标准或用户明确停止
```

3
.codex/config.toml Normal file
View File

@@ -0,0 +1,3 @@
approval_policy = "never"
sandbox_mode = "danger-full-access"

View File

@@ -0,0 +1,101 @@
---
name: goal-driven
description: Run a goal-driven execution loop for very large, long-horizon, rigorously verifiable tasks. Use when the user explicitly wants the lidangzzz/goal-driven method, a master-agent plus worker-agent style workflow, or a persistent loop that keeps working until concrete success criteria are satisfied.
---
# Goal-Driven
Use this skill when the user wants a strict goal-driven workflow for a hard task with:
- one clear end goal
- explicit success criteria
- repeated verification against those criteria
- continued execution until the criteria are actually met
This skill is adapted from `lidangzzz/goal-driven`, but trimmed for local skill use to avoid bloating context.
## When To Use
Use it for tasks like:
- compilers, interpreters, theorem-like proof work, deep refactors
- long-running system design or implementation work
- problems that are expensive and complex, but still objectively testable
Do not use it for:
- vague brainstorming without a success condition
- short one-shot edits
- tasks where "done" cannot be evaluated in a meaningful way
## Core Model
The workflow has two roles:
1. Master role
Defines the goal, defines the success criteria, audits progress, and decides whether the work is actually complete.
2. Worker role
Keeps advancing the task toward the goal. If a result is partial, stalled, or unverifiable, the worker continues.
In Codex, only use actual subagents when the user explicitly asks for delegation or subagent work and the platform supports it. Otherwise emulate the same loop locally: keep working, checkpointing, and re-verifying until the criteria are satisfied.
## Workflow
1. Normalize the task into two blocks:
- `Goal`
- `Criteria for success`
2. Make the criteria concrete and testable.
Good criteria usually include:
- required outputs
- required validations or tests
- edge cases or coverage thresholds
- what evidence proves completion
3. Break the work into milestones that can each produce evidence.
4. Execute the next milestone.
If subagents are explicitly allowed, the master may delegate bounded worker tasks.
If not, do the work locally but keep the master/worker mindset.
5. Whenever work pauses, stalls, or appears complete, audit against the criteria directly.
Check artifacts, tests, logs, diffs, metrics, or other real evidence.
6. If the criteria are not met, continue with a specific delta:
- what is still missing
- what evidence failed
- what the next worker pass must improve
7. Stop only when the criteria are met, or when the user explicitly stops the process.
## Operating Rules
- Prefer objective checks over self-reported completion.
- Do not confuse progress with completion.
- If the worker says "done", verify it.
- If verification fails, continue from the gap instead of restarting blindly.
- Keep the goal stable unless the user changes it.
- Tighten fuzzy criteria before sinking large amounts of effort.
## Recommended Response Shape
When starting a goal-driven task, structure the kickoff like this:
```md
Goal
- ...
Criteria for success
- ...
Current plan
1. ...
2. ...
3. ...
Verification
- What evidence will prove completion
```
For a reusable prompt template, read [references/prompt-template.md](references/prompt-template.md).

View File

@@ -0,0 +1,7 @@
interface:
display_name: "Goal-Driven"
short_description: "Drive complex work until explicit success criteria are met."
default_prompt: "Use $goal-driven to turn this task into a concrete goal, explicit success criteria, and a verification-driven execution loop."
policy:
allow_implicit_invocation: true

View File

@@ -0,0 +1,38 @@
# Goal-Driven Prompt Template
Use this when you want a reusable kickoff prompt for a master/worker execution loop.
```md
# Goal-Driven System
Goal: [[[[[DEFINE THE FINAL GOAL HERE]]]]]
Criteria for success: [[[[[DEFINE THE SUCCESS CRITERIA HERE]]]]]
You are the master agent.
Your job is to:
1. Keep the goal and criteria fixed.
2. Start worker execution toward the goal.
3. Audit any claimed progress against the criteria.
4. If the criteria are not met, continue the work with a precise next delta.
5. Stop only when the criteria are satisfied or the user explicitly stops the process.
Worker requirements:
1. Break the task into subproblems.
2. Keep producing concrete progress toward the goal.
3. Report evidence, not just claims.
4. Continue until the criteria are satisfied.
Master audit loop:
1. Check whether the worker is still making progress.
2. If the worker stalls or claims completion, verify against the criteria.
3. If verification fails, resume work from the remaining gap.
4. Repeat until the criteria are met.
```
## Notes
- Stronger criteria produce better results than stronger rhetoric.
- Prefer measurable checks such as tests, parity checks, generated artifacts, benchmarks, or reviewable outputs.
- If the environment does not support subagents, emulate the same loop locally.

114
README.md
View File

@@ -227,6 +227,120 @@ bun run build
启动服务后访问: `http://localhost:8000/docs`
## WSL / Windows 局域网访问
如果服务运行在 WSL 中,而你希望:
- Windows 本机浏览器访问开发服务
- 同一局域网内的手机或其他电脑访问开发服务
推荐按下面顺序排查和配置。
### 1. 在 WSL 中启动服务
```bash
./planet.sh start --allow-lan
```
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`
### 2. 先确认 WSL 内部服务正常
在 WSL 中执行:
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
预期:
- `3000` 返回前端 HTML
- `8000/health` 返回健康检查 JSON
- `ss` 中能看到 `0.0.0.0:3000``0.0.0.0:8000`
如果这一步不通,先不要继续做 Windows 转发。
### 3. 在 Windows 本机验证 localhost 直通
在 Windows PowerShell 中执行:
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
```
在常见的 WSL2 开发环境下Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`
```powershell
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
```
再放行 Windows 防火墙:
```powershell
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
检查转发规则是否生效:
```powershell
netsh interface portproxy show all
```
预期能看到:
- `0.0.0.0:3000 -> 127.0.0.1:3000`
- `0.0.0.0:8000 -> 127.0.0.1:8000`
### 5. 查 Windows 局域网 IP并让其他设备访问
在 Windows PowerShell 中执行:
```powershell
ipconfig
```
找到当前联网网卡的 IPv4 地址,例如 `192.168.8.228`
局域网其他设备可访问:
- `http://<Windows局域网IP>:3000/earth`
- `http://<Windows局域网IP>:3000/admin`
例如:
- `http://192.168.8.228:3000/earth`
### 6. 常见现象与判断
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
- `whoami /groups``S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
### 7. 本项目一次性验证顺序
建议固定按这个顺序验证:
1. WSL 中执行 `curl http://localhost:3000`
2. WSL 中执行 `curl http://localhost:8000/health`
3. Windows 中执行 `curl http://localhost:3000`
4. Windows 中执行 `curl http://localhost:8000/health`
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
## 启动容错参数
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。

10
TODO.md
View File

@@ -22,3 +22,13 @@
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis``OpenAI / Stargate``CoreWeave``Lambda``Crusoe` 这类同一对象多种写法导致的地点匹配失败
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例

View File

@@ -1 +1 @@
0.31.3
0.37.2

View File

@@ -420,6 +420,7 @@ async def list_datasources(
collector_list.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"module": datasource.module,
"priority": datasource.priority,

View File

@@ -13,6 +13,7 @@ from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
from app.core.collected_data_fields import get_record_field
from app.core.countries import get_country_centroid
from app.core.satellite_tle import build_tle_lines_from_elements
from app.core.time import to_iso8601_utc
from app.db.session import get_db
@@ -363,6 +364,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
return {"type": "FeatureCollection", "features": features}
def _parse_float(value: Any) -> Optional[float]:
try:
if value in (None, ""):
return None
return float(value)
except (TypeError, ValueError):
return None
COMPUTE_CENTER_COORDINATE_HINTS = (
("el capitan", 37.6819, -121.7681),
("livermore", 37.6819, -121.7681),
("llnl", 37.6819, -121.7681),
("lawrence livermore", 37.6819, -121.7681),
("frontier", 35.9319, -84.3107),
("oak ridge", 35.9319, -84.3107),
("ornl", 35.9319, -84.3107),
("aurora", 41.7130, -87.9820),
("argonne", 41.7130, -87.9820),
("anl", 41.7130, -87.9820),
("fugaku", 34.6953, 135.1974),
("kobe", 34.6953, 135.1974),
("riken", 34.6953, 135.1974),
("summit", 35.9319, -84.3107),
("leonardo", 44.4949, 11.3426),
("bologna", 44.4949, 11.3426),
("alps", 46.0037, 8.9511),
("lugano", 46.0037, 8.9511),
("sunway taihulight", 31.4912, 120.3119),
("wuxi", 31.4912, 120.3119),
("tianhe-2", 23.1291, 113.2644),
("tianhe-2a", 23.1291, 113.2644),
("guangzhou", 23.1291, 113.2644),
("colossus", 35.1495, -90.0490),
("memphis", 35.1495, -90.0490),
("xai", 35.1495, -90.0490),
)
def _normalize_hint_text(*parts: Any) -> str:
return " ".join(
str(part).strip().lower()
for part in parts
if part not in (None, "")
)
def _resolve_compute_center_coordinates(
record: CollectedData,
metadata: Dict[str, Any],
) -> Dict[str, Any]:
latitude = _parse_float(get_record_field(record, "latitude"))
longitude = _parse_float(get_record_field(record, "longitude"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
return {
"latitude": latitude,
"longitude": longitude,
"location_precision": "precise",
"geography_mode": "source_coordinates",
"is_estimated": False,
"estimated_reason": None,
}
hint_text = _normalize_hint_text(
record.name,
get_record_field(record, "city"),
get_record_field(record, "country"),
metadata.get("site"),
metadata.get("organization"),
metadata.get("operator"),
)
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
if needle in hint_text:
return {
"latitude": resolved_latitude,
"longitude": resolved_longitude,
"location_precision": "estimated_site",
"geography_mode": "site_hint",
"is_estimated": True,
"estimated_reason": f"Matched known site hint: {needle}",
}
centroid = get_country_centroid(get_record_field(record, "country"))
if centroid:
return {
"latitude": centroid.get("latitude"),
"longitude": centroid.get("longitude"),
"location_precision": "estimated_country",
"geography_mode": "country_centroid",
"is_estimated": True,
"estimated_reason": "Estimated from country centroid",
}
return {
"latitude": latitude,
"longitude": longitude,
"location_precision": "unknown",
"geography_mode": "unknown",
"is_estimated": True,
"estimated_reason": "No resolvable location hints",
}
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
if capacity_value is None:
return "unknown"
unit = str(capacity_unit or "").strip().lower()
if unit in {"pflop/s", "pflops", "pflop"}:
normalized_tflops = capacity_value * 1000
elif unit in {"gflop/s", "gflops", "gflop"}:
normalized_tflops = capacity_value / 1000
else:
normalized_tflops = capacity_value
if normalized_tflops >= 1_000_000:
return "exascale"
if normalized_tflops >= 100_000:
return "ultra"
if normalized_tflops >= 10_000:
return "large"
if normalized_tflops > 0:
return "regional"
return "unknown"
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert compute infrastructure records into a unified GeoJSON layer."""
features = []
for record in records:
metadata = record.extra_data or {}
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
latitude = coordinate_info.get("latitude")
longitude = coordinate_info.get("longitude")
site_type = (
"supercomputer"
if record.source == "top500" or record.data_type == "supercomputer"
else "gpu_cluster"
)
if latitude in (None, 0.0) or longitude in (None, 0.0):
continue
if site_type == "supercomputer":
capacity_value = _parse_float(get_record_field(record, "rmax"))
capacity_unit = "GFlops"
else:
capacity_value = _parse_float(get_record_field(record, "value"))
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
vendor = (
metadata.get("manufacturer")
or metadata.get("vendor")
or metadata.get("gpu_type")
)
operator = (
metadata.get("organization")
or metadata.get("operator")
or metadata.get("owner")
)
rank = metadata.get("rank")
if rank in (None, "") and site_type == "supercomputer":
rank = get_record_field(record, "rank")
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
features.append(
{
"type": "Feature",
"id": record.id,
"geometry": {
"type": "Point",
"coordinates": [longitude or 0, latitude or 0],
},
"properties": {
"id": record.id,
"source_id": record.source_id,
"name": record.name,
"site_type": site_type,
"country": get_record_field(record, "country"),
"city": get_record_field(record, "city"),
"latitude": latitude,
"longitude": longitude,
"operator": operator,
"vendor": vendor,
"capacity_value": capacity_value,
"capacity_unit": capacity_unit,
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
"rank": rank,
"gpu_count": metadata.get("gpu_count"),
"gpu_type": metadata.get("gpu_type"),
"cores": get_record_field(record, "cores"),
"power": get_record_field(record, "power"),
"source": record.source,
"updated_at": updated_at,
"status": "observed",
"location_precision": coordinate_info.get("location_precision"),
"geography_mode": coordinate_info.get("geography_mode"),
"is_estimated": coordinate_info.get("is_estimated", False),
"estimated_reason": coordinate_info.get("estimated_reason"),
"data_type": "compute_center",
"metadata": metadata,
},
}
)
return {"type": "FeatureCollection", "features": features}
def convert_bgp_anomalies_to_geojson(
records: List[BGPAnomaly],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
@@ -975,6 +1185,53 @@ async def get_gpu_clusters_geojson(
}
@router.get("/geo/compute-centers")
async def get_compute_centers_geojson(
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
"""获取统一算力中心 GeoJSON 数据"""
records_by_source = await _load_current_collected_data_by_sources(
db,
["top500", "epoch_ai_gpu"],
)
records = _filter_known_records(
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
)
if limit is not None:
records = records[:limit]
if not records:
return {
"type": "FeatureCollection",
"features": [],
"count": 0,
"stats": {
"total": 0,
"supercomputers": 0,
"gpu_clusters": 0,
},
}
geojson = convert_compute_centers_to_geojson(records)
features = geojson.get("features", [])
return {
**geojson,
"count": len(features),
"stats": {
"total": len(features),
"supercomputers": sum(
1 for feature in features
if feature.get("properties", {}).get("site_type") == "supercomputer"
),
"gpu_clusters": sum(
1 for feature in features
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
),
},
}
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),

View File

@@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = {
"iptoasn_prefix_geo": "iptoasn.combined_url",
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
"news_live_streams": "news_live_streams.channels_url",
}

View File

@@ -86,3 +86,11 @@ opengeofeed:
nro:
# NRO delegated stats 下载地址
delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats"
news_live_streams:
# IPTV-org 频道元数据 JSON
channels_url: "https://iptv-org.github.io/api/channels.json"
# IPTV-org 频道播放流 JSON
streams_url: "https://iptv-org.github.io/api/streams.json"
# IPTV-org 台标 JSON
logos_url: "https://iptv-org.github.io/api/logos.json"

View File

@@ -1,10 +1,16 @@
from __future__ import annotations
import asyncio
import base64
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import select
from app.core.data_sources import get_data_sources_config
from app.models.datasource_config import DataSourceConfig
from app.services.collectors.base import BaseCollector
@@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector):
data_type = "news_live_stream"
fail_on_empty = False
DEFAULT_TIMEOUT = 45.0
DEFAULT_HEADERS = {
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
"Accept": "application/json",
}
RESPONSE_CANDIDATE_KEYS = ("sources", "streams", "channels", "items", "results", "data")
DEFAULT_ADAPTER = "iptv_org"
DEFAULT_IPTV_ORG_STREAMS_URL = "https://iptv-org.github.io/api/streams.json"
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
DEFAULT_IPTV_ORG_MAX_SOURCES = 120
async def fetch(self) -> list[dict[str, Any]]:
request_url = (self._resolved_url or "").strip()
if not request_url:
return []
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client:
response = await client.get(
datasource_config = await self._load_datasource_config()
effective_config = self._get_effective_config(datasource_config)
adapter = str(effective_config.get("adapter") or "").strip().lower()
if adapter == "iptv_org":
return await self._fetch_iptv_org(request_url, effective_config)
request_headers = self._build_request_headers(datasource_config)
request_config = self._get_request_config(datasource_config)
request_params = self._build_request_params(datasource_config)
request_json = self._build_request_json_body(datasource_config)
request_data = self._build_request_form_body(datasource_config)
timeout = self._get_timeout(datasource_config)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.request(
request_config["method"],
request_url,
headers={
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
"Accept": "application/json",
},
headers=request_headers,
params=request_params or None,
json=request_json,
data=request_data,
)
response.raise_for_status()
return self.parse_response(response.json())
return self.parse_response(
response.json(),
response_path=request_config["response_path"],
)
def parse_response(self, response: Any) -> list[dict[str, Any]]:
if isinstance(response, dict):
candidates = response.get("sources") or response.get("streams") or response.get("data") or []
elif isinstance(response, list):
candidates = response
async def _load_datasource_config(self) -> DataSourceConfig | None:
if not self._db_session:
return None
result = await self._db_session.execute(
select(DataSourceConfig)
.where(DataSourceConfig.name == self.name)
.where(DataSourceConfig.is_active.is_(True))
.limit(1)
)
return result.scalar_one_or_none()
def _get_effective_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
payload = dict(datasource_config.config or {}) if datasource_config else {}
if payload:
return payload
yaml_config = get_data_sources_config()
return {
"adapter": self.DEFAULT_ADAPTER,
"streams_url": yaml_config.get_yaml_value("news_live_streams.streams_url")
or self.DEFAULT_IPTV_ORG_STREAMS_URL,
"logos_url": yaml_config.get_yaml_value("news_live_streams.logos_url")
or self.DEFAULT_IPTV_ORG_LOGOS_URL,
"news_categories": list(self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES),
"exclude_categories": list(self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES),
"max_sources": self.DEFAULT_IPTV_ORG_MAX_SOURCES,
}
def _get_request_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
payload = self._get_effective_config(datasource_config)
raw_method = payload.get("method") or payload.get("request_method") or "GET"
method = str(raw_method).strip().upper() or "GET"
if method not in {"GET", "POST"}:
method = "GET"
response_path = payload.get("response_path") or payload.get("payload_path") or payload.get("items_path")
if isinstance(response_path, str):
response_path = response_path.strip()
else:
candidates = []
response_path = None
return {
"method": method,
"response_path": response_path or None,
}
def _get_timeout(self, datasource_config: DataSourceConfig | None) -> float:
payload = self._get_effective_config(datasource_config)
try:
return float(payload.get("timeout", self.DEFAULT_TIMEOUT))
except (TypeError, ValueError):
return self.DEFAULT_TIMEOUT
def _build_request_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
headers = dict(self.DEFAULT_HEADERS)
if datasource_config:
headers.update(self._normalize_headers(datasource_config.headers))
headers.update(self._build_auth_headers(datasource_config))
return headers
def _build_request_params(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
params: dict[str, Any] = {}
if not datasource_config:
return params
payload = datasource_config.config or {}
candidate = payload.get("params") or payload.get("query_params")
if isinstance(candidate, dict):
params.update(candidate)
if datasource_config.auth_type == "api_key":
auth_config = datasource_config.auth_config or {}
if str(auth_config.get("in") or auth_config.get("location") or "header").lower() == "query":
api_key = auth_config.get("api_key")
key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key"
if api_key and key_name:
params[str(key_name)] = api_key
return params
def _build_request_json_body(self, datasource_config: DataSourceConfig | None) -> Any:
if not datasource_config:
return None
payload = datasource_config.config or {}
body = payload.get("json_body")
if body is None and str(payload.get("body_type") or "").lower() in {"json", ""}:
candidate = payload.get("body")
if isinstance(candidate, (dict, list)):
body = candidate
return body
def _build_request_form_body(self, datasource_config: DataSourceConfig | None) -> Any:
if not datasource_config:
return None
payload = datasource_config.config or {}
form_body = payload.get("form_body")
if form_body is not None:
return form_body
if str(payload.get("body_type") or "").lower() == "form":
candidate = payload.get("body")
if isinstance(candidate, dict):
return candidate
return None
def _normalize_headers(self, headers: Any) -> dict[str, str]:
if not isinstance(headers, dict):
return {}
normalized: dict[str, str] = {}
for key, value in headers.items():
header_name = str(key).strip()
if not header_name or value is None:
continue
normalized[header_name] = str(value)
return normalized
def _build_auth_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
if not datasource_config:
return {}
auth_type = str(datasource_config.auth_type or "none").lower()
auth_config = datasource_config.auth_config or {}
if auth_type == "bearer" and auth_config.get("token"):
return {"Authorization": f"Bearer {auth_config['token']}"}
if auth_type == "api_key" and auth_config.get("api_key"):
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
if location == "query":
return {}
key_name = auth_config.get("key_name") or "X-API-Key"
return {str(key_name): str(auth_config["api_key"])}
if auth_type == "basic":
username = str(auth_config.get("username") or "")
password = str(auth_config.get("password") or "")
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {encoded}"}
return {}
def _extract_candidates(self, response: Any, response_path: str | None) -> list[Any]:
if response_path:
extracted = self._extract_from_path(response, response_path)
if isinstance(extracted, list):
return extracted
if isinstance(extracted, dict):
for key in self.RESPONSE_CANDIDATE_KEYS:
nested = extracted.get(key)
if isinstance(nested, list):
return nested
return [extracted]
if isinstance(response, dict):
for key in self.RESPONSE_CANDIDATE_KEYS:
nested = response.get(key)
if isinstance(nested, list):
return nested
return []
if isinstance(response, list):
return response
return []
def _extract_from_path(self, payload: Any, path: str) -> Any:
current = payload
for segment in (part.strip() for part in path.split(".") if part.strip()):
if isinstance(current, dict):
current = current.get(segment)
continue
if isinstance(current, list):
try:
current = current[int(segment)]
except (TypeError, ValueError, IndexError):
return None
continue
return None
return current
def _infer_source_type(self, item: dict[str, Any]) -> str:
explicit = str(item.get("source_type") or item.get("type") or "").strip().lower()
if explicit in {"iframe", "hls", "video", "external", "youtube"}:
return explicit
youtube_video_id = self._clean_text(
item.get("youtube_video_id")
or item.get("video_id")
or item.get("youtubeVideoId")
)
youtube_channel = self._clean_text(item.get("youtube_channel") or item.get("channel_handle"))
embed_url = self._clean_url(item.get("embed_url") or item.get("embed") or item.get("page_url"))
stream_url = self._clean_url(item.get("stream_url") or item.get("stream") or item.get("playback_url") or item.get("hls_url"))
homepage_url = self._clean_url(item.get("homepage_url") or item.get("source_url") or item.get("website"))
if youtube_video_id or youtube_channel:
return "youtube"
if stream_url.endswith(".m3u8"):
return "hls"
if stream_url:
return "video"
if embed_url:
parsed = urlparse(embed_url)
if "youtube.com" in (parsed.netloc or "") or "youtu.be" in (parsed.netloc or ""):
return "youtube"
return "iframe"
if homepage_url:
return "external"
return "iframe"
def _parse_enabled(self, item: dict[str, Any]) -> bool:
if "is_enabled" in item:
return self._to_bool(item.get("is_enabled"), default=True)
if "enabled" in item:
return self._to_bool(item.get("enabled"), default=True)
if "active" in item:
return self._to_bool(item.get("active"), default=True)
if "status" in item:
status = str(item.get("status") or "").strip().lower()
if status in {"disabled", "inactive", "offline"}:
return False
if status in {"enabled", "active", "online", "live"}:
return True
return True
def _to_bool(self, value: Any, *, default: bool) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on", "enabled", "active", "online", "live"}:
return True
if lowered in {"0", "false", "no", "off", "disabled", "inactive", "offline"}:
return False
return bool(value)
def _clean_text(self, value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _clean_url(self, value: Any) -> str:
text = self._clean_text(value)
if not text:
return ""
parsed = urlparse(text)
if parsed.scheme and parsed.scheme not in {"http", "https"}:
return ""
if parsed.scheme and not parsed.netloc:
return ""
return text
async def _fetch_iptv_org(self, channels_url: str, collector_config: dict[str, Any]) -> list[dict[str, Any]]:
streams_url = self._clean_url(collector_config.get("streams_url")) or self.DEFAULT_IPTV_ORG_STREAMS_URL
logos_url = self._clean_url(collector_config.get("logos_url")) or self.DEFAULT_IPTV_ORG_LOGOS_URL
news_categories = {
self._clean_text(value).lower()
for value in (collector_config.get("news_categories") or self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES)
if self._clean_text(value)
}
exclude_categories = {
self._clean_text(value).lower()
for value in (collector_config.get("exclude_categories") or self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES)
if self._clean_text(value)
}
try:
max_sources = int(collector_config.get("max_sources", self.DEFAULT_IPTV_ORG_MAX_SOURCES))
except (TypeError, ValueError):
max_sources = self.DEFAULT_IPTV_ORG_MAX_SOURCES
timeout = self.DEFAULT_TIMEOUT
try:
timeout = float(collector_config.get("timeout", self.DEFAULT_TIMEOUT))
except (TypeError, ValueError):
timeout = self.DEFAULT_TIMEOUT
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
channels_payload, streams_payload, logos_payload = await self._gather_iptv_org_payloads(
client,
channels_url,
streams_url,
logos_url,
)
channels = channels_payload if isinstance(channels_payload, list) else []
streams = streams_payload if isinstance(streams_payload, list) else []
logos = logos_payload if isinstance(logos_payload, list) else []
logo_by_channel = {
self._clean_text(item.get("channel")): self._clean_url(item.get("url"))
for item in logos
if isinstance(item, dict) and self._clean_text(item.get("channel")) and self._clean_url(item.get("url"))
}
streams_by_channel: dict[str, list[dict[str, Any]]] = {}
for stream in streams:
if not isinstance(stream, dict):
continue
channel_id = self._clean_text(stream.get("channel"))
if not channel_id:
continue
streams_by_channel.setdefault(channel_id, []).append(stream)
normalized: list[dict[str, Any]] = []
for channel in channels:
if not isinstance(channel, dict):
continue
categories = [
self._clean_text(value).lower()
for value in (channel.get("categories") or [])
if self._clean_text(value)
]
if news_categories and not any(category in news_categories for category in categories):
continue
if exclude_categories and any(category in exclude_categories for category in categories):
continue
if channel.get("is_nsfw") is True:
continue
if channel.get("closed"):
continue
channel_id = self._clean_text(channel.get("id"))
if not channel_id:
continue
stream = self._pick_iptv_org_stream(streams_by_channel.get(channel_id) or [])
if not stream:
continue
stream_url = self._clean_url(stream.get("url"))
if not stream_url:
continue
name = self._clean_text(channel.get("name")) or channel_id
notes_parts = [
f"Imported from IPTV-org catalog ({channel_id})",
f"Categories: {', '.join(categories)}" if categories else "",
f"Quality: {self._clean_text(stream.get('quality'))}" if self._clean_text(stream.get("quality")) else "",
]
metadata = {
"provider": self._clean_text(channel.get("network")) or "IPTV-org",
"region": self._clean_text(channel.get("country")) or "Global",
"language": "und",
"source_type": "hls" if stream_url.endswith(".m3u8") else "video",
"embed_url": "",
"stream_url": stream_url,
"homepage_url": self._clean_url(channel.get("website")),
"poster_url": logo_by_channel.get(channel_id, ""),
"youtube_video_id": "",
"youtube_channel": "",
"sort_order": 400 + len(normalized),
"notes": "; ".join(part for part in notes_parts if part),
"is_enabled": True,
"collector_adapter": "iptv_org",
"channel_id": channel_id,
"categories": categories,
"quality": self._clean_text(stream.get("quality")),
"stream_label": self._clean_text(stream.get("label") or stream.get("title")),
"stream_referrer": self._clean_text(stream.get("referrer")),
"stream_user_agent": self._clean_text(stream.get("user_agent")),
}
normalized.append(
{
"source_id": channel_id,
"name": name,
"description": metadata["notes"],
"metadata": metadata,
"reference_date": datetime.now(UTC).isoformat(),
}
)
if len(normalized) >= max_sources:
break
return normalized
async def _gather_iptv_org_payloads(
self,
client: httpx.AsyncClient,
channels_url: str,
streams_url: str,
logos_url: str,
) -> tuple[Any, Any, Any]:
headers = dict(self.DEFAULT_HEADERS)
channels_payload, streams_payload, logos_payload = await asyncio.gather(
client.get(channels_url, headers=headers),
client.get(streams_url, headers=headers),
client.get(logos_url, headers=headers),
)
channels_payload.raise_for_status()
streams_payload.raise_for_status()
logos_payload.raise_for_status()
return channels_payload.json(), streams_payload.json(), logos_payload.json()
def _pick_iptv_org_stream(self, streams: list[dict[str, Any]]) -> dict[str, Any] | None:
if not streams:
return None
def score(stream: dict[str, Any]) -> tuple[int, int]:
url = self._clean_url(stream.get("url"))
quality = self._clean_text(stream.get("quality")).lower()
quality_score = 0
if quality.endswith("p"):
try:
quality_score = int(quality[:-1])
except ValueError:
quality_score = 0
stream_score = 1000 if url.endswith(".m3u8") else 0
return stream_score, quality_score
sorted_streams = sorted(streams, key=score, reverse=True)
return sorted_streams[0]
def parse_response(self, response: Any, *, response_path: str | None = None) -> list[dict[str, Any]]:
candidates = self._extract_candidates(response, response_path)
normalized: list[dict[str, Any]] = []
for index, item in enumerate(candidates):
if not isinstance(item, dict):
continue
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}"
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip()
stream_id = (
item.get("id")
or item.get("source_id")
or item.get("slug")
or item.get("channel_id")
or item.get("code")
or f"news-live-{index + 1}"
)
name = self._clean_text(
item.get("name")
or item.get("title")
or item.get("channel")
or item.get("display_name")
or f"News Live {index + 1}"
)
if not name:
continue
source_type = self._infer_source_type(item)
stream_url = self._clean_url(
item.get("stream_url")
or item.get("stream")
or item.get("playback_url")
or item.get("hls_url")
or item.get("m3u8_url")
)
embed_url = self._clean_url(
item.get("embed_url")
or item.get("embed")
or item.get("page_url")
or (item.get("url") if source_type == "iframe" else "")
)
homepage_url = self._clean_url(
item.get("homepage_url")
or item.get("source_url")
or item.get("website")
or item.get("url")
)
metadata = {
"provider": item.get("provider") or item.get("publisher") or "Collector",
"region": item.get("region") or item.get("country") or "Global",
"language": item.get("language") or "und",
"source_type": item.get("source_type") or "iframe",
"embed_url": item.get("embed_url") or item.get("url") or "",
"stream_url": item.get("stream_url") or "",
"homepage_url": item.get("homepage_url") or item.get("source_url") or "",
"poster_url": item.get("poster_url") or "",
"provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector",
"region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global",
"language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und",
"source_type": source_type,
"embed_url": embed_url,
"stream_url": stream_url,
"homepage_url": homepage_url,
"poster_url": self._clean_url(item.get("poster_url") or item.get("thumbnail_url") or item.get("logo_url")),
"youtube_video_id": self._clean_text(
item.get("youtube_video_id")
or item.get("video_id")
or item.get("youtubeVideoId")
),
"youtube_channel": self._clean_text(
item.get("youtube_channel")
or item.get("channel_handle")
or item.get("youtubeChannel")
),
"sort_order": item.get("sort_order", 200 + index),
"notes": item.get("notes") or item.get("description") or "",
"is_enabled": item.get("is_enabled", True),
"notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")),
"is_enabled": self._parse_enabled(item),
}
normalized.append(
@@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector):
"name": name,
"description": metadata["notes"],
"metadata": metadata,
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()),
"reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(),
}
)

View File

@@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
DEFAULT_TV_SETTINGS = {
"default_source_id": DEFAULT_TV_SOURCE_ID,
"default_source_id": DEFAULT_TV_SOURCE_ID,
"auto_fallback": True,
"sources": [
{
@@ -362,7 +362,7 @@ def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, A
"sort_order": metadata.get("sort_order", 200 + index),
"collector_source": record.source,
"notes": record.description or metadata.get("notes") or "",
"updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)),
"updated_at": to_iso8601_utc(record.collected_at or record.reference_date or datetime.now(UTC)),
},
index=index,
)

View File

@@ -0,0 +1,217 @@
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from app.api.v1.visualization import convert_compute_centers_to_geojson
from app.db.session import get_db
from app.main import app
from app.models.collected_data import CollectedData
def _build_record(
*,
record_id: int,
source: str,
data_type: str,
name: str,
country: str,
city: str,
latitude: float,
longitude: float,
metadata: dict,
):
return CollectedData(
id=record_id,
source=source,
data_type=data_type,
source_id=f"{source}-{record_id}",
name=name,
extra_data={
"country": country,
"city": city,
"latitude": latitude,
"longitude": longitude,
**metadata,
},
collected_at=datetime(2026, 4, 22, tzinfo=timezone.utc),
reference_date=datetime(2026, 4, 21, tzinfo=timezone.utc),
is_current=True,
)
def test_convert_compute_centers_to_geojson_unifies_sources():
top500_record = _build_record(
record_id=1,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="Oak Ridge",
latitude=35.93,
longitude=-84.31,
metadata={
"rank": 1,
"manufacturer": "HPE",
"organization": "ORNL",
"rmax": 1102000.0,
"cores": 8730112,
"power": 21510.0,
},
)
gpu_record = _build_record(
record_id=2,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Colossus",
country="United States",
city="Memphis",
latitude=35.15,
longitude=-90.05,
metadata={
"organization": "xAI",
"gpu_type": "H100",
"gpu_count": 100000,
"value": "20000",
"unit": "TFlop/s",
},
)
payload = convert_compute_centers_to_geojson([top500_record, gpu_record])
assert payload["type"] == "FeatureCollection"
assert len(payload["features"]) == 2
supercomputer_feature = payload["features"][0]
assert supercomputer_feature["properties"]["site_type"] == "supercomputer"
assert supercomputer_feature["properties"]["capacity_unit"] == "GFlops"
assert supercomputer_feature["properties"]["capacity_band"] == "exascale"
assert supercomputer_feature["properties"]["operator"] == "ORNL"
assert supercomputer_feature["properties"]["location_precision"] == "precise"
assert supercomputer_feature["properties"]["is_estimated"] is False
gpu_feature = payload["features"][1]
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
assert gpu_feature["properties"]["vendor"] == "H100"
assert gpu_feature["properties"]["gpu_count"] == 100000
assert gpu_feature["properties"]["capacity_band"] == "large"
assert gpu_feature["properties"]["location_precision"] == "precise"
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
hinted_record = _build_record(
record_id=3,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="",
latitude=0.0,
longitude=0.0,
metadata={
"organization": "Oak Ridge National Laboratory",
"rmax": 1102000.0,
},
)
payload = convert_compute_centers_to_geojson([hinted_record])
assert len(payload["features"]) == 1
coords = payload["features"][0]["geometry"]["coordinates"]
assert coords[0] == pytest.approx(-84.3107)
assert coords[1] == pytest.approx(35.9319)
assert payload["features"][0]["properties"]["is_estimated"] is True
assert payload["features"][0]["properties"]["location_precision"] == "estimated_site"
def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
centroid_record = _build_record(
record_id=4,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Unknown Cluster",
country="United States",
city="",
latitude=0.0,
longitude=0.0,
metadata={
"organization": "Unknown Operator",
"value": "10000",
"unit": "TFlop/s",
},
)
payload = convert_compute_centers_to_geojson([centroid_record])
assert len(payload["features"]) == 1
props = payload["features"][0]["properties"]
coords = payload["features"][0]["geometry"]["coordinates"]
assert coords[0] == pytest.approx(-98.5795)
assert coords[1] == pytest.approx(39.8283)
assert props["is_estimated"] is True
assert props["location_precision"] == "estimated_country"
assert props["geography_mode"] == "country_centroid"
@pytest.mark.asyncio
async def test_compute_centers_geojson_endpoint_returns_stats():
records = [
_build_record(
record_id=1,
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=2,
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):
self._rows = rows
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):
return _ScalarResult(records)
async def override_get_db():
yield _FakeSession()
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/compute-centers")
assert response.status_code == 200
data = response.json()
assert data["count"] == 2
assert data["stats"]["supercomputers"] == 1
assert data["stats"]["gpu_clusters"] == 1
assert data["features"][0]["properties"]["data_type"] == "compute_center"
finally:
app.dependency_overrides.clear()

View File

@@ -8,6 +8,153 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.37.2] — 2026-04-23
### ✨ Highlights
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
### 🔧 Improvements
- 经纬线正式接入 Earth layer registry复用现有图层切换、移动端图层卡片与设置持久化流
### 🐛 Fixes
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
---
## [0.37.1] — 2026-04-23
### ✨ Highlights
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
### 🔧 Improvements
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
### 🐛 Fixes
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
---
## [0.35.1] — 2026-04-22
## [0.37.0] — 2026-04-23
### ✨ Highlights
- Earth 连线系统正式从巡航里解耦成通用 callout connector桌面端和移动端统一支持对象级锚点、四边切换与临界区边缘滑动
- BGP 巡航展示继续收口为稳定的“先定位卡片、再连真实锚点、再展示卡片”链路,移动端 popup 与桌面 info panel 的路线规则统一
### 🔧 Improvements
- connector 配置从 `CRUISE_CONFIG` 拆到独立 `CONNECTOR_CONFIG`,默认类名、动画名和实例命名也全部去 cruise 语义
- 移动端 popup 增加更稳定的 dock/obstacle 处理,拖动卡片时连线起终点会持续按几何关系自适应刷新
- Earth 多个图层与控制逻辑继续收口,补充算力中心/BGP 风格对齐、layer panel 与相关交互细节调整
### 🐛 Fixes
- 修复巡航模式下终点只像“视觉锚点”而不是真实绑定对象的问题,卡片拖动后终点现在会跟随
- 修复移动端与桌面端多类连线路线异常:压线、反向、临界区折返、起点遮挡事件点等问题
- 修复对象矩形临界区内连线仍强制中点到中点导致路线像“先钻进 source 内部”再出去的问题
---
## [0.35.1] — 2026-04-22
## [0.36.0] — 2026-04-22
### ✨ Highlights
- Earth 新增统一“算力中心”图层:接入超算与 GPU 集群,支持搜索、统计、图例、详情卡与独立图层开关
- 算力中心支持精确位置与估算位置两种状态,估算点会以问号角标区分,避免数据不全时整批节点在地图上消失
### 🔧 Improvements
- Earth 详情卡拖拽与地球拖拽交互继续收口,减少拖动卡片和旋转地球时的选中文本与 pointer 竞争
- `planet.sh` 改为通过独立脚本计算 AI Provider 依赖指纹,降低与根仓库依赖版本文件的无关耦合
- README 补充 WSL / Windows 局域网访问排查与转发配置说明,便于开发环境联调
### 🐛 Fixes
- 修复 Earth 算力中心图层在无原始坐标时无法显示的问题,支持站点提示和国家级估算回退
- 修复信息卡拖拽事件可能被卡片级 stopPropagation 吞掉,导致拖拽流中断的问题
---
## [0.35.1] — 2026-04-22
### ✨ Highlights
- Earth 统计展示改为统一 `data-earth-stat` 绑定机制,桌面 HUD 和移动端抽屉复用同一套状态更新入口
### 🔧 Improvements
- 收口海缆、登陆点、卫星、BGP 事件与 BGP 状态的统计写入逻辑,减少后续继续补桌面/移动双写分支的成本
### 🐛 Fixes
- 修复移动端态势抽屉中的海缆、登陆点与 BGP 统计在图层切换后可能停留旧值的问题
---
## [0.35.0] — 2026-04-22
### ✨ Highlights
- Earth 移动端底部抽屉系统全面上线响应式布局自动切换、Tab 导航、手势上拉/下滑开合、惯性速度判定
- 移动端点击可交互物件海缆、登陆点、卫星、BGP后弹出智能定位悬浮卡片可拖动点击跳转详情
### 🔧 Improvements
- 抽屉把手区域缩小至 36pxcollapsed 时仅露出把手,不遮挡地球操作区)
- 抽屉定期弹跳动画提示用户可上拉5 秒间隔,打开后自动停止
- 通知胶囊位置调整,不再覆盖品牌 logo
- 移动端单指旋转、双指捏合缩放地球触控事件冲突修复pointer-events 级联)
### 🐛 Fixes
- 修复移动端抽屉 shell 因 layout 高度240px+遮挡地球触控区域pointer-events 改为按层级精确控制
- 修复悬浮卡片因 setPointerCapture 在 iOS Safari 抑制合成 click 事件导致无法点击的问题
---
## [0.34.0] — 2026-04-22
### ✨ Highlights
- Earth 搜索面板正式接入支持搜索海缆、登陆点、卫星、BGP 事件与观测站,并可直接聚焦到对应对象
- `planet.sh --allow-lan` 打通 Bun + Vite 的局域网开放链路,启动成功后自动打印推荐访问地址与后端健康检查地址
### 🔧 Improvements
- 前端开发启动链统一改成 Bun 直接执行 Vite 入口,不再依赖 shell 中额外暴露的 Node 路径
- Earth 搜索结果接入登陆点详情卡片与对象聚焦,搜索后可直接进入对应详情流
- `planet.sh` 补充局域网 IPv4 自动识别与推荐地址输出,减少 WSL 局域网调试成本
### 🐛 Fixes
- 修复 `./planet.sh restart --allow-lan` 全量重启时未把 `--allow-lan` 继续传给 `start()`,导致前端退回本机监听的问题
- 修复 WSL + Bun 环境下前端偶发因 Vite 启动链不稳定而无法正确监听 `0.0.0.0:3000` 的问题
---
## [0.33.0] — 2026-04-22
### ✨ Highlights
- `news_live_streams` 采集器默认接入 `iptv-org` 频道目录,并将采集结果稳定并入 Earth TV 直播源列表
- 数据源页支持直接编辑内置数据源 override并为内置源提供一键恢复默认配置入口
### 🔧 Improvements
- `News Live Streams` 现在作为可直接触发的内置默认数据源提供,无需先手工补 override 才能采集
- TV 播放源菜单会直接区分 `[内置]``[采集]` 来源,频道来源信息也会同步展示
- 新增 [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md),正式规划 Earth 态势新闻源配置化与后续采集器化路线
### 🐛 Fixes
- 修复 `news_live_streams` 采集完成后 `/api/v1/tv/streams` 因读取不存在的 `updated_at` 字段而导致默认频道全部消失的问题
- 修复内置数据源操作列按钮显示不全,以及编辑抽屉中多个 `Collapse` 紧贴的问题
---
## [0.32.0] — 2026-04-22
### ✨ Highlights
- Earth 设置新增“地球默认大小”持久化项,重置视角、缩放百分比重置和 BGP 巡航视图现在统一复用这一份默认 zoom
- 卫星焦点层次继续收口:巡航进入 presentation 前不再过早 dim非焦点卫星改成“降亮度/尾迹/背板”而不是去饱和度
### 🔧 Improvements
- Earth 设置面板区块和左右留白进一步收紧,整体更贴近 HUD 面板的密度
- toolbar 展开边界缓存改为按需刷新,减少 document 级 mousemove 期间的重复布局读取
- Scrollbar 和 ScrollbarOverlay 收窄 observer 范围,减少大表格和动态菜单下的额外刷新成本
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md),补充默认视图大小已进入 Earth 设置持久化真源
### 🐛 Fixes
- 修复开启巡航后,尚未进入连线/presentation 时卫星已经整体变暗的问题
- 修复默认大小重置链路分散在多个入口、实际 reset/cruise/缩放提示不一致的问题
- 修复开启地形后卫星反馈层与地球背面可见性之间的一组表现问题,保留正面反馈同时恢复背面轨道遮挡
---
## [0.31.3] — 2026-04-22
### ✨ Highlights
@@ -29,8 +176,6 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
## [0.31.2] — 2026-04-21
### ✨ Highlights
@@ -67,6 +212,8 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
### ✨ Features
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式)
- 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
@@ -80,8 +227,6 @@ This project follows the repository versioning rule:
---
## [0.29.1] — 2026-04-20
## [0.30.0] — 2026-04-21
### ✨ Features

View File

@@ -16,10 +16,13 @@
当前重点入口:
- [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-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-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,372 @@
# Earth Compute Center BGP-Style Plan
## Goal
这份文档定义如何按照 BGP 模块的产品方式,把“算力中心”提升为 Earth 上的一级能力。
这里的“按 BGP 方式”指的是:
- 有独立的数据语义和接口入口
- 有独立的 Earth 图层与图例
- 有独立的 hover / click / 选中态 / 详情卡
- 有独立的统计口径与后续专题页扩展空间
这里的“按 BGP 方式”不指:
- 机械复制 BGP 的 anomaly / incident / collector 三层事件模型
- 为静态算力设施强行引入不必要的复杂告警语义
算力中心本质上更接近“长期基础设施分布层”,不是“高频动态异常层”。
因此应该复用 BGP 的模块化方法,而不是照搬 BGP 的事件结构。
## Why
当前仓库里已经有算力相关基础:
- 后端已有 `top500``epoch_ai_gpu` 数据采集
- 可视化接口已有 `/api/v1/visualization/geo/supercomputers``/api/v1/visualization/geo/gpu-clusters`
- Earth 信息卡已对 `supercomputer``gpu_cluster` 做了基础类型兼容
但当前能力还停留在“数据可取到”的阶段,没有形成像 BGP 那样完整的可视化模块:
- Earth 缺少独立的算力图层加载模块
- 缺少算力 marker 体系和视觉层级
- 缺少算力图例、统计、开关和搜索接入
- 缺少与海缆、BGP、卫星的关系表达
- 缺少算力专题页和后续告警/研判扩展入口
所以当前真正的缺口不是“有没有数据”,而是“有没有产品级模块”。
## Core Principle
算力中心应当采用和 BGP 一致的模块化分层:
1. 数据层:稳定的数据契约和 GeoJSON 输出
2. 渲染层:独立的 Earth 图层、marker 和视觉状态管理
3. 交互层hover、click、锁定态、详情卡、图例和统计
4. 扩展层:后续专题页、关系分析、告警和 AI 研判
但语义上必须保持算力中心自身的特点:
- `site / center` 是主对象,不是事件
- `capacity / rank / vendor / operator / status` 是主信息,不是异常严重度
- `distribution / concentration / dependency` 是后续分析方向,不是第一阶段必须项
## Recommended Scope
第一版“算力中心”建议统一承载两类对象:
- `supercomputer`
- `gpu_cluster`
并在 Earth 上收口为一个主题层:`compute_centers`
这样做有几个好处:
- 用户看到的是统一的“算力基础设施”语义,而不是零散数据源
- 后端仍可保留 `top500``epoch_ai_gpu` 的来源差异
- 前端可以在一个图层里再细分两种 marker 语言
## Current Gap
和 BGP 对比,当前差距主要在下面几层。
### 1. Data Contract Gap
现在的算力 GeoJSON 还是通用 `collected_data` 输出思路,字段较轻:
- `gpu_cluster` 只有基础名称和地点
- `supercomputer` 只暴露一部分性能字段
- 缺少统一的 `site_type / operator / capacity_band / source / updated_at / confidence`
- 缺少统一的算力层聚合出口
### 2. Earth Rendering Gap
当前 Earth 里没有类似 `bgp.js` 的算力模块:
- `constants.js` 没有算力 API 路径和视觉配置
- `main.js` 没有算力加载、拾取、状态同步和 HUD 更新
- `controls.js` 没有算力图层开关和启动加载优先级
- `layer-startup-tasks.js` 没有算力启动任务
- `legend.js` / `ui.js` 没有算力统计与图例模式
### 3. Interaction Gap
虽然 `info-card.js` 支持基础字段,但还没有形成 BGP 那种完整交互链路:
- 没有 hover / selected / dimmed 的视觉状态
- 没有算力对象专属 tooltip 与摘要文案
- 没有锁定后与其他基础设施的联动高亮
- 没有搜索、统计卡和详情组织方式
### 4. Product Expansion Gap
当前还没有“算力中心”专题页与分析语义:
- 没有全球分布/国家聚合/厂商聚合视图
- 没有算力与海缆/BGP/区域的关系表达
- 没有 AI brief / assessment 的后续落点
## Architecture Direction
推荐把算力中心做成“BGP 同级能力”,但采用更适合静态基础设施的结构。
### Backend
建议新增统一聚合接口,例如:
- `/api/v1/visualization/geo/compute-centers`
它的职责是把:
- `top500`
- `epoch_ai_gpu`
统一转换成一个主题层输出,同时保留对象细分类型:
- `site_type: supercomputer | gpu_cluster`
建议统一字段至少包括:
- `id`
- `name`
- `site_type`
- `country`
- `city`
- `latitude`
- `longitude`
- `operator`
- `vendor`
- `capacity_value`
- `capacity_unit`
- `capacity_band`
- `rank`
- `source`
- `updated_at`
- `location_precision`
- `geography_mode`
- `is_estimated`
- `estimated_reason`
- `metadata`
这里建议优先做“统一聚合出口”,而不是一开始就新增独立数据库表。
原因:
- 当前源数据更新频率低,先复用 `collected_data` 成本更低
- 可以先把 Earth 产品体验做完整
- 如果后续要做历史趋势、关系推断、告警,再评估是否拆成独立模型
### Frontend Earth
建议新增独立模块,例如:
- `frontend/public/earth/js/compute-centers.js`
职责参照 `bgp.js`
- 拉取算力中心 GeoJSON
- 创建 marker
- 管理 hover / selected / dimmed 状态
- 输出图例项
- 输出统计摘要
- 提供 overlay 和详情格式化辅助函数
推荐视觉分层:
1. `supercomputer` 用更稳定、更规整的设施型符号
2. `gpu_cluster` 用更活跃、更现代的密度型符号
3. 选中态通过 halo / ring / related infrastructure highlight 表达
视觉上应避免把算力中心做成“BGP 事件点”那种高频脉冲风格。
它应该更像长期存在的高价值设施。
## Phases
## Phase 1: Unified Earth Layer
目标:
- 先把算力中心做成 Earth 上可用、可点、可解释的一级图层
工作项:
- 新增统一算力 GeoJSON 接口
- 新增 `compute-centers.js`
-`constants.js` 增加 API 路径和视觉配置
-`controls.js` 增加算力图层开关与启动元数据
-`layer-startup-tasks.js` 增加算力启动加载任务
-`main.js` 接入算力拾取、hover、click、锁定态和 HUD 统计
-`ui.js` / `legend.js` / `index.html` 增加算力统计与图例入口
-`info-card.js` 提升算力详情字段组织
- 对无法精确定位、但可按国家或弱线索推测的大概位置,仍然生成地图点位
- 这类对象必须带显式“估算位置”状态,例如图标问号角标与详情说明
完成标准:
- Earth 上能独立显示/隐藏算力中心
- 两类对象有可区分的视觉表达
- hover / click / 详情卡 / 图例 / 统计全部打通
- 精确位置与估算位置在图标或文案上可区分,不会误导为同一精度
- 不干扰现有海缆、卫星、BGP 的交互链路
## Phase 2: Relationship Layer
目标:
- 让算力中心不只是“点”,而是和其他基础设施产生上下文关系
工作项:
- 建立算力中心与国家/区域聚合摘要
- 增加与附近海缆登陆点的关系提示
- 增加与 BGP 事件/观测范围的空间邻近提示
- 增加与卫星覆盖或区域连通性的实验性提示
完成标准:
- 点击算力中心时,用户能看到“它和哪些基础设施相关”
- 信息表达以辅助判断为主,不做夸张推断
## Phase 3: Compute Center Observatory
目标:
- 把算力中心从 Earth 图层扩展成独立专题观测能力
工作项:
- 新增算力中心专题页
- 提供国家/厂商/类型/容量分布统计
- 支持列表、筛选、详情和历史快照
- 预留 AI brief / assessment 入口
完成标准:
- 算力中心不再只是 Earth 上的视觉点位
- 能作为独立业务上下文进入日常观察与研判
## Phase 4: Alerts And Assessment
目标:
- 在不滥造“假动态告警”的前提下,引入真正有价值的变化感知
候选方向:
- 新增大规模算力中心
- 既有中心容量显著变化
- 国家/区域集中度显著变化
- 高价值中心与关键网络基础设施关系变化
完成标准:
- 告警来自可解释的结构变化
- 不把静态数据硬做成噪声式实时事件流
## Implementation Notes
建议按下面顺序推进:
1. 先统一 GeoJSON 契约
2. 再做 Earth 独立模块和图层开关
3. 再补详情卡、图例和统计
4. 最后才做关系层和专题页
这样可以避免一开始把范围摊得过大。
## Unknown Location Strategy
由于部分算力数据源不会直接提供经纬度,未知位置补全不能只依赖“继续找 API 字段”。
更稳妥的方式是做成一条分层富化链路,而不是单一猜测规则。
推荐按下面优先级推进:
1. 直接源信息
- 源记录显式给出 `latitude / longitude`
- 源记录给出 `city / region / facility / campus / operator`
- 源页面详情、内嵌 JSON、结构化元数据、新闻稿链接里能抽出地点线索
2. 名称与机构归一化
- 建立 `canonical_name / aliases / operator / facility` 归一化表
-`cluster name``operator``campus name` 归一到同一个实体
- 优先解决同一对象多写法导致的命中失败,而不是先扩大猜测范围
3. 本地位置注册表
- 用仓库内可维护的 registry 保存高价值对象的位置知识
- 每条记录至少包含:`canonical_name``aliases``operator``country``region``city``lat``lon``confidence``source_note`
- 转换层优先读取 registry避免地点知识长期散落在转换代码里
4. 分层回退定位
- `precise`
- `estimated_site`
- `estimated_city`
- `estimated_region`
- `estimated_national_hub`
- `estimated_country`
这里建议把“国家内主要算力城市”作为国家质心之前的一层。
例如没有美国精确位置时,优先考虑已知的主要算力/数据中心城市候选,而不是直接落在几何质心。
5. 候选证据富化
- 如果源 API 无地点信息,可以允许采集链路读取公开辅助证据
- 例如机构官网、数据中心介绍页、新闻稿、百科型页面、公开 PDF
- 但只提取“地点线索”,不把外部页面上的经纬度当真值直接写回
6. 人工校验闭环
- 对高价值且仍然未知的对象输出待核验清单
- 把人工确认结果回写到位置注册表
- 后续采集继续优先复用这层人工确认结果
### Additional Solution Paths
除了静态映射表,还可以考虑下面这些办法:
- 基于国家和运营方建立“主要园区候选集”,用稳定散列把同国未知节点分散到若干可信城市,而不是全部压到一个点
- 基于数据中心/云厂商公开 region 列表建立 `operator -> city set` 候选映射,用于云 GPU 集群类对象
- 把“估算依据”结构化,例如 `matched_alias``matched_operator``matched_city_text``fallback_country_hub`
- 给位置补全增加 `last_verified_at`,便于后续按时间重新校验老旧映射
- 单独维护“不可可靠定位”状态;这类对象仍可在国家级聚合统计中出现,但可以允许用户在地图上过滤掉
- 后续如果你们愿意投入更多,可把这条链路做成小型 enrichment pipeline而不是仅在 API 转换时临时判断
## Non-Goals
第一阶段不建议做这些内容:
- 不复制 BGP 巡航模式到算力中心
- 不先做复杂实时 websocket 推送
- 不先引入独立 `compute_center_incident` 一类模型
- 不先做全量 AI 分析面板
原因是算力中心的第一需求是“被看清楚”,不是“被实时播报”。
但“被看清楚”不等于“只显示精确坐标对象”。
对于没有精确经纬度、但能推测到国家或区域级位置的算力中心,应优先以上图并标注估算状态的方式处理,而不是直接在地图上消失。
## Acceptance Checklist
- 后端存在统一的算力中心 GeoJSON 出口
- Earth 有独立算力图层模块,而不是散落在 `main.js`
- 页面上有清晰的算力开关、图例和统计
- `supercomputer``gpu_cluster` 在视觉和详情上都可区分
- 估算位置对象在地图和详情中都有明确状态提示
- 现有 BGP / 海缆 / 卫星功能无回归
- 代码结构上为后续专题页和关系分析留出了明确扩展点
## Summary
这项工作的本质不是“再多画几个点”。
它应该把算力中心从已有数据源,升级成与 BGP 同级的 Earth 观测主题:
- 有独立语义
- 有独立图层
- 有独立交互
- 有后续分析扩展能力
推荐先完成 Phase 1把算力中心做成真正可用的 Earth 一级模块,再继续推进关系层和专题页。

View File

@@ -0,0 +1,400 @@
# Earth Mobile Drawer UI Plan
## 背景
当前 Earth 移动端已经补上了基础触控能力,例如:
- 单指拖拽旋转地球
- 双指缩放
- 点击阈值和基础事件隔离
但移动端 UI 仍然存在一个根本问题:
它还在沿用桌面 HUD 的内容切分方式,只是把原来的 panel、modal、toolbar 改位置、改层级、改容器。这样虽然能快速复用旧代码,但手机端体验仍然是生硬的,因为:
- 信息密度和结构是按桌面设计的
- 面板标题、关闭、折叠、开关项是桌面心智,不是手机心智
- 很多内容只是“被塞进抽屉”,而不是为抽屉重新设计
- 设置里仍然带有“显示/隐藏某些 panel”的思路但移动端本来就不应该存在那些独立 panel
因此本计划进一步收紧:
移动端不只是“底部抽屉化”,而是**重新设计一套 fit 抽屉体系的 mobile-first UI**。
## 新目标
1. 手机端不再使用现有 `toolbar` 作为主入口。
2. 手机端不再使用现有独立 `panel / modal / sheet` 作为直接 UI 单元。
3. 手机端统一采用“底部抽屉 + 顶部标题 + tab 切换 + 卡片内容”的单前景模式。
4. 抽屉内部每个 tab 页面都按移动端重新设计内容结构,而不是直接复用旧 panel 结构。
5. 设置页移除“显示/隐藏 panel”的桌面遗留配置。
6. 媒体页拆成两个移动端页面:`新闻``TV`,都归入抽屉体系。
7. 桌面端保持现有 HUD 体系,不回退。
## 核心原则
### 1. 只复用数据和状态,不复用桌面 UI 结构
可复用:
- 图层注册表
- 搜索结果数据
- BGP / 海缆 / 卫星详情数据
- 媒体数据
- 旋转、缩放、选择、高亮等运行时状态
不直接复用:
- 桌面 panel DOM 结构
- 桌面 panel header / close / collapse 交互
- 桌面 settings 项里的“显示某 panel”逻辑
- 桌面媒体面板布局
### 2. 抽屉是唯一主前景层
移动端同一时刻只有一个主前景层:底部抽屉。
抽屉内部切换内容页,而不是多个悬浮层互相覆盖。
### 3. 每个 tab 都是移动端页面,而不是 panel 容器
抽屉中的每一项都应视为一个移动端子页面:
- 有自己的标题
- 有自己的内容层次
- 有自己的滚动区域
- 有自己的主操作
而不是简单挂一个旧面板进去。
### 4. 移动端状态提示不占据屏幕正中
桌面端当前很多通知、状态提示、胶囊消息更适合在屏幕上方居中出现,但移动端不应继续沿用这套布局。
移动端统一改为:
- 通知栏放在右上角安全区
- 胶囊提示放在右上角堆叠
- 不遮挡地球中心视野
- 不与底部抽屉主交互区冲突
## 交互模型
### 默认态
移动端默认只显示:
- 地球主画布
- 底部半露出的抽屉头部
不再单独显示上箭头按钮。
### 展开态
用户从底边直接上拉抽屉,或点击抽屉头部展开。
展开后显示:
- 当前页面标题
- tab 导航
- 当前页面内容
### 收起态
用户下拉抽屉头部收起,或点击背景收起。
## 信息架构
移动端抽屉内的一级页面重定为:
1. 图层
2. 搜索
3. 态势
4. 新闻
5. TV
6. 设置
7. 详情(按需出现,不固定常驻 tab
其中 `新闻``TV` 不再共享同一个移动端媒体面板。
## 页面重设计要求
### 图层页
目标:
- 成为移动端最核心的控制页
- 强调快速开关,不强调桌面 panel 感
内容建议:
- 顶部摘要:当前已启用图层数量
- 图层列表卡片
- 每个图层项只保留:
- 图标
- 中文名
- 英文副标题
- 开关
- 去掉桌面式 header / collapse / close 结构
### 搜索页
目标:
- 成为抽屉中的完整搜索页
- 避免看起来像桌面 modal 被塞进抽屉
内容建议:
- 顶部搜索输入框
- 搜索提示文案
- 结果列表
- 结果项更适合手指点击
- 结果点击后:
- 聚焦地球对象
- 自动切换到详情页
### 态势页
目标:
- 合并原来的 `stats + legend` 思路
- 成为移动端全局态势页
内容建议:
- 顶部核心统计卡
- 海缆数量
- 登陆点数量
- 卫星数量
- BGP 事件数量
- 当前关注层图例
- BGP 状态摘要
- 不再出现独立 legend 面板和独立 stats 面板
### 新闻页
目标:
- 从原媒体面板中拆出单独的移动端新闻页
内容建议:
- 当前区域焦点
- 新闻源数量
- 新闻卡片列表
- 卡片内显示标题、来源、时间、区域
- 外链操作更清晰
### TV 页
目标:
- 从原媒体面板中拆出单独的移动端 TV 页
内容建议:
- 顶部频道选择
- 直播状态
- 当前频道说明
- 视频播放器区域
- 刷新和外链按钮
不再保留桌面式“新闻/TV tab 共处一个 panel”的结构。
### 设置页
目标:
- 只保留对移动端仍有意义的系统配置
必须移除:
- 图层控制 panel 显示/隐藏
- 图例 panel 显示/隐藏
- 全球态势 panel 显示/隐藏
- 媒体 panel 显示/隐藏
保留项建议:
- 旋转模式
- 日夜模式
- 地球默认大小
- 地形透明度
- 系统入口
原因:
移动端已经没有这些独立 panel 了,所以继续保留这些开关会制造错误心智。
### 详情页
目标:
- 成为海缆 / BGP / 卫星对象的统一移动端详情页
内容建议:
- 标题区
- 类型标签
- 关键属性列表
- 相关对象摘要
- 相关图层或态势提示
行为建议:
- 点击对象后自动切入详情页
- 搜索结果点击后也切入详情页
## 阶段重定义
### 阶段 2抽屉壳层
目标:
1. 实现底部抽屉基本壳层。
2. 支持上拉展开、下拉收起、背景点击关闭。
3. `mobile` 模式下隐藏旧 toolbar。
4. `mobile` 模式下不再直接显示旧 panel。
完成标准:
1. 手机端只有地球主视图和抽屉。
2. 抽屉开合稳定。
### 阶段 3基础页面重做
目标:
1. 重新设计并实现图层页。
2. 重新设计并实现搜索页。
3. 重新设计并实现设置页。
完成标准:
1. 这三个页面不再是旧 panel 原样移植。
2. 设置页已移除 panel 可见性开关。
### 阶段 4态势与详情重做
目标:
1. 将 stats 和 legend 合并为新的态势页。
2. 实现统一详情页。
3. 对象点击与搜索结果点击都可切入详情页。
完成标准:
1. 不再存在移动端独立 legend / stats 面板。
2. 详情页成为统一对象信息入口。
### 阶段 5媒体拆分重做
目标:
1. 将原媒体面板拆成两个移动端页面新闻页、TV 页。
2. 分别重做这两个页面的布局。
3. 保留各自必要操作,但不继续共享桌面 panel 结构。
完成标准:
1. 新闻与 TV 各自成为独立移动端页面。
2. 不再使用桌面媒体 panel 的 tab 结构作为移动端主体。
### 阶段 6手感与真机修正
目标:
1. 调整抽屉高度、节奏、手势阈值。
2. 调整 tab 密度与文字层级。
3. 优化 iPhone / Android 安全区。
4. 优化抽屉滚动与地球拖拽边界。
完成标准:
1. 抽屉和地球不会抢手势。
2. 手机端各页面信息层次清晰。
3. 真机下无遮挡、无死层、无错误交互心智。
## 技术落点调整
### [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
职责:
- 只保留移动端抽屉壳层
- 为各页面提供新的页面容器
不再把旧 panel 作为最终结构直接塞进抽屉。
### [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
职责:
- 管理抽屉开合
- 管理 tab 切换
- 管理详情页切入
- 管理 mobile / desktop 分流
### [frontend/public/earth/js/search.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/search.js)
职责:
- 保留搜索能力和结果逻辑
- 输出给新的移动端搜索页
### [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
职责:
- 从桌面 info-card 逻辑中提取可复用的数据层
- 服务新的移动端详情页
### [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
职责:
- 为新的 TV 页面提供数据和状态
- 不再直接主导移动端媒体 panel 壳层
### [frontend/public/earth/js/news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
职责:
- 为新的新闻页面提供列表和区域焦点数据
### CSS
需要新增真正的移动端页面样式,而不是继续在旧 panel class 上堆条件分支:
- 图层页样式
- 搜索页样式
- 态势页样式
- 新闻页样式
- TV 页样式
- 设置页样式
- 详情页样式
- 移动端右上角通知 / 胶囊提示样式
## 验收标准
1. `mobile` 模式下不再显示旧 toolbar。
2. `mobile` 模式下不再把旧 panel 直接作为最终 UI。
3. 图层、搜索、态势、新闻、TV、设置都是重新设计的移动端页面。
4. 设置页不再包含移动端无意义的 panel 显示/隐藏项。
5. 新闻与 TV 已拆分为两个移动端页面。
6. legend / stats 已整合为态势页。
7. 详情页成为统一对象详情入口。
8. 移动端通知栏和胶囊提示已统一放到右上角安全区,而不是屏幕正中。
## 结论
本计划进一步明确:
移动端目标不是“把桌面 HUD 放进抽屉”,而是“以抽屉为载体,重做一套适合手机端的信息页面”。
后续开发必须以此为准:
- 复用数据
- 重做界面
- 清除桌面遗留心智

View File

@@ -0,0 +1,156 @@
# Earth News Source Configuration And Collector Plan
## Why
当前 Earth 的“态势新闻”由 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 直接在请求时抓取 RSS / Google News feed再按当前地球视角中心区域聚合返回。
这条链已经可用,但存在两个明显限制:
- 新闻源写死在代码里,不能像 TV 直播源一样从后台维护
- 新闻并未进入统一采集体系,没有采集状态、失败监控、历史数据和后续 AI 复用能力
因此这块更合理的路线不是一步到位重写,而是分阶段推进:
1. 先做“新闻源配置化”
2. 再做“新闻采集器化”
## Current State
当前实现分布在:
- 新闻接口
- [news.py](/home/ray/dev/linkong/planet/backend/app/api/v1/news.py)
- 实时聚合逻辑
- [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py)
- 前端消费
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
当前新闻源包含:
- `BBC World` RSS
- `DW Top Stories` RSS
- 按区域关键词拼出来的 `Google News RSS`
- `Global`
- `Americas`
- `Europe`
- `Middle East / Africa`
- `Asia Pacific`
当前不是采集器,也不落库,只做内存缓存。
## Phase 1: Source Configuration
### Goal
`NEWS_FEED_SOURCES` 从硬编码列表升级成可配置新闻源目录,但继续保留当前“实时聚合”的工作方式。
### Scope
- 为 Earth news 建立独立配置结构
- 支持后台维护 feed 源
- 支持启用/禁用、优先级、区域、源类型
- 保持现有 `/api/v1/news/earth-feed` 输出协议不变
### Proposed Shape
建议配置字段至少包括:
- `id`
- `name`
- `region`
- `feed_url`
- `homepage_url`
- `source_type`
- `priority`
- `is_enabled`
- 可选 `query_profile`
- 可选 `language`
- 可选 `notes`
### Suggested Storage
优先走系统设置或单独的 news source settings payload而不是先建复杂新表。
推荐原因:
- 改动小
- 易上线
- 和当前 TV settings 维护体验更接近
- 先解决“写死在代码里”的问题
### Non-goals
这一阶段不做:
- 新闻入库
- 新闻历史回看
- 新闻采集任务监控
- 新闻去重流水线
## Phase 2: News Collectorization
### Goal
把“态势新闻”升级为真正的采集器链路,使其进入采集系统和数据层。
### Scope
- 新增专用 news collector
- 按配置源定时采集 RSS / feed
- 做标题/链接级去重
- 建立统一新闻记录模型
- 为 Earth、控制台、AI 研判复用同一份新闻数据
### Benefits
- 有采集状态
- 有失败监控
- 有历史缓存
- 可以做时间轴 / 区域新闻基线
- 可以作为 AI 引用证据
### Required Design Work
需要提前明确:
- 新闻数据模型
- 去重策略
- 过期清理策略
- 区域映射策略
- 聚合排序策略
- 新闻与 Earth 当前视角/区域的关联方式
### Candidate Output Model
至少应包含:
- `source_id`
- `headline`
- `summary`
- `url`
- `publisher`
- `region`
- `published_at`
- `language`
- `tags`
- `raw_feed_source`
- `reference_date`
## Recommended Order
推荐执行顺序:
1. 先完成 Phase 1 配置化
2. 保持 Earth 继续实时聚合,但改为读取配置源
3. 等新闻源稳定后,再设计 Phase 2 的 collector / storage / dedupe
## Decision
当前结论:
- TV 直播源:优先采集器化
- 态势新闻:优先配置化,再采集器化
## Source Note
This plan is newly created for the Planet repo to separate the short-term "configurable source directory" work from the longer-term "collectorized news pipeline" work.

View File

@@ -258,6 +258,7 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
当前持久化的范围是:
- 旋转模式
- 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源)
- HUD 面板显示/隐藏
- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP`
- 地形透明度

View File

@@ -95,3 +95,93 @@
- 手工配置源
- `news_live_streams` 采集器采集源
- 当前默认兜底源为 `CCTV-4 中文国际`
- `news_live_streams` 在未配置 override 时,默认使用 `iptv-org`
- `channels.json`
- `streams.json`
- `logos.json`
并自动筛出新闻类频道目录
## 采集器配置方式
`news_live_streams` 不需要单独新页面,直接复用现有数据源配置:
- `endpoint`
- 频道目录 JSON API 地址
- `auth_type`
- `none` / `bearer` / `api_key` / `basic`
- `headers`
- 额外请求头
- `config`
- 采集器请求与解析行为
### 支持的 `config` 字段
```json
{
"timeout": 30,
"method": "GET",
"params": {
"region": "global"
},
"body_type": "json",
"body": {
"include_disabled": false
},
"response_path": "payload.channels"
}
```
- `timeout`
- 请求超时秒数
- `method`
- `GET``POST`
- `params`
- 查询参数对象
- `body_type`
- `json``form`
- `body`
- 配合 `POST` 使用的请求体
- `json_body`
- 显式 JSON 请求体,优先级高于 `body`
- `form_body`
- 显式表单请求体,优先级高于 `body`
- `response_path`
- 返回 JSON 中频道数组所在路径,支持点路径,例如:
- `payload.channels`
- `data.items`
- `result.streams`
### 认证补充
- `bearer`
- 使用 `Authorization: Bearer <token>`
- `api_key`
- 默认作为请求头发送
- 如果 `auth_config.in = "query"`,则作为 query param 发送
- `basic`
- 使用 HTTP Basic Authorization
## 兼容的响应结构
采集器会优先读取:
- 顶层数组
- 或这些常见字段下的数组:
- `sources`
- `streams`
- `channels`
- `items`
- `results`
- `data`
同时会兼容这些字段别名:
- `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

@@ -16,12 +16,21 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.31.3`
- `dev` 当前开发分支历史推导到:`0.37.2`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh``uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override并修复 TV 合并采集源后默认频道消失的问题 |
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.31.3",
"version": "0.37.2",
"private": true,
"packageManager": "bun@1",
"dependencies": {
@@ -25,8 +25,8 @@
"vite": "^5.0.10"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"dev": "bun ./node_modules/vite/bin/vite.js",
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
"preview": "bun ./node_modules/vite/bin/vite.js preview"
}
}

View File

@@ -8,6 +8,10 @@
:root {
--hud-scale: 1;
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--hud-offset: calc(20px * var(--hud-scale));
--hud-radius: calc(22px * var(--hud-scale));
--hud-panel-padding: calc(18px * var(--hud-scale));
@@ -66,12 +70,23 @@ body.earth-page {
overflow: hidden;
}
html.is-globe-dragging,
body.earth-page.is-globe-dragging,
body.earth-page.is-globe-dragging * {
user-select: none !important;
-webkit-user-select: none !important;
}
.earth-app {
position: relative;
width: 100vw;
height: 100vh;
}
.earth-app canvas {
touch-action: none;
}
.earth-app.dragging {
cursor: grabbing;
}

View File

@@ -133,3 +133,20 @@
right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-stats {
position: fixed;
top: calc(8px + var(--safe-top));
right: 8px;
width: min(180px, calc(100vw - 16px));
z-index: 205;
}
.layout-mode-mobile.earth-search-open .hud-panel-stats,
.layout-mode-mobile.earth-settings-open .hud-panel-stats,
.layout-mode-mobile.earth-media-open .hud-panel-stats,
.layout-mode-mobile.earth-info-open .hud-panel-stats {
opacity: 0;
pointer-events: none;
transform: translateY(-12px);
}

File diff suppressed because it is too large Load Diff

View File

@@ -153,15 +153,28 @@
transition:
opacity 0.22s ease,
transform 0.22s ease;
visibility: hidden;
user-select: none;
-webkit-user-select: none;
}
.hud-panel-info.is-visible {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
visibility: visible;
}
.info-card-cruise-link {
.hud-panel-info.hud-panel-info--anchor-stable {
transform: none;
transition: opacity 0.22s ease;
}
.hud-panel-info.hud-panel-info--anchor-stable.is-visible {
transform: none;
}
.callout-connector {
position: absolute;
inset: 0;
width: 100%;
@@ -173,7 +186,7 @@
z-index: 49;
}
.info-card-cruise-link polyline {
.callout-connector polyline {
fill: none;
stroke: rgba(255, 255, 255, 0.98);
stroke-width: 2.15;
@@ -185,7 +198,7 @@
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
}
.info-card-cruise-link circle {
.callout-connector circle {
fill: rgba(255, 255, 255, 0.98);
stroke: rgba(7, 16, 32, 0.72);
stroke-width: 1.0;
@@ -197,29 +210,29 @@
transform-origin: center;
}
.info-card-cruise-link.is-visible {
.callout-connector.is-visible {
opacity: 1;
}
.info-card-cruise-link.is-animating polyline {
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
.callout-connector.is-animating polyline {
animation: calloutConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.info-card-cruise-link.is-animating circle {
.callout-connector.is-animating circle {
opacity: 0;
}
.info-card-cruise-link.is-animating circle:first-of-type {
animation: cruiseConnectorNodeIn 0.14s ease forwards;
.callout-connector.is-animating circle:first-of-type {
animation: calloutConnectorNodeIn 0.14s ease forwards;
animation-delay: 0.02s;
}
.info-card-cruise-link.is-animating circle:last-of-type {
animation: cruiseConnectorNodeIn 0.16s ease forwards;
.callout-connector.is-animating circle:last-of-type {
animation: calloutConnectorNodeIn 0.16s ease forwards;
animation-delay: 0.34s;
}
@keyframes cruiseConnectorDraw {
@keyframes calloutConnectorDraw {
from {
stroke-dashoffset: var(--connector-length, 0px);
}
@@ -228,7 +241,7 @@
}
}
@keyframes cruiseConnectorNodeIn {
@keyframes calloutConnectorNodeIn {
from {
opacity: 0;
transform: scale(0.72);
@@ -290,6 +303,8 @@
scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
pointer-events: auto;
user-select: none;
-webkit-user-select: none;
}
.info-card-content::-webkit-scrollbar {
@@ -327,6 +342,8 @@
cursor: pointer;
flex-shrink: 0;
transition: color 0.18s ease;
user-select: none;
-webkit-user-select: none;
}
.info-card-label:hover {
@@ -341,6 +358,8 @@
text-align: right;
max-width: calc(180px * var(--hud-scale));
word-break: break-word;
user-select: none;
-webkit-user-select: none;
}
/* Type-specific header accent colors */
@@ -367,3 +386,39 @@
.earth-app.layout-expanded .earth-left-column {
transform: translate(calc(-100% + var(--hud-offset)), 0);
}
.layout-mode-mobile .earth-left-column {
top: calc(8px + var(--safe-top));
left: 8px;
max-width: min(300px, calc(100vw - 16px));
}
.layout-mode-mobile .hud-panel-info {
position: fixed;
left: 8px !important;
right: 8px !important;
top: auto !important;
bottom: calc(84px + var(--safe-bottom)) !important;
width: auto;
max-width: none;
max-height: min(58vh, 520px);
z-index: 240;
}
.layout-mode-mobile .info-card-header {
cursor: default;
}
.layout-mode-mobile .info-card-content {
max-height: min(46vh, 420px);
}
.layout-mode-mobile .info-card-property {
flex-direction: column;
align-items: stretch;
}
.layout-mode-mobile .info-card-value {
max-width: none;
text-align: left;
}

View File

@@ -160,6 +160,23 @@
.layer-panel-list {
display: flex;
flex-direction: column;
max-height: calc(5 * (56px * var(--hud-scale)));
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
}
.layer-panel-list::-webkit-scrollbar {
width: 4px;
}
.layer-panel-list::-webkit-scrollbar-track {
background: transparent;
}
.layer-panel-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
border-radius: 999px;
}
.layer-row {
@@ -169,6 +186,7 @@
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line);
transition: background 0.14s ease;
min-height: calc(56px * var(--hud-scale));
}
.layer-row:last-child {
@@ -317,3 +335,34 @@
/* Layout-expanded: layer panel slides off with .earth-left-column — no
individual rule needed since the whole column translates together. */
.layout-mode-mobile .hud-panel-layers {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(88px + var(--safe-bottom));
width: auto;
max-height: min(60vh, 520px);
margin-top: 0;
z-index: 220;
transform: translateY(calc(100% + 28px));
opacity: 0;
pointer-events: none;
transition: transform 0.24s ease, opacity 0.2s ease;
}
.layout-mode-mobile .hud-panel-layers.is-mobile-open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.layout-mode-mobile .layer-panel-body {
max-height: min(52vh, 460px);
overflow: auto;
}
.layout-mode-mobile .layer-panel-list {
max-height: none;
overflow-y: visible;
}

View File

@@ -138,3 +138,24 @@
bottom: var(--hud-offset);
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-legend {
position: fixed;
left: 8px;
bottom: calc(84px + var(--safe-bottom));
width: min(172px, calc(100vw - 16px));
z-index: 205;
}
.layout-mode-mobile .legend-list {
max-height: min(20vh, 180px);
}
.layout-mode-mobile.earth-search-open .hud-panel-legend,
.layout-mode-mobile.earth-settings-open .hud-panel-legend,
.layout-mode-mobile.earth-media-open .hud-panel-legend,
.layout-mode-mobile.earth-info-open .hud-panel-legend {
opacity: 0;
pointer-events: none;
transform: translateY(12px);
}

View File

@@ -105,6 +105,14 @@
pointer-events: auto;
}
.earth-toolbar-orb:has(#layer-action) {
display: none;
}
.layout-mode-mobile .earth-toolbar-group {
display: none;
}
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
pointer-events: none;
}

View File

@@ -285,6 +285,27 @@
cursor: nesw-resize;
}
.layout-mode-mobile .hud-panel-media {
position: fixed;
left: 8px;
right: 8px;
top: calc(8px + var(--safe-top));
bottom: calc(84px + var(--safe-bottom));
width: auto;
max-width: none;
max-height: none;
min-width: 0;
z-index: 230;
}
.layout-mode-mobile .tv-panel-player {
min-height: min(42vh, 360px);
}
.layout-mode-mobile .tv-panel-edge {
display: none;
}
/* 右下角视觉标记 */
.tv-panel-edge[data-edge="br"]::before {
content: "";

View File

@@ -16,6 +16,22 @@
}
</script>
<script>
(function applyInitialEarthViewportMode() {
var width = window.innerWidth;
var height = window.innerHeight;
var mode = "desktop";
if (width <= 820) {
mode = "mobile";
} else if (width <= 1080 || height <= 760) {
mode = "compact";
}
document.documentElement.classList.toggle("layout-mode-mobile", mode === "mobile");
document.documentElement.classList.toggle("layout-mode-compact", mode === "compact");
document.documentElement.dataset.earthLayoutMode = mode;
})();
(function applyInitialHudScale() {
var referenceWidth = 1920;
var referenceHeight = 1080;
@@ -102,6 +118,16 @@
<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">
@@ -132,6 +158,16 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="算力中心 compute centers">
<span class="material-symbols-rounded layer-row-icon">memory</span>
<div class="layer-row-copy">
<span class="layer-row-label">算力中心</span>
<span class="layer-row-meta">Compute Centers</span>
</div>
<button id="toggle-compute-centers" 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="bgp观测 routing signals">
<span class="material-symbols-rounded layer-row-icon">hub</span>
<div class="layer-row-copy">
@@ -156,14 +192,22 @@
<div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">layers</span>
</span>
<span class="tooltip earth-toolbar-tooltip">图层</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">search</span>
</span>
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
<span class="tooltip earth-toolbar-tooltip">搜索</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
<span class="icon rotate-icon icon-pause" aria-hidden="true">
<span class="material-symbols-rounded">pause</span>
@@ -174,7 +218,7 @@
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">live_tv</span>
@@ -182,7 +226,7 @@
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">refresh</span>
@@ -190,7 +234,7 @@
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
</button>
</div>
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">zoom_in</span>
@@ -203,7 +247,7 @@
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button>
</div>
</div>
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">settings</span>
@@ -211,7 +255,7 @@
<span class="tooltip earth-toolbar-tooltip">设置</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">my_location</span>
@@ -219,7 +263,7 @@
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
<span class="icon layout-icon layout-expand" aria-hidden="true">
<span class="material-symbols-rounded">open_in_full</span>
@@ -274,23 +318,27 @@
<!-- 2-col KPI grid -->
<div class="stats-grid">
<div class="stat-cell">
<span class="stat-num" id="cable-count"></span>
<span class="stat-num" id="cable-count" data-earth-stat="cable-count"></span>
<span class="stat-label">海缆系统</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="landing-point-count"></span>
<span class="stat-num" id="landing-point-count" data-earth-stat="landing-point-count"></span>
<span class="stat-label">登陆点</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="satellite-count"></span>
<span class="stat-num" id="satellite-count" data-earth-stat="satellite-count"></span>
<span class="stat-label">在轨卫星</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="bgp-anomaly-count"></span>
<span class="stat-num" id="compute-center-count" data-earth-stat="compute-center-count"></span>
<span class="stat-label">算力中心</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="bgp-anomaly-count" data-earth-stat="bgp-anomaly-count"></span>
<span class="stat-label">BGP 事件</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="bgp-collector-count"></span>
<span class="stat-num" id="bgp-collector-count" data-earth-stat="bgp-collector-count"></span>
<span class="stat-label">BGP 观测站</span>
</div>
<div class="stat-cell">
@@ -302,12 +350,12 @@
<!-- BGP status footer -->
<div class="stats-footer">
<span class="stats-footer-dot"></span>
<span id="bgp-status-summary" class="stats-footer-text">暂无观测数据</span>
<span id="bgp-status-summary" class="stats-footer-text" data-earth-stat="bgp-status-summary">暂无观测数据</span>
</div>
<!-- hidden elements kept for JS compatibility -->
<span id="terrain-status" hidden></span>
<span id="texture-quality" hidden></span>
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
<span id="texture-quality" data-earth-stat="texture-quality" hidden></span>
<span id="camera-distance" hidden></span>
</div>
@@ -412,6 +460,300 @@
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="tooltip" class="earth-tooltip"></div>
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
<span id="earth-mobile-popup-dock" class="earth-mobile-popup-dock" aria-hidden="true"></span>
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
<div class="earth-mobile-popup-body">
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>
<div class="earth-mobile-popup-sub" id="earth-mobile-popup-sub"></div>
</div>
<span class="material-symbols-rounded earth-mobile-popup-chevron">chevron_right</span>
</div>
<div id="mobile-drawer-overlay" class="earth-mobile-drawer-overlay" hidden></div>
<div id="mobile-drawer-shell" class="earth-mobile-drawer-shell" aria-hidden="true">
<div class="earth-mobile-drawer-sheet">
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
</div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
</div>
<div class="earth-mobile-drawer-content">
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
<div class="earth-mobile-page earth-mobile-page--layers">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Layer Control</span>
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
</div>
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
<div class="earth-mobile-page earth-mobile-page--search">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Object Search</span>
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星、算力中心和 BGP 事件</span>
</div>
<div class="earth-mobile-search-shell">
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
<input
id="mobile-earth-search-input"
class="earth-mobile-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="输入名称、地点、NORAD、ASN..."
>
<button id="mobile-earth-search-clear" class="earth-mobile-search-clear" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
</div>
</section>
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
<div class="earth-mobile-page earth-mobile-page--situation">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Situation</span>
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
</div>
<div class="earth-mobile-stats-grid">
<div class="earth-mobile-stat-card">
<span id="mobile-cable-count" class="earth-mobile-stat-num" data-earth-stat="cable-count"></span>
<span class="earth-mobile-stat-label">海缆系统</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-landing-point-count" class="earth-mobile-stat-num" data-earth-stat="landing-point-count"></span>
<span class="earth-mobile-stat-label">登陆点</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count"></span>
<span class="earth-mobile-stat-label">在轨卫星</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count"></span>
<span class="earth-mobile-stat-label">算力中心</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num" data-earth-stat="bgp-anomaly-count"></span>
<span class="earth-mobile-stat-label">BGP 事件</span>
</div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">图例</div>
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">BGP 状态</div>
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
<div class="earth-mobile-page earth-mobile-page--news">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">News</span>
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
</div>
<div class="earth-mobile-news-focus">
<div>
<div class="earth-mobile-news-focus-kicker">当前关注区域</div>
<div id="mobile-news-focus-label" class="earth-mobile-news-focus-label">全球焦点</div>
<div id="mobile-news-focus-coords" class="earth-mobile-news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
</div>
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
<div class="earth-mobile-news-actions">
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
</div>
<a id="mobile-news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="tv">
<div class="earth-mobile-page earth-mobile-page--tv">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">TV</span>
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
id="mobile-tv-iframe"
class="earth-mobile-tv-iframe"
hidden
title="移动端新闻直播"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; fullscreen; picture-in-picture"
></iframe>
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
</div>
<div class="earth-mobile-tv-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
<div class="earth-mobile-page earth-mobile-page--settings">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Settings</span>
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">旋转</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地球默认大小</span>
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
data-default-earth-size-slider
aria-label="移动端调整地球默认大小"
>
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">地形</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地形透明度</span>
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
data-terrain-opacity-slider
aria-label="移动端调整地形透明度"
>
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">系统</div>
<div class="earth-mobile-settings-actions">
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
<div class="earth-mobile-page earth-mobile-page--details">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Details</span>
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
</div>
<div class="earth-mobile-detail-card">
<div class="earth-mobile-detail-header">
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
<div class="earth-mobile-detail-heading">
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
</div>
</div>
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
<div id="search-backdrop" class="earth-search-backdrop"></div>
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
<div class="earth-search-header hud-panel__header">
<div class="hud-panel__title-group">
<div class="earth-search-kicker">搜索</div>
</div>
<div class="hud-panel__actions">
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div class="earth-search-content hud-panel__body">
<div class="earth-search-input-shell">
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
<input
id="earth-search-input"
class="earth-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="搜索海缆、登陆点、卫星、算力中心、BGP 事件..."
>
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
</div>
</div>
</div>
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
@@ -512,6 +854,30 @@
</label>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">地球默认大小</span>
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
</div>
<div class="earth-settings-slider-row">
<input
id="default-earth-size-slider"
class="earth-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
aria-label="调整地球默认大小"
>
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">地形</div>
<div class="earth-settings-list">

View File

@@ -1,17 +1,73 @@
import * as THREE from "three";
import { CRUISE_CONFIG, PATHS } from "./constants.js";
import { createElbowConnectorPoints } from "./callout-connector.js";
import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js";
import {
computeNearestPerimeterAnchor,
createConnectorPath,
resolveConnectorAnchor,
} from "./callout-connector.js";
const scratchBGPWorldPosition = new THREE.Vector3();
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18;
const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17;
const CRUISE_MOBILE_POPUP_MARGIN_PX = 14;
const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52;
const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const CRUISE_CONNECTOR_DRAW_MS = 420;
const CRUISE_PRESENTATION_HIDE_MS = 220;
const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16;
const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12;
const CRUISE_MARKER_SCREEN_PADDING_PX = 4;
function getDockAxisOffsets(dockSide, gapPx) {
return {
offsetX:
dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0,
offsetY:
dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0,
};
}
function getObstaclePaddingBySide(side, paddingPx) {
if (side === "left") {
return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx };
}
if (side === "right") {
return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx };
}
if (side === "top") {
return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx };
}
return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 };
}
const scratchMarkerWorldScale = new THREE.Vector3();
const scratchCameraQuaternion = new THREE.Quaternion();
const scratchCameraRight = new THREE.Vector3();
const scratchCameraUp = new THREE.Vector3();
const scratchMarkerRightPoint = new THREE.Vector3();
const scratchMarkerLeftPoint = new THREE.Vector3();
const scratchMarkerTopPoint = new THREE.Vector3();
const scratchMarkerBottomPoint = new THREE.Vector3();
function projectWorldToScreen(point, camera) {
if (!point || !camera) return null;
const projected = point.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getMarkerTimestamp(marker) {
const rawValue = marker?.userData?.created_at_raw;
@@ -53,14 +109,78 @@ export function createBGPCruiseAdapter({
if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
const projected = scratchBGPWorldPosition.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return projectWorldToScreen(scratchBGPWorldPosition, camera);
}
function getVisibleMobilePopup() {
const mobilePopup = document.getElementById("earth-mobile-popup");
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
? mobilePopup
: null;
}
function getVisibleInfoPanel() {
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
function getMarkerScreenRect(marker) {
const center = getMarkerScreenCoords(marker);
if (!center || !camera || !marker) return null;
marker.getWorldScale(scratchMarkerWorldScale);
const worldWidth = Math.max(
0.0001,
Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x,
);
const worldHeight = Math.max(
0.0001,
Number(scratchMarkerWorldScale.y || worldWidth),
);
camera.getWorldQuaternion(scratchCameraQuaternion);
scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize();
scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize();
scratchMarkerRightPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraRight, worldWidth * 0.5);
scratchMarkerLeftPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraRight, -worldWidth * 0.5);
scratchMarkerTopPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraUp, worldHeight * 0.5);
scratchMarkerBottomPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraUp, -worldHeight * 0.5);
const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera);
const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera);
const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera);
const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera);
if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) {
return null;
}
const halfWidth = Math.max(
Math.abs(rightPoint.x - center.x),
Math.abs(leftPoint.x - center.x),
1,
);
const halfHeight = Math.max(
Math.abs(topPoint.y - center.y),
Math.abs(bottomPoint.y - center.y),
1,
);
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX,
y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX,
width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
};
}
@@ -68,6 +188,53 @@ export function createBGPCruiseAdapter({
const markerCoords = getMarkerScreenCoords(marker);
if (!markerCoords) return null;
if (document.body.classList.contains("layout-mode-mobile")) {
const safeBottom =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
const estimatedCardWidth = Math.min(
CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2,
);
const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX;
const topBound = Math.max(
CRUISE_MOBILE_POPUP_MARGIN_PX,
Math.min(
window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO,
window.innerHeight -
CRUISE_MOBILE_DRAWER_CLEARANCE_PX -
safeBottom -
estimatedCardHeight -
CRUISE_MOBILE_POPUP_MARGIN_PX,
),
);
const rightSlotLeft = Math.max(
CRUISE_MOBILE_POPUP_MARGIN_PX,
window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX,
);
const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX;
const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5;
const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5;
const rightClearance = rightSlotLeft - markerCoords.x;
const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth);
const rightCost =
Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
Math.abs(rightSlotCenterX - markerCoords.x);
const leftCost =
Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
Math.abs(markerCoords.x - leftSlotCenterX);
const placeOnRight = rightCost <= leftCost;
const left = placeOnRight ? rightSlotLeft : leftSlotLeft;
return {
x: left,
y: topBound,
width: estimatedCardWidth,
height: estimatedCardHeight,
dockSide: placeOnRight ? "left" : "right",
};
}
const hudScale =
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -94,38 +261,148 @@ export function createBGPCruiseAdapter({
Math.max(margin, y),
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
);
const anchorY = clampedY + Math.max(
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
estimatedCardHeight * 0.18,
);
return {
x: clampedX,
y: clampedY,
width: estimatedCardWidth,
height: estimatedCardHeight,
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
anchorY,
};
}
function getCardAnchorTarget() {
const mobilePopup = getVisibleMobilePopup();
if (
document.body.classList.contains("layout-mode-mobile") &&
mobilePopup
) {
const dockSide = mobilePopup.dataset.dockSide || "left";
const { offsetX, offsetY } = getDockAxisOffsets(
dockSide,
CONNECTOR_CONFIG.panelGapPx,
);
return {
element: mobilePopup,
side: dockSide,
alignRatio: 0.5,
offsetX,
offsetY,
};
}
return null;
}
function getCardObstacleTarget(fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
if (
document.body.classList.contains("layout-mode-mobile") &&
mobilePopup
) {
const side = mobilePopup.dataset.dockSide || "left";
return {
element: mobilePopup,
padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX),
};
}
const infoPanel = getVisibleInfoPanel();
if (infoPanel) {
return {
element: infoPanel,
padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX),
};
}
if (fallbackPlacement) {
return {
x: fallbackPlacement.x,
y: fallbackPlacement.y,
width: fallbackPlacement.width ?? 0,
height: fallbackPlacement.height ?? 0,
padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX,
};
}
return null;
}
function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
const popupRect =
mobilePopup
? mobilePopup.getBoundingClientRect()
: fallbackPlacement
? {
left: fallbackPlacement.x,
top: fallbackPlacement.y,
right: fallbackPlacement.x + (fallbackPlacement.width ?? 0),
bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0),
}
: null;
if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left";
return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left";
}
function syncMobileDockSide(markerCoords, fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement);
if (mobilePopup) {
mobilePopup.dataset.dockSide = dockSide;
}
}
function getConnectorPath(marker) {
const markerCoords = getMarkerScreenCoords(marker);
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
if (!markerCoords || !targetCardCoords) return null;
const markerRect = getMarkerScreenRect(marker);
if (!markerCoords) return null;
return createElbowConnectorPoints(
markerCoords,
{
x: targetCardCoords.anchorX,
y: targetCardCoords.anchorY,
},
{
if (document.body.classList.contains("layout-mode-mobile")) {
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
syncMobileDockSide(markerCoords, targetCardCoords);
const cardAnchorTarget = getCardAnchorTarget();
const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget);
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
if (!cardAnchorCoords) return null;
return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, {
routingMode: "adaptive",
sourceRect: markerRect,
targetAnchor: cardAnchorTarget ?? cardAnchorCoords,
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
startFrom: "source",
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
});
}
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
if (!targetCardCoords) return null;
const infoPanel = getVisibleInfoPanel();
const desktopTarget =
infoPanel
? infoPanel
: {
x: targetCardCoords.x,
y: targetCardCoords.y,
width: targetCardCoords.width,
height: targetCardCoords.height,
};
return createConnectorPath(
markerCoords,
desktopTarget,
{
routingMode: "adaptive",
sourceRect: markerRect,
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
startFrom: "source",
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
},
);
}
@@ -166,25 +443,43 @@ export function createBGPCruiseAdapter({
async focusMarker(marker, { interrupt = false } = {}) {
if (!marker) return;
currentMarkerId = marker.userData?.id || null;
cardPlacement = getCardScreenCoords(marker);
setMarkerLocked(marker);
showMarkerOverlay(marker);
applySatelliteHighlights(marker);
await focusView({
lat: marker.userData?.latitude ?? 0,
lon: marker.userData?.longitude ?? 0,
rotLon: (marker.userData?.longitude ?? 0) - 270,
zoom: 1.0,
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
cardPlacement = getCardScreenCoords(marker);
},
async presentMarker(marker, { context }) {
if (!marker) return false;
const showCruiseMarkerInfo = ({ reveal = true } = {}) =>
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
reveal,
anchorStable: true,
dockSide: cardPlacement?.dockSide,
});
showCruiseMarkerInfo({ reveal: false });
await context.nextFrame();
if (!context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
const startedAt = performance.now();
let connectorReady = false;
while (context.isCurrent()) {
@@ -203,6 +498,8 @@ export function createBGPCruiseAdapter({
return false;
}
applySatelliteHighlights(marker);
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
if (!connectorDelayCompleted || !context.isCurrent()) {
cardPlacement = null;
@@ -211,18 +508,10 @@ export function createBGPCruiseAdapter({
return false;
}
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
showCruiseMarkerInfo();
await context.nextFrame();
if (!isInfoVisible()) {
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
showCruiseMarkerInfo();
await context.nextFrame();
}

View File

@@ -1,7 +1,7 @@
import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group();
@@ -280,37 +280,23 @@ function blendHexColors(fromHex, toHex, ratio) {
function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
marker.getWorldPosition(collectorWorldPosition);
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset;
const referenceFovRad = (75 * Math.PI) / 180;
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6);
const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9);
const worldPerPixel =
distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel =
referenceDistance * Math.tan(referenceFovRad / 2);
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
referenceFov: 75,
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
});
}
function getEventDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
marker.getWorldPosition(collectorWorldPosition);
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset;
const referenceFovRad = (75 * Math.PI) / 180;
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7);
const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9);
const worldPerPixel =
distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel =
referenceDistance * Math.tan(referenceFovRad / 2);
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: BGP_CONFIG.altitudeOffset,
referenceFov: 75,
min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
});
}
function orientCollectorMarkerToSurface(marker, position) {

View File

@@ -9,8 +9,8 @@ import {
CABLE_STATE,
CABLE_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import { updateEarthStats, showStatusMessage } from "./ui.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js";
import { setLegendItems, setLegendMode } from "./legend.js";
@@ -21,7 +21,6 @@ let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
let landingPointGeometry = null;
const landingPointWorldPosition = new THREE.Vector3();
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
@@ -33,24 +32,13 @@ function getLandingPointDistanceScale(point, camera) {
!camera ||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
) return 1;
point.getWorldPosition(landingPointWorldPosition);
const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition);
const referenceDistance =
CONFIG.defaultCameraZ -
CONFIG.earthRadius +
CABLE_CONFIG.landingPoint.altitudeOffset;
const referenceFovDeg =
CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75;
const referenceFovRad = (referenceFovDeg * Math.PI) / 180;
const cameraFovRad =
(((camera.fov || referenceFovDeg)) * Math.PI) / 180;
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
return clamp(
worldPerPixel / referenceWorldPerPixel,
CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
);
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
});
}
function disposeMaterial(material) {
@@ -336,9 +324,8 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
feature.properties.status === "In Service"),
).length;
const cableCountEl = document.getElementById("cable-count");
const statusEl = document.getElementById("cable-status-summary");
if (cableCountEl) cableCountEl.textContent = cableCount + "个";
setEarthStatValue("cable-count", `${cableCount}`);
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
updateEarthStats({
@@ -435,10 +422,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
validCount++;
}
const landingPointCountEl = document.getElementById("landing-point-count");
if (landingPointCountEl) {
landingPointCountEl.textContent = validCount + "个";
}
setEarthStatValue("landing-point-count", `${validCount}`);
if (!silent) {
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");

View File

@@ -1,13 +1,316 @@
const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
const DEFAULT_CLASS_NAME = "callout-connector";
const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
function createSvgElement(tagName) {
return document.createElementNS(SVG_NS, tagName);
}
function resolveElementAnchorSide(side) {
switch (side) {
case "right":
case "top":
case "bottom":
case "left":
return side;
default:
return "left";
}
}
function resolveElementAnchorAlignRatio(ratio) {
if (!Number.isFinite(ratio)) return 0.5;
return Math.min(Math.max(ratio, 0), 1);
}
function resolveAnchorElement(target) {
if (target instanceof HTMLElement) return target;
if (target?.element instanceof HTMLElement) return target.element;
if (typeof target?.selector === "string") {
const matched = document.querySelector(target.selector);
return matched instanceof HTMLElement ? matched : null;
}
return null;
}
function resolveRectElement(target) {
if (target instanceof HTMLElement) return target;
if (target?.element instanceof HTMLElement) return target.element;
if (typeof target?.selector === "string") {
const matched = document.querySelector(target.selector);
return matched instanceof HTMLElement ? matched : null;
}
return null;
}
function isFiniteRect(rect) {
return (
rect &&
Number.isFinite(rect.left) &&
Number.isFinite(rect.top) &&
Number.isFinite(rect.right) &&
Number.isFinite(rect.bottom)
);
}
function normalizeRect(rect) {
if (!rect) return null;
const left = Number(rect.left);
const top = Number(rect.top);
const right = Number(rect.right);
const bottom = Number(rect.bottom);
if (
!Number.isFinite(left) ||
!Number.isFinite(top) ||
!Number.isFinite(right) ||
!Number.isFinite(bottom)
) {
return null;
}
return {
left: Math.min(left, right),
top: Math.min(top, bottom),
right: Math.max(left, right),
bottom: Math.max(top, bottom),
};
}
function expandRect(rect, padding = 0) {
const normalized = normalizeRect(rect);
if (!normalized) return null;
if (typeof padding === "object" && padding !== null) {
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
return {
left: normalized.left - leftPadding,
top: normalized.top - topPadding,
right: normalized.right + rightPadding,
bottom: normalized.bottom + bottomPadding,
};
}
return {
left: normalized.left - padding,
top: normalized.top - padding,
right: normalized.right + padding,
bottom: normalized.bottom + padding,
};
}
function dedupeSequentialPoints(points) {
const nextPoints = [];
for (const point of points) {
const previous = nextPoints[nextPoints.length - 1];
if (
previous &&
Math.abs(previous.x - point.x) < 0.5 &&
Math.abs(previous.y - point.y) < 0.5
) {
continue;
}
nextPoints.push(point);
}
return nextPoints;
}
// Compute the anchor point on the nearest perimeter edge of rect to source.
// gap is applied outward from the edge, so the anchor is outside the rect.
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
const { left, top, right, bottom } = rect;
const midX = (left + right) * 0.5;
const midY = (top + bottom) * 0.5;
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
// Source inside rect: snap to nearest edge midpoint
const dLeft = source.x - left;
const dRight = right - source.x;
const dTop = source.y - top;
const dBottom = bottom - source.y;
const minD = Math.min(dLeft, dRight, dTop, dBottom);
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
return { x: midX, y: bottom + gap, side: "bottom" };
}
export function resolveConnectorObstacleRect(target, options = {}) {
if (!target) return null;
if (typeof target === "function") {
return resolveConnectorObstacleRect(target(), options);
}
const resolvedPadding =
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
? target.padding
: options.padding;
const padding =
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
? resolvedPadding
: 0;
if (isFiniteRect(target)) {
return expandRect(target, padding);
}
if (
Number.isFinite(target.x) &&
Number.isFinite(target.y) &&
Number.isFinite(target.width) &&
Number.isFinite(target.height)
) {
return expandRect(
{
left: Number(target.x),
top: Number(target.y),
right: Number(target.x) + Number(target.width),
bottom: Number(target.y) + Number(target.height),
},
padding,
);
}
const element = resolveRectElement(target);
if (!(element instanceof HTMLElement)) return null;
return expandRect(element.getBoundingClientRect(), padding);
}
export function resolveConnectorAnchor(target) {
if (!target) return null;
if (typeof target === "function") {
return resolveConnectorAnchor(target());
}
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
return { x: Number(target.x), y: Number(target.y) };
}
const element = resolveAnchorElement(target);
if (!(element instanceof HTMLElement)) return null;
const rect = element.getBoundingClientRect();
const side = resolveElementAnchorSide(target.side);
const alignRatio = resolveElementAnchorAlignRatio(
target.alignRatio ?? target.anchorRatio ?? target.ratio,
);
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
let x = rect.left + rect.width * 0.5;
let y = rect.top + rect.height * 0.5;
if (side === "left") {
x = rect.left;
y = rect.top + rect.height * alignRatio;
} else if (side === "right") {
x = rect.right;
y = rect.top + rect.height * alignRatio;
} else if (side === "top") {
x = rect.left + rect.width * alignRatio;
y = rect.top;
} else if (side === "bottom") {
x = rect.left + rect.width * alignRatio;
y = rect.bottom;
}
return {
x: x + offsetX,
y: y + offsetY,
};
}
function resolveConnectorRect(target) {
return resolveConnectorObstacleRect(target, { padding: 0 });
}
function createRectSideMidpoint(rect, side, gap = 0) {
const normalizedRect = normalizeRect(rect);
if (!normalizedRect) return null;
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
if (side === "left") {
return { x: normalizedRect.left - gap, y: midpointY, side };
}
if (side === "right") {
return { x: normalizedRect.right + gap, y: midpointY, side };
}
if (side === "top") {
return { x: midpointX, y: normalizedRect.top - gap, side };
}
if (side === "bottom") {
return { x: midpointX, y: normalizedRect.bottom + gap, side };
}
return null;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function createRectEdgeAnchor(rect, side, position, gap = 0) {
const normalizedRect = normalizeRect(rect);
if (!normalizedRect) return null;
const x =
side === "left"
? normalizedRect.left - gap
: side === "right"
? normalizedRect.right + gap
: clamp(
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
normalizedRect.left,
normalizedRect.right,
);
const y =
side === "top"
? normalizedRect.top - gap
: side === "bottom"
? normalizedRect.bottom + gap
: clamp(
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
normalizedRect.top,
normalizedRect.bottom,
);
return { x, y, side };
}
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
if (!startPoint || !endPoint) return null;
const normalizedDirections = directions.filter(Boolean);
if (!normalizedDirections.length) {
return dedupeSequentialPoints([startPoint, endPoint]);
}
const firstDirection = normalizedDirections[0];
const corner =
firstDirection === "left" || firstDirection === "right"
? { x: endPoint.x, y: startPoint.y }
: { x: startPoint.x, y: endPoint.y };
return dedupeSequentialPoints([startPoint, corner, endPoint]);
}
function resolveSourceAnchorGapPx(sourceGapPx) {
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
}
export function createElbowConnectorPoints(source, target, options = {}) {
if (!source || !target) return null;
const resolvedSource = resolveConnectorAnchor(source);
const resolvedTarget = resolveConnectorAnchor(target);
if (!resolvedSource || !resolvedTarget) return null;
const {
startFrom = "source",
@@ -17,8 +320,8 @@ export function createElbowConnectorPoints(source, target, options = {}) {
elbowDropPx = 14,
} = options;
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
const targetPoint = { x: Number(target.x), y: Number(target.y) };
const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
if (
!Number.isFinite(sourcePoint.x) ||
!Number.isFinite(sourcePoint.y) ||
@@ -53,6 +356,163 @@ export function createElbowConnectorPoints(source, target, options = {}) {
};
}
function createAdaptiveConnectorPoints(source, target, options = {}) {
const resolvedSource = resolveConnectorAnchor(source);
if (!resolvedSource) return null;
const {
startFrom = "source",
sourceGapPx = 12,
targetGapPx = 8,
obstacleClearancePx = 8,
obstacles = [],
targetAnchor = null,
sourceRect = null,
} = options;
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
.filter(Boolean);
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
const resolvedTarget =
resolveConnectorAnchor(targetAnchor ?? target) ||
(fallbackTargetRect
? computeNearestPerimeterAnchor(
sp,
fallbackTargetRect,
Math.max(targetGapPx, obstacleClearancePx + 1),
)
: null);
if (!resolvedTarget) return null;
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
const primaryObstacle = normalizedObstacles[0] || null;
const relationRect = fallbackTargetRect || primaryObstacle;
if (!relationRect) return null;
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
const isTargetAbove = relationRect.bottom < sp.y;
const isTargetBelow = relationRect.top > sp.y;
const isSourceWithinAnchorHorizontalRange =
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
let directions = [];
let targetSide = null;
const isTargetCenterWithinSourceVerticalRange =
sourceRelationRect &&
targetCenterY >= sourceRelationRect.top &&
targetCenterY <= sourceRelationRect.bottom;
const isTargetCenterWithinSourceHorizontalRange =
sourceRelationRect &&
targetCenterX >= sourceRelationRect.left &&
targetCenterX <= sourceRelationRect.right;
if (isRightMidpointLeftOfSource) {
targetSide = "right";
if (isTargetCenterWithinSourceVerticalRange) {
directions = ["left"];
} else if (rightMidpoint.y < sp.y) {
directions = ["top", "left"];
} else if (rightMidpoint.y > sp.y) {
directions = ["bottom", "left"];
} else {
directions = ["left"];
}
} else if (isLeftMidpointRightOfSource) {
targetSide = "left";
if (isTargetCenterWithinSourceVerticalRange) {
directions = ["right"];
} else if (leftMidpoint.y < sp.y) {
directions = ["top", "right"];
} else if (leftMidpoint.y > sp.y) {
directions = ["bottom", "right"];
} else {
directions = ["right"];
}
} else if (isSourceWithinAnchorHorizontalRange) {
if (isTargetAbove) {
targetSide = "bottom";
directions = isTargetCenterWithinSourceHorizontalRange
? ["top"]
: targetCenterX >= sp.x
? ["right", "top"]
: ["left", "top"];
} else if (isTargetBelow) {
targetSide = "top";
directions = isTargetCenterWithinSourceHorizontalRange
? ["bottom"]
: targetCenterX >= sp.x
? ["right", "bottom"]
: ["left", "bottom"];
}
}
if (!targetSide || !directions.length) {
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
}
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
const targetPoint =
resolvedTarget && targetAnchor
? end
: derivedTargetAnchor || end;
const sourceSide = directions[0] || null;
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
const shouldSlideSourceAnchorAlongEdge =
(sourceSide === "left" || sourceSide === "right") &&
isTargetCenterWithinSourceVerticalRange ||
(sourceSide === "top" || sourceSide === "bottom") &&
isTargetCenterWithinSourceHorizontalRange;
const derivedSourceAnchor =
sourceRelationRect && sourceSide
? shouldSlideSourceAnchorAlongEdge
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
: null;
const startPoint = derivedSourceAnchor || sp;
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
if (!pts) {
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
}
pts = dedupeSequentialPoints(pts);
return {
points: startFrom === "target" ? pts.slice().reverse() : pts,
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
};
}
export function createConnectorPath(source, target, options = {}) {
const {
routingMode = "simple",
} = options;
if (routingMode === "adaptive") {
return createAdaptiveConnectorPoints(source, target, options);
}
if (routingMode === "simple") {
return createElbowConnectorPoints(source, target, options);
}
return createElbowConnectorPoints(source, target, options);
}
export class CalloutConnector {
constructor({
container = null,

View File

@@ -0,0 +1,375 @@
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const textureCache = new Map();
let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
function buildComputeCenterMarkerData(feature) {
const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || [];
const longitude = Number(coordinates[0]);
const latitude = Number(coordinates[1]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
...props,
latitude,
longitude,
displayLatitude: latitude,
displayLongitude: longitude,
site_type: normalizeSiteType(props.site_type),
};
}
function spreadComputeCenterPositions(markers) {
const groups = new Map();
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
markers.forEach((marker) => {
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(marker);
});
groups.forEach((group) => {
if (group.length <= 1) return;
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
group.forEach((marker, index) => {
const angle = (Math.PI * 2 * index) / group.length;
marker.displayLatitude =
marker.latitude + Math.sin(angle) * radius * offsetStep;
marker.displayLongitude =
marker.longitude + Math.cos(angle) * radius * offsetStep;
marker.isSpread = true;
marker.groupSize = group.length;
});
});
markers.forEach((marker) => {
if (marker.isSpread) return;
marker.displayLatitude = marker.latitude;
marker.displayLongitude = marker.longitude;
marker.isSpread = false;
marker.groupSize = 1;
});
return markers;
}
function createMarkerTexture(siteType, isEstimated = false) {
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
if (textureCache.has(textureKey)) {
return textureCache.get(textureKey);
}
const color =
COMPUTE_CENTER_CONFIG.colors[siteType] ||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
const centerX = 64;
const centerY = 64;
const baseFill = color;
function fillPath(draw, options = {}) {
const { fillStyle = color } = options;
context.save();
context.fillStyle = fillStyle;
context.beginPath();
draw();
context.fill();
context.restore();
}
context.clearRect(0, 0, 128, 128);
if (siteType === "supercomputer") {
fillPath(() => {
context.roundRect(40, 42, 48, 30, 7);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.roundRect(58, 74, 12, 8, 3);
context.roundRect(50, 84, 28, 5, 2.5);
}, {
fillStyle: baseFill,
});
} else {
fillPath(() => {
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
context.rect(46, 46, 36, 28);
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
context.rect(52, 58, 24, 6);
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
}
if (isEstimated) {
fillPath(() => {
context.arc(94, 36, 12, 0, Math.PI * 2);
}, {
fillStyle: "rgba(15,23,42,0.92)",
});
context.save();
context.fillStyle = "rgba(255,255,255,0.98)";
context.font = "bold 18px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("?", 94, 36);
context.restore();
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function normalizeSiteType(siteType) {
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
}
function getBaseScale(siteType) {
return siteType === "supercomputer"
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
}
function getDistanceScale(marker, camera) {
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
return 1;
}
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
referenceFov: 75,
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
});
}
function clearGroup(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
group.remove(child);
}
}
function createComputeCenterMarker(markerData) {
const siteType = markerData.site_type;
const material = new THREE.SpriteMaterial({
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
transparent: true,
depthWrite: false,
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
});
const marker = new THREE.Sprite(material);
const baseScale = getBaseScale(siteType);
marker.position.copy(
latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
),
);
marker.scale.setScalar(baseScale);
marker.renderOrder = 8;
marker.visible = showComputeCenters;
marker.userData = {
...markerData,
site_type: siteType,
type: "compute_center",
baseScale,
state: "normal",
pulseOffset: Math.random() * Math.PI * 2,
};
computeCenterGroup.add(marker);
computeCenterMarkers.push(marker);
return marker;
}
export function formatComputeCenterTypeLabel(siteType) {
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
}
export function formatComputeCenterCapacity(markerData) {
const value = markerData?.capacity_value;
const unit = markerData?.capacity_unit;
if (value === null || value === undefined || value === "") return "-";
return `${value}${unit ? ` ${unit}` : ""}`;
}
export function formatComputeCenterUpdatedAt(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("zh-CN", { hour12: false });
}
export function formatComputeCenterLocationPrecision(markerData) {
const precision = markerData?.location_precision;
if (precision === "precise") return "精确坐标";
if (precision === "estimated_site") return "估算位置(站点级)";
if (precision === "estimated_country") return "估算位置(国家级)";
return "位置未知";
}
export function getComputeCenterLegendItems() {
return [
{
label: "超算中心",
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
},
{
label: "GPU 集群",
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
},
];
}
export function getComputeCenterMarkers() {
return computeCenterMarkers;
}
export function getComputeCenterCount() {
return computeCenterMarkers.length;
}
export function getComputeCenterSupercomputerCount() {
return supercomputerCount;
}
export function getComputeCenterGPUClusterCount() {
return gpuClusterCount;
}
export function getComputeCenterStatusSummary() {
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
}
export function setComputeCenterMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== "compute_center") return;
marker.userData.state = state;
}
export function clearComputeCenterSelection() {
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
}
export function clearComputeCenterData(earth) {
computeCenterMarkers.length = 0;
supercomputerCount = 0;
gpuClusterCount = 0;
clearGroup(computeCenterGroup);
if (earth && computeCenterGroup.parent === earth) {
earth.remove(computeCenterGroup);
}
}
export function toggleComputeCenters(show) {
showComputeCenters = Boolean(show);
computeCenterGroup.visible = showComputeCenters;
computeCenterMarkers.forEach((marker) => {
marker.visible = showComputeCenters;
});
}
export function getShowComputeCenters() {
return showComputeCenters;
}
export async function loadComputeCenters(_scene, earth) {
const response = await fetch(PATHS.computeCentersApi);
if (!response.ok) {
throw new Error(`Compute centers HTTP ${response.status}`);
}
const payload = await response.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
clearComputeCenterData(earth);
spreadComputeCenterPositions(
features
.map((feature) => buildComputeCenterMarkerData(feature))
.filter(Boolean),
)
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
.forEach((markerData) => {
const marker = createComputeCenterMarker(markerData);
if (!marker) return;
if (marker.userData.site_type === "supercomputer") {
supercomputerCount += 1;
} else {
gpuClusterCount += 1;
}
});
if (earth && !computeCenterGroup.parent) {
earth.add(computeCenterGroup);
}
computeCenterGroup.visible = showComputeCenters;
return {
totalCount: computeCenterMarkers.length,
supercomputerCount,
gpuClusterCount,
summary: getComputeCenterStatusSummary(),
};
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
const now = Date.now();
computeCenterMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
const state = marker.userData?.state || "normal";
const pulse =
1 +
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
let scaleMultiplier = 1;
if (isLocked) {
opacity = 1;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
} else if (state === "hover") {
opacity = 0.98;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
} else if (hasFocus) {
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
}
const distanceScale = getDistanceScale(marker, camera);
marker.material.opacity = showComputeCenters ? opacity : 0;
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
marker.visible = showComputeCenters;
});
}

View File

@@ -3,6 +3,7 @@
// Scene configuration
export const CONFIG = {
defaultCameraZ: 300,
defaultViewZoom: 1.0,
minZoom: 0.5,
maxZoom: 5.0,
earthRadius: 100,
@@ -24,12 +25,12 @@ export const CRUISE_CONFIG = {
maxPolledEvents: 200,
cardAnchorXRatio: 0.68,
cardAnchorYRatio: 0.24,
linkMarkerGapPx: 18,
linkPanelGapPx: 12,
linkElbowOffsetPx: 72,
linkAnchorHeightRatio: 0.26,
linkForcedBendPx: 34,
linkElbowDropPx: 24,
};
export const CONNECTOR_CONFIG = {
markerGapPx: 18,
panelGapPx: 12,
obstacleClearancePx: 8,
};
export const HUD_CONFIG = {
@@ -155,11 +156,43 @@ export const TERRAIN_CONFIG = {
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
};
export const COMPUTE_CENTER_CONFIG = {
altitudeOffset: 0.48,
maxRenderedMarkers: 300,
overlapSpread: {
groupPrecision: 4,
radius: 1.4,
offsetStep: 0.28,
},
marker: {
baseOpacity: 0.88,
supercomputerScale: 12,
gpuClusterScale: 12,
hoverScale: 1.16,
lockedScale: 1.22,
dimmedScale: 0.82,
dimmedOpacity: 0.34,
pulseSpeed: 0.0038,
pulseAmplitude: 0.03,
},
colors: {
supercomputer: "#38bdf8",
gpu_cluster: "#2dd4bf",
linked: "#f8fafc",
},
sizeStabilization: {
enabled: true,
min: 0.12,
max: 3.0,
},
};
// Cable colors mapping
export const CABLE_COLORS = {
'Americas II': 0xff4444,

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { latLonToVector3 } from './utils.js';
export let earth = null;
export let clouds = null;
export let terrain = null;
let showGridLines = true;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
@@ -321,6 +322,7 @@ 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.visible = showGridLines;
earthObj.add(line);
latitudeLines.push(line);
}
@@ -335,11 +337,26 @@ 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.visible = showGridLines;
earthObj.add(line);
longitudeLines.push(line);
}
}
export function toggleGridLines(visible) {
showGridLines = visible;
latitudeLines.forEach((line) => {
line.visible = visible;
});
longitudeLines.forEach((line) => {
line.visible = visible;
});
}
export function getShowGridLines() {
return showGridLines;
}
export function getEarth() {
return earth;
}

View File

@@ -4,6 +4,219 @@ import { showStatusMessage } from './ui.js';
let currentType = null;
let cardMounted = false;
// ── Mobile popup ─────────────────────────────────────────────
function getMobilePopupTitle(type, data) {
switch (type) {
case 'cable': return data.name || '海缆';
case 'landing_point': return data.name || '登陆点';
case 'satellite': return data.name || '卫星';
case 'bgp': return data.anomaly_type || 'BGP事件';
case 'bgp_collector': return data.collector || 'BGP观测站';
case 'supercomputer': return data.name || '超算';
case 'gpu_cluster': return data.name || 'GPU集群';
default: return '详情';
}
}
function getMobilePopupSubtitle(type, data) {
switch (type) {
case 'cable': return data.owner || data.status || '海缆';
case 'landing_point': return data.country || '登陆点';
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
case 'bgp': return data.severity || 'BGP路由异常';
case 'bgp_collector': return data.location || 'BGP观测站';
case 'supercomputer': return data.country || '超级计算机';
case 'gpu_cluster': return data.country || 'GPU集群';
default: return '';
}
}
function positionMobilePopup(popup, touchX, touchY, options = {}) {
const margin = 14;
const drawerClearance = 52;
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const safeBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
) || 0;
const bottomBound = vpH - drawerClearance - safeBottom;
// Measure actual popup size (it's rendered but invisible via opacity)
const popW = popup.offsetWidth || 200;
const popH = popup.offsetHeight || 68;
if (options.absolute === true) {
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
return;
}
const gap = 22;
const spaceRight = vpW - touchX;
const spaceLeft = touchX;
const spaceBottom = bottomBound - touchY;
const spaceTop = touchY;
let left, top;
// Horizontal: side with more room
if (spaceRight >= popW + gap + margin) {
left = touchX + gap;
} else if (spaceLeft >= popW + gap + margin) {
left = touchX - gap - popW;
} else {
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
}
// Vertical: prefer above touch, then below
if (spaceTop >= popH + gap + margin) {
top = touchY - gap - popH;
} else if (spaceBottom >= popH + gap + margin) {
top = touchY + gap;
} else {
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
}
left = Math.max(margin, Math.min(left, vpW - popW - margin));
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
}
let popupShowToken = 0;
function showMobilePopup(type, data, x, y, options = {}) {
// Require coordinates — skip if called without position (e.g. from handleCableClick)
if (x == null || y == null) return;
const popup = document.getElementById('earth-mobile-popup');
const iconEl = document.getElementById('earth-mobile-popup-icon');
const titleEl = document.getElementById('earth-mobile-popup-title');
const subEl = document.getElementById('earth-mobile-popup-sub');
if (!popup || !iconEl || !titleEl || !subEl) return;
const config = CARD_CONFIG[type];
if (!config) return;
iconEl.textContent = config.icon;
titleEl.textContent = getMobilePopupTitle(type, data);
subEl.textContent = getMobilePopupSubtitle(type, data);
// Invalidate any in-flight hide listener
popupShowToken += 1;
const token = popupShowToken;
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
popup.removeAttribute('hidden');
popup.classList.remove('is-visible');
requestAnimationFrame(() => {
positionMobilePopup(popup, x, y, options);
if (options.reveal === false) {
return;
}
if (token !== popupShowToken) return; // superseded
void popup.getBoundingClientRect();
popup.classList.add('is-visible');
});
}
function hideMobilePopup() {
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
popupShowToken += 1; // invalidate any pending show
popup.classList.remove('is-visible');
popup.classList.remove('earth-mobile-popup--anchor-stable');
delete popup.dataset.dockSide;
popup.addEventListener('transitionend', () => {
if (!popup.classList.contains('is-visible')) {
popup.setAttribute('hidden', '');
}
}, { once: true });
}
let popupClickBound = false;
function ensurePopupClickHandler() {
if (popupClickBound) return;
popupClickBound = true;
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
let dragPointerId = null;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
let dragged = false;
const DRAG_THRESHOLD = 10;
const emitDragEvent = (dragging) => {
const rect = popup.getBoundingClientRect();
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
detail: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
dragging,
},
}));
};
popup.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
e.stopPropagation();
dragPointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
const rect = popup.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
dragged = false;
});
// Track drag at document level so pointer can leave popup bounds
document.addEventListener('pointermove', (e) => {
if (e.pointerId !== dragPointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
dragged = true;
e.stopPropagation();
const margin = 8;
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
emitDragEvent(true);
});
document.addEventListener('pointerup', (e) => {
if (e.pointerId !== dragPointerId) return;
const wasDragged = dragged;
dragPointerId = null;
dragged = false;
emitDragEvent(false);
if (!wasDragged) {
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
}
});
document.addEventListener('pointercancel', (e) => {
if (e.pointerId === dragPointerId) {
dragPointerId = null;
dragged = false;
emitDragEvent(false);
}
});
// Block click from bubbling to document (which would close the drawer)
popup.addEventListener('click', (e) => e.stopPropagation());
}
const CARD_CONFIG = {
cable: {
icon: '🛥️',
@@ -18,6 +231,18 @@ const CARD_CONFIG = {
{ key: 'rfs', label: '投入使用' }
]
},
landing_point: {
icon: '📍',
title: '登陆点详情',
className: 'cable',
fields: [
{ key: 'name', label: '名称' },
{ key: 'country', label: '国家' },
{ key: 'status', label: '状态' },
{ key: 'cable_count', label: '关联海缆数' },
{ key: 'cables', label: '关联海缆' }
]
},
satellite: {
icon: '🛰️',
title: '卫星详情',
@@ -79,15 +304,22 @@ const CARD_CONFIG = {
},
supercomputer: {
icon: '🖥️',
title: '超算详情',
title: '超算中心详情',
className: 'supercomputer',
fields: [
{ key: 'name', label: '名称' },
{ key: 'site_type_label', label: '类型' },
{ key: 'rank', label: '排名' },
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
{ key: 'capacity', label: '实测算力' },
{ key: 'vendor', label: '厂商' },
{ key: 'operator', label: '运营方' },
{ key: 'cores', label: '核心数' },
{ key: 'power', label: '功耗', unit: 'kW' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' }
{ key: 'city', label: '城市' },
{ key: 'location_precision_label', label: '位置精度' },
{ key: 'source', label: '来源' },
{ key: 'updated_at', label: '更新时间' }
]
},
gpu_cluster: {
@@ -96,8 +328,17 @@ const CARD_CONFIG = {
className: 'gpu_cluster',
fields: [
{ key: 'name', label: '名称' },
{ key: 'site_type_label', label: '类型' },
{ key: 'capacity', label: '估算算力' },
{ key: 'gpu_count', label: 'GPU 数量' },
{ key: 'gpu_type', label: 'GPU 型号' },
{ key: 'vendor', label: '芯片/平台' },
{ key: 'operator', label: '运营方' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' }
{ key: 'city', label: '城市' },
{ key: 'location_precision_label', label: '位置精度' },
{ key: 'source', label: '来源' },
{ key: 'updated_at', label: '更新时间' }
]
}
};
@@ -114,19 +355,47 @@ function setupInfoCardDrag(panel) {
if (!handle) return;
let isDragging = false;
let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = () => {
const emitDragEvent = () => {
const rect = panel.getBoundingClientRect();
window.dispatchEvent(
new CustomEvent('earth:info-card-drag', {
detail: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
dragging: isDragging,
},
})
);
};
const stopDragging = (event) => {
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
isDragging = false;
activePointerId = null;
panel.classList.remove('is-dragging');
document.body.style.userSelect = '';
emitDragEvent();
};
const onMove = (event) => {
if (!isDragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
event.preventDefault();
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextLeft = Math.min(
@@ -139,11 +408,14 @@ function setupInfoCardDrag(panel) {
);
panel.style.left = `${nextLeft}px`;
panel.style.top = `${nextTop}px`;
emitDragEvent();
};
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -157,11 +429,14 @@ function setupInfoCardDrag(panel) {
panel.classList.add('is-dragging');
document.body.style.userSelect = 'none';
handle.setPointerCapture?.(event.pointerId);
emitDragEvent();
});
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', stopDragging);
handle.addEventListener('pointercancel', stopDragging);
// Listen in capture phase so card-level stopPropagation used to shield the
// globe canvas does not swallow the drag stream before we can reposition.
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
window.addEventListener('pointerup', stopDragging, { capture: true });
window.addEventListener('pointercancel', stopDragging, { capture: true });
handle.addEventListener('lostpointercapture', stopDragging);
}
@@ -175,6 +450,8 @@ function mountCard() {
panel.id = 'info-panel';
panel.className = 'hud-panel hud-panel-info';
panel.setAttribute('aria-live', 'polite');
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('hidden', '');
panel.innerHTML = `
<div id="info-card" class="info-card">
<div class="info-card-header hud-panel-drag-handle">
@@ -240,6 +517,13 @@ function mountCard() {
function positionPanel(panel, x, y, options = {}) {
if (!panel) return;
if (document.body.classList.contains('layout-mode-mobile')) {
panel.style.left = '8px';
panel.style.right = '8px';
panel.style.top = 'auto';
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
return;
}
const margin = 12;
const offset = 14;
const vpW = window.innerWidth;
@@ -282,13 +566,35 @@ function positionPanel(panel, x, y, options = {}) {
function showPanel(x, y, options = {}) {
const panel = getPanel();
if (!panel) return;
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
panel.removeAttribute('hidden');
panel.setAttribute('aria-hidden', 'false');
if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible');
if (options.reveal === false) {
panel.classList.remove('is-visible');
return;
}
requestAnimationFrame(() => {
panel.classList.add('is-visible');
});
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
function hidePanel() {
const panel = getPanel();
if (panel) panel.classList.remove('is-visible');
if (panel) {
panel.classList.remove('is-visible');
panel.classList.remove('hud-panel-info--anchor-stable');
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('hidden', '');
}
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
}
// No-op: event binding now happens lazily in mountCard()
@@ -308,6 +614,51 @@ export function showInfoCard(type, data, options = {}) {
return;
}
if (document.body.classList.contains('layout-mode-mobile')) {
currentType = type;
// Fill drawer details slot (accessible when user taps popup → opens details tab)
const icon = document.getElementById('mobile-info-card-icon');
const title = document.getElementById('mobile-info-card-title');
const typeLabel = document.getElementById('mobile-info-card-type');
const content = document.getElementById('mobile-info-card-content');
if (icon) icon.textContent = config.icon;
if (title) title.textContent = config.title;
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
if (content) {
let html = '';
for (const field of config.fields) {
let value = data[field.key];
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') value = value + ' ' + field.unit;
html += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
}
// Show the floating mini popup near the touch point (requires coordinates)
if (options.x != null && options.y != null) {
ensurePopupClickHandler();
showMobilePopup(type, data, options.x, options.y, options);
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
return;
}
mountCard();
currentType = type;
@@ -347,6 +698,15 @@ export function showInfoCard(type, data, options = {}) {
}
export function hideInfoCard() {
if (document.body.classList.contains('layout-mode-mobile')) {
hideMobilePopup();
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
currentType = null;
return;
}
hidePanel();
currentType = null;
}

View File

@@ -14,6 +14,10 @@ import {
loadBGPAnomalies,
toggleBGP,
} from "./bgp.js";
import {
loadComputeCenters,
toggleComputeCenters,
} from "./compute-centers.js";
/**
* Layer startup task registry.
@@ -70,6 +74,7 @@ function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCableStartupTask();
registerSatelliteStartupTask();
registerComputeCenterStartupTask();
registerBGPStartupTask();
}
@@ -171,4 +176,26 @@ function registerBGPStartupTask() {
});
}
function registerComputeCenterStartupTask() {
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
);
await context.yieldFrame(12);
try {
const computeCenterResult = await loadComputeCenters(context.scene, context.earth);
if (!context.isCancelled()) {
toggleComputeCenters(context.getShowComputeCenters());
context.updateComputeCenterHud(computeCenterResult);
context.setLegendItems("computeCenters", context.getComputeCenterLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
registerBuiltinLayerStartupTasks();

View File

@@ -1,9 +1,10 @@
import { createHUDPanel } from "./hud-panels.js";
const LEGEND_MODES = {
cables: { title: "海缆" },
satellites: { title: "卫星" },
bgp: { title: "BGP" },
cables: { title: "海缆" },
satellites: { title: "卫星" },
computeCenters: { title: "算力" },
bgp: { title: "BGP" },
};
let currentLegendMode = "cables";
@@ -11,6 +12,7 @@ let legendPanel = null;
let legendItemsByMode = {
cables: [],
satellites: [],
computeCenters: [],
bgp: [],
};
@@ -62,17 +64,18 @@ export function setLegendItems(mode, items) {
}
function syncCurrentLabel(mode) {
const labelEl = document.getElementById("legend-current-label");
if (!labelEl) return;
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
.forEach((labelEl) => {
if (labelEl) {
labelEl.textContent = nextLabel;
}
});
}
function renderLegend(mode) {
const listEl = document.querySelector("#legend .legend-list");
if (!listEl) return;
const items = legendItemsByMode[mode] || [];
listEl.innerHTML = items
const html = items
.map(
(item) => `
<div class="legend-item">
@@ -81,4 +84,14 @@ function renderLegend(mode) {
</div>`,
)
.join("");
const desktopList = document.querySelector("#legend .legend-list");
if (desktopList) {
desktopList.innerHTML = html;
}
const mobileList = document.getElementById("mobile-situation-legend-list");
if (mobileList) {
mobileList.innerHTML = html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,17 +18,18 @@ let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
refreshBtn: document.getElementById("news-refresh"),
openBtn: document.getElementById("news-open-external"),
status: document.getElementById("news-board-status"),
focusLabel: document.getElementById("news-focus-label"),
focusCoords: document.getElementById("news-focus-coords"),
sourceCount: document.getElementById("news-source-count"),
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById("news-board-list"),
empty: document.getElementById("news-board-empty"),
feedAnchor: document.getElementById("news-feed-anchor"),
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
};
}
@@ -81,18 +82,33 @@ function renderPayload(nextPayload) {
openBtn,
feedAnchor,
} = getElements();
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
return;
}
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
return;
}
} else {
return;
}
}
if (regionChip) {
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
}
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page does not show the compact chip row.
} else if (!regionChip) {
return;
}
focusLabel.textContent = focus.label || "全球焦点";
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
@@ -275,8 +291,6 @@ export function initNewsPanel() {
if (initialized) return;
initialized = true;
const { refreshBtn, openBtn } = getElements();
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
@@ -287,16 +301,22 @@ export function initNewsPanel() {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
});
openBtn?.addEventListener("click", openCurrentSourceHomepage);
["news-open-external", "mobile-news-open-external"].forEach((id) => {
const openBtn = document.getElementById(id);
openBtn?.addEventListener("click", openCurrentSourceHomepage);
});
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}

View File

@@ -31,6 +31,10 @@ let satelliteSatrecCache = new Map();
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
const DIMMED_SATELLITE_BACKDROP_OPACITY = 0.1;
const scratchWorldSatellitePosition = new THREE.Vector3();
const scratchToCamera = new THREE.Vector3();
@@ -746,16 +750,16 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
const rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule);
if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) {
const lum = r * 0.299 + g * 0.587 + b * 0.114;
colors[i * 3] = lum * 0.75 + r * 0.25;
colors[i * 3 + 1] = lum * 0.75 + g * 0.25;
colors[i * 3 + 2] = lum * 0.75 + b * 0.25;
} else {
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
}
const isNonFocusDimmed =
highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i);
const pointBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_BRIGHTNESS : 1;
const trailBrightness = isNonFocusDimmed
? DIMMED_SATELLITE_TRAIL_BRIGHTNESS
: 1;
colors[i * 3] = r * pointBrightness;
colors[i * 3 + 1] = g * pointBrightness;
colors[i * 3 + 2] = b * pointBrightness;
const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) {
@@ -771,9 +775,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
trailPositions[trailIdx + 1] = trailPoint.y;
trailPositions[trailIdx + 2] = trailPoint.z;
const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha;
trailColors[trailIdx + 1] = g * alpha;
trailColors[trailIdx + 2] = b * alpha;
trailColors[trailIdx] = r * alpha * trailBrightness;
trailColors[trailIdx + 1] = g * alpha * trailBrightness;
trailColors[trailIdx + 2] = b * alpha * trailBrightness;
continue;
}
}
@@ -1097,10 +1101,10 @@ export function setSatelliteRingState(index, state, position) {
function applyDimMaterialState(isDimmed) {
if (satellitePoints) {
satellitePoints.material.opacity = isDimmed ? 0.32 : 0.9;
satellitePoints.material.opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
}
if (satelliteBackdropPoints) {
satelliteBackdropPoints.material.opacity = isDimmed ? 0.12 : 0.42;
satelliteBackdropPoints.material.opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
}
}

View File

@@ -0,0 +1,282 @@
let initialized = false;
let resolveResultsFn = null;
let onSelectResultFn = null;
let currentResults = [];
let activeIndex = -1;
let searchTimerId = null;
let isOpen = false;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
modal: document.getElementById("search-modal"),
backdrop: document.getElementById("search-backdrop"),
input: document.getElementById(
isMobile ? "mobile-earth-search-input" : "earth-search-input",
),
clear: document.getElementById(
isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
),
meta: document.getElementById(
isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
),
results: document.getElementById(
isMobile ? "mobile-earth-search-results" : "earth-search-results",
),
empty: document.getElementById(
isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
),
close: document.getElementById("search-close"),
};
}
function setMeta(text) {
const { meta } = getElements();
if (meta) meta.textContent = text;
}
function updateEmptyState(query) {
const { empty } = getElements();
if (!empty) return;
if (!query) {
empty.textContent = "支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。";
return;
}
empty.textContent = "未找到匹配对象可尝试名称、地点、NORAD、ASN、前缀等关键词。";
}
function renderResults(query) {
const { results, empty } = getElements();
if (!results || !empty) return;
results.innerHTML = "";
const hasResults = currentResults.length > 0;
empty.hidden = hasResults;
updateEmptyState(query);
if (!hasResults) return;
currentResults.forEach((result, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "earth-search-result";
button.setAttribute("role", "option");
button.dataset.index = String(index);
button.innerHTML = `
<span class="earth-search-result-icon" aria-hidden="true">
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
</span>
<span class="earth-search-result-copy">
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
</span>
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
`;
button.addEventListener("click", async () => {
await selectResult(index);
});
results.appendChild(button);
});
syncActiveResult();
}
function syncActiveResult() {
const { results } = getElements();
if (!results) return;
Array.from(results.children).forEach((node, index) => {
node.classList.toggle("is-active", index === activeIndex);
});
}
function moveActiveResult(delta) {
if (currentResults.length === 0) return;
activeIndex =
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
currentResults.length;
syncActiveResult();
const { results } = getElements();
const activeNode = results?.children?.[activeIndex];
activeNode?.scrollIntoView({ block: "nearest" });
}
async function selectResult(index) {
const result = currentResults[index];
if (!result || typeof onSelectResultFn !== "function") return;
closeSearchPanel();
try {
await onSelectResultFn(result);
} catch (error) {
console.error("Search selection failed:", error);
}
}
async function runSearch() {
const { input, clear } = getElements();
if (!input) return;
const query = input.value.trim();
if (clear) {
clear.hidden = query.length === 0;
}
if (!query) {
currentResults = [];
activeIndex = -1;
setMeta("输入关键词以搜索当前地球对象");
renderResults("");
return;
}
setMeta("正在检索…");
try {
const nextResults = await resolveResultsFn?.(query);
currentResults = Array.isArray(nextResults) ? nextResults : [];
activeIndex = currentResults.length > 0 ? 0 : -1;
setMeta(`找到 ${currentResults.length} 个结果`);
renderResults(query);
} catch (error) {
console.error("Search failed:", error);
currentResults = [];
activeIndex = -1;
setMeta("搜索失败");
renderResults(query);
}
}
function scheduleSearch() {
if (searchTimerId) {
clearTimeout(searchTimerId);
}
searchTimerId = window.setTimeout(() => {
searchTimerId = null;
runSearch();
}, 120);
}
function handleKeydown(event) {
const { modal, input } = getElements();
const isMobile = document.body.classList.contains("layout-mode-mobile");
if (!isMobile && !modal?.classList.contains("is-open")) return;
if (event.key === "Escape") {
event.preventDefault();
closeSearchPanel();
return;
}
if (event.target !== input) return;
if (event.key === "ArrowDown") {
event.preventDefault();
moveActiveResult(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveActiveResult(-1);
} else if (event.key === "Enter" && activeIndex >= 0) {
event.preventDefault();
selectResult(activeIndex).catch((error) => {
console.warn("Selecting search result failed:", error);
});
}
}
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
resolveResultsFn = resolveResults;
onSelectResultFn = onSelectResult;
if (initialized) return;
initialized = true;
const inputs = ["earth-search-input", "mobile-earth-search-input"]
.map((id) => document.getElementById(id))
.filter((node) => node instanceof HTMLInputElement);
const clears = ["earth-search-clear", "mobile-earth-search-clear"]
.map((id) => document.getElementById(id))
.filter((node) => node instanceof HTMLButtonElement);
const close = document.getElementById("search-close");
const backdrop = document.getElementById("search-backdrop");
inputs.forEach((input) => {
input.addEventListener("input", scheduleSearch);
input.addEventListener("keydown", handleKeydown);
});
clears.forEach((clear) => {
clear.addEventListener("click", () => {
const { input } = getElements();
if (!input) return;
input.value = "";
input.focus();
runSearch().catch((error) => {
console.warn("Clearing search failed:", error);
});
});
});
close?.addEventListener("click", () => {
closeSearchPanel();
});
backdrop?.addEventListener("click", () => {
closeSearchPanel();
});
document.addEventListener("keydown", handleKeydown);
}
export function openSearchPanel() {
const { modal, input } = getElements();
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
if (isOpen) return;
isOpen = true;
document.body.classList.add("earth-search-open");
modal?.classList.add("is-open");
modal?.setAttribute("aria-hidden", "false");
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);
});
}, 16);
}
export function focusSearchInput({ select = false } = {}) {
const { input } = getElements();
if (!(input instanceof HTMLInputElement)) return;
input.focus();
if (select) {
input.select();
}
}
export function refreshSearchResults() {
return runSearch();
}
export function closeSearchPanel() {
const { modal } = getElements();
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
if (!isOpen) return;
isOpen = false;
document.body.classList.remove("earth-search-open");
modal?.classList.remove("is-open");
modal?.setAttribute("aria-hidden", "true");
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: false } }),
);
}
export function isSearchPanelOpen() {
return isOpen;
}

View File

@@ -56,21 +56,22 @@ const HLS_RETRY_CONFIG = {
};
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
select: document.getElementById("tv-source-select"),
title: document.getElementById("tv-source-title"),
meta: document.getElementById("tv-source-meta"),
catalog: document.getElementById("tv-source-catalog"),
status: document.getElementById("tv-source-status"),
notes: document.getElementById("tv-source-notes"),
iframe: document.getElementById("tv-iframe"),
video: document.getElementById("tv-video"),
empty: document.getElementById("tv-empty-state"),
refreshBtn: document.getElementById("tv-refresh"),
openBtn: document.getElementById("tv-open-external"),
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
@@ -351,6 +352,7 @@ function setPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
mediaPanel?.setVisible(visible);
document.body.classList.toggle("earth-media-open", visible);
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
@@ -877,11 +879,12 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment();
sources.forEach((source) => {
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option");
option.value = source.id;
option.textContent = `${source.name}${defaultMark}${failMark}`;
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option);
});
@@ -913,8 +916,12 @@ function renderSource(source) {
const latestLabel = latestUpdatedAt
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
: "尚未同步";
const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : "";
catalog.textContent = ` ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`;
const sourceOriginLabel = source?.collector_source
? `采集源 ${source.collector_source}`
: source
? "内置源"
: "";
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
}
if (notes) {
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
@@ -1066,12 +1073,16 @@ export function initTVPanel() {
showStatusMessage("已切换到态势新闻", "info");
});
select?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((selectEl) => {
selectEl?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
});
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
@@ -1079,9 +1090,13 @@ export function initTVPanel() {
setMetaCollapsed(isNowCollapsed);
});
refreshBtn?.addEventListener("click", () => {
refreshTVPanel();
});
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((refreshEl) => {
refreshEl?.addEventListener("click", () => {
refreshTVPanel();
});
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
@@ -1090,24 +1105,32 @@ export function initTVPanel() {
setActiveTab("news");
});
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((iframeEl) => {
iframeEl?.addEventListener("load", () => {
if (iframeEl.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
});
video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
[video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((videoEl) => {
videoEl?.addEventListener("loadedmetadata", () => {
if (videoEl.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
video?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
videoEl?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
});
setupResizeHandle();
syncPanelActiveTab("live");

View File

@@ -19,6 +19,20 @@ function getElement(id) {
return document.getElementById(id);
}
function getEarthStatTargets(statKey) {
return Array.from(
document.querySelectorAll(`[data-earth-stat="${statKey}"]`),
);
}
export function setEarthStatValue(statKey, value) {
getEarthStatTargets(statKey).forEach((element) => {
if (element instanceof HTMLElement) {
element.textContent = value;
}
});
}
function setElementDisplay(element, visible, displayValue = "block") {
if (!element) return;
element.style.display = visible ? displayValue : "none";
@@ -155,7 +169,15 @@ export function updateZoomDisplay(zoomLevel, distance) {
const slider = getElement("zoom-slider");
const cameraDistanceEl = getElement("camera-distance");
if (zoomValueEl) zoomValueEl.textContent = percent + "%";
if (zoomValueEl) {
const tooltip = zoomValueEl.querySelector(".tooltip");
const label = `${percent}%`;
if (zoomValueEl.firstChild?.nodeType === Node.TEXT_NODE) {
zoomValueEl.firstChild.nodeValue = label;
} else {
zoomValueEl.insertBefore(document.createTextNode(label), tooltip || null);
}
}
if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
if (slider) slider.value = zoomLevel;
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
@@ -163,27 +185,14 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
const cableCountEl = getElement("cable-count");
const landingPointCountEl = getElement("landing-point-count");
const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
const bgpCollectorCountEl = getElement("bgp-collector-count");
const bgpStatusSummaryEl = getElement("bgp-status-summary");
const terrainStatusEl = getElement("terrain-status");
const textureQualityEl = getElement("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl)
landingPointCountEl.textContent = stats.landingPointCount || 0;
if (bgpAnomalyCountEl)
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (bgpCollectorCountEl)
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
if (bgpStatusSummaryEl)
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl)
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
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 卫星图");
}
// Show/hide loading via status message

View File

@@ -4,6 +4,10 @@ import * as THREE from "three";
import { CONFIG } from "./constants.js";
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
// Convert latitude/longitude to 3D vector
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
const phi = (90 - lat) * (Math.PI / 180);
@@ -90,3 +94,31 @@ export function calculateDistance(
return radius * c;
}
export function getSurfaceMarkerCameraScale(camera, options = {}) {
if (!camera) return 1;
const {
altitudeOffset = 0,
referenceFov = 75,
min = 0.12,
max = 3.0,
} = options;
const cameraDistanceFromCenter = camera.position.length();
const surfaceDistance = Math.max(
1,
cameraDistanceFromCenter - (CONFIG.earthRadius + altitudeOffset),
);
const referenceSurfaceDistance = Math.max(
1,
CONFIG.defaultCameraZ - (CONFIG.earthRadius + altitudeOffset),
);
const cameraFovRad = (((camera.fov || referenceFov)) * Math.PI) / 180;
const referenceFovRad = (referenceFov * Math.PI) / 180;
const worldPerPixel = surfaceDistance * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel =
referenceSurfaceDistance * Math.tan(referenceFovRad / 2);
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
}

View File

@@ -132,13 +132,10 @@ function Scrollbar({
resizeObserver.observe(viewport)
resizeObserver.observe(trackX)
resizeObserver.observe(trackY)
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
mutationObserver.observe(viewport, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
})
viewport.addEventListener('scroll', scheduleUpdate, { passive: true })

View File

@@ -156,7 +156,6 @@ function ScrollbarOverlay({
scheduleUpdate()
})
resizeObserver.observe(target)
Array.from(target.children).forEach((child) => resizeObserver?.observe(child))
scheduleUpdate()
}
@@ -169,7 +168,6 @@ function ScrollbarOverlay({
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
})
window.addEventListener('resize', scheduleUpdate)

View File

@@ -1562,6 +1562,14 @@ body {
border-radius: 12px;
}
.data-source-drawer-collapse {
margin-bottom: 12px;
}
.data-source-drawer-collapse:last-of-type {
margin-bottom: 0;
}
.stat-card {
background: white;
padding: 24px;

View File

@@ -19,6 +19,7 @@ import { useWebSocket } from '../../hooks/useWebSocket'
interface BuiltInDataSource {
id: number
source: string
name: string
module: string
priority: string
@@ -180,6 +181,19 @@ interface CustomDataSource {
updated_at: string | null
}
interface EditableDataSourceConfig {
id: number
name: string
description: string | null
source_type: string
endpoint: string
auth_type: string
auth_config: Record<string, any>
headers: Record<string, string>
config: Record<string, any>
is_active?: boolean
}
interface ViewDataSource {
id: number
name: string
@@ -205,6 +219,7 @@ function DataSources() {
const [drawerVisible, setDrawerVisible] = useState(false)
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
const [builtinEditingSource, setBuiltinEditingSource] = useState<BuiltInDataSource | null>(null)
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false)
@@ -219,6 +234,81 @@ function DataSources() {
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
const [form] = Form.useForm()
const headersMapToList = useCallback((headers?: Record<string, string> | null) => {
return Object.entries(headers || {})
.filter(([key, value]) => key && value !== undefined && value !== null && String(value).trim() !== '')
.map(([key, value]) => ({ key, value }))
}, [])
const headersListToMap = useCallback((headers?: Array<{ key?: string; value?: string }> | Record<string, string>) => {
if (!headers) return {}
if (!Array.isArray(headers)) return headers
return headers.reduce<Record<string, string>>((acc, item) => {
const key = item?.key?.trim()
const value = item?.value?.trim()
if (!key || value === undefined) return acc
acc[key] = value
return acc
}, {})
}, [])
const applyConfigToForm = useCallback((config?: Partial<EditableDataSourceConfig> | null) => {
form.setFieldsValue({
name: config?.name || '',
description: config?.description || '',
source_type: config?.source_type || 'http',
endpoint: config?.endpoint || '',
auth_type: config?.auth_type || 'none',
auth_config: config?.auth_config || {},
headers: headersMapToList(config?.headers || {}),
config: config?.config || { timeout: 30, retry: 3 },
})
}, [form, headersMapToList])
const loadConfigDetail = useCallback(async (configId: number) => {
const res = await axios.get<EditableDataSourceConfig>(`/api/v1/datasources/configs/${configId}`)
return res.data
}, [])
const createDefaultConfigDraft = useCallback((overrides?: Partial<EditableDataSourceConfig>) => ({
source_type: 'http',
auth_type: 'none',
headers: {},
config: { timeout: 30, retry: 3 },
...overrides,
}), [])
const getBuiltinOverrideDescription = useCallback(
(source?: Pick<BuiltInDataSource, 'name'> | null) =>
source ? `Built-in datasource override for ${source.name}` : undefined,
[],
)
const createFormPayload = useCallback((values: any) => ({
...values,
name: builtinEditingSource ? builtinEditingSource.source : values.name,
description:
values.description ||
getBuiltinOverrideDescription(builtinEditingSource),
source_type: builtinEditingSource ? 'http' : values.source_type,
headers: headersListToMap(values.headers),
}), [builtinEditingSource, getBuiltinOverrideDescription, headersListToMap])
const closeDrawerAfterLoadError = useCallback((
errorMessage: string,
options?: { clearBuiltin?: boolean; clearEditingConfig?: boolean },
) => {
messageApi.error(errorMessage)
setDrawerVisible(false)
if (options?.clearBuiltin) {
setBuiltinEditingSource(null)
}
if (options?.clearEditingConfig) {
setEditingConfig(null)
}
}, [messageApi])
const fetchData = useCallback(async () => {
setLoading(true)
try {
@@ -711,9 +801,11 @@ function DataSources() {
const handleViewSource = async (source: BuiltInDataSource) => {
try {
const [res, statsRes] = await Promise.all([
const existingOverride = customSources.find((item) => item.name === source.source)
const [res, statsRes, overrideDetail] = await Promise.all([
axios.get(`/api/v1/datasources/${source.id}`),
axios.get(`/api/v1/datasources/${source.id}/stats`)
axios.get(`/api/v1/datasources/${source.id}/stats`),
existingOverride ? loadConfigDetail(existingOverride.id) : Promise.resolve(null),
])
const data = res.data
setViewingSource({
@@ -721,10 +813,10 @@ function DataSources() {
name: data.name,
description: null,
source_type: data.collector_class,
endpoint: data.endpoint || '',
auth_type: 'none',
headers: {},
config: {},
endpoint: overrideDetail?.endpoint || data.endpoint || '',
auth_type: overrideDetail?.auth_type || 'none',
headers: overrideDetail?.headers || {},
config: overrideDetail?.config || {},
collector_class: data.collector_class,
module: data.module,
priority: data.priority,
@@ -753,7 +845,8 @@ function DataSources() {
const values = await form.validateFields()
setTesting(true)
setTestResult(null)
const res = await axios.post('/api/v1/datasources/configs/test', values)
const payload = createFormPayload(values)
const res = await axios.post('/api/v1/datasources/configs/test', payload)
setTestResult(res.data)
if (res.data.success) {
messageApi.success('连接测试成功')
@@ -771,16 +864,18 @@ function DataSources() {
const handleSave = async () => {
try {
const values = await form.validateFields()
const payload = createFormPayload(values)
if (editingConfig) {
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, payload)
messageApi.success('配置已更新')
} else {
await axios.post('/api/v1/datasources/configs', values)
await axios.post('/api/v1/datasources/configs', payload)
messageApi.success('配置已创建')
}
setDrawerVisible(false)
form.resetFields()
setEditingConfig(null)
setBuiltinEditingSource(null)
setTestResult(null)
fetchData()
} catch (error: unknown) {
@@ -800,6 +895,23 @@ function DataSources() {
}
}
const handleResetBuiltinOverride = async () => {
if (!builtinEditingSource || !editingConfig) return
try {
await axios.delete(`/api/v1/datasources/configs/${editingConfig.id}`)
messageApi.success(`已恢复 ${builtinEditingSource.name} 的默认配置`)
setDrawerVisible(false)
form.resetFields()
setEditingConfig(null)
setBuiltinEditingSource(null)
setTestResult(null)
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '恢复默认失败')
}
}
const handleToggleCustom = async (id: number, current: boolean) => {
try {
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
@@ -811,24 +923,53 @@ function DataSources() {
}
}
const openDrawer = (config?: CustomDataSource) => {
const openDrawer = async (config?: CustomDataSource) => {
setBuiltinEditingSource(null)
setEditingConfig(config || null)
if (config) {
form.setFieldsValue({
...config,
auth_config: {},
})
} else {
form.resetFields()
form.setFieldsValue({
source_type: 'http',
auth_type: 'none',
config: { timeout: 30, retry: 3 },
headers: {},
})
}
setDrawerVisible(true)
setTestResult(null)
setDrawerVisible(true)
if (config) {
try {
const detail = await loadConfigDetail(config.id)
applyConfigToForm(detail)
} catch {
closeDrawerAfterLoadError('获取配置详情失败', { clearEditingConfig: true })
}
return
}
form.resetFields()
applyConfigToForm(createDefaultConfigDraft())
}
const openBuiltinConfigDrawer = async (source: BuiltInDataSource) => {
setBuiltinEditingSource(source)
setTestResult(null)
setDrawerVisible(true)
const existingOverride = customSources.find((item) => item.name === source.source)
setEditingConfig(existingOverride || null)
if (existingOverride) {
try {
const detail = await loadConfigDetail(existingOverride.id)
applyConfigToForm(detail)
} catch {
closeDrawerAfterLoadError('获取内置数据源配置失败', {
clearBuiltin: true,
clearEditingConfig: true,
})
}
return
}
form.resetFields()
applyConfigToForm(createDefaultConfigDraft({
name: source.source,
description: getBuiltinOverrideDescription(source),
endpoint: source.endpoint || '',
}))
}
const handleCopyLink = async (value: string, successText: string) => {
@@ -945,12 +1086,18 @@ function DataSources() {
title: '操作',
key: 'action',
fixed: 'right' as const,
width: builtinActionsCollapsed ? 40 : 164,
width: builtinActionsCollapsed ? 40 : 228,
onCell: () => actionCellProps,
render: (_: unknown, record: BuiltInDataSource) => (
<TableActions
collapsed={builtinActionsCollapsed}
items={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => { void openBuiltinConfigDrawer(record) },
},
{
key: 'trigger',
label: '触发',
@@ -967,6 +1114,14 @@ function DataSources() {
},
]}
>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => { void openBuiltinConfigDrawer(record) }}
>
</Button>
<Button
type="link"
size="small"
@@ -1035,7 +1190,7 @@ function DataSources() {
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => openDrawer(record),
onClick: () => { void openDrawer(record) },
},
{
key: 'toggle',
@@ -1059,7 +1214,7 @@ function DataSources() {
},
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { void openDrawer(record) }}></Button>
<Button
type="link"
size="small"
@@ -1171,7 +1326,7 @@ function DataSources() {
children: (
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
<div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { void openDrawer() }}>
</Button>
</div>
@@ -1215,24 +1370,40 @@ function DataSources() {
</div>
<Drawer
title={editingConfig ? '编辑数据源' : '添加数据源'}
title={builtinEditingSource ? `编辑内置数据源配置 · ${builtinEditingSource.name}` : editingConfig ? '编辑数据源' : '添加数据源'}
width={600}
open={drawerVisible}
onClose={() => {
setDrawerVisible(false)
form.resetFields()
setEditingConfig(null)
setBuiltinEditingSource(null)
setTestResult(null)
}}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleTest}
>
</Button>
<Space>
{builtinEditingSource && editingConfig ? (
<Popconfirm
title="恢复内置默认配置?"
description="这会删除当前 override并重新使用代码内置默认配置。"
okText="恢复默认"
cancelText="取消"
onConfirm={handleResetBuiltinOverride}
>
<Button danger icon={<ClearOutlined />}>
</Button>
</Popconfirm>
) : null}
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleTest}
>
</Button>
</Space>
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleSave}>
@@ -1243,29 +1414,46 @@ function DataSources() {
}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="My API Data Source" />
</Form.Item>
{builtinEditingSource ? (
<Card size="small" bordered={false} style={{ marginBottom: 16, background: '#fafafa' }}>
<Row gutter={[12, 12]}>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={builtinEditingSource.name} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>Collector Key</div>
<Input value={builtinEditingSource.source} disabled />
</Col>
</Row>
</Card>
) : (
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="My API Data Source" />
</Form.Item>
)}
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="数据源描述" />
</Form.Item>
<Form.Item
name="source_type"
label="数据源类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="http">HTTP API</Select.Option>
<Select.Option value="api">REST API</Select.Option>
<Select.Option value="database"></Select.Option>
</Select>
</Form.Item>
{builtinEditingSource ? null : (
<Form.Item
name="source_type"
label="数据源类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="http">HTTP API</Select.Option>
<Select.Option value="api">REST API</Select.Option>
<Select.Option value="database"></Select.Option>
</Select>
</Form.Item>
)}
<Form.Item
name="endpoint"
@@ -1276,6 +1464,7 @@ function DataSources() {
</Form.Item>
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'auth',
@@ -1311,6 +1500,12 @@ function DataSources() {
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
<Input placeholder="X-API-Key" />
</Form.Item>
<Form.Item name={['auth_config', 'in']} label="传递位置" initialValue="header">
<Select>
<Select.Option value="header">Header</Select.Option>
<Select.Option value="query">Query Param</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['auth_config', 'api_key']} label="API Key">
<Input.Password placeholder="API Key" />
</Form.Item>
@@ -1345,6 +1540,7 @@ function DataSources() {
/>
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'headers',
@@ -1376,6 +1572,7 @@ function DataSources() {
/>
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'config',

View File

@@ -21,5 +21,5 @@
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"references": [{ "path": "./tsconfig.tooling.json" }]
}

153
planet.sh
View File

@@ -59,6 +59,7 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
@@ -371,6 +372,41 @@ log_success() {
log_line "done" "$GREEN" "$1"
}
get_recommended_lan_ipv4() {
local candidate
while read -r candidate; do
[ -z "$candidate" ] && continue
case "$candidate" in
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
continue
;;
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
printf "%s" "$candidate"
return 0
;;
esac
done <<EOF
$(hostname -I 2>/dev/null | tr ' ' '\n')
EOF
return 1
}
log_lan_access_notes() {
local frontend_port="$1"
local backend_port="$2"
local recommended_lan_ip=""
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
else
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
fi
}
print_splash() {
clear_wait_spinner
printf "%b" "$CYAN"
@@ -484,12 +520,13 @@ print_database_failure_diagnostics() {
compute_ai_provider_build_fingerprint() {
(
cd "$SCRIPT_DIR" || exit 1
tar -cf - \
aiprovider \
pyproject.toml \
uv.lock \
docker-compose.yml \
docker-compose.simple.yml 2>/dev/null
{
tar -cf - \
aiprovider \
docker-compose.yml \
docker-compose.simple.yml 2>/dev/null
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py"
}
) | sha256sum | awk '{print $1}'
}
@@ -766,8 +803,8 @@ ensure_frontend_deps() {
cd "$SCRIPT_DIR/frontend"
set_wait_detail "检查 vite 是否已安装"
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
set_wait_detail "检查 Vite Bun 入口是否已安装"
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
if ! run_with_retry \
@@ -780,9 +817,9 @@ ensure_frontend_deps() {
fi
fi
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
close_wait_session_context "$owns_wait_session"
log_error "前端依赖安装失败,未找到 vite"
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
exit 1
fi
@@ -988,7 +1025,14 @@ start_postgres_service() {
# Backend lifecycle helpers
cleanup_backend_processes() {
pkill -f "uvicorn" 2>/dev/null || true
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
terminate_backend_processes TERM "$backend_port"
if ! wait_for_port_release "$backend_port"; then
terminate_backend_processes KILL "$backend_port"
wait_for_port_release "$backend_port" || true
fi
}
start_backend_with_retry() {
@@ -996,7 +1040,7 @@ start_backend_with_retry() {
local retry=1
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
cleanup_backend_processes
cleanup_backend_processes "$backend_port"
cd "$SCRIPT_DIR/backend"
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=$!
@@ -1205,6 +1249,39 @@ collect_port_pids() {
return 1
}
terminate_process_group() {
local signal="$1"
local pid="$2"
local pgid=""
[ -n "$pid" ] || return 0
kill -0 "$pid" 2>/dev/null || return 0
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
[ -n "$pgid" ] || return 0
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
}
terminate_backend_processes() {
local signal="$1"
local backend_port="$2"
local pids=""
local pid=""
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done
pids="$(collect_port_pids "$backend_port" || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done
}
terminate_process_tree() {
local signal="$1"
local pid="$2"
@@ -1324,15 +1401,15 @@ cleanup_frontend_processes() {
rm -f "$FRONTEND_PID_FILE"
fi
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
}
start_frontend_with_retry() {
local frontend_port="$1"
local frontend_port_requested="${2:-0}"
local frontend_lan_enabled="${3:-0}"
local retry=1
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
@@ -1342,7 +1419,13 @@ start_frontend_with_retry() {
fi
cd "$SCRIPT_DIR/frontend"
: > /tmp/planet_frontend.log
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 &
local -a frontend_args
frontend_args=("$FRONTEND_VITE_ENTRY")
if [ "$frontend_lan_enabled" -eq 1 ]; then
frontend_args+=(--host 0.0.0.0)
fi
frontend_args+=(--port "$frontend_port" --strictPort)
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
FRONTEND_PID=$!
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
@@ -1368,6 +1451,7 @@ start_frontend_with_retry() {
start_frontend_service() {
local frontend_port="$1"
local frontend_port_requested="$2"
local frontend_lan_enabled="${3:-0}"
if [ "$frontend_port_requested" -eq 1 ]; then
kill_port_if_requested "$frontend_port" "前端"
@@ -1379,8 +1463,12 @@ start_frontend_service() {
log_success "前端依赖已就绪"
start_wait_session "启动前端服务"
set_wait_detail "启动 Vite 开发服务器"
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
if [ "$frontend_lan_enabled" -eq 1 ]; then
set_wait_detail "启动 Vite 开发服务器(局域网开放)"
else
set_wait_detail "启动 Vite 开发服务器"
fi
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
stop_wait_session
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}"
tail -10 /tmp/planet_frontend.log
@@ -1399,6 +1487,7 @@ parse_service_args() {
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
DATABASE_REQUESTED=0
FRONTEND_LAN_ENABLED=0
while [ "$#" -gt 0 ]; do
case "$1" in
@@ -1433,6 +1522,10 @@ parse_service_args() {
DATABASE_REQUESTED=1
shift 1
;;
--allow-lan)
FRONTEND_LAN_ENABLED=1
shift 1
;;
*)
log_error "未知参数: $1"
exit 1
@@ -1468,8 +1561,8 @@ stop_container_if_running() {
}
stop_backend_service() {
if pgrep -f "uvicorn" >/dev/null 2>&1; then
cleanup_backend_processes
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
log_halt "后端服务已停止"
fi
}
@@ -1479,7 +1572,7 @@ stop_ai_provider_service() {
}
stop_frontend_service() {
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
cleanup_frontend_processes "$FRONTEND_PORT"
@@ -1607,13 +1700,16 @@ start() {
print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
}
stop() {
@@ -1633,7 +1729,7 @@ restart() {
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
sleep 1
start
start "$@"
return 0
fi
@@ -1658,7 +1754,7 @@ restart() {
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
stop_frontend_service
sleep 1
start_frontend_service "$FRONTEND_PORT" 1
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
fi
echo ""
@@ -1674,6 +1770,9 @@ restart() {
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
log_note "前端: http://localhost:${FRONTEND_PORT}"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
fi
}
@@ -1754,9 +1853,9 @@ case "$1" in
;;
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"

View File

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

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
from typing import Any
IMPORT_TO_DISTRIBUTION = {
"pydantic_settings": "pydantic-settings",
}
# Some runtime dependencies are referenced indirectly:
# - uvicorn is launched by the container command rather than imported.
# - python-dotenv is used by pydantic-settings when loading the local .env file.
EXTRA_RUNTIME_DISTRIBUTIONS = {
"python-dotenv",
"uvicorn",
}
def normalize_name(value: str) -> str:
return value.strip().lower().replace("_", "-").replace(".", "-")
def extract_table(text: str, table_name: str) -> str:
pattern = re.compile(
rf"(?ms)^\[{re.escape(table_name)}\]\s*$\n(.*?)(?=^\[[^\]]+\]\s*$|\Z)"
)
match = pattern.search(text)
return match.group(1) if match else ""
def extract_string_value(text: str, key: str) -> str | None:
match = re.search(rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', text)
return match.group(1) if match else None
def extract_string_list_value(text: str, key: str) -> list[str]:
match = re.search(rf"(?ms)^{re.escape(key)}\s*=\s*\[(.*?)\]\s*$", text)
if not match:
return []
return re.findall(r'"([^"]+)"', match.group(1))
def project_dependency_map(pyproject_text: str) -> tuple[str | None, dict[str, str]]:
project_block = extract_table(pyproject_text, "project")
requires_python = extract_string_value(project_block, "requires-python")
dependencies = extract_string_list_value(project_block, "dependencies")
mapping: dict[str, str] = {}
for spec in dependencies:
name = spec.split(";", 1)[0].strip()
for marker in ("<", ">", "=", "!", "~"):
if marker in name:
name = name.split(marker, 1)[0].strip()
if "[" in name:
name = name.split("[", 1)[0].strip()
mapping[normalize_name(name)] = spec.strip()
return requires_python, mapping
def collect_runtime_imports(aiprovider_dir: Path) -> set[str]:
imports: set[str] = set()
stdlib = set(sys.stdlib_module_names)
for path in sorted(aiprovider_dir.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name.split(".", 1)[0])
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module.split(".", 1)[0])
return {
name
for name in imports
if name not in stdlib and name != "aiprovider"
}
def resolve_relevant_root_dependencies(
pyproject_deps: dict[str, str],
imported_modules: set[str],
) -> dict[str, str]:
relevant_names: set[str] = set(EXTRA_RUNTIME_DISTRIBUTIONS)
for module_name in imported_modules:
mapped_name = IMPORT_TO_DISTRIBUTION.get(module_name, module_name)
normalized_name = normalize_name(mapped_name)
if normalized_name in pyproject_deps:
relevant_names.add(normalized_name)
return {
name: pyproject_deps[name]
for name in sorted(relevant_names)
if name in pyproject_deps
}
def lock_metadata(lock_text: str) -> dict[str, str | None]:
return {
"version": extract_string_value(lock_text, "version") or re.search(r"(?m)^version\s*=\s*(\d+)\s*$", lock_text).group(1),
"revision": extract_string_value(lock_text, "revision") or re.search(r"(?m)^revision\s*=\s*(\d+)\s*$", lock_text).group(1),
"requires_python": extract_string_value(lock_text, "requires-python"),
}
def lock_package_map(lock_text: str) -> dict[str, dict[str, Any]]:
mapping: dict[str, dict[str, Any]] = {}
sections = re.split(r"(?m)^\[\[package\]\]\s*$\n?", lock_text)
for section in sections[1:]:
raw_section = section.strip()
name = extract_string_value(raw_section, "name")
if not name:
continue
version = extract_string_value(raw_section, "version")
dependency_names = [
normalize_name(dep_name)
for dep_name in re.findall(r'\{\s*name\s*=\s*"([^"]+)"', raw_section)
]
mapping[normalize_name(name)] = {
"name": name,
"version": version,
"dependencies": dependency_names,
"raw": raw_section,
}
return mapping
def dependency_closure(
lock_packages: dict[str, dict[str, Any]],
root_dependencies: dict[str, str],
) -> list[dict[str, Any]]:
pending = list(root_dependencies.keys())
visited: set[str] = set()
resolved: list[dict[str, Any]] = []
while pending:
current = pending.pop()
if current in visited:
continue
visited.add(current)
package = lock_packages.get(current)
if package is None:
continue
resolved.append(package)
for dep_name in package.get("dependencies", []):
pending.append(dep_name)
resolved.sort(key=lambda pkg: (normalize_name(pkg["name"]), pkg.get("version", "")))
return resolved
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
pyproject_path = repo_root / "pyproject.toml"
uv_lock_path = repo_root / "uv.lock"
aiprovider_dir = repo_root / "aiprovider"
pyproject_text = pyproject_path.read_text(encoding="utf-8")
uv_lock_text = uv_lock_path.read_text(encoding="utf-8")
requires_python, pyproject_deps = project_dependency_map(pyproject_text)
imported_modules = collect_runtime_imports(aiprovider_dir)
relevant_roots = resolve_relevant_root_dependencies(pyproject_deps, imported_modules)
relevant_packages = dependency_closure(lock_package_map(uv_lock_text), relevant_roots)
fingerprint_payload = {
"requires_python": requires_python,
"relevant_root_dependencies": relevant_roots,
"lock": {**lock_metadata(uv_lock_text), "packages": relevant_packages},
}
print(json.dumps(fingerprint_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())

2
uv.lock generated
View File

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