Compare commits

...

22 Commits

Author SHA1 Message Date
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
rayd1o
437efc848c release: bump version to 0.31.3 2026-04-22 03:52:09 +08:00
rayd1o
003a46ac30 release: bump version to 0.31.2 2026-04-21 23:50:35 +08:00
rayd1o
4b0be4cb76 release: bump version to 0.31.1 2026-04-21 22:49:39 +08:00
linkong
b7647379de release: bump version to 0.31.0 2026-04-21 18:35:40 +08:00
linkong
0f89372d71 release: bump version to 0.30.0 2026-04-21 12:28:04 +08:00
linkong
2b0d4cfc49 release: bump version to 0.29.2 2026-04-21 10:43:48 +08:00
rayd1o
e6d0332fba release: bump version to 0.29.1 2026-04-20 22:12:19 +08:00
linkong
fe45a99cbd release: bump version to 0.29.0 2026-04-20 17:43:59 +08:00
linkong
ae77b06c3c docs: expand earth celestial background implementation plan 2026-04-20 16:29:03 +08:00
linkong
b5dd4f12f8 release: bump version to 0.28.2 2026-04-20 16:00:54 +08:00
linkong
75cb214f23 release: bump version to 0.28.1 2026-04-20 15:14:53 +08:00
linkong
4c21973197 release: bump version to 0.28.0 2026-04-20 14:10:27 +08:00
linkong
51ae5e6ec9 release: bump version to 0.27.8 2026-04-20 12:03:09 +08:00
linkong
1cf1f32ddd feat: refine earth hud panel behaviors and news board 2026-04-20 11:23:05 +08:00
linkong
8f3ab88743 release: bump version to 0.27.7 2026-04-16 10:04:14 +08:00
113 changed files with 18378 additions and 2050 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.

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

View File

@@ -20,3 +20,7 @@
- [x] 在 activity layer 之后继续补 `route leak``path instability / flap` detector
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation降低后续维护复杂度
- [ ] 可选优化(非必做):将 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

View File

@@ -1 +1 @@
0.27.6
0.36.0

View File

@@ -4,7 +4,7 @@
完整使用说明见:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
当前支持:

View File

@@ -13,6 +13,7 @@ from app.api.v1 import (
collected_data,
visualization,
bgp,
news,
system_control,
tv,
)
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(news.router, prefix="/news", tags=["news"])

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

@@ -0,0 +1,13 @@
from fastapi import APIRouter, Query
from app.services.earth_news import get_earth_news_payload
router = APIRouter()
@router.get("/earth-feed")
async def get_earth_feed(
lat: float | None = Query(None, description="Current Earth view center latitude"),
lon: float | None = Query(None, description="Current Earth view center longitude"),
):
return await get_earth_news_payload(lat=lat, lon=lon)

View File

@@ -6,12 +6,14 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
from datetime import UTC, datetime
import math
from fastapi import APIRouter, HTTPException, Depends, Query
import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
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
@@ -23,6 +25,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
router = APIRouter()
TERRAIN_TILE_URL_TEMPLATE = (
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
)
# ============== Converter Functions ==============
@@ -359,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,
@@ -782,9 +996,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/landing-points")
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
try:
records = await _load_current_collected_data(db, "arcgis_landing_points")
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation")
cable_records = await _load_current_collected_data(db, "arcgis_cables")
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_landing_points",
"arcgis_cable_landing_relation",
"arcgis_cables",
],
)
records = records_by_source.get("arcgis_landing_points", [])
relation_records = records_by_source.get(
"arcgis_cable_landing_relation",
[],
)
cable_records = records_by_source.get("arcgis_cables", [])
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records,
@@ -804,6 +1029,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
async def get_terrarium_tile(z: int, x: int, y: int):
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
if z < 0 or x < 0 or y < 0:
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
try:
async with httpx.AsyncClient(
timeout=20.0,
follow_redirects=True,
) as client:
upstream = await client.get(url)
upstream.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Terrain tile upstream error: {exc.response.status_code}",
) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=502,
detail=f"Terrain tile fetch failed: {exc}",
) from exc
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
etag = upstream.headers.get("etag")
last_modified = upstream.headers.get("last-modified")
headers = {
"Cache-Control": cache_control,
}
if etag:
headers["ETag"] = etag
if last_modified:
headers["Last-Modified"] = last_modified
return Response(
content=upstream.content,
media_type=upstream.headers.get("content-type", "image/png"),
headers=headers,
)
@router.get("/geo/all")
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
records_by_source = await _load_current_collected_data_by_sources(
@@ -916,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

@@ -0,0 +1,490 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import hashlib
import html
import re
from typing import Any
from urllib.parse import quote
import xml.etree.ElementTree as ET
import httpx
from bs4 import BeautifulSoup
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
REQUEST_TIMEOUT = 12.0
MAX_ITEMS_PER_SOURCE = 6
MAX_ITEMS_TOTAL = 12
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
@dataclass(frozen=True)
class RegionProfile:
key: str
label: str
query: str
accent: str
@dataclass(frozen=True)
class NewsFeedSource:
id: str
name: str
region: str
feed_url: str
homepage_url: str
source_type: str = "rss"
priority: int = 100
@dataclass
class ParsedNewsItem:
id: str
title: str
summary: str
url: str
source: str
feed_name: str
feed_region: str
homepage_url: str
published_at: datetime | None
@dataclass
class CachedRegionFeed:
region: str
fetched_at: datetime
items: list[ParsedNewsItem]
sources: list[NewsFeedSource]
REGION_PROFILES: dict[str, RegionProfile] = {
"americas": RegionProfile(
key="americas",
label="美洲焦点",
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
accent="#79d3ff",
),
"europe": RegionProfile(
key="europe",
label="欧洲焦点",
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
accent="#8fd4ff",
),
"middle-east-africa": RegionProfile(
key="middle-east-africa",
label="中东与非洲焦点",
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
accent="#ffb56a",
),
"asia-pacific": RegionProfile(
key="asia-pacific",
label="亚太焦点",
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
accent="#78f2cf",
),
"global": RegionProfile(
key="global",
label="全球焦点",
query='"world news" OR geopolitics OR "global affairs"',
accent="#d6e6ff",
),
}
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
return (
"https://news.google.com/rss/search?q="
+ quote(query, safe="")
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
)
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
NewsFeedSource(
id="bbc-world",
name="BBC World",
region="global",
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
homepage_url="https://www.bbc.com/news/world",
priority=10,
),
NewsFeedSource(
id="dw-top",
name="DW Top Stories",
region="europe",
feed_url="https://rss.dw.com/rdf/rss-en-top",
homepage_url="https://www.dw.com/en/top-stories/s-9097",
priority=20,
),
NewsFeedSource(
id="global-scan",
name="Global Monitor / World",
region="global",
feed_url=_google_news_feed(
REGION_PROFILES["global"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=30,
),
NewsFeedSource(
id="google-americas",
name="Global Monitor / Americas",
region="americas",
feed_url=_google_news_feed(
REGION_PROFILES["americas"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-europe",
name="Global Monitor / Europe",
region="europe",
feed_url=_google_news_feed(
REGION_PROFILES["europe"].query,
hl="en-GB",
gl="GB",
ceid="GB:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-mea",
name="Global Monitor / MEA",
region="middle-east-africa",
feed_url=_google_news_feed(
REGION_PROFILES["middle-east-africa"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-apac",
name="Global Monitor / APAC",
region="asia-pacific",
feed_url=_google_news_feed(
REGION_PROFILES["asia-pacific"].query,
hl="en-SG",
gl="SG",
ceid="SG:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
)
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
def determine_focus_region(lat: float | None, lon: float | None) -> str:
if lat is None or lon is None:
return "global"
if -170 <= lon <= -30:
return "americas"
if -30 < lon <= 45:
return "europe" if lat >= 30 else "middle-east-africa"
if 45 < lon <= 150:
return "middle-east-africa" if lat < 10 else "asia-pacific"
return "asia-pacific"
def get_region_profile(region: str) -> RegionProfile:
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
return sorted(
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
key=lambda source: (source.priority, source.name),
)
def _strip_html(value: str) -> str:
if not value:
return ""
soup = BeautifulSoup(value, "html.parser")
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
def _truncate(value: str, limit: int = 180) -> str:
text = value.strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def _normalize_source_name(raw: str, fallback: str) -> str:
text = html.unescape((raw or "").strip())
if " - " in text:
return text.split(" - ")[-1].strip() or fallback
return text or fallback
def _parse_datetime(raw: str | None) -> datetime | None:
if not raw:
return None
text = raw.strip()
if not text:
return None
for parser in (
lambda value: parsedate_to_datetime(value),
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
):
try:
parsed = parser(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
except Exception:
continue
return None
def _extract_item_text(element: ET.Element, *names: str) -> str:
for name in names:
node = element.find(name)
if node is not None and node.text:
return node.text.strip()
return ""
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
root = ET.fromstring(xml_text)
items: list[ParsedNewsItem] = []
rss_items = root.findall("./channel/item")
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
nodes = rss_items or atom_entries
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
if node.tag.endswith("entry"):
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
summary = _extract_item_text(
node,
"{http://www.w3.org/2005/Atom}summary",
"{http://www.w3.org/2005/Atom}content",
)
link_node = node.find("{http://www.w3.org/2005/Atom}link")
link = link_node.get("href", "").strip() if link_node is not None else ""
published = _extract_item_text(
node,
"{http://www.w3.org/2005/Atom}updated",
"{http://www.w3.org/2005/Atom}published",
)
else:
title = _extract_item_text(node, "title")
summary = _extract_item_text(node, "description", "content")
link = _extract_item_text(node, "link")
published = _extract_item_text(node, "pubDate", "published", "updated")
clean_title = html.unescape(title).strip()
clean_summary = _truncate(_strip_html(summary), 180)
if not clean_title or not link:
continue
item_source = _normalize_source_name(clean_title, source.name)
display_title = clean_title
if source.source_type == "aggregated" and " - " in clean_title:
parts = clean_title.rsplit(" - ", 1)
display_title = parts[0].strip()
item_source = _normalize_source_name(parts[1], source.name)
items.append(
ParsedNewsItem(
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
title=display_title,
summary=clean_summary,
url=link,
source=item_source,
feed_name=source.name,
feed_region=source.region,
homepage_url=source.homepage_url,
published_at=_parse_datetime(published),
)
)
return items
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
return [
{
"id": source.id,
"name": source.name,
"region": source.region,
"homepage_url": source.homepage_url,
}
for source in sources
]
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
published_at = item.published_at
return {
"id": item.id,
"title": item.title,
"summary": item.summary,
"url": item.url,
"source": item.source,
"feed_name": item.feed_name,
"region": item.feed_region,
"homepage_url": item.homepage_url,
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
"is_focus_match": item.feed_region == active_region,
}
def _build_payload(
*,
lat: float | None,
lon: float | None,
active_region: str,
items: list[ParsedNewsItem],
sources: list[NewsFeedSource],
errors: list[str],
stale: bool,
generated_at: datetime | None = None,
) -> dict[str, Any]:
profile = get_region_profile(active_region)
timestamp = generated_at or datetime.now(UTC)
return {
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
"focus": {
"lat": lat,
"lon": lon,
"region": active_region,
"label": profile.label,
"accent": profile.accent,
},
"sources": _serialize_sources(sources),
"items": [_serialize_item(item, active_region=active_region) for item in items],
"errors": errors,
"stale": stale,
}
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
deduped: dict[str, ParsedNewsItem] = {}
for item in items:
key = item.url.strip() or item.title.strip().lower()
if key not in deduped:
deduped[key] = item
return sorted(
deduped.values(),
key=lambda item: (
item.feed_region != active_region,
item.published_at is None,
-(item.published_at.timestamp() if item.published_at else 0),
item.feed_name,
),
)[:MAX_ITEMS_TOTAL]
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
cached = _REGION_CACHE.get(region)
if not cached:
return None
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
return None
return cached
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
_REGION_CACHE[region] = CachedRegionFeed(
region=region,
fetched_at=datetime.now(UTC),
items=list(items),
sources=list(sources),
)
async def _fetch_source(
client: httpx.AsyncClient,
source: NewsFeedSource,
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
try:
response = await client.get(source.feed_url)
response.raise_for_status()
return source, _parse_feed_entries(response.text, source), None
except Exception as exc:
return source, [], str(exc)
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
active_region = determine_focus_region(lat, lon)
sources = get_sources_for_region(active_region)
errors: list[str] = []
async with httpx.AsyncClient(
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
) as client:
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
fetched_items: list[ParsedNewsItem] = []
for source, items, error in results:
if error:
errors.append(f"{source.name}: {error}")
continue
fetched_items.extend(items)
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
if ranked_items:
_store_region_cache(active_region, items=ranked_items, sources=sources)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=ranked_items,
sources=sources,
errors=errors,
stale=False,
)
cached = _get_cached_region_feed(active_region)
if cached:
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=cached.items,
sources=cached.sources,
errors=errors,
stale=True,
generated_at=cached.fetched_at,
)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=[],
sources=sources,
errors=errors,
stale=False,
)

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,326 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [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
- Earth 图层注册表和启动任务框架继续收口,启动顺序、启动模式、启动提示和任务注册现在都能从统一入口扩展
- 修复 Earth 普通旋转模式与巡航模式切换时的一组交互回归,同时让卫星/地形/昼夜模式的表现更稳定
### 🔧 Improvements
- 新增 [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) 启动任务注册表,支持 `registerLayerStartupTask(id, taskFactory)`,并拆成海缆 / 卫星 / BGP 独立注册函数
- Earth 图层控制改成注册表驱动,统一承载 `startupPriority``startupMode``startupLabel``startupMessage` 与图层持久化元信息
- Earth 设置支持持久化图层开关、旋转模式、HUD 面板显示状态、地形透明度与日夜模式,并提供一键重置
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 记录图层注册表、启动任务、设置持久化与巡航适配边界
### 🐛 Fixes
- 修复普通旋转模式下点击海缆 / 卫星 / BGP 后卡片和选中表现会被异常清空的问题
- 修复巡航模式切回旋转再切回巡航后无法继续自动巡航的问题
- 修复开启地形后卫星选中反馈层被高海拔区域吞掉的问题,并恢复轨道只在地球前半侧可见
- 修复关闭日夜模式后地球照明仍沿真实昼夜切换、亮部过曝和偏色的问题,改成更中性的 inspection lighting
- 修复 toolbar 收起态仍挡住地球交互,以及首帧短暂展开闪现的问题
---
## [0.31.2] — 2026-04-21
### ✨ Highlights
- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机
- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场
### 🔧 Improvements
- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复
- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画
- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界
### 🐛 Fixes
- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题
- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题
- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点
---
## [0.31.1] — 2026-04-21
### ✨ Highlights
- Earth 图层开关状态统一成可复用的 `active / loading` 状态机,首次启用地形和卫星时不再像按钮失效
- 文档目录重构为 `docs/technical``docs/plans``docs/deprecated`,并吸收 `.sisyphus/plans` 中有价值的 Earth / 卫星 / UE5 草案
### 🔧 Improvements
- 新增 [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js),统一按钮 tooltip、`aria-busy`、禁用态和状态文本同步
- 地形图层支持 hover/focus 预热与空闲预热,首次点击等待前移,加载中状态持续可见
- 卫星图层启用前会立即切换为 `loading` 中间态,请求完成后再切回正常开关表现
### 🐛 Fixes
- 修复地形首次加载时通知过早消失、开关仍像关闭状态导致用户误判按钮损坏的问题
- 修复卫星接口较慢时按钮没有任何中间态反馈的问题
---
## [0.31.0] — 2026-04-21
### ✨ Features
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式)
- 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
- BGP 事件图标新增填充 W 形波动符号flap 类型),替换原有难以辨认的贝塞尔细线
- 巡航/点击激活时其余卫星自动降饱和度 + 增加透明度以突出焦点;海缆未受影响时同步变暗
### 🔧 Improvements
- 修复巡航轮播期间 BGP 事件 polling 刷新导致标记闪烁消失的问题clearBGPData 延迟到请求完成后执行)
- 点击与巡航锁定颜色统一为 hover 色0.92, 0.98, 1.0 全透明),移除锁定态脉冲动画
- 巡航连接折线转折点从尖角调整为钝角linkElbowDropPx提升连线可读性
---
## [0.30.0] — 2026-04-21
### ✨ Features
- Earth 新增真实地形图层:后端代理 Terrarium DEM 瓦片(`/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png`),前端新增 `terrain.js` 负责瓦片拉取、顶点位移与按海拔着色
- 设置弹窗新增"地形"分组,支持通过滑块实时调整地形图层透明度
### 🔧 Improvements
- 地形按钮改为异步加载,首次点击显示进度提示并在失败时自动回退
- 启动阶段改用 `applyImmediateView` 直接应用初始视角,`showStatusMessage` / `queueStatusMessage` 区分即时与队列态状态消息,加载中不再被临时状态打断
- 控制面板抽取 `applyTerrainUiState` / `getViewRotation` 收敛地形切换与视角旋转的重复 UI 同步逻辑
---
## [0.29.2] — 2026-04-21
### ✨ Highlights
- Earth 继续收口 HUD 交互与设置面板表现,设置弹窗改成更接近从按钮展开的窗口感,同时加入系统级 admin 入口
- 修正天球太阳方向与地球受光解耦后的日照逻辑,地表昼夜判断改为按太阳直射点经纬度落到地球贴图坐标
### 🔧 Improvements
- toolbar 进一步收成更贴近 hub 的浅弓形排列,并统一成与 HUD panel 一致的液态玻璃配色与透明度
- 设置弹窗与各 HUD panel 继续统一样式、等比缩放和头部基线,设置列表补充系统分组与 admin 跳转
- 所有 HUD panel 增加更统一的液态玻璃高光与 hover / press 反馈
### 🐛 Fixes
- 修复设置弹窗仍像旧圆角矩形、标题文案重复和从底边直直飞出的动画问题
- 修复天球与太阳方向混用显示校准导致中国白天仍落在夜面的日照错误
---
## [0.29.1] — 2026-04-20
### ✨ Highlights
- Earth 加载状态条改成单一队列式通知面板,加载阶段不再因为步骤文案变化而回缩,也不会被其他通知打断
- 调整 brand panel 的呈现方式与昼夜/选中态可读性,让品牌区更自然、交互高亮在白天和黑夜里都更稳定
### 🔧 Improvements
- 移除旧的地球加载浮层结构,统一由 HUD 状态消息承载三点脉冲加载过程
- brand panel 改为无边框品牌层,仅保留轻微氛围光,不再因为非常规尺寸显得像第五块功能面板
- 温和收敛地球昼夜材质与主背光强度,保留昼夜辨识度的同时提升白天地表纹理和夜面交互可见性
### 🐛 Fixes
- 修复加载地球时通知条在步骤切换中反复缩短、其他状态消息抢占加载流程的问题
- 修复海缆、登陆点和 BGP 选中高亮在黑夜中过暗、在高光中过亮导致难以辨识的问题
---
## [0.29.0] — 2026-04-20
### ✨ Highlights
- Earth 新增天球层第一版:引入真实全天星图、亮星层与太阳/月亮位置计算,地球场景首次具备可校准的天文背景
- 地球昼夜分隔升级为更明显的日夜增强效果,夜面、晨昏带和太阳方向联动更容易直接读出来
### 🔧 Improvements
- 新增 `celestial.js` 模块和 `assets/celestial/` 资源目录,统一管理星图、亮星数据以及太阳/月亮与光照同步
- 卫星图例改为按倾角分组,严格固定为“赤道轨道 → 低倾角轨道 → 中倾角轨道 → 高倾角轨道 → 逆行轨道”顺序,并全部中文化
- 图层面板补齐关闭按钮,拖拽脱离左列后不再被流布局 margin 影响,能够真正贴到品牌面板下沿
### 🐛 Fixes
- 修复天球球壳放大后被相机 far plane 裁剪导致的外层黑环问题
- 修复图层面板在左侧上移时始终与 brand panel 保持额外间距的问题
---
## [0.28.2] — 2026-04-20
### ✨ Highlights
- 修正媒体情报面板在 `电视直播 / 态势聚合` tab 间切换时的尺寸记忆逻辑,切回原 tab 后可恢复各自大小状态
- 清理 `docs/` 根目录遗留的旧路径文档,只保留新的分组目录和归档目录,结束同一文档双路径并存状态
### 🔧 Improvements
- `media-panel` 切换逻辑改成按 tab 分别记忆尺寸状态,避免 `A -> B -> A` 时继续共用同一套外层尺寸
- 目录整理真正完成收尾:旧的 `docs/*.md` 平铺计划文档删除,继续以 `docs/agents / earth / backend / frontend / ops / ue5 / deprecated` 为唯一入口
### 🐛 Fixes
- 修复拉伸 `media-panel` 后切换 tab 时,`news-panel` 高度回退到旧默认值的问题
- 修复拉伸后切换 tab 导致面板视觉锚点异常的问题,切换时改为围绕当前卡片自身右下角进行尺寸恢复
---
## [0.28.1] — 2026-04-20
### ✨ Highlights
- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界
- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated`
### 🔧 Improvements
- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合”
- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义
- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构
### 🐛 Fixes
- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位
- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签
---
## [0.28.0] — 2026-04-20
### ✨ Features
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
说明:
- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel``news-panel`
### 🔧 Improvements
- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口
- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀
- `tv.js``news.js` 进一步清理共享 HUDPanel 迁移后的残留逻辑,收紧 tab / resize / reform 相关局部 helper
### 🐛 Fixes
- 修复媒体情报面板右下角缩放时高度异常抬升、标题栏被顶出视口的问题
- 修复直播/聚合 tab 切换时按钮高亮、内容切换和底边基准表现不一致的问题
---
## [0.27.7] — 2026-04-16
## [0.27.8] — 2026-04-20
### 🔧 Improvements
- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机
- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响
### 🐛 Fixes
- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题
- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题
---
## [0.27.7] — 2026-04-16
### 🔧 Improvements
- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
### 🐛 Fixes
- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
---
## [0.27.6] — 2026-04-15
### 🔧 Improvements
@@ -147,7 +467,7 @@ Released: 2026-04-12
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/technical/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/technical/earth-news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
### Improved
@@ -233,7 +553,7 @@ Released: 2026-04-10
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
### Fixed
@@ -271,7 +591,7 @@ Released: 2026-04-10
### Improved
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
## 0.24.6
@@ -288,7 +608,7 @@ Released: 2026-04-10
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
- Improved [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
- Improved [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
### Fixed
@@ -394,8 +714,8 @@ Released: 2026-04-09
### Added
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
- Added [docs/frontend/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
- Added [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
### Improved
@@ -500,7 +820,7 @@ Released: 2026-04-07
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
### Improved
@@ -626,7 +946,7 @@ Released: 2026-04-02
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md).
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-prefix-geography-plan.md).
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
@@ -641,7 +961,7 @@ Released: 2026-04-02
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
### Fixed
@@ -799,7 +1119,7 @@ Released: 2026-03-31
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md).
### Improved
@@ -1002,7 +1322,7 @@ Released: 2026-03-26
### Added
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-module-plan.md).
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.

22
docs/deprecated/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Deprecated Docs
这个目录用于存放两类文档:
1. 已经完成、主要保留为历史记录的实施计划
2. 已被现有实现或新方案替代的旧计划
放到这里并不代表这些文档“错误”,而是表示:
- 它们不再适合作为当前开发的主指导文档
- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考
当前归档原则:
- 明确写明“已完成”的计划,优先归档
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
补充说明:
- 一部分归档文档来自外部或临时工作流草案,例如 sisyphus 生成的初稿
- 这类文档如果有可用内容,应先吸收到 `docs/plans/``docs/technical/`,再归档保留来源记录

View File

@@ -0,0 +1,165 @@
# HUD Panel Component Plan
## Goal
Unify Earth HUD panels into a reusable component layer so new panels can share:
- a consistent shell
- a consistent header
- a consistent action-button system
- a consistent body and collapse pattern
## Scope
Target panels:
- `tv-panel`
- `news-panel`
- `legend`
- `layer-panel`
- `earth-stats`
- `info-card`
- settings modal header/actions
## Component Model
### Base shell
- `.hud-panel`
- `.hud-panel--compact`
- `.hud-panel--media`
- `.hud-panel--collapsed`
- `.hud-panel-hidden`
- `.hud-panel.is-dragging`
- `.hud-panel.is-layout-animating`
### Header
- `.hud-panel__header`
- `.hud-panel__title-group`
- `.hud-panel__title`
- `.hud-panel__subtitle`
- `.hud-panel__chip`
- `.hud-panel__actions`
Header baseline rule:
- Header title styling is fixed by the component layer and should not drift per panel
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
### Actions
- `.hud-panel__action`
- `.hud-panel__action--icon`
- `.hud-panel__action--collapse`
- `.hud-panel__action--close`
- `.hud-panel__action--refresh`
- `.hud-panel__action--external`
Action-button baseline rule:
- Header action buttons must have one fixed default style baseline across all HUD panels
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
### Body
- `.hud-panel__body`
- `.hud-panel__body--scroll`
- `.hud-panel__body--collapsible`
### Collapse behavior
- `.hud-panel--collapsed`
- `.hud-panel--expand-up`
- `.hud-panel--expand-down`
Adaptive collapse / expand rule:
- HUD panels support two expansion directions:
- top-to-bottom expansion
- bottom-to-top expansion
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
- Use:
- `d` = available distance from the header anchor to the viewport bottom edge
- `h` = expected expanded panel height
- buffer = `20px`
- Collapsed-state direction rule:
- if `d > h + 20px`, the next action direction is `expand-up`
- if `d <= h + 20px`, the next action direction is `expand-down`
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
- if the current direction is already `up`, keep it until `d <= h`
- if the current direction is already `down`, keep it until `d > h + 20px`
- The opposite edge is still a safety guard:
- if the chosen side cannot fit at all, fall back to the other side if it can fit
- if neither side fully fits, choose the side with more space and let the body scroll
- If neither direction fully fits, choose the direction with more available space and let the body scroll
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
- The collapse icon describes the next action, not the current state
- This mapping is fixed component behavior and must not drift per panel:
- collapsed + expand-down => `expand_more`
- expanded + expand-down => `expand_less`
- collapsed + expand-up => `expand_less`
- expanded + expand-up => `expand_more`
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
- Expansion direction and icon direction must come from one shared source of truth in the component controller
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
## Tokens
Promote panel differences into CSS variables instead of duplicating selectors:
- `--hud-panel-padding`
- `--hud-header-padding`
- `--hud-header-gap`
- `--hud-action-padding`
- `--hud-action-gap`
- `--hud-action-icon-size`
- `--hud-body-gap`
- `--hud-body-max-height`
- `--hud-chip-radius`
- `--hud-title-font-size`
- `--hud-title-font-weight`
- `--hud-title-letter-spacing`
- `--hud-title-line-height`
- `--hud-title-color`
- `--hud-header-border-color`
- `--hud-header-divider-opacity`
- `--hud-expand-direction`
## Migration Order
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
3. Migrate `legend` and `layer-panel` into a compact variant
4. Migrate `earth-stats` and `info-card`
5. Align settings modal header/actions with the same action system
6. Remove legacy one-off button selectors after verification
## Guardrails
- Do not change panel behavior and data flow during the first pass
- Keep old class names temporarily as compatibility hooks
- Prefer variable overrides over per-panel reimplementation
- Treat header action-button default styling as fixed component API, not per-panel design space
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
- Treat collapse direction as a component behavior contract, not a one-off panel trick
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
- Verify header alignment and drag/collapse behavior after each migration batch
## First Implementation Batch
Batch 1 should only do:
- shared header structure
- shared action-button system
- shared title typography and header border/divider baseline
- shared collapsible body pattern
- adaptive collapse direction logic and direction-aware collapse icons
- migration of `tv-panel` and `news-panel`
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,37 @@
# Plans Docs
这里放“未来实施方案和未完成计划”的文档,重点回答:
- 我们准备做什么
- 为什么要做
- 分几期做
- 当前差距和下一步是什么
适合放入这里的内容:
- Earth / BGP / 地形 / 天球实施方案
- AI Playground 发展计划
- backend / datasource / agent roadmap
- UE5 MVP 方案
当前重点入口:
- [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)
不适合放入这里的内容:
- 当前代码结构说明
- 组件现状和实现入口
- 已经落地的技术上下文说明
这些应放入:
- [docs/technical/README.md](/home/ray/dev/linkong/planet/docs/technical/README.md)

View File

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

View File

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

View File

@@ -0,0 +1,715 @@
# Earth 天球背景与日月位置实施方案
## 目标
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
1. 用真实天球背景替换当前随机星点
2. 在当前时间下显示太阳与月亮的相对位置
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
本方案优先追求:
- 与当前 Three.js Earth 架构兼容
- 风险可控
- 先落地一版真实感明显提升的 V1
- 为后续更严格的天文参考系升级预留余地
## 当前现状
当前 Earth 的基础条件已经具备:
- 地球、云层、地形、网格都基于 Three.js主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
这意味着:
- 不需要重写 Earth
- 可以在现有 scene/world 层新增一个 celestial layer
- 第一阶段不必拆 Earth / satellite / cable 的参考系
## 总体策略
采用“两层现实”设计:
### 1. 世界层world-space celestial layer
用于放置:
- 天球背景
- 太阳
- 月亮
- 太阳光方向
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
### 2. 地球层earth-fixed layer
继续保持当前结构:
- 海缆
- 登陆点
- 卫星点与轨迹
- BGP 覆盖
- 地球纹理、云层、地形
这些对象继续挂在 `earthObj` 下,不打断现有交互。
## 为什么先这样做
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
如果现在直接做严格惯性参考系改造,会同时影响:
- `earthObj.rotation`
- 卫星轨迹与锁定逻辑
- 海缆与登陆点附着关系
- resetView / autoRotate / hover / click 等交互链路
所以第一阶段只做:
- 真正的天空
- 真正的日月方向
- 不碰现有 Earth 附着对象的语义
## 推荐技术选型
### 天文计算库
推荐:
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
原因:
- 有 JavaScript 版本
- 支持 Sun / Moon 的矢量与坐标变换
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
不作为主选的库:
- [SunCalc](https://github.com/mourner/suncalc)
原因:
- 更偏本地观察者视角的太阳/月亮高度角
- 用于“地面日出日落”很好
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
### Three.js 表现层
推荐组合:
- 天球:内翻球壳 + 星图纹理
- 太阳:`THREE.Sprite`
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
- 太阳光:`THREE.DirectionalLight`
参考:
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
## 天球背景资源与星体数据来源
为避免把“视觉背景”和“可计算天体位置”混为一谈,本方案明确分成两类资源:
### 1. 背景资源:全天星图贴图
用于 Phase 1 的“真实天空背景”。
推荐优先来源:
- NASA SVS 的 Tycho 全天星图
- [The Tycho Catalog Skymap - Version 2.0](https://svs.gsfc.nasa.gov/3572/)
- NASA Deep Star Maps 2020
- SatelliteMap.space 在 credits 中明确提到其使用了 `NASA Deep Star Maps 2020 - High-resolution star field (1.7 billion stars from Gaia DR2)` 作为星空视觉资源
- 这说明行业内成熟实现并不一定直接渲染全部星表点,而很可能先使用一张高质量官方深空星图作为背景层
- 如需后续替换,也可评估 ESA / Gaia 的全天 sky map 资源
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
建议要求:
- 使用官方来源或官方衍生可复用资源
- 等距矩形投影equirectangular
- 坐标定义尽量明确为赤道坐标展开
- 分辨率建议至少 `4k`
- 颜色不要过亮,避免压过 Earth HUD 前景
- 尽量优先选择官方天文机构已经生产好的深空图,而不是自行拼接低质量星空纹理
建议本地资源目录:
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
### 2. 位置数据:星表与天体计算
用于 Phase 2+ 的“位置正确的星体”。
推荐来源分两层:
- 太阳、月亮位置
- 使用 [Astronomy Engine](https://github.com/cosinekitty/astronomy)
- 恒星位置
- 第一优先Hipparcos / Tycho
- [Hipparcos overview](https://www.cosmos.esa.int/web/Hipparcos)
- [Hipparcos catalogues](https://www.cosmos.esa.int/web/hipparcos/catalogues)
- 第二优先Gaia
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
建议策略:
- V1背景球壳只用全天星图不立即生成全量恒星点
- V2只挑选亮星例如星等 `< 5.5`)生成恒星点层
- V3如果确实需要更丰富的星场再逐步扩展到更深星等
这样做的原因:
- 背景球壳负责“天球真实感”
- 亮星点负责“位置正确、可后续标注和高亮”
- 不需要一开始就处理数十万甚至数百万颗星
### 3. 对外部成熟实现的参考结论
`SatelliteMap.space` 的公开 credits 提供了一个很有价值的参考样板:
- 图形渲染使用 `TWGL.js`
- 天文计算使用 `Skyfield``Astronomia`
- 星空/天球视觉资源使用 `NASA Deep Star Maps 2020`
这给本项目的启发是:
- “真实感强的天球背景”完全可以先依赖官方高质量深空图
- “位置正确的动态天体”则应依赖单独的天文计算链路
- 没有必要在第一版就直接渲染完整星表
因此本项目推荐继续坚持两层拆分:
- 背景层:官方深空图 / 全天星图
- 计算层:太阳、月亮与后续亮星点
## 如何保证星体位置正确
位置正确不是只看“图看起来像”,而是要统一参考系和转换链路。
### 1. 统一坐标基准
本方案推荐统一使用:
- `J2000` 赤道坐标系作为恒星位置基准
原因:
- Hipparcos / Tycho 资料和大量天文可视化都容易映射到该基准
- 太阳、月亮也可以通过 Astronomy Engine 转到同一坐标系
- 这样背景、恒星点、太阳、月亮就能共用一套 sky orientation
### 2. 背景贴图与点位必须使用同一展开逻辑
如果背景球壳使用赤道坐标全天图,那么:
- 亮星点也必须按赤道坐标贴到同一球面方向
- 太阳/月亮 sprite 也必须按赤道坐标转换后落到同一 world-space
否则会出现:
- 背景银河带是对的
- 但太阳/月亮或亮星点飘到不匹配的位置
### 3. RA / Dec 到 Three.js 坐标的落点方式
亮星点和日月方向最终都要转成单位球面向量。
概念步骤:
1. 读取赤经 `RA`
2. 读取赤纬 `Dec`
3. 转成弧度
4. 映射到单位球面向量
5. 再根据 Three.js 当前世界坐标定义做轴向映射
参考公式:
```text
x = cos(dec) * cos(ra)
y = sin(dec)
z = cos(dec) * sin(ra)
```
实际接入 Three.js 时,需要做一次项目内坐标轴校准:
- 验证 `RA = 0h`
- 验证 `RA = 6h`
- 验证北天极
- 验证银河带主方向
然后确定最终的:
- `x/y/z` 对应 Three.js 哪个轴
- 是否需要 `z` 取反
- 是否需要整体再做一个固定 `rotation`
建议把这层显式封装在:
```js
function equatorialToWorldVector(raRad, decRad)
```
不要把轴映射散落在不同模块里。
### 4. 背景球壳与恒星点的关系
推荐最终组合:
- 背景层:全天星图球壳
- 点位层:亮星点
- 动态层:太阳 / 月亮
这样有三个好处:
- 背景层提供密集真实的天空纹理
- 亮星点提供位置正确、可扩展的标注基础
- 太阳/月亮提供与时间相关的真实动态对象
## 数据与资源建议清单
### 推荐首批引入资源
1. 全天星图
- 来源NASA Tycho all-sky map
- 用途:背景球壳纹理
2. 月亮纹理
- 用途Phase 4 月相表现
- 路径建议:
- `frontend/public/earth/assets/celestial/moon_albedo_2k.jpg`
3. 太阳 glow 贴图
- 用途:太阳 sprite halo
- 路径建议:
- `frontend/public/earth/assets/celestial/sun_glow.png`
### 推荐首批数据文件
如果要上亮星层,建议新增一个预处理后的轻量数据文件:
- `frontend/public/earth/assets/celestial/bright-stars.json`
建议字段:
```json
[
{
"id": 32349,
"name": "Sirius",
"raDeg": 101.2875,
"decDeg": -16.7161,
"mag": -1.46,
"colorIndex": 0.00
}
]
```
建议不要在浏览器里直接吞原始 Gaia 大表,而是先离线裁剪成:
- 只保留亮星
- 只保留渲染必需字段
- JSON 或二进制轻量格式
## 资源与数据实施路线
### 路线 A先做可用版本推荐
1. 引入 NASA Tycho 全天图
- 或评估替换为更接近 SatelliteMap.space 路线的 `NASA Deep Star Maps 2020`
2. 实现背景球壳
3. 用 Astronomy Engine 计算太阳/月亮方向
4. 暂不做亮星点
优点:
- 最快见效
- 风险最低
- 就能明显提升天球真实感
### 路线 B在 A 基础上增强
1. 离线生成 `bright-stars.json`
2. 浏览器端渲染亮星点
3. 后续可加:
- 星座线
- 亮星名称
- 特定星体高亮
优点:
- 背景真实感和“位置正确的可交互星体”同时兼顾
## 代码模块建议细化
### 新增模块
- `frontend/public/earth/js/celestial.js`
- 管理天球背景
- 管理太阳/月亮
- 管理亮星层(后续)
- `frontend/public/earth/js/celestial-data.js`
- 资源路径
- 星图方向配置
- 亮星数据加载(后续)
### 建议函数设计
```js
export function initCelestialLayer(scene)
export function updateCelestialLayer(date)
export function setCelestialVisibility(visible)
export function disposeCelestialLayer()
function loadStarMapTexture()
function createSkySphere(texture)
function createSunSprite()
function createMoonSprite()
function getSunEquatorialPosition(date)
function getMoonEquatorialPosition(date)
function equatorialToWorldVector(raRad, decRad)
```
### 推荐后续预处理脚本
如要引入亮星层,建议单独做离线脚本:
- `scripts/build_bright_stars.py`
职责:
- 从 Hipparcos / Tycho 源数据读取
- 过滤亮星
- 生成 `bright-stars.json`
这样浏览器端只消费轻量结果,不承担大表解析成本。
## 分阶段实施
## Phase 1真实天球背景
### 目标
用真实全天星图替换当前随机星点背景。
### 做法
1. 新增一张全天星图纹理
建议路径:
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
纹理要求:
- 等距矩形投影
- 赤经/赤纬坐标展开
- 无地平线、无地景遮挡
- 尽量深色、弱干扰,适合大屏 HUD 叠加
2. 新增天球球壳
新增模块:
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
建议接口:
```js
export function initCelestialLayer(scene)
export function updateCelestialLayer(date, camera, earth)
export function disposeCelestialLayer()
```
3. 实现一个大半径内翻球体
建议参数:
- 半径:`600 ~ 900`
- 材质:`MeshBasicMaterial`
- `side: THREE.BackSide`
- 不受场景光照影响
- 始终围绕场景中心
### 验收标准
- 初始加载后背景不再是随机星点
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
- 不明显干扰海缆/卫星/BGP 的前景识别
## Phase 2太阳与月亮真实位置
### 目标
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
### 做法
1.`celestial.js` 内封装天体位置计算
建议函数:
```js
function getSunDirection(date)
function getMoonDirection(date)
```
输出统一为 world-space `THREE.Vector3`
2. 太阳显示
- 一个暖色发光 sprite
- 比月亮更大、更亮
- 可选添加柔和 halo
3. 月亮显示
- 一个较小 sprite 或 sphere
- 灰白偏冷色
- 后续 Phase 3 再做月相
4. 更新频率
不要每帧重新做完整天文计算,建议:
- 每 30 秒或 60 秒重算一次真实位置
- 渲染帧内做平滑过渡
### 验收标准
- 页面可见太阳与月亮两个对象
- 时间变化时位置会更新
- 日月不会跟随地球局部旋转而错误附着
## Phase 3太阳驱动地球受光
### 目标
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
### 做法
1. 替换或接管当前主定向光
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`
建议改为:
- 保留环境补光
- 主太阳光方向由 `sunDirection` 决定
2. 太阳光参数建议
- `DirectionalLight` 颜色偏暖白
- 强度略高于当前主光
- 保留一个弱背光作为氛围补偿,避免背面过死黑
3. 先不做物理级大气散射
第一版只要求:
- 亮面与暗面方向真实
- 云层和大气仍保持当前风格
### 验收标准
- 地球明暗面会随太阳方向改变
- 太阳 sprite 和地球亮面方向一致
- 不破坏现有海缆、卫星、BGP 的可见性
## Phase 4月相与天文细节增强
### 目标
在日月真实位置基础上增加更强的“天文可信度”。
### 可选项
1. 月相
- 根据日月夹角计算 illuminated fraction
- 用月相纹理或 shader 表达盈亏
2. 赤道/黄道辅助线
- 可作为开发调试层,不默认显示
3. 太阳 terminator 增强
- 给地球夜面加入更自然的 night tint
- 未来可叠加城市夜光纹理
4. 天文时间入口
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
### 验收标准
- 月亮不再只是一个静态圆点
- 后续扩展行星或观测模式时无需推倒重来
## Phase 5严格参考系升级可选不作为 V1 必做)
### 目标
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
### 需要处理的问题
- 地球自转角与 UTC 的一致性
- 赤道坐标系、地固坐标系、相机交互层分离
- 卫星轨道显示与 Earth 旋转同步关系
- resetView 和 autoRotate 的语义重定
### 风险
这一步会影响:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
因此不建议与 V1 同时推进。
## 代码改造清单
## 1. 新增文件
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
职责:
- 管理天球背景、太阳、月亮
- 对外暴露 init/update/dispose
## 2. 修改 `constants.js`
文件:
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
新增:
```js
export const CELESTIAL_CONFIG = {
sphereRadius: 800,
updateIntervalMs: 60000,
sunSpriteScale: 28,
moonSpriteScale: 16,
sunLightIntensity: 1.25,
ambientIntensity: 0.28,
backLightIntensity: 0.18,
};
```
## 3. 修改 `main.js`
文件:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
主要改动:
1. `init()` 中:
- 初始化 celestial layer
2. `addLights()` 中:
- 把固定太阳光改成可更新的 celestial sun light
3. `animate()` 中:
- 每帧调 `updateCelestialLayer()`
4. `destroy()` 中:
- 清理 celestial 资源
## 4. 修改 `earth.js`
文件:
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
主要改动:
- `createStars()` 逐步退役
- 第一阶段可先保留作为 fallback
- 当真实星图加载成功后,不再显示随机星点
## 5. 新增资源
目录建议:
- `frontend/public/earth/assets/celestial/`
建议至少包含:
- `starmap_equatorial_4k.jpg`
- `sun_glow.png`
- `moon_albedo_2k.jpg`
## 数据流设计
```mermaid
flowchart TD
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
B --> C["创建天球球壳"]
B --> D["创建太阳 sprite + 主定向光"]
B --> E["创建月亮 sprite"]
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
H --> I["更新 sun sprite / moon sprite 位置"]
H --> J["更新太阳 DirectionalLight 方向"]
J --> K["地球昼夜方向变化"]
```
## 风险与注意事项
### 1. 星图投影方向容易反
这会表现为:
- 星图左右镜像
- 赤经方向颠倒
- 日月位置和背景对不上
建议:
- 先做一个开发调试模式
- 显示赤经/赤纬参考点,快速校正纹理朝向
### 2. 不要让天球跟随 Earth 旋转
天球背景和日月必须属于 scene/world而不是 `earthObj`
### 3. 不要每帧做重型天文计算
真实位置更新应节流,否则会浪费 CPU。
### 4. 月亮先求“方向正确”,再求“月相精致”
月相属于第二步优化,不应阻塞 V1 上线。
## 推荐实施顺序
1. 新建 `celestial.js`
2. 用星图球壳替换随机星点
3. 接入 Astronomy Engine
4. 加太阳/月亮 sprite
5. 用太阳方向驱动主光
6. 再决定要不要做月相和更严格参考系
## 最终建议
对于当前 Planet Earth最稳妥的方案是
- 先做真实天球背景
- 再做真实太阳/月亮方向
- 再让太阳驱动地球受光
- 暂时不做 Earth 参考系重构
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。

View File

@@ -0,0 +1,312 @@
# 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. 最后才做关系层和专题页
这样可以避免一开始把范围摊得过大。
## 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

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

View File

@@ -0,0 +1,472 @@
# Earth Real Terrain Plan
## Goal
将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。
当前占位实现位于:
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
具体问题:
- `createTerrain()` 直接对球体顶点应用 `simplex noise`
- 没有真实海拔数据来源
- 没有分辨率分层
- 没有和当前相机/视角配套的性能控制
## Constraints
本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎:
- 地球主体仍然是一个 Three.js sphere
- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上
- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换
- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形
## Recommended Data Source
### Primary recommendation
使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入:
- Mapzen/AWS `Terrarium` elevation tiles
参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/)
原因:
- 已经是全球瓦片化高程
- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe
- 编码简单稳定:
- `heightMeters = (R * 256 + G + B / 256) - 32768`
- 不需要我们先离线拼整球 DEM
### Data quality upgrade path
如果后面第一阶段效果确认可用,再逐步升级到底层源:
- Copernicus DEM GLO-30
参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles
这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。
## Why Not Replace the Engine
不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因:
- 现有 Earth 业务对象都依附当前球面坐标
- 切引擎会同时波及:
- 海缆绘制
- 卫星/轨迹
- BGP 标记
- HUD 与交互
- 这是“重做一页”,不是“给地形层接真实数据”
所以推荐路线是:
- 保持当前 sphere globe
- 为 sphere 增加真实高度位移层
## Implementation Strategy
分三期推进。
### Phase 1 — Global Heightmap Terrain Overlay
目标:
- 地形层切换后显示真实海拔起伏
- 全球范围可用
- 性能可控
做法:
1. 新增 terrain 数据模块
建议文件:
- `frontend/public/earth/js/terrain.js`
职责:
- 选择 DEM zoom level
- 请求 Terrarium tiles
- 解码 tile 高程
- 将高程重采样到当前地形球体网格
2. 替换 `createTerrain()`
当前:
- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形
调整后:
- `createTerrain()` 只负责创建 terrain mesh 骨架
- 真正的顶点位移由 terrain 模块异步注入
3. 第一阶段采用“整球低分辨率位移”
不要一上来做动态 patch stitching。第一阶段更稳的办法是
- 保留一张全球 terrain sphere
- 使用较低分辨率几何
- 例如 `SphereGeometry(radius, 192, 192)``256/256`
- 运行时按一个固定地形 zoom`z=4``z=5`)抓取覆盖全球的 Terrarium tiles
- 将 tile 解码后重投影到经纬度采样网格
- 将每个球面顶点按真实高度抬升
这样第一阶段就能做到:
- 有真实地形
- 不需要复杂的局部 LOD
- 不会让现有球体对象体系爆炸
### Phase 2 — View-Aware Refinement
目标:
- 正面可见区域更精细
- 背面与远处维持低成本
做法:
- 引入“基础全球地形 + 当前视角高分局部补丁”
- 正面区域额外抓更高 zoom 的高程 tile
- 只替换局部顶点位移或局部 overlay mesh
这一阶段适合在第一阶段稳定后做。
### Phase 3 — Normals / Shading / Terrain UX
目标:
- 地形不仅有起伏,还更好看、更可读
包括:
- 根据高度生成更合理的 normals
- 调整 terrain material使山脉/高原更易读
- 可选加入:
- hillshade
- contour lines
- snowline / bathymetry tint
## Calibration Overlay Before More Terrain Tuning
在当前项目里terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。
没有清晰的海岸线、国界线和地表分层时,人眼很难判断:
- 山脉是不是在应该高的地方高
- terrain 是否真的贴在正确的大陆位置上
- 地球纹理、本初子午线、terrain 采样之间是否存在偏移
这里要明确区分两件事:
- 国界线不会修好错误的 terrain
- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准
所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。
### Recommended order for the calibration layer
1. 海岸线
2. 国界线
3. 再继续调 terrain
原因:
- 海岸线比国界线更基础,也更接近真实地表边界
- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系
- 国界线更多是政治边界,只能作为辅助参照
如果只加国界线,不加海岸线,效果仍然可能会怪,因为:
- 很多国界线本来就是人为直线
- 它们并不总是跟真实地形走
### Suggested layer order during debugging
建议调试期临时把地球层次明确成:
1. base earth texture
2. coastline / borders overlay
3. terrain relief
4. cables / landing points / bgp / satellites
这样会比现在更容易判断:
- 山脉是否位于正确区域
- terrain 是否和地表对齐
- 国界/海岸是否漂移
### Suggested data source for the calibration overlay
优先用 `Natural Earth` 的轻量全球矢量数据:
- 海岸线coastline
- Admin 0 国界线country borders
优点:
- 全球一致
- 轻量
- 很适合当前 Three.js globe 做 overlay
### Recommended execution path
#### Phase A — Add reference overlays
先加两层可开关的参考线:
- 海岸线
- 国界线
这两层的目标不是最终美术表现,而是调试 / 校准。
#### Phase B — Recalibrate terrain against coastline
有了海岸线以后,再重新看 terrain
- terrain 是否和大陆边缘错位
- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移
#### Phase C — Decide whether to keep the current terrain path
这时再决定后面的路线:
- 如果发现真实高程整体是对的,只是缺少 shading / readability
继续保留当前 DEM + terrain overlay 路线
- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭
再考虑重做 terrain pipeline
### Practical recommendation
当前阶段不建议“从头开始重做 terrain”。
更稳的策略是:
- 暂停继续盲调 terrain 参数
- 先补海岸线 / 国界线作为校准参照层
- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移”
## Recommended Geometry Model
### First usable model
保留一层独立 terrain sphere
- base earth sphere贴纹理、昼夜、海洋
- terrain sphere略高于地球半径真实高程位移
建议:
- `terrainBaseRadius = CONFIG.earthRadius + 0.2`
- 高度缩放使用真实米制换算,再乘一个可调 exaggeration
示例关系:
- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration`
建议第一阶段 `exaggeration = 1.3 ~ 1.8`
因为完全真实比例在全球球体上会太平,看不出来。
## Tile Decoding Plan
### Terrarium decode
对于每个高程 tile 像素:
```text
heightMeters = (R * 256 + G + B / 256) - 32768
```
### Sampling path
对于 terrain mesh 上每个顶点:
1. 将顶点方向转成经纬度
2. 将经纬度映射到 Web Mercator tile 坐标
3. 找到对应的 tile 和像素
4. 解码高程
5. 将顶点沿法线方向抬升
### Needed helpers
建议新增:
- `latLonToTileXY(lat, lon, z)`
- `tilePixelFromLatLon(lat, lon, z, tileSize)`
- `decodeTerrariumHeight(r, g, b)`
## Caching Strategy
为了不让地形开关每次重开都重新抓全量 tile
- terrain tile 按 `z/x/y` 存到内存缓存
- terrain mesh 结果也缓存一份
- 当用户关闭/开启 terrain
- 直接复用已有位移结果
建议:
- `Map<string, Float32Array | ImageBitmap>`
## Material Strategy
第一阶段不要复杂化。
建议 terrain material
- 半透明低饱和地形色
- 比 base earth 稍亮或稍偏冷
- 保留当前 HUD 风格下的可读性
第一阶段不需要:
- 真实土地覆被纹理
- 独立卫星影像贴 terrain
因为那会和现有地球纹理、云层、昼夜 shader 打架。
## Integration Points
### Files to change
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- 重写 `createTerrain()`
- 删除 simplex noise 占位逻辑
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- 初始化 terrain 数据加载
- 控制 terrain readiness / loading message
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
- `toggleTerrain` 逻辑保持,但应能区分:
- mesh 已就绪
- 正在加载
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
- 新增 `TERRAIN_CONFIG`
- 新文件:
- `frontend/public/earth/js/terrain.js`
### Suggested new config
建议新增:
```js
export const TERRAIN_CONFIG = {
enabled: true,
tileSize: 256,
baseZoom: 4,
baseRadiusOffset: 0.2,
exaggeration: 1.5,
opacity: 0.55,
color: 0x6c876f,
maxConcurrentRequests: 8,
cacheEnabled: true,
};
```
## Loading UX
地形第一次开启时,不能像现在一样瞬时切换。
建议:
- 如果地形数据尚未准备:
- 顶部状态条显示:`正在加载真实地形数据...`
- 完成后:
- `真实地形已就绪`
如果加载失败:
- 保留 base earth
- 显示轻量错误提示
- 不要让 terrain 开关卡死在“开”状态
## Risks
### 1. Global tile count too high
即使 `z=5` 全球 tile 数也不少。
缓解:
- 第一阶段限定低 zoom
- 并发上限
- 缓存
### 2. Mesh resolution too low
如果球面分段太低,山脉会被抹平。
缓解:
- 第一阶段先选一个中等分辨率
- 用 exaggeration 保证可见性
### 3. Existing overlays may z-fight with terrain
海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。
缓解:
- terrain sphere 单独作为 overlay
- overlay 保持略低或略高的固定 offset
- 必要时局部调整 landing point / cable altitude offset
### 4. Mercator sampling distortion near poles
Web Mercator 在高纬会有失真。
缓解:
- 第一阶段接受
- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline
## Acceptance Criteria
第一阶段完成后,应满足:
1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声
2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认
3. 关闭/重新开启 terrain 不重复全量请求
4. 不破坏:
- 海缆
- 卫星
- BGP
- 地球昼夜
- 天球层
## Suggested Execution Order
1. 引入 `TERRAIN_CONFIG`
2. 新建 `terrain.js`
3. 实现 Terrarium tile 请求与 decode
4. 用低 zoom 全球 tile 构建真实 terrain sphere
5. 接管 `toggleTerrain()`
6. 调整 terrain material 和高度 exaggeration
7. 做缓存
8. 再考虑第二阶段局部高分 refinement
## Source References
- Mapzen Terrarium / AWS terrain tiles
[Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/)
- Terrarium tile experiments / format background
[mapzen/terrarium](https://github.com/mapzen/terrarium)
- Copernicus DEM overview
[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
## Recommendation Summary
如果现在就要开始做,我建议直接按这条路线开工:
- 第一阶段接入 Terrarium 全球高程 tile
- 替换掉当前 simplex 假地形
- 先做一层真实可见的全球 terrain overlay
- 等第一阶段稳定,再做视角高分 refinement
这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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

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

View File

@@ -16,12 +16,31 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.27.6`
- `dev` 当前开发分支历史推导到:`0.36.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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 重构归档 |
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD并校正太阳受光方向 |
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示brand panel 去框并收敛昼夜与选中态可读性 |
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
| `0.28.2` | bugfix | `dev` | `pending` | 修正媒体情报 tab 尺寸记忆与切换锚点逻辑,并清理 docs 根目录遗留旧路径文档 |
| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 |
| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab并稳定 TV/news 的 reform、resize 与共享 HUD 行为 |
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 |
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.27.6",
"version": "0.36.0",
"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

@@ -0,0 +1,30 @@
[
{ "id": 32349, "name": "Sirius", "raDeg": 101.2875, "decDeg": -16.7161, "mag": -1.46, "colorIndex": 0.00 },
{ "id": 30438, "name": "Canopus", "raDeg": 95.9879, "decDeg": -52.6957, "mag": -0.74, "colorIndex": 0.15 },
{ "id": 69673, "name": "Arcturus", "raDeg": 213.9153, "decDeg": 19.1824, "mag": -0.05, "colorIndex": 1.23 },
{ "id": 71683, "name": "Alpha Centauri", "raDeg": 219.9021, "decDeg": -60.8339, "mag": -0.01, "colorIndex": 0.71 },
{ "id": 91262, "name": "Vega", "raDeg": 279.2347, "decDeg": 38.7837, "mag": 0.03, "colorIndex": 0.00 },
{ "id": 24608, "name": "Capella", "raDeg": 79.1723, "decDeg": 45.9979, "mag": 0.08, "colorIndex": 0.80 },
{ "id": 24436, "name": "Rigel", "raDeg": 78.6345, "decDeg": -8.2016, "mag": 0.12, "colorIndex": -0.03 },
{ "id": 37279, "name": "Procyon", "raDeg": 114.8255, "decDeg": 5.2250, "mag": 0.34, "colorIndex": 0.42 },
{ "id": 7588, "name": "Achernar", "raDeg": 24.4286, "decDeg": -57.2368, "mag": 0.46, "colorIndex": -0.16 },
{ "id": 27989, "name": "Betelgeuse", "raDeg": 88.7929, "decDeg": 7.4071, "mag": 0.50, "colorIndex": 1.85 },
{ "id": 68702, "name": "Hadar", "raDeg": 210.9559, "decDeg": -60.3731, "mag": 0.61, "colorIndex": -0.23 },
{ "id": 97649, "name": "Altair", "raDeg": 297.6958, "decDeg": 8.8683, "mag": 0.76, "colorIndex": 0.22 },
{ "id": 60718, "name": "Acrux", "raDeg": 186.6490, "decDeg": -63.0991, "mag": 0.77, "colorIndex": -0.24 },
{ "id": 21421, "name": "Aldebaran", "raDeg": 68.9800, "decDeg": 16.5093, "mag": 0.85, "colorIndex": 1.54 },
{ "id": 65474, "name": "Spica", "raDeg": 201.2983, "decDeg": -11.1614, "mag": 0.98, "colorIndex": -0.23 },
{ "id": 80763, "name": "Antares", "raDeg": 247.3519, "decDeg": -26.4320, "mag": 1.06, "colorIndex": 1.83 },
{ "id": 37826, "name": "Pollux", "raDeg": 116.3289, "decDeg": 28.0262, "mag": 1.14, "colorIndex": 1.00 },
{ "id": 113368, "name": "Fomalhaut", "raDeg": 344.4128, "decDeg": -29.6222, "mag": 1.16, "colorIndex": 0.09 },
{ "id": 102098, "name": "Deneb", "raDeg": 310.3579, "decDeg": 45.2803, "mag": 1.25, "colorIndex": 0.09 },
{ "id": 49669, "name": "Regulus", "raDeg": 152.0929, "decDeg": 11.9672, "mag": 1.35, "colorIndex": -0.11 },
{ "id": 65477, "name": "Mimosa", "raDeg": 191.9303, "decDeg": -59.6888, "mag": 1.25, "colorIndex": -0.23 },
{ "id": 33579, "name": "Alphard", "raDeg": 141.8969, "decDeg": -8.6586, "mag": 1.98, "colorIndex": 1.44 },
{ "id": 21444, "name": "Bellatrix", "raDeg": 81.2828, "decDeg": 6.3497, "mag": 1.64, "colorIndex": -0.22 },
{ "id": 25336, "name": "Elnath", "raDeg": 81.5729, "decDeg": 28.6075, "mag": 1.65, "colorIndex": -0.13 },
{ "id": 26311, "name": "Alnilam", "raDeg": 84.0534, "decDeg": -1.2019, "mag": 1.69, "colorIndex": -0.19 },
{ "id": 26727, "name": "Alnitak", "raDeg": 85.1897, "decDeg": -1.9426, "mag": 1.77, "colorIndex": -0.19 },
{ "id": 25930, "name": "Saiph", "raDeg": 86.9391, "decDeg": -9.6696, "mag": 2.06, "colorIndex": -0.20 },
{ "id": 58001, "name": "Alioth", "raDeg": 193.5073, "decDeg": 55.9598, "mag": 1.76, "colorIndex": -0.02 }
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

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));
@@ -19,6 +23,7 @@
--hud-font-size: calc(0.88rem * var(--hud-scale));
--hud-font-size-sm: calc(0.75rem * var(--hud-scale));
--hud-title-size: calc(1.02rem * var(--hud-scale));
--hud-panel-header-title-size: calc(0.82rem * var(--hud-scale));
--hud-kicker-size: calc(0.68rem * var(--hud-scale));
--hud-surface-top: rgba(17, 31, 53, 0.84);
--hud-surface-bottom: rgba(7, 17, 31, 0.76);
@@ -65,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;
}
@@ -82,59 +98,17 @@ body.earth-page {
pointer-events: none;
}
.earth-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 240;
min-width: min(calc(320px * var(--hud-scale)), 78vw);
padding: calc(26px * var(--hud-scale));
border-radius: calc(18px * var(--hud-scale));
border: 1px solid rgba(77, 184, 255, 0.34);
background:
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.12), transparent 35%),
linear-gradient(180deg, rgba(13, 24, 46, 0.95), rgba(7, 14, 28, 0.94));
box-shadow:
0 0 30px rgba(77, 184, 255, 0.22),
0 16px 40px rgba(0, 0, 0, 0.28);
text-align: center;
color: #4db8ff;
}
.earth-loading-text {
color: #4db8ff;
}
.earth-loading-title {
font-size: calc(1.15rem * var(--hud-scale));
font-weight: 600;
}
.earth-loading-subtitle {
margin-top: calc(10px * var(--hud-scale));
color: #9ab7d4;
font-size: calc(0.84rem * var(--hud-scale));
line-height: 1.45;
}
.earth-loading-spinner {
width: calc(40px * var(--hud-scale));
height: calc(40px * var(--hud-scale));
margin: 0 auto calc(15px * var(--hud-scale));
border: 4px solid rgba(77, 184, 255, 0.28);
border-top: 4px solid #4db8ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
@keyframes earthLoadingPulse {
0%,
80%,
100% {
opacity: 0.28;
transform: scale(0.78);
}
100% {
transform: rotate(360deg);
40% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -28,20 +28,22 @@
.stats-kicker {
color: var(--hud-text-soft);
font-size: calc(0.64rem * var(--hud-scale));
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: var(--hud-panel-header-title-size);
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
}
/* Reuse hud-panel-close — just override size to match kicker line */
.stats-drag-bar .hud-panel-close {
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
min-width: calc(20px * var(--hud-scale));
align-self: auto;
width: auto;
height: auto;
min-width: 0;
padding: calc(7px * var(--hud-scale));
}
.stats-drag-bar .hud-panel-close .material-symbols-rounded {
font-size: calc(12px * var(--hud-scale));
font-size: calc(16px * var(--hud-scale));
}
/* ── 2-column KPI grid ────────────────────────────────────────── */
@@ -131,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

@@ -26,13 +26,23 @@
.hud-panel-brand {
--brand-scale: 0.88;
--brand-copy-width: 160px;
border-radius: 0;
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
/* Reserve full panel height before brand images load */
min-height: calc(66px * var(--hud-scale));
background: transparent;
border: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
isolation: isolate;
}
.hud-panel-brand::before,
.hud-panel-brand::after {
display: none;
}
.hud-panel-brand .earth-brand {
@@ -40,6 +50,20 @@
align-items: center;
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
min-width: 0;
position: relative;
}
.hud-panel-brand .earth-brand::after {
content: "";
position: absolute;
inset: calc(4px * var(--hud-scale)) calc(-10px * var(--hud-scale)) calc(6px * var(--hud-scale)) calc(-10px * var(--hud-scale));
background:
radial-gradient(circle at 18% 50%, rgba(145, 186, 255, 0.14), transparent 32%),
linear-gradient(90deg, rgba(145, 186, 255, 0.05), transparent 58%);
opacity: 0.75;
pointer-events: none;
filter: blur(14px);
z-index: -1;
}
.hud-panel-brand .earth-brand__logo {
@@ -137,6 +161,84 @@
pointer-events: auto;
}
.info-card-cruise-link {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
z-index: 49;
}
.info-card-cruise-link polyline {
fill: none;
stroke: rgba(255, 255, 255, 0.98);
stroke-width: 2.15;
stroke-linecap: round;
stroke-linejoin: round;
filter:
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
drop-shadow(0 0 2px rgba(6, 14, 28, 0.56))
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
}
.info-card-cruise-link circle {
fill: rgba(255, 255, 255, 0.98);
stroke: rgba(7, 16, 32, 0.72);
stroke-width: 1.0;
filter:
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
drop-shadow(0 0 2px rgba(6, 14, 28, 0.54))
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
transform-box: fill-box;
transform-origin: center;
}
.info-card-cruise-link.is-visible {
opacity: 1;
}
.info-card-cruise-link.is-animating polyline {
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.info-card-cruise-link.is-animating circle {
opacity: 0;
}
.info-card-cruise-link.is-animating circle:first-of-type {
animation: cruiseConnectorNodeIn 0.14s ease forwards;
animation-delay: 0.02s;
}
.info-card-cruise-link.is-animating circle:last-of-type {
animation: cruiseConnectorNodeIn 0.16s ease forwards;
animation-delay: 0.34s;
}
@keyframes cruiseConnectorDraw {
from {
stroke-dashoffset: var(--connector-length, 0px);
}
to {
stroke-dashoffset: 0px;
}
}
@keyframes cruiseConnectorNodeIn {
from {
opacity: 0;
transform: scale(0.72);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ── Info Card ────────────────────────────────────────────────── */
.info-card {
@@ -160,7 +262,7 @@
.info-card-header h3 {
flex: 1;
margin: 0;
font-size: calc(0.92rem * var(--hud-scale));
font-size: var(--hud-panel-header-title-size);
color: var(--hud-title);
font-weight: 600;
white-space: nowrap;
@@ -170,6 +272,15 @@
.info-card-close {
flex-shrink: 0;
align-self: auto;
width: auto;
height: auto;
min-width: 0;
padding: calc(7px * var(--hud-scale));
}
.info-card-close .material-symbols-rounded {
font-size: calc(16px * var(--hud-scale));
}
.info-card-content {
@@ -256,3 +367,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

@@ -39,7 +39,7 @@
flex: 1 1 auto;
margin: 0;
color: var(--hud-text-soft);
font-size: calc(0.82rem * var(--hud-scale));
font-size: var(--hud-panel-header-title-size);
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.2;
@@ -51,38 +51,33 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(22px * var(--hud-scale));
height: calc(22px * var(--hud-scale));
min-width: calc(22px * var(--hud-scale));
padding: 0;
border: none;
padding: calc(7px * var(--hud-scale));
border: 1px solid transparent;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: background 0.14s ease, color 0.14s ease;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
}
.layer-panel-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--hud-text);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong);
}
.layer-panel-btn .material-symbols-rounded {
font-size: calc(14px * var(--hud-scale));
font-size: calc(16px * var(--hud-scale));
line-height: 1;
pointer-events: none;
transition: transform 0.22s ease;
}
/* Chevron展开时朝上可折叠折叠时朝下可展开 */
.layer-panel-btn .material-symbols-rounded {
transform: rotate(180deg);
}
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
transform: rotate(0deg);
transition: color 0.18s ease;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
}
/* ── Search bar ───────────────────────────────────────────────── */
@@ -240,6 +235,7 @@
border: none;
background: transparent;
cursor: pointer;
transition: opacity 0.18s ease;
}
.layer-row-toggle-track {
@@ -252,6 +248,11 @@
transition: background 0.18s ease, border-color 0.18s ease;
}
.layer-row-toggle:disabled {
cursor: progress;
opacity: 1;
}
/* Thumb */
.layer-row-toggle-track::after {
content: "";
@@ -266,6 +267,24 @@
transition: transform 0.18s ease, background 0.18s ease;
}
.layer-row-toggle.is-loading .layer-row-toggle-track {
background: linear-gradient(
90deg,
rgba(104, 147, 221, 0.38),
rgba(143, 185, 255, 0.72),
rgba(104, 147, 221, 0.38)
);
background-size: 180% 100%;
border-color: rgba(223, 236, 252, 0.28);
animation: layer-toggle-loading-track 1.2s linear infinite;
}
.layer-row-toggle.is-loading .layer-row-toggle-track::after {
background: #f0f6ff;
transform: translateX(calc(7px * var(--hud-scale)));
animation: layer-toggle-loading-thumb 1s ease-in-out infinite;
}
/* Active (ON) state */
.layer-row-toggle.active .layer-row-toggle-track {
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
@@ -277,5 +296,50 @@
transform: translateX(calc(14px * var(--hud-scale)));
}
@keyframes layer-toggle-loading-track {
0% {
background-position: 0% 50%;
}
100% {
background-position: 180% 50%;
}
}
@keyframes layer-toggle-loading-thumb {
0%,
100% {
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 0 rgba(174, 205, 255, 0.18);
}
50% {
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 calc(10px * var(--hud-scale)) rgba(174, 205, 255, 0.42);
}
}
/* Layout-expanded: layer panel slides off with .earth-left-column — no
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;
}

View File

@@ -27,38 +27,38 @@
cursor: grabbing;
}
/* ── Mode tabs ────────────────────────────────────────────────── */
/* ── Current mode label ───────────────────────────────────────── */
.legend-tabs {
.legend-current {
display: flex;
gap: calc(2px * var(--hud-scale));
align-items: center;
gap: calc(6px * var(--hud-scale));
flex: 1 1 auto;
min-width: 0;
}
.legend-tab {
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
border-radius: calc(4px * var(--hud-scale));
border: 1px solid transparent;
background: transparent;
color: var(--hud-text-muted);
font-size: calc(0.68rem * var(--hud-scale));
font-family: inherit;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease;
.legend-title {
flex: 0 0 auto;
color: var(--hud-text-soft);
font-size: var(--hud-panel-header-title-size);
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
white-space: nowrap;
}
.legend-tab:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--hud-text);
}
.legend-tab--active {
.legend-current-label {
display: inline-flex;
align-items: center;
min-width: 0;
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
border-radius: calc(4px * var(--hud-scale));
border: 1px solid rgba(120, 180, 255, 0.2);
background: rgba(120, 180, 255, 0.12);
border-color: rgba(120, 180, 255, 0.2);
color: var(--hud-accent-strong);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.08em;
white-space: nowrap;
}
/* ── Bar action buttons ───────────────────────────────────────── */
@@ -66,7 +66,7 @@
.legend-bar-actions {
display: flex;
align-items: center;
gap: calc(2px * var(--hud-scale));
gap: var(--hud-gap-xs);
flex-shrink: 0;
}
@@ -74,54 +74,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
min-width: calc(20px * var(--hud-scale));
padding: 0;
border: none;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease;
}
.legend-bar-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--hud-text);
}
.legend-bar-btn .material-symbols-rounded {
font-size: calc(13px * var(--hud-scale));
line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none;
}
/* Collapse chevron */
#legend-collapse .material-symbols-rounded {
transition: transform 0.22s ease;
}
.legend--collapsed #legend-collapse .material-symbols-rounded {
transform: rotate(180deg);
}
/* ── Collapsible list body ────────────────────────────────────── */
.legend-body {
max-height: calc(220px * var(--hud-scale));
overflow: hidden;
transition:
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease;
opacity: 1;
}
.legend--collapsed .legend-body {
max-height: 0;
opacity: 0;
pointer-events: none;
--hud-body-collapse-gap: calc(4px * var(--hud-scale));
--hud-body-max-height: calc(220px * var(--hud-scale));
}
/* ── Item list ────────────────────────────────────────────────── */
@@ -175,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

@@ -0,0 +1,182 @@
/* news-panel.css */
.news-panel-title-row {
display: flex;
align-items: center;
min-width: 0;
flex: 1 1 auto;
}
.news-region-chip {
--news-accent: #d6e6ff;
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);
border-radius: 999px;
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
color: color-mix(in srgb, var(--news-accent) 82%, white);
background: color-mix(in srgb, var(--news-accent) 12%, transparent);
font-size: calc(0.62rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
}
.news-panel-subtitle {
color: var(--hud-text-soft);
font-size: calc(0.7rem * var(--hud-scale));
}
.news-panel-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--hud-gap-sm);
min-height: 0;
overflow: hidden;
}
.news-panel-focus {
display: grid;
grid-template-columns: 1fr auto;
gap: calc(10px * var(--hud-scale));
padding: calc(12px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale));
background:
radial-gradient(circle at 16% 18%, rgba(123, 205, 255, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(104, 166, 232, 0.04));
border: 1px solid rgba(205, 231, 255, 0.08);
}
.news-focus-kicker,
.news-board-status {
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
}
.news-focus-label {
margin-top: calc(4px * var(--hud-scale));
color: var(--hud-text);
font-size: calc(1rem * var(--hud-scale));
font-weight: 600;
}
.news-focus-coords {
margin-top: calc(3px * var(--hud-scale));
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale));
}
.news-source-count {
align-self: start;
color: var(--hud-accent-strong);
font-size: calc(0.72rem * var(--hud-scale));
}
.news-board {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--hud-gap-sm);
min-height: 0;
overflow: hidden;
}
.news-board-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: calc(8px * var(--hud-scale));
min-height: 0;
max-height: none;
overflow-y: auto;
padding-right: calc(4px * var(--hud-scale));
scrollbar-width: thin;
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
}
.news-board-list::-webkit-scrollbar {
width: 6px;
}
.news-board-list::-webkit-scrollbar-track {
background: transparent;
}
.news-board-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.24), rgba(110, 176, 255, 0.28));
border-radius: 999px;
}
.news-story-card {
display: flex;
flex-direction: column;
gap: calc(8px * var(--hud-scale));
text-decoration: none;
padding: calc(12px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale));
border: 1px solid rgba(201, 225, 247, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(92, 151, 218, 0.03));
transition:
border-color 0.18s ease,
background 0.18s ease,
transform 0.18s ease;
}
.news-story-card:hover {
border-color: rgba(214, 235, 255, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
transform: translateY(-1px);
}
.news-story-card--focus {
border-color: rgba(127, 219, 255, 0.22);
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
}
.news-story-meta,
.news-story-tags {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(8px * var(--hud-scale));
flex-wrap: wrap;
}
.news-story-source,
.news-story-time,
.news-story-tag {
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
}
.news-story-source {
color: var(--hud-accent-strong);
}
.news-story-title {
color: var(--hud-text);
font-size: calc(0.9rem * var(--hud-scale));
font-weight: 600;
line-height: 1.4;
}
.news-story-summary {
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale));
line-height: 1.45;
}
.news-story-tag {
border-radius: 999px;
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
background: rgba(255, 255, 255, 0.04);
}
.news-board-empty {
color: var(--hud-text-muted);
font-size: calc(0.82rem * var(--hud-scale));
line-height: 1.5;
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
}

View File

@@ -1,4 +1,4 @@
/* toolbar.css - bottom dock and floating toolbar primitives */
/* toolbar.css - orbital hub toolbar */
.earth-toolbar-group {
position: absolute;
@@ -6,10 +6,10 @@
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 200;
pointer-events: none;
}
.earth-toolbar-group,
@@ -24,119 +24,170 @@
}
.earth-toolbar {
--toolbar-scale: 1;
--toolbar-orb-size: calc(46px * var(--toolbar-scale));
--toolbar-hub-size: calc(58px * var(--toolbar-scale));
--toolbar-arc-width: calc(420px * var(--toolbar-scale));
--toolbar-arc-height: calc(160px * var(--toolbar-scale));
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale));
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale));
position: relative;
width: min(620px, calc(100vw - 40px));
height: calc(200px * var(--toolbar-scale));
display: flex;
align-items: center;
justify-content: center;
gap: 0;
background: transparent;
border: none;
box-shadow: none;
padding: 0;
}
.earth-toolbar-items {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
.earth-toolbar-popover {
position: relative;
}
.earth-toolbar-popover::before {
content: '';
position: absolute;
left: 50%;
bottom: 100%;
transform: translateX(-50%);
width: 56px;
height: 16px;
background: transparent;
}
.earth-toolbar-popover > .earth-stack-toolbar {
position: absolute;
left: 50%;
top: auto;
right: auto;
bottom: calc(100% + 12px);
transform: translate(-50%, 10px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.22s ease,
visibility 0.22s ease;
z-index: 220;
}
.earth-toolbar-btn {
.earth-toolbar-cluster {
position: relative;
width: 100%;
height: 100%;
pointer-events: none;
}
.earth-toolbar-cluster::before {
content: "";
position: absolute;
left: 50%;
bottom: calc(14px * var(--toolbar-scale));
width: var(--toolbar-arc-width);
height: var(--toolbar-arc-height);
transform: translateX(-50%);
border-radius: 50%;
border: 1px solid rgba(145, 186, 255, 0.08);
border-bottom-color: transparent;
background:
radial-gradient(circle at 50% 100%, rgba(145, 186, 255, 0.05), transparent 58%);
opacity: 0.9;
mask: linear-gradient(180deg, rgba(0, 0, 0, 0.82), transparent 86%);
pointer-events: none;
}
.earth-toolbar-orb {
position: absolute;
left: 50%;
bottom: calc(30px * var(--toolbar-scale));
transform: translate(-50%, -50%);
}
.earth-toolbar-hub {
position: absolute;
left: 50%;
bottom: calc(8px * var(--toolbar-scale));
transform: translateX(-50%);
}
.earth-toolbar-orb {
pointer-events: none;
opacity: 1;
transition:
transform 0.36s cubic-bezier(0.34, 1.15, 0.64, 1),
opacity 0.24s ease;
}
.earth-toolbar-cluster.is-expanded .earth-toolbar-orb {
transform: translate(calc(-50% + var(--orb-x)), calc(-50% + var(--orb-y)));
}
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb {
transform: translate(-50%, -50%) scale(0.42);
opacity: 0;
}
.earth-toolbar-orb > *,
.earth-toolbar-hub > * {
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;
}
.earth-toolbar-hub > * {
pointer-events: auto;
}
.earth-toolbar-orb > .liquid-glass-surface {
animation: floatDock 4.6s ease-in-out infinite;
animation-delay: var(--orb-delay, 0s);
}
.earth-toolbar-cluster.is-dock-engaged .earth-toolbar-orb > .liquid-glass-surface {
animation-play-state: paused;
}
.earth-toolbar-btn,
.earth-toolbar-hub-btn {
position: relative;
width: 28px;
height: 28px;
border: none;
border-radius: 0;
background: transparent;
color: #4db8ff;
font-size: 14px;
color: var(--hud-text-soft);
font-size: calc(14px * var(--toolbar-scale));
cursor: pointer;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0;
margin: 0;
overflow: visible;
appearance: none;
-webkit-appearance: none;
}
.earth-toolbar-btn.floating-btn {
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
width: var(--toolbar-orb-size);
height: var(--toolbar-orb-size);
min-width: var(--toolbar-orb-size);
min-height: var(--toolbar-orb-size);
border-radius: 50%;
overflow: hidden;
}
.earth-toolbar-btn:not(.liquid-glass-surface)::after {
content: none;
.earth-toolbar-hub-btn {
width: var(--toolbar-hub-size);
height: var(--toolbar-hub-size);
min-width: var(--toolbar-hub-size);
min-height: var(--toolbar-hub-size);
border-radius: 50%;
overflow: hidden;
color: var(--hud-title);
}
.earth-toolbar-btn .icon,
.earth-toolbar-hub-btn .material-symbols-rounded {
position: relative;
z-index: 1;
line-height: 1;
}
.earth-toolbar-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
transform: translateZ(0);
transition: transform 0.16s ease, opacity 0.16s ease;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
line-height: 1;
}
.earth-toolbar-btn svg {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.earth-toolbar-btn .material-symbols-rounded {
font-size: 21px;
.earth-toolbar-btn .material-symbols-rounded,
.earth-toolbar-hub-btn .material-symbols-rounded {
font-size: calc(21px * var(--toolbar-scale));
line-height: 1;
font-variation-settings:
'FILL' 0,
@@ -154,28 +205,6 @@
-moz-osx-font-smoothing: grayscale;
}
.earth-toolbar-btn img {
width: 20px;
height: 20px;
display: block;
user-select: none;
pointer-events: none;
shape-rendering: geometricPrecision;
image-rendering: -webkit-optimize-contrast;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.earth-toolbar-items > :nth-child(2n).floating-btn,
.earth-toolbar-items > :nth-child(2n) .floating-btn {
animation-delay: 0.18s;
}
.earth-toolbar-items > :nth-child(3n).floating-btn,
.earth-toolbar-items > :nth-child(3n) .floating-btn {
animation-delay: 0.34s;
}
.liquid-glass-surface {
--elastic-x: 0px;
--elastic-y: 0px;
@@ -190,12 +219,14 @@
position: relative;
isolation: isolate;
transform-style: preserve-3d;
transform-origin: center center;
will-change: transform, box-shadow;
overflow: hidden;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)),
rgba(8, 20, 38, 0.22);
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.12), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.05), transparent 30%),
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)),
rgba(8, 20, 38, 0.12);
border: 1px solid var(--hud-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
@@ -205,7 +236,11 @@
backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%);
transform:
translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0)
translate3d(
var(--elastic-x),
calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)),
0
)
scale(var(--btn-scale));
transition:
transform 0.22s ease,
@@ -213,17 +248,16 @@
background 0.22s ease,
opacity 0.18s ease,
border-color 0.22s ease;
animation: floatDock 3.8s ease-in-out infinite;
}
.liquid-glass-surface::before {
content: '';
content: "";
position: absolute;
inset: 1px 1px 18px 1px;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%);
opacity: 0.5;
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04) 28%, transparent 68%);
opacity: 0.42;
pointer-events: none;
transform:
perspective(120px)
@@ -234,14 +268,14 @@
}
.liquid-glass-surface::after {
content: '';
content: "";
position: absolute;
inset: -1px;
padding: 1.35px;
border-radius: inherit;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
opacity: 0.82;
opacity: 0.72;
pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px);
transform:
@@ -260,15 +294,28 @@
transition: opacity 0.18s ease, transform 0.18s ease;
}
.earth-toolbar-hub-btn.liquid-glass-surface {
background:
radial-gradient(circle at 50% 24%, rgba(255, 255, 255, 0.16), transparent 34%),
linear-gradient(180deg, rgba(28, 54, 90, 0.26), rgba(11, 24, 43, 0.22)),
rgba(10, 28, 52, 0.16);
border-color: var(--hud-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
0 16px 30px rgba(0, 0, 0, 0.24),
0 0 30px rgba(104, 181, 247, 0.18);
}
.liquid-glass-surface:hover {
--btn-scale: 1.035;
--btn-scale: 1.04;
--press-offset: -1px;
--glow-opacity: 0.32;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)),
rgba(8, 20, 38, 0.2);
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(128, 198, 255, 0.06)),
rgba(8, 20, 38, 0.14);
border-color: var(--hud-border-hover);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
@@ -289,52 +336,16 @@
.liquid-glass-surface:active,
.liquid-glass-surface.is-pressed {
--btn-scale: 0.942;
--btn-scale: 0.95;
--press-offset: 2px;
--glow-opacity: 0.2;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)),
rgba(10, 24, 44, 0.24);
border-color: rgba(240, 249, 255, 0.58);
box-shadow:
inset 0 2px 10px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.16),
0 4px 10px rgba(0, 0, 0, 0.18),
0 0 14px rgba(176, 226, 255, 0.18);
}
.liquid-glass-surface:active::before,
.liquid-glass-surface.is-pressed::before {
opacity: 0.46;
transform: translateY(2px) scale(0.985);
}
.liquid-glass-surface:active::after,
.liquid-glass-surface.is-pressed::after {
opacity: 0.78;
transform: scale(0.985);
}
.liquid-glass-surface:active .icon,
.liquid-glass-surface.is-pressed .icon {
transform: translateY(1.5px);
}
.liquid-glass-surface:active img,
.liquid-glass-surface.is-pressed img,
.liquid-glass-surface:active .material-symbols-rounded,
.liquid-glass-surface.is-pressed .material-symbols-rounded {
transform: translateY(1.5px);
transition: transform 0.16s ease, opacity 0.16s ease;
}
.liquid-glass-surface.active {
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)),
rgba(11, 34, 58, 0.26);
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)),
rgba(11, 34, 58, 0.18);
border-color: var(--hud-border-active);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
@@ -357,151 +368,64 @@
.earth-zoom-group:hover > .earth-zoom-toolbar,
.earth-zoom-group:focus-within > .earth-zoom-toolbar,
.earth-zoom-group.open > .earth-zoom-toolbar,
.earth-info-group:hover > .earth-info-toolbar,
.earth-info-group:focus-within > .earth-info-toolbar,
.earth-info-group.open > .earth-info-toolbar {
.earth-zoom-group.open > .earth-zoom-toolbar {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate(-50%, 0);
}
.earth-zoom-group.force-closed > .earth-zoom-toolbar,
.earth-info-group.force-closed > .earth-info-toolbar {
.earth-zoom-group.force-closed > .earth-zoom-toolbar {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate(-50%, 8px);
transform: translate(-50%, calc(8px * var(--toolbar-scale)));
}
.earth-zoom-group > .earth-zoom-toolbar,
.earth-info-group > .earth-info-toolbar {
.earth-toolbar-popover::before {
content: "";
position: absolute;
left: 50%;
bottom: 100%;
transform: translateX(-50%);
width: calc(56px * var(--toolbar-scale));
height: calc(16px * var(--toolbar-scale));
background: transparent;
}
.earth-toolbar-popover > .earth-stack-toolbar {
position: absolute;
left: 50%;
top: auto;
right: auto;
left: 50%;
bottom: calc(100% + 12px);
bottom: calc(100% + (12px * var(--toolbar-scale)));
transform: translate(-50%, calc(10px * var(--toolbar-scale)));
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
.earth-info-toolbar {
width: min(280px, calc(100vw - 36px));
padding: 12px;
border-radius: 22px;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%),
linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94));
border: 1px solid rgba(211, 228, 246, 0.14);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(18px) saturate(135%);
-webkit-backdrop-filter: blur(18px) saturate(135%);
}
.earth-layer-toolbar-header {
width: 100%;
padding: 2px 4px 8px;
border-bottom: 1px solid rgba(201, 225, 247, 0.08);
margin-bottom: 2px;
}
.earth-layer-toolbar-title {
display: block;
color: var(--hud-accent-strong);
font-size: 0.86rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.earth-layer-toolbar-subtitle {
display: block;
margin-top: 4px;
color: var(--hud-text-soft);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.earth-layer-btn {
width: 100%;
min-width: 0;
min-height: 52px;
height: auto;
border-radius: 16px;
padding: 12px 14px;
justify-content: space-between;
align-items: center;
gap: 12px;
overflow: hidden;
animation: none;
}
.earth-layer-btn__copy {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.earth-layer-btn__label {
color: var(--hud-text);
font-size: 0.92rem;
font-weight: 600;
line-height: 1.15;
}
.earth-layer-btn__meta {
color: var(--hud-text-soft);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1.2;
}
.earth-layer-btn__state {
flex: 0 0 auto;
min-width: 42px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(201, 225, 247, 0.12);
color: var(--hud-text-soft);
font-size: 0.67rem;
font-weight: 700;
letter-spacing: 0.12em;
text-align: center;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.04);
}
.earth-layer-btn.active .earth-layer-btn__state {
color: #dff4ff;
border-color: rgba(220, 240, 255, 0.24);
background: rgba(131, 197, 255, 0.14);
}
.earth-layer-btn .earth-toolbar-tooltip {
display: none;
gap: calc(8px * var(--toolbar-scale));
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.22s ease,
visibility 0.22s ease;
z-index: 220;
}
.earth-zoom-toolbar .earth-zoom-btn,
.earth-zoom-toolbar .earth-zoom-value {
width: 42px;
min-width: 42px;
width: calc(42px * var(--toolbar-scale));
min-width: calc(42px * var(--toolbar-scale));
border-radius: 50%;
color: #4db8ff;
animation: floatDock 3.8s ease-in-out infinite;
color: var(--hud-text-soft);
animation: none;
}
.earth-zoom-toolbar .earth-zoom-btn {
height: 42px;
font-size: 20px;
height: calc(42px * var(--toolbar-scale));
font-size: calc(20px * var(--toolbar-scale));
font-weight: 500;
line-height: 1;
}
@@ -510,41 +434,12 @@
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
height: calc(42px * var(--toolbar-scale));
padding: 0;
font-size: 0.68rem;
font-size: calc(11px * var(--toolbar-scale));
letter-spacing: normal;
animation-delay: 0.18s;
}
.earth-zoom-toolbar .earth-zoom-btn:active,
.earth-zoom-toolbar .earth-zoom-btn.is-pressed,
.earth-zoom-toolbar .earth-zoom-value:active,
.earth-zoom-toolbar .earth-zoom-value.is-pressed {
letter-spacing: -0.01em;
}
.earth-zoom-toolbar .earth-zoom-btn:nth-child(1) {
animation-delay: 0s;
}
.earth-zoom-toolbar .earth-zoom-btn:nth-child(3) {
animation-delay: 0.34s;
}
.earth-zoom-toolbar .earth-toolbar-tooltip {
bottom: calc(100% + 10px);
}
.earth-zoom-toolbar .earth-toolbar-tooltip::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}
.earth-app.layout-expanded .earth-toolbar-group {
bottom: 18px;
transform: translateX(-50%);
@@ -552,21 +447,23 @@
.earth-toolbar-btn .earth-toolbar-tooltip {
position: absolute;
bottom: 56px;
bottom: calc(56px * var(--toolbar-scale));
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
background:
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
color: var(--hud-text);
padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
border-radius: calc(6px * var(--toolbar-scale));
font-size: calc(12px * var(--toolbar-scale));
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
border: 1px solid var(--hud-border);
pointer-events: none;
z-index: 100;
box-shadow: var(--hud-shadow-soft);
}
.earth-toolbar-btn:hover .earth-toolbar-tooltip,
@@ -574,15 +471,15 @@
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
opacity: 1;
visibility: visible;
bottom: 58px;
bottom: calc(58px * var(--toolbar-scale));
}
.earth-toolbar-btn .earth-toolbar-tooltip::after {
content: '';
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
border: calc(6px * var(--toolbar-scale)) solid transparent;
border-top-color: rgba(18, 31, 52, 0.96);
}

View File

@@ -1,10 +1,15 @@
/* tv-panel */
/* media-panel
* Outer HUD shell: #media-panel
* Inner live pane: #tv-panel
* Inner news pane: #news-panel
*/
.hud-panel-tv {
.hud-panel-media {
bottom: var(--hud-offset);
right: var(--hud-offset);
width: calc(420px * var(--hud-scale));
max-width: calc(100vw - 32px);
max-height: calc(100vh - (2 * var(--hud-offset)));
min-width: calc(300px * var(--hud-scale));
padding: calc(10px * var(--hud-scale));
display: flex;
@@ -13,14 +18,28 @@
z-index: 18;
}
/* header 内嵌 select + actions */
.hud-panel-tv .hud-panel-header {
.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) {
max-height: min(
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
calc(100vh - (2 * var(--hud-offset)))
);
}
.hud-panel-media.is-reforming {
transition:
height 0.24s cubic-bezier(0.22, 1, 0.36, 1),
top 0.24s cubic-bezier(0.22, 1, 0.36, 1);
will-change: height, top;
}
.hud-panel-media .hud-panel__header {
align-items: center;
gap: var(--hud-gap-xs);
}
.hud-panel-tv .hud-panel-header .hud-panel-close {
align-self: center;
.hud-panel-media .hud-panel__title-group {
flex: 0 0 auto;
min-width: auto;
}
.tv-panel-header-title {
@@ -29,13 +48,53 @@
margin: 0;
}
/* select / action 在 drag-handle 内,恢复正常指针 */
.hud-panel-tv .hud-panel-header .tv-panel-select,
.hud-panel-tv .hud-panel-header .tv-panel-action {
.hud-panel-media .hud-panel__header .hud-panel__action,
.hud-panel-media .hud-panel__header .hud-panel-close {
cursor: pointer;
user-select: auto;
}
.hud-panel-media .hud-panel__header .tv-panel-select,
.hud-panel-media .hud-panel__header .media-panel-tab {
cursor: pointer;
user-select: auto;
}
.tv-panel-header-controls {
display: flex;
align-items: center;
gap: var(--hud-gap-xs);
flex: 1 1 auto;
min-width: 0;
}
.tv-panel-header-controls--news {
justify-content: flex-end;
}
.tv-panel-content {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.tv-tab-pane {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
gap: var(--hud-gap-sm);
overflow: hidden;
}
.tv-panel-toolbar-actions {
display: inline-flex;
align-items: center;
gap: var(--hud-gap-xs);
flex-shrink: 0;
}
.tv-panel-status {
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
@@ -63,58 +122,6 @@
color: #eef5fc;
}
.tv-panel-actions {
display: flex;
gap: var(--hud-gap-xs);
}
.tv-panel-action {
border: 1px solid transparent;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
padding: calc(7px * var(--hud-scale));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
}
.tv-panel-action--icon .material-symbols-rounded {
font-size: calc(16px * var(--hud-scale));
line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none;
}
.tv-panel-action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong);
}
.tv-panel-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* toggle 箭头:展开时朝下,折叠时朝上 */
.tv-panel-meta-toggle .material-symbols-rounded {
transition: transform 0.22s ease;
transform: rotate(0deg);
}
.tv-panel-meta-toggle.is-collapsed .material-symbols-rounded {
transform: rotate(180deg);
}
/* meta wrap折叠时用负 margin 抵消 flex gap无死区 */
.tv-panel-meta-wrap {
overflow: hidden;
max-height: calc(120px * var(--hud-scale));
@@ -126,8 +133,8 @@
max-height: 0;
opacity: 0;
pointer-events: none;
margin-top: calc(-1 * var(--hud-gap-sm));
margin-bottom: calc(-1 * var(--hud-gap-sm));
margin-top: 0;
margin-bottom: 0;
}
.tv-panel-meta {
@@ -162,7 +169,7 @@
.tv-panel-player {
position: relative;
flex: 1 0 auto;
flex: 1 1 auto;
min-height: calc(220px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale));
overflow: hidden;
@@ -196,6 +203,41 @@
background: #050a14;
}
.media-panel-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: calc(8px * var(--hud-scale));
}
.media-panel-tab {
border: 1px solid rgba(201, 225, 247, 0.12);
border-radius: calc(12px * var(--hud-scale));
background: rgba(255, 255, 255, 0.03);
color: var(--hud-text-muted);
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
font-size: calc(0.78rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.media-panel-tab:hover {
color: var(--hud-text);
border-color: rgba(214, 235, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.media-panel-tab--active {
color: var(--hud-accent-strong);
border-color: rgba(120, 180, 255, 0.24);
background: rgba(120, 180, 255, 0.12);
}
.tv-tab-pane[hidden] {
display: none !important;
}
/* ── Multi-edge resize handles ───────────────────────────────── */
.tv-panel-edge {
@@ -243,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: "";
@@ -257,35 +320,35 @@
border-color: rgba(244, 249, 255, 0.75);
}
.hud-panel-tv.is-resizing {
.hud-panel-media.is-resizing {
transition: none !important;
user-select: none;
}
/* Whole panel is draggable; player overrides back to default */
.hud-panel-tv:not(.is-resizing) {
.hud-panel-media:not(.is-resizing) {
cursor: grab;
}
.hud-panel-tv.is-dragging {
.hud-panel-media.is-dragging {
cursor: grabbing;
}
.hud-panel-tv .tv-panel-player {
.hud-panel-media .tv-panel-player {
cursor: default;
}
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
.hud-panel-tv.is-dragging .tv-panel-iframe,
.hud-panel-tv.is-dragging .tv-panel-video {
.hud-panel-media.is-dragging .tv-panel-iframe,
.hud-panel-media.is-dragging .tv-panel-video {
pointer-events: none;
}
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
.earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
bottom: var(--hud-offset);
right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
}
/* TV panel keeps its fixed width on all screen sizes.
/* Media panel keeps its fixed width on all screen sizes.
Responsive stretching removed — width only changes if user manually resizes. */

View File

@@ -10,7 +10,8 @@
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
"hls.js": "https://esm.sh/hls.js@1.6.15"
"hls.js": "https://esm.sh/hls.js@1.6.15",
"astronomy-engine": "https://esm.sh/astronomy-engine@2.1.19"
}
}
</script>
@@ -35,6 +36,7 @@
<link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css">
<link rel="stylesheet" href="css/tv-panel.css">
<link rel="stylesheet" href="css/news-panel.css">
<link rel="stylesheet" href="css/layer-panel.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
@@ -61,7 +63,10 @@
<span class="material-symbols-rounded layer-panel-icon">layers</span>
<span class="layer-panel-title">图层</span>
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
<span class="material-symbols-rounded">expand_more</span>
<span class="material-symbols-rounded">expand_less</span>
</button>
<button class="layer-panel-btn hud-panel-close" type="button" data-close-panel="layer-toggles" aria-label="关闭图层面板" title="关闭">
<span class="material-symbols-rounded">close</span>
</button>
</div>
@@ -93,7 +98,7 @@
<span class="layer-row-label">地形</span>
<span class="layer-row-meta">Terrain</span>
</div>
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示">
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
<span class="layer-row-toggle-track"></span>
</button>
</div>
@@ -127,6 +132,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">
@@ -145,39 +160,55 @@
</div>
</div>
<div id="error-message" class="hud-error-message"></div>
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
<div id="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar">
<div class="earth-toolbar-items">
<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>
</button>
<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>
</span>
<span class="icon rotate-icon icon-play" aria-hidden="true">
<span class="material-symbols-rounded">play_arrow</span>
</span>
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
</button>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
</button>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
</button>
<div id="zoom-control-group" class="earth-toolbar-popover earth-zoom-group">
<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="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>
</button>
</div>
<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>
</span>
<span class="icon rotate-icon icon-play" aria-hidden="true">
<span class="material-symbols-rounded">play_arrow</span>
</span>
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
</button>
</div>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
</button>
</div>
<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>
</span>
<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="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>
@@ -190,51 +221,61 @@
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button>
</div>
</div>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">设置</span>
</button>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
</button>
<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>
</span>
<span class="icon layout-icon layout-collapse" aria-hidden="true">
<span class="material-symbols-rounded">close_fullscreen</span>
</span>
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
</button>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">设置</span>
</button>
</div>
<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>
</span>
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
</button>
</div>
<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>
</span>
<span class="icon layout-icon layout-collapse" aria-hidden="true">
<span class="material-symbols-rounded">close_fullscreen</span>
</span>
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
</button>
</div>
<div class="earth-toolbar-hub">
<button id="toolbar-hub" class="earth-toolbar-hub-btn liquid-glass-surface" title="工具菜单" aria-label="展开工具菜单">
<span class="material-symbols-rounded" aria-hidden="true">tune</span>
</button>
</div>
</div>
</div>
</div>
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
<!-- Drag bar: mode tabs + collapse + close -->
<!-- Drag bar: current mode + collapse + close -->
<div class="legend-bar hud-panel-drag-handle">
<div class="legend-tabs" id="legend-tabs">
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button>
<button class="legend-tab" data-legend-mode="satellites">卫星</button>
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
<div class="legend-current" id="legend-current">
<span class="legend-title">图例</span>
<span id="legend-current-label" class="legend-current-label">海缆</span>
</div>
<div class="legend-bar-actions">
<button id="legend-collapse" class="legend-bar-btn" title="折叠">
<button id="legend-collapse" class="legend-bar-btn hud-panel__action hud-panel__action--collapse" title="折叠">
<span class="material-symbols-rounded">expand_less</span>
</button>
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
<button class="legend-bar-btn hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<!-- Collapsible list -->
<div id="legend-body" class="legend-body">
<div id="legend-body" class="legend-body hud-panel__body hud-panel__body--collapsible">
<div class="legend-list"></div>
</div>
</div>
@@ -251,23 +292,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">
@@ -279,54 +324,106 @@
<!-- 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>
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true">
<div class="hud-panel-header hud-panel-drag-handle">
<span class="hud-panel-title tv-panel-header-title">新闻直播</span>
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
<div class="tv-panel-actions">
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
<span class="material-symbols-rounded">refresh</span>
</button>
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
<span class="material-symbols-rounded">open_in_new</span>
</button>
<button id="tv-meta-toggle" class="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息">
<span class="material-symbols-rounded">expand_more</span>
<div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-panel" data-drag-self="true">
<div class="hud-panel__header hud-panel-drag-handle">
<div class="hud-panel__title-group">
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
</div>
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
<div class="tv-panel-toolbar-actions">
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
<span class="material-symbols-rounded">refresh</span>
</button>
<button id="tv-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网">
<span class="material-symbols-rounded">open_in_new</span>
</button>
<button id="tv-meta-toggle" class="hud-panel__action hud-panel__action--collapse tv-panel-meta-toggle" type="button" title="折叠新闻直播内容" aria-label="折叠新闻直播内容">
<span class="material-symbols-rounded">expand_less</span>
</button>
</div>
</div>
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
<div class="news-panel-title-row">
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
</div>
<div class="tv-panel-toolbar-actions">
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
<span class="material-symbols-rounded">refresh</span>
</button>
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
<span class="material-symbols-rounded">open_in_new</span>
</button>
</div>
</div>
<div class="hud-panel__actions">
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭媒体情报面板">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
<div class="tv-panel-meta" id="tv-panel-meta">
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
</div>
<div class="tv-panel-content">
<section id="tv-panel" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
<div class="tv-panel-meta" id="tv-panel-meta">
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
</div>
</div>
<div class="tv-panel-player">
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
id="tv-iframe"
class="tv-panel-iframe"
hidden
title="新闻直播"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; fullscreen; picture-in-picture"
></iframe>
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
</div>
</section>
<section id="news-panel" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
<div id="news-panel-body" class="news-panel-body">
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
<div class="news-panel-focus">
<div>
<div class="news-focus-kicker">当前关注区域</div>
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
</div>
<div class="news-board">
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
<div id="news-board-list" class="news-board-list"></div>
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
</div>
</div>
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</section>
</div>
<div class="tv-panel-player">
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
id="tv-iframe"
class="tv-panel-iframe"
hidden
title="新闻直播"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; fullscreen; picture-in-picture"
></iframe>
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
<div class="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
<button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
<button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
</div>
<div class="tv-panel-edge" data-edge="r"></div>
<div class="tv-panel-edge" data-edge="b"></div>
@@ -335,29 +432,359 @@
<div class="tv-panel-edge" data-edge="bl"></div>
</div>
<div id="loading" class="earth-loading">
<div id="loading-spinner" class="earth-loading-spinner"></div>
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
<div id="loading-subtitle" class="earth-loading-subtitle">同步卫星、海底光缆、登陆点与BGP态势数据</div>
</div>
<div id="status-message" class="earth-status-message"></div>
<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 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 liquid-glass-surface" role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="earth-settings-header">
<div>
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
<div class="earth-settings-header hud-panel__header">
<div class="hud-panel__title-group">
<div class="earth-settings-kicker">设置</div>
<h3 id="settings-title" class="earth-settings-title hud-panel-title">显示与视图</h3>
</div>
<button id="settings-close" class="earth-settings-close hud-panel-close" type="button" aria-label="关闭设置">
<button id="settings-reset" class="earth-settings-reset hud-panel__action" type="button" aria-label="重置设置">
<span class="material-symbols-rounded">restart_alt</span>
<span>重置</span>
</button>
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="earth-settings-content">
<div class="earth-settings-content hud-panel__body">
<section class="earth-settings-section">
<div class="earth-settings-section-title">旋转</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">旋转模式</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-rotation-mode="rotate"
aria-pressed="true"
>
旋转模式
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-rotation-mode="cruise"
aria-pressed="false"
>
巡航模式
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-daynight" type="checkbox" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span>
@@ -390,16 +817,84 @@
</label>
<label class="earth-settings-item" for="toggle-view-tv">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">电视直播</span>
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
<span class="earth-settings-item-title">新闻直播</span>
<span class="earth-settings-item-subtitle">控制电视直播 / 态势聚合显示</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
<span class="earth-settings-switch-track"></span>
</span>
</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">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">地形透明度</span>
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-settings-slider-row">
<input
id="terrain-opacity-slider"
class="earth-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
aria-label="调整地形透明度"
>
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">系统</div>
<div class="earth-settings-list">
<a
class="earth-settings-item earth-settings-link"
href="/admin"
target="_blank"
rel="noreferrer noopener"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">Admin</span>
<span class="earth-settings-item-subtitle">打开管理后台仪表盘</span>
</div>
<span class="earth-settings-link-meta">
<span class="material-symbols-rounded">admin_panel_settings</span>
<span class="material-symbols-rounded">arrow_forward</span>
</span>
</a>
</div>
</section>
</div>
</div>
</div>

View File

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

View File

@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
}
function drawWaveSymbol(context) {
context.lineWidth = 12;
context.lineCap = "round";
context.beginPath();
context.moveTo(18, 76);
context.bezierCurveTo(34, 46, 46, 46, 64, 76);
context.bezierCurveTo(80, 106, 94, 106, 110, 76);
context.stroke();
context.moveTo(14, 100);
context.lineTo(38, 26);
context.lineTo(64, 100);
context.lineTo(90, 26);
context.lineTo(114, 100);
context.closePath();
context.fill();
}
function drawBurstSymbol(context) {
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
}
export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
? collectorsPayload.features
: [];
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
clearBGPData(earth);
totalAnomalyCount = selectedEventData.totalAnomalyCount;
totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear();
@@ -1351,7 +1353,7 @@ export async function loadBGPAnomalies(scene, earth) {
};
}
export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
const now = performance.now();
updateCollectorOverlayScan(lockedObjectType, lockedObject);
const hasLockedLayer = Boolean(
@@ -1386,21 +1388,17 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
if (isLocked) {
scale *= 1.1 + 0.14 * pulse;
opacity = BGP_CONFIG.opacity.collectorHover;
haloOpacity = 0.022;
pulseOpacity = 0.012;
coverageOpacity = 0.02;
markerColor = blendHexColors(
BGP_CONFIG.collectorIcon.lockedNeutralColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.collectorIcon.lockedBlend,
);
opacity = 0.96;
haloOpacity = 0.05;
pulseOpacity = 0.024;
coverageOpacity = 0.036;
markerColor = 0xcff2ff;
} else if (isHovered) {
scale *= 1.08;
opacity = BGP_CONFIG.opacity.collectorHover;
haloOpacity = 0.016;
pulseOpacity = 0.008;
coverageOpacity = 0.014;
opacity = 0.88;
haloOpacity = 0.03;
pulseOpacity = 0.014;
coverageOpacity = 0.02;
markerColor = blendHexColors(
BGP_CONFIG.collectorIcon.hoverNeutralColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor,
@@ -1463,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.collector;
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
const isActive = isLocked || isLinkedCollectorLocked || isCruise;
const isHovered = marker.userData.state === "hover";
const pulse =
0.5 +
@@ -1481,17 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity =
BGP_CONFIG.opacity.lockedMin +
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
opacity = 0.9 + 0.1 * pulse;
markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2;
} else if (isCruise) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
ringBaseOpacity *= 1.2;
} else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale;
opacity = BGP_CONFIG.opacity.hover;
opacity = 0.9;
ringBaseOpacity *= 1.05;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale;
opacity = 0.1;
opacity = 0.22;
markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02;
} else {
@@ -1503,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
marker.renderOrder = isActive ? 7 : 3;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => {

View File

@@ -10,7 +10,7 @@ import {
CABLE_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import { updateEarthStats, showStatusMessage } from "./ui.js";
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js";
import { setLegendItems, setLegendMode } from "./legend.js";
@@ -20,6 +20,7 @@ export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
let landingPointGeometry = null;
const landingPointWorldPosition = new THREE.Vector3();
function clamp(value, min, max) {
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
if (owner) {
owner.remove(object);
}
if (object.geometry) {
if (object.geometry && !object.userData?.sharedGeometry) {
object.geometry.dispose();
}
if (object.material) {
@@ -245,9 +246,12 @@ export function clearCableData(earthObj = null) {
clearLandingPoints(earthObj);
}
export async function loadGeoJSONFromPath(scene, earthObj) {
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载电缆数据...");
showStatusMessage("正在加载电缆数据...", "warning");
if (!silent) {
showStatusMessage("正在加载电缆数据...", "warning");
}
const response = await fetch(PATHS.cablesApi);
if (!response.ok) {
@@ -332,9 +336,8 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
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({
@@ -344,11 +347,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
textureQuality: "8K 卫星图",
});
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
if (!silent) {
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
}
return cableLines.length;
}
export async function loadLandingPoints(scene, earthObj) {
export async function loadLandingPoints(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载登陆点数据...");
const response = await fetch(PATHS.landingPointsApi);
@@ -363,78 +369,76 @@ export async function loadLandingPoints(scene, earthObj) {
clearLandingPoints(earthObj);
const sphereGeometry = new THREE.SphereGeometry(
CABLE_CONFIG.landingPoint.radius,
CABLE_CONFIG.landingPoint.widthSegments,
CABLE_CONFIG.landingPoint.heightSegments,
);
if (!landingPointGeometry) {
landingPointGeometry = new THREE.SphereGeometry(
CABLE_CONFIG.landingPoint.radius,
CABLE_CONFIG.landingPoint.widthSegments,
CABLE_CONFIG.landingPoint.heightSegments,
);
}
let validCount = 0;
try {
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {};
const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {};
if (
typeof lon !== "number" ||
typeof lat !== "number" ||
Number.isNaN(lon) ||
Number.isNaN(lat) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
continue;
}
const position = latLonToVector3(
lat,
lon,
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
);
if (
Number.isNaN(position.x) ||
Number.isNaN(position.y) ||
Number.isNaN(position.z)
) {
continue;
}
const sphere = new THREE.Mesh(
sphereGeometry.clone(),
new THREE.MeshStandardMaterial({
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
}),
);
sphere.position.copy(position);
sphere.userData = {
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale,
};
earthObj.add(sphere);
landingPoints.push(sphere);
validCount++;
if (
typeof lon !== "number" ||
typeof lat !== "number" ||
Number.isNaN(lon) ||
Number.isNaN(lat) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
continue;
}
} finally {
sphereGeometry.dispose();
const position = latLonToVector3(
lat,
lon,
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
);
if (
Number.isNaN(position.x) ||
Number.isNaN(position.y) ||
Number.isNaN(position.z)
) {
continue;
}
const sphere = new THREE.Mesh(
landingPointGeometry,
new THREE.MeshStandardMaterial({
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
}),
);
sphere.position.copy(position);
sphere.userData = {
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale,
sharedGeometry: true,
};
earthObj.add(sphere);
landingPoints.push(sphere);
validCount++;
}
const landingPointCountEl = document.getElementById("landing-point-count");
if (landingPointCountEl) {
landingPointCountEl.textContent = validCount + "个";
}
setEarthStatValue("landing-point-count", `${validCount}`);
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
if (!silent) {
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
}
return validCount;
}
@@ -558,14 +562,18 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) {
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
lp.material.color.setHex(0xffd27a);
lp.material.emissive.setHex(0x7a4a00);
lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse;
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
lp.material.opacity =
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse;
Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
);
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(

View File

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

View File

@@ -0,0 +1,645 @@
import * as THREE from "three";
import * as Astronomy from "astronomy-engine";
import {
CELESTIAL_CONFIG,
EARTH_CONFIG,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const textureLoader = new THREE.TextureLoader();
const defaultSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
const defaultMoonDirection = new THREE.Vector3(-0.6, 0.45, -0.2).normalize();
let celestialRoot = null;
let skySphere = null;
let brightStarsGroup = null;
let sunSprite = null;
let moonSprite = null;
let sunHaloSprite = null;
let moonHaloSprite = null;
let brightStarTexture = null;
let sunDirection = defaultSunDirection.clone();
let moonDirection = defaultMoonDirection.clone();
let lastUpdatedAt = 0;
let linkedSunLight = null;
let linkedBackLight = null;
let linkedAmbientLight = null;
let linkedPointLight = null;
let linkedCamera = null;
let linkedEarth = null;
let dayNightLightingEnabled = true;
let brightStarSprites = [];
let celestialRotationQuaternion = new THREE.Quaternion();
let celestialViewQuaternion = new THREE.Quaternion();
let runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
let runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
const scratchEuler = new THREE.Euler(0, 0, 0, "YXZ");
function normalizeDegrees180(value) {
let normalized = value;
while (normalized <= -180) normalized += 360;
while (normalized > 180) normalized -= 360;
return normalized;
}
function computeGreenwichMeanSiderealDegrees(date) {
const jd = date.getTime() / 86400000 + 2440587.5;
const t = (jd - 2451545.0) / 36525.0;
const gmst =
280.46061837 +
360.98564736629 * (jd - 2451545.0) +
0.000387933 * t * t -
(t * t * t) / 38710000;
return THREE.MathUtils.euclideanModulo(gmst, 360);
}
function computeSubsolarLocalDirection(date) {
const vector = Astronomy.GeoVector(Astronomy.Body.Sun, date, false);
const radius = Math.sqrt(
vector.x * vector.x +
vector.y * vector.y +
vector.z * vector.z,
);
if (!Number.isFinite(radius) || radius === 0) {
return defaultSunDirection.clone();
}
const rightAscensionDeg = THREE.MathUtils.radToDeg(
Math.atan2(vector.y, vector.x),
);
const declinationDeg = THREE.MathUtils.radToDeg(
Math.asin(THREE.MathUtils.clamp(vector.z / radius, -1, 1)),
);
const gmstDeg = computeGreenwichMeanSiderealDegrees(date);
const subsolarLonDeg = normalizeDegrees180(rightAscensionDeg - gmstDeg);
return latLonToVector3(declinationDeg, subsolarLonDeg, 1).normalize();
}
function getPhysicalSunDirection(date = new Date()) {
const localSunDirection = computeSubsolarLocalDirection(date);
if (!linkedEarth) {
return localSunDirection;
}
return localSunDirection
.clone()
.applyQuaternion(linkedEarth.quaternion)
.normalize();
}
function getCelestialEuler() {
const { x, y, z } = runtimeOrientationEuler;
return new THREE.Euler(x, y, z, "YXZ");
}
function refreshCelestialOrientation() {
celestialRotationQuaternion.setFromEuler(getCelestialEuler());
refreshCelestialView();
}
function refreshCelestialView() {
if (linkedEarth && runtimeFollowConfig.enabled) {
const followX = runtimeFollowConfig.x
? (linkedEarth.rotation.x - EARTH_CONFIG.tiltRad) * (runtimeFollowConfig.invertX ? -1 : 1)
: 0;
const followY = runtimeFollowConfig.y
? linkedEarth.rotation.y * (runtimeFollowConfig.invertY ? -1 : 1)
: 0;
const followZ = runtimeFollowConfig.z
? linkedEarth.rotation.z * (runtimeFollowConfig.invertZ ? -1 : 1)
: 0;
scratchEuler.set(
followX,
followY,
followZ,
"YXZ",
);
celestialViewQuaternion.setFromEuler(scratchEuler);
} else {
celestialViewQuaternion.identity();
}
if (celestialRoot) {
celestialRoot.quaternion
.copy(celestialRotationQuaternion)
.multiply(celestialViewQuaternion);
}
}
function applyCelestialOrientation(direction) {
return direction
.clone()
.applyQuaternion(celestialRotationQuaternion)
.applyQuaternion(celestialViewQuaternion)
.normalize();
}
function configureTextureEncoding(texture) {
if (!texture) return;
if ("colorSpace" in texture && THREE.SRGBColorSpace) {
texture.colorSpace = THREE.SRGBColorSpace;
} else if ("encoding" in texture && THREE.sRGBEncoding) {
texture.encoding = THREE.sRGBEncoding;
}
}
function createDiscTexture(stops, size = 256) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const gradient = ctx.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2,
);
stops.forEach(([offset, color]) => gradient.addColorStop(offset, color));
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
configureTextureEncoding(texture);
texture.needsUpdate = true;
return texture;
}
function createSprite({ texture, scale, opacity = 1, name, color = 0xffffff }) {
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity,
depthWrite: false,
depthTest: true,
toneMapped: false,
color,
});
const sprite = new THREE.Sprite(material);
sprite.name = name;
sprite.scale.setScalar(scale);
sprite.renderOrder = 100;
return sprite;
}
function createSkySphere() {
const geometry = new THREE.SphereGeometry(
CELESTIAL_CONFIG.skyRadius,
64,
64,
);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.BackSide,
transparent: CELESTIAL_CONFIG.skyOpacity < 1,
opacity: CELESTIAL_CONFIG.skyOpacity,
depthWrite: false,
depthTest: false,
fog: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = "celestial-sky-sphere";
mesh.renderOrder = -1000;
mesh.raycast = () => {};
textureLoader.load(
CELESTIAL_CONFIG.starMapUrl,
(texture) => {
configureTextureEncoding(texture);
material.map = texture;
material.needsUpdate = true;
},
undefined,
() => {
console.warn("Failed to load celestial star map texture");
},
);
return mesh;
}
function geoVectorToWorldDirection(vector) {
return new THREE.Vector3(vector.x, vector.z, vector.y).normalize();
}
function raDecToWorldDirection(raDeg, decDeg) {
const raRad = THREE.MathUtils.degToRad(raDeg);
const decRad = THREE.MathUtils.degToRad(decDeg);
const x = Math.cos(decRad) * Math.cos(raRad);
const y = Math.sin(decRad);
const z = Math.cos(decRad) * Math.sin(raRad);
return new THREE.Vector3(x, z, y).normalize();
}
function colorFromBvIndex(colorIndex) {
if (colorIndex <= -0.2) return 0xa8c9ff;
if (colorIndex <= 0.0) return 0xc8dcff;
if (colorIndex <= 0.3) return 0xf4f7ff;
if (colorIndex <= 0.7) return 0xfff4dc;
if (colorIndex <= 1.1) return 0xffdfb0;
if (colorIndex <= 1.5) return 0xffc47f;
return 0xffa35e;
}
function scaleFromMagnitude(mag) {
const brightness = THREE.MathUtils.clamp(
1 - (mag - (-1.5)) / (CELESTIAL_CONFIG.brightStarMinMag - (-1.5)),
0,
1,
);
return THREE.MathUtils.lerp(
CELESTIAL_CONFIG.brightStarMinScale,
CELESTIAL_CONFIG.brightStarMaxScale,
Math.pow(brightness, 0.72),
);
}
function createBrightStarTexture() {
return createDiscTexture([
[0, "rgba(255,255,255,1)"],
[0.18, "rgba(255,255,255,0.98)"],
[0.46, "rgba(215,230,255,0.42)"],
[1, "rgba(128,168,255,0)"],
], 128);
}
function createBrightStarsGroup() {
const group = new THREE.Group();
group.name = "bright-stars-group";
group.visible = true;
return group;
}
async function loadBrightStars() {
try {
const response = await fetch(CELESTIAL_CONFIG.brightStarsUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const stars = await response.json();
const filteredStars = stars
.filter((star) => Number.isFinite(star?.raDeg) && Number.isFinite(star?.decDeg))
.filter((star) => star.mag <= CELESTIAL_CONFIG.brightStarMinMag)
.sort((a, b) => a.mag - b.mag)
.slice(0, CELESTIAL_CONFIG.brightStarMaxCount);
brightStarTexture = createBrightStarTexture();
brightStarSprites = filteredStars.map((star) => {
const sprite = createSprite({
texture: brightStarTexture,
scale: scaleFromMagnitude(star.mag),
opacity: CELESTIAL_CONFIG.brightStarOpacity,
name: `bright-star-${star.name}`,
color: colorFromBvIndex(star.colorIndex ?? 0.4),
});
sprite.position
.copy(raDecToWorldDirection(star.raDeg, star.decDeg))
.multiplyScalar(CELESTIAL_CONFIG.brightStarDistance);
sprite.userData.star = star;
return sprite;
});
brightStarSprites.forEach((sprite) => brightStarsGroup?.add(sprite));
} catch (error) {
console.warn("Failed to load bright star layer", error);
}
}
function computeBodyDirection(body, date) {
const vector = Astronomy.GeoVector(body, date, false);
return geoVectorToWorldDirection(vector);
}
function updateSpritePositions() {
if (sunSprite) {
sunSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
}
if (sunHaloSprite) {
sunHaloSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
}
if (moonSprite) {
moonSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
}
if (moonHaloSprite) {
moonHaloSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
}
}
function updateLighting() {
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
const inspection = CELESTIAL_CONFIG.inspectionLighting;
const cameraDirection = linkedCamera
? linkedCamera.position.clone().normalize()
: defaultSunDirection.clone();
const worldUp = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
if (right.lengthSq() < 1e-6) {
right.set(1, 0, 0);
} else {
right.normalize();
}
const adjustedUp = new THREE.Vector3()
.crossVectors(cameraDirection, right)
.normalize();
const resolveInspectionDirection = (offset) =>
cameraDirection
.clone()
.multiplyScalar(offset.z)
.add(right.clone().multiplyScalar(offset.x))
.add(adjustedUp.clone().multiplyScalar(offset.y))
.normalize();
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(inspection.ambientColor);
linkedAmbientLight.intensity = inspection.ambientIntensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(inspection.keyLightColor);
linkedSunLight.intensity = inspection.keyLightIntensity;
linkedSunLight.position
.copy(resolveInspectionDirection(inspection.keyLightOffset))
.multiplyScalar(inspection.keyLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(inspection.backLightColor);
linkedBackLight.intensity = inspection.backLightIntensity;
linkedBackLight.position
.copy(resolveInspectionDirection(inspection.backLightOffset))
.multiplyScalar(inspection.backLightDistance);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(inspection.pointLightColor);
linkedPointLight.intensity = inspection.pointLightIntensity;
linkedPointLight.position
.copy(resolveInspectionDirection(inspection.pointLightOffset))
.multiplyScalar(inspection.pointLightDistance);
}
return;
}
const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()),
);
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
linkedSunLight.position
.copy(physicalSunDirection)
.multiplyScalar(CELESTIAL_CONFIG.sunLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(CELESTIAL_CONFIG.backLightColor);
linkedBackLight.intensity = CELESTIAL_CONFIG.backLightIntensity;
linkedBackLight.position
.copy(physicalSunDirection)
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
linkedPointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
}
}
function computeCelestialState(date = new Date()) {
sunDirection = computeBodyDirection(Astronomy.Body.Sun, date);
moonDirection = computeBodyDirection(Astronomy.Body.Moon, date);
updateSpritePositions();
updateLighting();
lastUpdatedAt = date.getTime();
}
export function initCelestialLayer(
scene,
{
camera = null,
sunLight = null,
backLight = null,
ambientLight = null,
pointLight = null,
earth = null,
} = {},
) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer();
linkedCamera = camera;
linkedSunLight = sunLight;
linkedBackLight = backLight;
linkedAmbientLight = ambientLight;
linkedPointLight = pointLight;
linkedEarth = earth;
celestialRoot = new THREE.Group();
celestialRoot.name = "celestial-root";
celestialRoot.position.set(0, 0, 0);
refreshCelestialOrientation();
skySphere = createSkySphere();
brightStarsGroup = createBrightStarsGroup();
const sunTexture = createDiscTexture([
[0, "rgba(255,255,255,1)"],
[0.08, "rgba(255,251,242,1)"],
[0.22, "rgba(255,242,205,0.98)"],
[0.52, "rgba(255,211,117,0.9)"],
[0.82, "rgba(255,162,54,0.18)"],
[1, "rgba(255,120,32,0)"],
]);
const sunHaloTexture = createDiscTexture([
[0, "rgba(255,245,214,0.9)"],
[0.2, "rgba(255,220,154,0.54)"],
[0.52, "rgba(255,160,72,0.16)"],
[1, "rgba(255,120,32,0)"],
], 512);
const moonTexture = createDiscTexture([
[0, "rgba(255,255,255,0.98)"],
[0.42, "rgba(229,236,248,0.92)"],
[0.76, "rgba(164,178,202,0.34)"],
[1, "rgba(80,92,118,0)"],
]);
const moonHaloTexture = createDiscTexture([
[0, "rgba(226,235,250,0.42)"],
[0.38, "rgba(188,203,230,0.16)"],
[1, "rgba(120,136,170,0)"],
], 384);
sunSprite = createSprite({
texture: sunTexture,
scale: CELESTIAL_CONFIG.sunScale,
name: "sun-sprite",
});
sunHaloSprite = createSprite({
texture: sunHaloTexture,
scale: CELESTIAL_CONFIG.sunHaloScale,
opacity: 0.78,
name: "sun-halo-sprite",
});
moonSprite = createSprite({
texture: moonTexture,
scale: CELESTIAL_CONFIG.moonScale,
opacity: 0.98,
name: "moon-sprite",
});
moonHaloSprite = createSprite({
texture: moonHaloTexture,
scale: CELESTIAL_CONFIG.moonHaloScale,
opacity: 0.52,
name: "moon-halo-sprite",
});
sunHaloSprite.renderOrder = 98;
moonHaloSprite.renderOrder = 98;
celestialRoot.add(skySphere);
celestialRoot.add(brightStarsGroup);
celestialRoot.add(sunHaloSprite);
celestialRoot.add(moonHaloSprite);
celestialRoot.add(sunSprite);
celestialRoot.add(moonSprite);
scene.add(celestialRoot);
computeCelestialState(new Date());
loadBrightStars();
return {
root: celestialRoot,
getSunDirection: () => sunDirection.clone(),
getMoonDirection: () => moonDirection.clone(),
};
}
export function updateCelestialLayer(date = new Date(), camera = null) {
if (!celestialRoot) return;
if (camera) {
linkedCamera = camera;
}
refreshCelestialView();
const now = date.getTime();
if (!lastUpdatedAt || now - lastUpdatedAt >= CELESTIAL_CONFIG.updateIntervalMs) {
computeCelestialState(date);
} else {
updateLighting();
updateSpritePositions();
}
}
export function getSunDirection() {
return getPhysicalSunDirection(new Date(lastUpdatedAt || Date.now()));
}
export function getMoonDirection() {
return applyCelestialOrientation(moonDirection);
}
export function getCelestialDebugState() {
return {
orientationEulerRad: { ...runtimeOrientationEuler },
followEarthRotation: { ...runtimeFollowConfig },
};
}
export function setCelestialOrientation(nextEuler = {}) {
runtimeOrientationEuler = {
...runtimeOrientationEuler,
...nextEuler,
};
refreshCelestialOrientation();
updateLighting();
return getCelestialDebugState();
}
export function setCelestialFollow(nextFollow = {}) {
runtimeFollowConfig = {
...runtimeFollowConfig,
...nextFollow,
};
refreshCelestialView();
updateLighting();
return getCelestialDebugState();
}
export function setCelestialDayNightEnabled(enabled) {
dayNightLightingEnabled = enabled;
updateLighting();
}
export function disposeCelestialLayer() {
if (celestialRoot?.parent) {
celestialRoot.parent.remove(celestialRoot);
}
[skySphere, sunSprite, moonSprite, sunHaloSprite, moonHaloSprite, ...brightStarSprites].forEach((object) => {
if (!object) return;
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (object.material.map) object.material.map.dispose?.();
object.material.dispose();
}
});
skySphere = null;
brightStarsGroup = null;
sunSprite = null;
moonSprite = null;
sunHaloSprite = null;
moonHaloSprite = null;
celestialRoot = null;
brightStarSprites = [];
brightStarTexture?.dispose?.();
brightStarTexture = null;
lastUpdatedAt = 0;
linkedSunLight = null;
linkedBackLight = null;
linkedAmbientLight = null;
linkedPointLight = null;
linkedCamera = null;
linkedEarth = null;
dayNightLightingEnabled = true;
sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
}

View File

@@ -0,0 +1,334 @@
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const textureCache = new Map();
const scratchMarkerWorldPosition = new THREE.Vector3();
let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
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;
}
const distanceToCamera = camera.position.distanceTo(
marker.getWorldPosition(scratchMarkerWorldPosition),
);
const referenceDistance =
CONFIG.defaultCameraZ - CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset;
const referenceFovRad = (75 * Math.PI) / 180;
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
const scale = worldPerPixel / referenceWorldPerPixel;
return THREE.MathUtils.clamp(
scale,
COMPUTE_CENTER_CONFIG.sizeStabilization.min,
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(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;
}
const siteType = normalizeSiteType(props.site_type);
const material = new THREE.SpriteMaterial({
map: createMarkerTexture(siteType, Boolean(props.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(
latitude,
longitude,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
),
);
marker.scale.setScalar(baseScale);
marker.renderOrder = 8;
marker.visible = showComputeCenters;
marker.userData = {
...props,
latitude,
longitude,
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);
features
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
.forEach((feature) => {
const marker = createComputeCenterMarker(feature);
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,
@@ -12,6 +13,26 @@ export const CONFIG = {
dragRotationScaleMax: 2.0,
};
export const ROTATION_MODE = {
ROTATE: "rotate",
CRUISE: "cruise",
};
export const CRUISE_CONFIG = {
dwellMs: 7_000,
focusDurationMs: 1_400,
pollIntervalMs: 15_000,
maxPolledEvents: 200,
cardAnchorXRatio: 0.68,
cardAnchorYRatio: 0.24,
linkMarkerGapPx: 18,
linkPanelGapPx: 12,
linkElbowOffsetPx: 72,
linkAnchorHeightRatio: 0.26,
linkForcedBendPx: 34,
linkElbowDropPx: 24,
};
export const HUD_CONFIG = {
scaleReferenceWidth: 1920,
scaleReferenceHeight: 1080,
@@ -34,14 +55,139 @@ export const EARTH_CONFIG = {
latCoefficient: 0.5
};
export const CELESTIAL_CONFIG = {
enabled: true,
updateIntervalMs: 10_000,
skyRadius: 2600,
skyOpacity: 1,
starMapUrl: "./assets/celestial/starmap_equatorial_4k.jpg",
brightStarsUrl: "./assets/celestial/bright-stars.json",
orientationEulerRad: {
x: 0.46,
y: 1.18,
z: -0.08,
},
followEarthRotation: {
enabled: true,
x: true,
y: true,
z: false,
invertX: true,
invertY: false,
invertZ: false,
},
sunDistance: 2150,
moonDistance: 2050,
sunScale: 78,
moonScale: 38,
sunHaloScale: 136,
moonHaloScale: 62,
brightStarDistance: 2350,
brightStarMinMag: 2.1,
brightStarMaxCount: 36,
brightStarMinScale: 2.4,
brightStarMaxScale: 6.8,
brightStarOpacity: 0.88,
sunLightDistance: 460,
sunLightIntensity: 1.02,
sunLightColor: 0xfff4df,
backLightIntensity: 0.3,
backLightColor: 0x2b4c78,
inspectionLighting: {
enabled: true,
ambientIntensity: 0.64,
ambientColor: 0x707070,
keyLightIntensity: 0.92,
keyLightColor: 0xfcfcfb,
keyLightDistance: 380,
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
backLightIntensity: 0.26,
backLightColor: 0x8f96a0,
backLightDistance: 260,
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
pointLightIntensity: 0.36,
pointLightColor: 0xfafcff,
pointLightDistance: 320,
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
},
};
export const SCENE_LIGHT_CONFIG = {
ambient: {
color: 0x404060,
intensity: 1,
},
sun: {
color: 0xffffff,
intensity: 1.2,
position: { x: 5, y: 3, z: 5 },
},
back: {
color: 0x446688,
intensity: 0.3,
position: { x: -5, y: 0, z: -5 },
},
point: {
color: 0xffffff,
intensity: 0.4,
position: { x: 10, y: 10, z: 10 },
},
};
export const TERRAIN_CONFIG = {
enabled: true,
tileSize: 256,
baseZoom: 4,
geometryWidthSegments: 320,
geometryHeightSegments: 320,
baseRadiusOffset: 0.04,
exaggeration: 34,
landRevealFadeMeters: 220,
maxConcurrentRequests: 10,
opacity: 0.62,
color: 0x7f9d7f,
emissive: 0x061008,
specular: 0x233126,
shininess: 10,
urlTemplate:
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
};
export const PATHS = {
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,
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: "#fb923c",
gpu_cluster: "#60a5fa",
linked: "#f8fafc",
},
sizeStabilization: {
enabled: true,
min: 0.12,
max: 3.0,
},
};
// Cable colors mapping
export const CABLE_COLORS = {
'Americas II': 0xff4444,
@@ -110,7 +256,12 @@ export const CABLE_STATE = {
export const SATELLITE_CONFIG = {
maxCount: -1,
initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true,
trailLength: 10,
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotSize: 4,
ringSize: 0.07,
apiPath: '/api/v1/visualization/geo/satellites',
@@ -237,18 +388,18 @@ export const EARTH_MATERIAL_CONFIG = {
occluderSegments: 48,
// Fresnel atmosphere glow — inner rim
atmosInnerRadiusFactor: 1.018,
atmosInnerRadiusFactor: 1.01,
atmosInnerSegments: 64,
atmosInnerColor: [0.25, 0.62, 1.0],
atmosInnerRimPower: 3.2,
atmosInnerIntensity: 0.72,
atmosInnerIntensity: 0.18,
// Fresnel atmosphere glow — outer corona
atmosOuterRadiusFactor: 1.07,
atmosOuterRadiusFactor: 1.016,
atmosOuterSegments: 48,
atmosOuterColor: [0.18, 0.45, 0.9],
atmosOuterRimPower: 5.0,
atmosOuterIntensity: 0.28,
atmosOuterIntensity: 0.02,
// Texture candidates — tried in order, first success wins
textureUrls: [
@@ -256,4 +407,16 @@ export const EARTH_MATERIAL_CONFIG = {
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
],
dayNight: {
enabled: true,
sunDirection: { x: 1, y: 0.2, z: 0.4 },
nightFloor: 0.32,
dayBoost: 0.94,
twilightWidth: 0.24,
twilightIntensity: 0.14,
twilightColor: 0x4ea0ff,
nightTintColor: 0x0b1830,
nightTintIntensity: 0.05,
},
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
// earth.js - 3D Earth creation module
import * as THREE from 'three';
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
import { latLonToVector3 } from './utils.js';
export let earth = null;
@@ -10,6 +10,87 @@ export let terrain = null;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
let _earthShader = null;
let _dayNightEnabled = true;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
).normalize();
function applyEarthDayNightShader(material) {
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
material.onBeforeCompile = (shader) => {
_earthShader = shader;
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
shader.uniforms.uTwilightColor = { value: twilightColor };
shader.uniforms.uNightTintColor = { value: nightTintColor };
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
shader.vertexShader = shader.vertexShader.replace(
"#include <common>",
`#include <common>
varying vec3 vWorldNormal;`,
).replace(
"#include <begin_vertex>",
`#include <begin_vertex>
vWorldNormal = normalize(mat3(modelMatrix) * normal);`,
);
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
varying vec3 vWorldNormal;
uniform vec3 uSunDirectionWorld;
uniform float uNightFloor;
uniform float uDayBoost;
uniform float uTwilightWidth;
uniform float uTwilightIntensity;
uniform vec3 uTwilightColor;
uniform vec3 uNightTintColor;
uniform float uNightTintIntensity;
uniform float uDayNightEnabled;`,
).replace(
"#include <output_fragment>",
`
vec3 worldNormal = normalize(vWorldNormal);
vec3 sunDir = normalize(uSunDirectionWorld);
float sunFacing = dot(worldNormal, sunDir);
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
float cameraBoost = mix(0.62, 1.08, nDotV);
float dn = uDayNightEnabled;
vec3 dnLight = outgoingLight;
dnLight *= mix(uNightFloor, uDayBoost, daylight);
dnLight += uTwilightColor * twilight * uTwilightIntensity;
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
// dn=1: full day/night solar lighting
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
#include <output_fragment>
`,
);
};
material.customProgramCacheKey = () => "earth-day-night-v5";
material.needsUpdate = true;
}
export function createEarth(scene) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
@@ -27,6 +108,7 @@ export function createEarth(scene) {
depthWrite: true,
depthTest: true,
});
applyEarthDayNightShader(material);
_earthMaterial = material;
earth = new THREE.Mesh(geometry, material);
@@ -144,34 +226,35 @@ export function createClouds(scene, earthObj) {
return clouds;
}
export function createTerrain(scene, earthObj, simplex) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
const positionAttribute = geometry.getAttribute('position');
for (let i = 0; i < positionAttribute.count; i++) {
const x = positionAttribute.getX(i);
const y = positionAttribute.getY(i);
const z = positionAttribute.getZ(i);
const noise = simplex(x / 20, y / 20, z / 20);
const height = 1 + noise * 0.02;
positionAttribute.setXYZ(i, x * height, y * height, z * height);
}
geometry.computeVertexNormals();
export function createTerrain(earthObj) {
const geometry = new THREE.SphereGeometry(
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
TERRAIN_CONFIG.geometryWidthSegments,
TERRAIN_CONFIG.geometryHeightSegments,
);
const material = new THREE.MeshPhongMaterial({
color: 0x00aa00,
flatShading: true,
color: TERRAIN_CONFIG.color,
emissive: TERRAIN_CONFIG.emissive,
specular: TERRAIN_CONFIG.specular,
shininess: TERRAIN_CONFIG.shininess,
vertexColors: true,
vertexAlphas: true,
transparent: true,
opacity: 0.7
opacity: TERRAIN_CONFIG.opacity,
flatShading: false,
depthWrite: false,
depthTest: true,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
});
terrain = new THREE.Mesh(geometry, material);
terrain.name = "earth-real-terrain";
terrain.visible = false;
terrain.renderOrder = 0.5;
earthObj.add(terrain);
return terrain;
}
@@ -271,6 +354,36 @@ export function clearEarthTexture() {
_earthMaterial.needsUpdate = true;
}
export function setEarthSunDirection(direction) {
if (!direction) return;
_earthSunDirection.copy(direction).normalize();
if (_earthShader?.uniforms?.uSunDirectionWorld) {
_earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection);
}
}
export function setDayNightEnabled(enabled) {
_dayNightEnabled = enabled;
if (_earthShader?.uniforms?.uDayNightEnabled) {
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
if (_earthMaterial) {
if (enabled) {
// Restore normal Phong lighting + custom day/night shader
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
_earthMaterial.emissiveMap = null;
} else {
// Full bright: zero diffuse so directional light has no effect;
// use original color as emissive map to show texture uniformly.
_earthMaterial.color.setRGB(0, 0, 0);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissiveMap = _earthMaterial.map;
}
_earthMaterial.needsUpdate = true;
}
}
export function loadEarthTexture() {
return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; }
@@ -291,6 +404,10 @@ export function loadEarthTexture() {
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
_earthMaterial.map = texture;
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
if (!_dayNightEnabled) {
_earthMaterial.emissiveMap = texture;
}
_earthMaterial.needsUpdate = true;
resolve();
},

View File

@@ -0,0 +1,233 @@
const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
function getHudScale() {
const rootStyle = getComputedStyle(document.documentElement);
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function getEdgeFlipThresholdPx() {
const rootStyle = getComputedStyle(document.documentElement);
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
if (Number.isFinite(hudOffset) && hudOffset > 0) {
return hudOffset;
}
return 20 * getHudScale();
}
function clampExpandDirection(direction) {
return direction === "up" ? "up" : "down";
}
function resolveElement(target, root = document) {
if (!target) return null;
if (target instanceof HTMLElement) return target;
if (typeof target === "string") {
return root.querySelector(target);
}
return null;
}
function pickExpandDirection({
panelRect,
preferredDirection,
}) {
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
const preferred = clampExpandDirection(preferredDirection);
// Pure edge-threshold contract:
// - d < threshold => "up" family
// - d >= threshold => "down" family
// Do not pre-flip early based on expanded height.
if (spaceBelow <= edgeFlipThresholdPx) return "up";
if (spaceBelow > edgeFlipThresholdPx) return "down";
return preferred;
}
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
// The arrow always describes the next action and must stay aligned with the
// real expansion direction chosen by the controller. Panels should not add
// their own extra CSS rotation on top of this mapping.
if (collapsed) {
return {
title: expandLabel,
icon: direction === "up" ? "expand_less" : "expand_more",
};
}
return {
title: collapseLabel,
icon: direction === "up" ? "expand_more" : "expand_less",
};
}
export function createHUDPanel({
panel,
header,
body,
collapseBtn,
bodyCollapsedClass = "",
preferredDirection = "down",
collapsedClass = DEFAULT_COLLAPSED_CLASS,
hiddenClass = DEFAULT_HIDDEN_CLASS,
expandLabel = "展开",
collapseLabel = "折叠",
}) {
const panelEl = resolveElement(panel);
const headerEl = resolveElement(header, panelEl ?? document);
const bodyEl = resolveElement(body, panelEl ?? document);
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
return {
panel: panelEl,
header: headerEl,
body: bodyEl,
collapseBtn: collapseBtnEl,
setCollapsed() {},
setVisible() {},
syncLayout() {},
destroy() {},
isCollapsed() {
return false;
},
isVisible() {
return false;
},
getExpandDirection() {
return clampExpandDirection(preferredDirection);
},
};
}
let currentDirection = clampExpandDirection(preferredDirection);
const shouldAnchorBottomDuringToggle = () =>
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
const compensateTopForBottomAnchor = (beforeBottom) => {
if (!shouldAnchorBottomDuringToggle()) return;
const afterBottom = panelEl.getBoundingClientRect().bottom;
const delta = afterBottom - beforeBottom;
if (delta === 0) return;
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
};
const syncDirection = () => {
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
const nextDirection = pickExpandDirection({
panelRect: panelEl.getBoundingClientRect(),
preferredDirection,
});
currentDirection = nextDirection;
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
panelEl.dataset.expandDirection = nextDirection;
};
const syncButton = () => {
if (!(collapseBtnEl instanceof HTMLElement)) return;
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
const { title, icon } = getCollapseButtonState({
collapsed: panelEl.classList.contains(collapsedClass),
direction: currentDirection,
expandLabel,
collapseLabel,
});
collapseBtnEl.title = title;
collapseBtnEl.setAttribute("aria-label", title);
collapseBtnEl.dataset.expandDirection = currentDirection;
if (iconEl) {
iconEl.textContent = icon;
}
};
const syncLayout = () => {
syncDirection();
syncButton();
};
const setCollapsed = (collapsed) => {
syncDirection();
const nextCollapsed = Boolean(collapsed);
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
if (shouldCompensate) {
bodyEl.style.transition = "none";
}
panelEl.classList.toggle(collapsedClass, nextCollapsed);
if (bodyCollapsedClass) {
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
}
if (shouldCompensate) {
void panelEl.offsetHeight;
compensateTopForBottomAnchor(bottomBefore);
requestAnimationFrame(() => {
bodyEl.style.transition = "";
});
}
syncButton();
};
const setVisible = (visible) => {
panelEl.classList.toggle(hiddenClass, !visible);
if (visible) {
syncLayout();
}
};
const handleViewportChange = () => {
if (!panelEl.classList.contains(hiddenClass)) {
syncLayout();
}
};
const handlePointerMove = () => {
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
syncLayout();
}
};
window.addEventListener("resize", handleViewportChange);
document.addEventListener("pointerup", handleViewportChange);
document.addEventListener("pointermove", handlePointerMove);
syncLayout();
requestAnimationFrame(syncLayout);
return {
panel: panelEl,
header: headerEl,
body: bodyEl,
collapseBtn: collapseBtnEl,
setCollapsed,
setVisible,
syncLayout,
destroy() {
window.removeEventListener("resize", handleViewportChange);
document.removeEventListener("pointerup", handleViewportChange);
document.removeEventListener("pointermove", handlePointerMove);
},
isCollapsed() {
return panelEl.classList.contains(collapsedClass);
},
isVisible() {
return !panelEl.classList.contains(hiddenClass);
},
getExpandDirection() {
return currentDirection;
},
};
}
export function setupCollapsibleHudPanel(options) {
return createHUDPanel(options);
}

View File

@@ -4,6 +4,189 @@ 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) {
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;
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) {
// 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.removeAttribute('hidden');
popup.classList.remove('is-visible');
requestAnimationFrame(() => {
positionMobilePopup(popup, x, y);
requestAnimationFrame(() => {
if (token !== popupShowToken) return; // superseded
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.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;
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`;
});
document.addEventListener('pointerup', (e) => {
if (e.pointerId !== dragPointerId) return;
const wasDragged = dragged;
dragPointerId = null;
dragged = false;
if (!wasDragged) {
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
}
});
document.addEventListener('pointercancel', (e) => {
if (e.pointerId === dragPointerId) {
dragPointerId = null;
dragged = 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 +201,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 +274,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 +298,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 +325,31 @@ 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 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 = '';
};
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(
@@ -143,7 +366,9 @@ function setupInfoCardDrag(panel) {
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();
@@ -159,9 +384,11 @@ function setupInfoCardDrag(panel) {
handle.setPointerCapture?.(event.pointerId);
});
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);
}
@@ -238,8 +465,15 @@ function mountCard() {
cardMounted = true;
}
function positionPanel(panel, x, y) {
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;
@@ -251,6 +485,22 @@ function positionPanel(panel, x, y) {
const estW = Math.min(300 * scale, vpW - 32);
const estH = Math.min(420 * scale, vpH * 0.7);
if (options.absolute === true) {
const clampedLeft = Math.min(
Math.max(margin, x),
Math.max(margin, vpW - estW - margin),
);
const clampedTop = Math.min(
Math.max(margin, y),
Math.max(margin, vpH - estH - margin),
);
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
return;
}
let left = x + offset;
let top = y + offset;
@@ -263,16 +513,24 @@ function positionPanel(panel, x, y) {
panel.style.bottom = 'auto';
}
function showPanel(x, y) {
function showPanel(x, y, options = {}) {
const panel = getPanel();
if (!panel) return;
if (x != null && y != null) positionPanel(panel, x, y);
if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible');
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');
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()
@@ -292,6 +550,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);
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
return;
}
mountCard();
currentType = type;
@@ -327,10 +630,19 @@ export function showInfoCard(type, data, options = {}) {
}
content.innerHTML = html;
showPanel(options.x, options.y);
showPanel(options.x, options.y, 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

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

View File

@@ -0,0 +1,201 @@
import {
loadGeoJSONFromPath,
loadLandingPoints,
getCableLegendItems,
toggleCables,
} from "./cables.js";
import {
clearSatelliteData,
getSatelliteLegendItems,
loadSatellites,
toggleSatellites,
} from "./satellites.js";
import {
loadBGPAnomalies,
toggleBGP,
} from "./bgp.js";
import {
loadComputeCenters,
toggleComputeCenters,
} from "./compute-centers.js";
/**
* Layer startup task registry.
*
* This module is the startup-task counterpart to the layer registry in controls.js:
* - controls.js owns layer metadata such as startupPriority/startupMode/startupMessage
* - this file owns the executable startup task factory for each layer id
*
* A startup task is registered via registerLayerStartupTask(id, taskFactory).
* The taskFactory receives a startup context from main.js and must return an async
* function with the signature async (layerDefinition) => void.
*
* Put a task here only when a layer needs dedicated startup loading work:
* - preloading data at boot
* - staged loading with progress/loading messages
* - post-load UI refresh or warmup
*
* Do not put plain visibility toggles or persistent UI state here; those still belong
* to the layer registry/state flow in controls.js.
*/
const startupTaskRegistry = new Map();
export function resolveStartupMessage(layer, key, fallback) {
const message = layer?.startupMessage;
if (message && typeof message === "object" && key in message) {
return message[key];
}
if (typeof message === "string" && message.trim()) {
return message;
}
return fallback;
}
export function createLayerStartupTaskMap(context) {
return Object.fromEntries(
Array.from(startupTaskRegistry.entries()).map(([id, taskFactory]) => [
id,
taskFactory(context),
]),
);
}
export function registerLayerStartupTask(id, taskFactory) {
if (typeof id !== "string" || !id.trim()) {
throw new Error("registerLayerStartupTask 需要有效的图层 id");
}
if (typeof taskFactory !== "function") {
throw new Error("registerLayerStartupTask 需要可调用的任务工厂");
}
startupTaskRegistry.set(id, taskFactory);
}
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCableStartupTask();
registerSatelliteStartupTask();
registerComputeCenterStartupTask();
registerBGPStartupTask();
}
function registerCableStartupTask() {
registerLayerStartupTask("cables", (context) => async (layer) => {
if (!context.isCablesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
);
await context.yieldFrame(12);
try {
await loadLandingPoints(context.scene, context.earth, { silent: true });
} catch (error) {
context.reportError("登陆点", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海缆..."),
);
await context.yieldFrame(12);
try {
await loadGeoJSONFromPath(context.scene, context.earth, { silent: true });
if (!context.isCancelled() && context.isCablesEnabled()) {
toggleCables(true);
context.updateCableToggleUi(true);
context.setLegendItems("cables", getCableLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "海缆", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerSatelliteStartupTask() {
registerLayerStartupTask("satellites", (context) => async (layer) => {
if (!context.isSatellitesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载卫星..."),
);
await context.yieldFrame(12);
try {
clearSatelliteData();
const loadResult = await loadSatellites({
limit: context.getInitialSatelliteLoadLimit(),
});
if (!context.isCancelled() && context.isSatellitesEnabled()) {
context.updateSatelliteToggleUi(true, loadResult.count);
context.setLegendItems("satellites", getSatelliteLegendItems());
context.refreshLegend();
context.scheduleSatellitePositionWarmup(() => {
if (!context.isCancelled() && context.isSatellitesEnabled()) {
toggleSatellites(true);
}
});
if (context.shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = context.nextSatelliteHydrationToken();
context.hydrateAllSatellitesInBackground(
() =>
hydrationToken === context.getSatelliteHydrationToken() &&
!context.isCancelled() &&
context.isSatellitesEnabled(),
);
}
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "卫星", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerBGPStartupTask() {
registerLayerStartupTask("bgp", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
);
await context.yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(context.scene, context.earth);
if (!context.isCancelled()) {
toggleBGP(context.getShowBGP());
context.updateBGPHud(bgpResult);
context.syncBGPKnownEventIds();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "BGP态势", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
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,45 +1,49 @@
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";
let legendPanel = null;
let legendItemsByMode = {
cables: [],
satellites: [],
computeCenters: [],
bgp: [],
};
export function initLegend() {
// Tab click → switch mode
const tabsEl = document.getElementById("legend-tabs");
if (tabsEl) {
tabsEl.addEventListener("click", (e) => {
const btn = e.target.closest(".legend-tab");
if (!btn) return;
const mode = btn.dataset.legendMode;
if (mode) setLegendMode(mode);
});
}
// Collapse toggle
const collapseBtn = document.getElementById("legend-collapse");
const legend = document.getElementById("legend");
if (collapseBtn && legend) {
legendPanel = createHUDPanel({
panel: legend,
header: ".legend-bar",
body: "#legend-body",
collapseBtn,
preferredDirection: "down",
expandLabel: "展开图例",
collapseLabel: "折叠图例",
});
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
legend.classList.toggle("legend--collapsed");
legendPanel?.setCollapsed(!(legendPanel?.isCollapsed() ?? false));
});
}
syncCurrentLabel(currentLegendMode);
renderLegend(currentLegendMode);
}
export function setLegendMode(mode) {
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
currentLegendMode = nextMode;
syncTabs(nextMode);
syncCurrentLabel(nextMode);
renderLegend(nextMode);
}
@@ -59,19 +63,19 @@ export function setLegendItems(mode, items) {
}
}
function syncTabs(mode) {
const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
tabs.forEach((tab) => {
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
});
function syncCurrentLabel(mode) {
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">
@@ -80,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

@@ -0,0 +1,322 @@
import { showStatusMessage } from "./ui.js";
import { isTVPanelVisible } from "./tv.js";
// News aggregation now lives inside the shared media panel:
// - outer shell: #media-panel
// - this module renders into inner pane: #news-panel
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const FOCUS_UPDATE_INTERVAL_MS = 4000;
const DATA_REFRESH_INTERVAL_MS = 180000;
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
const REQUEST_TIMEOUT_MS = 15000;
let initialized = false;
let refreshPromise = null;
let payload = null;
let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
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(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"),
};
}
function formatCoord(value, positiveLabel, negativeLabel) {
const abs = Math.abs(value).toFixed(1);
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
}
function formatRelativeTime(raw) {
if (!raw) return "刚刚同步";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return "刚刚同步";
const diff = Date.now() - date.getTime();
const minutes = Math.max(1, Math.round(diff / 60000));
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.round(hours / 24);
return `${days} 天前`;
}
export function updateNewsToggleUI(visible) {
void visible;
}
function renderEmptyState(message) {
const { board, empty, status, openBtn } = getElements();
if (board) board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = message;
}
if (status) {
status.textContent = "等待聚合新闻源";
}
if (openBtn) openBtn.disabled = true;
}
function renderPayload(nextPayload) {
payload = nextPayload;
const {
board,
empty,
status,
focusLabel,
focusCoords,
sourceCount,
regionChip,
openBtn,
feedAnchor,
} = getElements();
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 || "全球焦点";
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
} else {
focusCoords.textContent = "跟随当前视角自动聚焦";
}
sourceCount.textContent = `${sources.length} 路聚合源`;
if (nextPayload?.stale) {
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length}`;
} else {
status.textContent = nextPayload?.errors?.length
? `已聚合 ${items.length} 条,部分源不可用`
: `已聚合 ${items.length} 条态势新闻`;
}
if (feedAnchor) {
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
}
if (openBtn) {
openBtn.disabled = !feedAnchor?.href;
}
if (items.length === 0) {
board.innerHTML = "";
if (empty) {
empty.hidden = false;
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
}
return;
}
if (empty) empty.hidden = true;
board.innerHTML = items
.map((item) => {
const cardClass = item.is_focus_match
? "news-story-card news-story-card--focus"
: "news-story-card";
const summary = item.summary
? `<div class="news-story-summary">${item.summary}</div>`
: "";
return `
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<div class="news-story-meta">
<span class="news-story-source">${item.source}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
</div>
<div class="news-story-title">${item.title}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${item.region}</span>
<span class="news-story-tag">${item.feed_name}</span>
</div>
</a>
`;
})
.join("");
}
async function fetchNews(lat, lon) {
const url = new URL(EARTH_NEWS_API, window.location.origin);
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(url.toString(), {
cache: "no-store",
signal: controller.signal,
}).finally(() => {
window.clearTimeout(timeoutId);
});
if (!response.ok) {
throw new Error(`新闻源请求失败: ${response.status}`);
}
return response.json();
}
async function refreshNews(lat, lon, { silent = false } = {}) {
if (refreshPromise) return refreshPromise;
const { status } = getElements();
if (status) {
status.textContent = "正在同步全球态势新闻...";
}
refreshPromise = fetchNews(lat, lon)
.then((nextPayload) => {
renderPayload(nextPayload);
lastFetchAt = Date.now();
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
const { status } = getElements();
if (status) {
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
}
}
return nextPayload;
})
.catch((error) => {
console.error("加载 Earth RSS 新闻失败:", error);
const message = error?.name === "AbortError"
? "新闻聚合请求超时,请稍后重试"
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
if (!payload) {
renderEmptyState(message);
} else if (!silent) {
showStatusMessage("态势新闻同步失败", "error");
}
throw error;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
function shouldRefreshForFocus(lat, lon, region) {
const now = Date.now();
if (!lastFocus) return true;
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
lastRegionSwitchAt = now;
return true;
}
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
return latDrift >= 18 || lonDrift >= 25;
}
function inferRegion(lat, lon) {
if (typeof lat !== "number" || typeof lon !== "number") return "global";
if (lon >= -170 && lon <= -30) return "americas";
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
return "asia-pacific";
}
function openCurrentSourceHomepage() {
const { feedAnchor } = getElements();
if (feedAnchor?.href) {
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
}
}
export function updateNewsViewFocus(coords) {
if (!initialized) return;
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
const region = inferRegion(coords.lat, coords.lon);
const nextFocus = {
lat: coords.lat,
lon: coords.lon,
region,
updatedAt: Date.now(),
};
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
lastFocus = nextFocus;
if (shouldRefresh) {
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
}
}
export async function ensureNewsPanelReady() {
if (!initialized) {
initNewsPanel();
}
if (!payload) {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
}
return payload;
}
export function initNewsPanel() {
if (initialized) return;
initialized = true;
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
["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");
}
});
});
["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

@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
let satellitePoints = null;
let satelliteBackdropPoints = null;
let satelliteTrails = null;
let satelliteData = [];
let showSatellites = false;
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
let lockedDotSprite = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
let highlightedSatelliteIndices = null;
let earthObjRef = null;
let sceneRef = null;
let cameraRef = null;
@@ -24,11 +26,15 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let selectedSatelliteLegendKey = null;
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();
@@ -38,47 +44,90 @@ export let breathingPhase = 0;
const SATELLITE_LEGEND_RULES = [
{
key: "starlink",
label: "Starlink",
color: "#00e6ff",
match: (props) => (props?.name || "").includes("STARLINK"),
},
{
key: "geo",
label: "GEO / 倾角 20-30",
color: "#ffcc00",
key: "equatorial",
label: "赤道轨道0-30°",
color: "#ff3333",
match: (props) => {
const inclination = props?.inclination || 53;
return inclination > 20 && inclination < 30;
const inclination = Number(props?.inclination ?? 0);
return inclination >= 0 && inclination < 30;
},
},
{
key: "iridium",
label: "Iridium",
color: "#ff8000",
match: (props) => (props?.name || "").includes("IRIDIUM"),
key: "low",
label: "低倾角轨道30-60°",
color: "#ff9933",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 30 && inclination < 60;
},
},
{
key: "mid-inclination",
label: "倾角 50-70",
color: "#00ff4d",
key: "medium",
label: "倾角轨道60-90°",
color: "#ffff33",
match: (props) => {
const inclination = props?.inclination || 53;
return inclination > 50 && inclination < 70;
const inclination = Number(props?.inclination ?? 0);
return inclination >= 60 && inclination < 90;
},
},
{
key: "high",
label: "高倾角轨道90-120°",
color: "#33ff33",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 90 && inclination < 120;
},
},
{
key: "retrograde",
label: "逆行轨道120-180°",
color: "#3333ff",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 120 && inclination <= 180;
},
},
{
key: "other",
label: "其他卫星",
color: "#ffffff",
label: "其他",
color: "#d7e2f4",
match: () => true,
},
];
const SATELLITE_RULE_COLOR_CACHE = new Map();
function getSatelliteLegendRule(props = {}) {
return (
SATELLITE_LEGEND_RULES.find((rule) => rule.match(props)) ||
SATELLITE_LEGEND_RULES[SATELLITE_LEGEND_RULES.length - 1]
);
}
function getSatelliteRuleColor(rule) {
if (!rule) {
return { r: 1, g: 1, b: 1 };
}
if (SATELLITE_RULE_COLOR_CACHE.has(rule.key)) {
return SATELLITE_RULE_COLOR_CACHE.get(rule.key);
}
const color = new THREE.Color(rule.color);
const rgb = { r: color.r, g: color.g, b: color.b };
SATELLITE_RULE_COLOR_CACHE.set(rule.key, rgb);
return rgb;
}
export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
function getBreathingPulse(phase) {
return 0.5 + 0.5 * Math.sin(phase);
}
export function getSatelliteLegendItems() {
const presentKeys = new Set();
@@ -98,31 +147,15 @@ export function getSatelliteLegendItems() {
.filter((item) => presentKeys.has(item.key))
.map(({ key, label, color }) => ({ key, label, color }));
if (!selectedSatelliteLegendKey) {
return items.map(({ label, color }) => ({ label, color }));
}
const selectedIndex = items.findIndex(
(item) => item.key === selectedSatelliteLegendKey,
);
if (selectedIndex > 0) {
const [selectedItem] = items.splice(selectedIndex, 1);
items.unshift(selectedItem);
}
return items.map(({ label, color }) => ({ label, color }));
}
export function setSelectedSatelliteLegend(props) {
const rule = SATELLITE_LEGEND_RULES.find((item) =>
item.match(props || {}),
);
selectedSatelliteLegendKey = rule?.key || null;
return getSatelliteLegendRule(props || {});
}
export function clearSelectedSatelliteLegend() {
selectedSatelliteLegendKey = null;
return null;
}
function disposeMaterial(material) {
@@ -182,6 +215,37 @@ function createDotTexture() {
return texture;
}
function createBackdropDotTexture() {
const canvas = document.createElement("canvas");
canvas.width = DOT_TEXTURE_SIZE;
canvas.height = DOT_TEXTURE_SIZE;
const ctx = canvas.getContext("2d");
const center = DOT_TEXTURE_SIZE / 2;
const radius = center - 1;
const gradient = ctx.createRadialGradient(
center,
center,
0,
center,
center,
radius,
);
gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
gradient.addColorStop(1, "rgba(7, 14, 27, 0)");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(center, center, radius, 0, Math.PI * 2);
ctx.fill();
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement("canvas");
@@ -204,8 +268,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
export function createSatellites(scene, earthObj) {
initSatelliteScene(scene, earthObj);
const dotTexture = createDotTexture();
const backdropTexture = createBackdropDotTexture();
const pointsGeometry = new THREE.BufferGeometry();
const backdropGeometry = new THREE.BufferGeometry();
const backdropMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize * 1.28,
map: backdropTexture,
color: 0x0b1626,
transparent: true,
opacity: 0.42,
sizeAttenuation: false,
alphaTest: 0.04,
depthWrite: false,
});
const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize,
@@ -215,29 +292,45 @@ export function createSatellites(scene, earthObj) {
opacity: 0.9,
sizeAttenuation: false,
alphaTest: 0.1,
depthWrite: false,
});
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
satelliteBackdropPoints.visible = false;
satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
satelliteBackdropPoints.renderOrder = 5;
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false;
satellitePoints.userData = { type: "satellitePoints" };
satellitePoints.renderOrder = 6;
const originalScale = { x: 1, y: 1, z: 1 };
satellitePoints.onBeforeRender = () => {
const syncPointScale = () => {
if (earthObj && earthObj.scale.x !== 1) {
satellitePoints.scale.set(
originalScale.x / earthObj.scale.x,
originalScale.y / earthObj.scale.y,
originalScale.z / earthObj.scale.z,
);
const scaleX = originalScale.x / earthObj.scale.x;
const scaleY = originalScale.y / earthObj.scale.y;
const scaleZ = originalScale.z / earthObj.scale.z;
satellitePoints.scale.set(scaleX, scaleY, scaleZ);
if (satelliteBackdropPoints) {
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
}
} else {
satellitePoints.scale.set(
originalScale.x,
originalScale.y,
originalScale.z,
);
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
if (satelliteBackdropPoints) {
satelliteBackdropPoints.scale.set(
originalScale.x,
originalScale.y,
originalScale.z,
);
}
}
};
satelliteBackdropPoints.onBeforeRender = syncPointScale;
satellitePoints.onBeforeRender = syncPointScale;
earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints);
const trailGeometry = new THREE.BufferGeometry();
@@ -259,7 +352,12 @@ export function createSatellites(scene, earthObj) {
return satellitePoints;
}
function getRequestedSatelliteLimit() {
function getRequestedSatelliteLimit(limitOverride) {
if (limitOverride === null) return null;
if (Number.isFinite(limitOverride) && limitOverride > 0) {
return Math.floor(limitOverride);
}
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
}
@@ -273,13 +371,50 @@ function createSatellitePositionState() {
}
function ensureSatelliteCapacity(count) {
if (!satellitePoints || !satelliteTrails) return;
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
const nextCapacity = Math.max(count, 0);
if (nextCapacity === satelliteCapacity) return;
const previousPointPositions =
satellitePoints.geometry.attributes.position?.array || null;
const previousBackdropPositions =
satelliteBackdropPoints.geometry.attributes.position?.array || null;
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
const previousTrailPositions =
satelliteTrails.geometry.attributes.position?.array || null;
const previousTrailColors =
satelliteTrails.geometry.attributes.color?.array || null;
const previousSatellitePositions = satellitePositions;
const previousCapacity = satelliteCapacity;
const positions = new Float32Array(nextCapacity * 3);
const backdropPositions = new Float32Array(nextCapacity * 3);
const colors = new Float32Array(nextCapacity * 3);
if (previousPointPositions) {
positions.set(
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
);
}
if (previousBackdropPositions) {
backdropPositions.set(
previousBackdropPositions.subarray(
0,
Math.min(previousBackdropPositions.length, backdropPositions.length),
),
);
}
if (previousColors) {
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
}
satelliteBackdropPoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(backdropPositions, 3),
);
satelliteBackdropPoints.geometry.setDrawRange(
0,
Math.min(previousCapacity, nextCapacity),
);
satellitePoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
@@ -288,10 +423,26 @@ function ensureSatelliteCapacity(count) {
"color",
new THREE.BufferAttribute(colors, 3),
);
satellitePoints.geometry.setDrawRange(0, 0);
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
if (previousTrailPositions) {
trailPositions.set(
previousTrailPositions.subarray(
0,
Math.min(previousTrailPositions.length, trailPositions.length),
),
);
}
if (previousTrailColors) {
trailColors.set(
previousTrailColors.subarray(
0,
Math.min(previousTrailColors.length, trailColors.length),
),
);
}
satelliteTrails.geometry.setAttribute(
"position",
new THREE.BufferAttribute(trailPositions, 3),
@@ -301,10 +452,19 @@ function ensureSatelliteCapacity(count) {
new THREE.BufferAttribute(trailColors, 3),
);
satellitePositions = Array.from(
{ length: nextCapacity },
createSatellitePositionState,
);
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
const previousState = previousSatellitePositions[index];
if (!previousState) {
return createSatellitePositionState();
}
return {
current: previousState.current.clone(),
trail: previousState.trail.slice(),
trailIndex: previousState.trailIndex,
trailCount: previousState.trailCount,
};
});
satelliteCapacity = nextCapacity;
}
@@ -315,7 +475,7 @@ function computeSatellitePosition(satellite, time) {
return null;
}
const satrec = buildSatrecFromProperties(props, time);
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
@@ -334,7 +494,8 @@ function computeSatellitePosition(satellite, time) {
}
const r = Math.sqrt(x * x + y * y + z * z);
const displayRadius = CONFIG.earthRadius * 1.05;
const displayRadius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale);
@@ -360,6 +521,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
return twoline2satrec(tleLines.line1, tleLines.line2);
}
function getSatelliteSatrecCacheKey(props) {
if (!props?.norad_cat_id) {
return null;
}
if (props.tle_line1 && props.tle_line2) {
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
}
if (props.epoch) {
return [
"elements",
props.norad_cat_id,
props.epoch,
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
].join(":");
}
return null;
}
function getOrBuildSatrec(props, fallbackTime) {
const cacheKey = getSatelliteSatrecCacheKey(props);
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
return satelliteSatrecCache.get(cacheKey);
}
const satrec = buildSatrecFromProperties(props, fallbackTime);
if (cacheKey && satrec && !satrec.error) {
satelliteSatrecCache.set(cacheKey, satrec);
}
return satrec;
}
function computeTleChecksum(line) {
let sum = 0;
@@ -442,7 +642,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
}
function generateFallbackPosition(satellite, index, total) {
const radius = CONFIG.earthRadius + 5;
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53;
@@ -469,8 +669,8 @@ function generateFallbackPosition(satellite, index, total) {
return new THREE.Vector3(x, y, z);
}
export async function loadSatellites() {
const limit = getRequestedSatelliteLimit();
export async function loadSatellites(options = {}) {
const limit = getRequestedSatelliteLimit(options.limit);
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
if (limit !== null) {
url.searchParams.set("limit", String(limit));
@@ -483,13 +683,17 @@ export async function loadSatellites() {
const data = await response.json();
satelliteData = data.features || [];
satelliteSatrecCache = new Map();
ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satelliteData.length;
return {
count: satelliteData.length,
requestedLimit: limit,
};
}
export function updateSatellitePositions(deltaTime = 0, force = false) {
if (!satellitePoints || satelliteData.length === 0) return;
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null;
@@ -506,6 +710,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positionUpdateAccumulator = 0;
const positions = satellitePoints.geometry.attributes.position.array;
const backdropPositions =
satelliteBackdropPoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array;
@@ -537,41 +743,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = pos.x;
positions[i * 3 + 1] = pos.y;
positions[i * 3 + 2] = pos.z;
backdropPositions[i * 3] = pos.x;
backdropPositions[i * 3 + 1] = pos.y;
backdropPositions[i * 3 + 2] = pos.z;
const inclination = props?.inclination || 53;
const name = props?.name || "";
const isStarlink = name.includes("STARLINK");
const isGeo = inclination > 20 && inclination < 30;
const isIridium = name.includes("IRIDIUM");
const rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule);
let r;
let g;
let b;
if (isStarlink) {
r = 0.0;
g = 0.9;
b = 1.0;
} else if (isGeo) {
r = 1.0;
g = 0.8;
b = 0.0;
} else if (isIridium) {
r = 1.0;
g = 0.5;
b = 0.0;
} else if (inclination > 50 && inclination < 70) {
r = 0.0;
g = 1.0;
b = 0.3;
} else {
r = 1.0;
g = 1.0;
b = 1.0;
}
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;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
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++) {
@@ -587,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;
}
}
@@ -607,6 +795,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
backdropPositions[i * 3] = 0;
backdropPositions[i * 3 + 1] = 0;
backdropPositions[i * 3 + 2] = 0;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
@@ -619,6 +810,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count);
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
satelliteBackdropPoints.geometry.setDrawRange(0, count);
satelliteTrails.geometry.attributes.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true;
@@ -637,6 +830,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
export function toggleSatellites(visible) {
showSatellites = visible;
if (satelliteBackdropPoints) {
satelliteBackdropPoints.visible = visible;
}
if (satellitePoints) {
satellitePoints.visible = visible;
}
@@ -652,6 +848,10 @@ export function toggleTrails(visible) {
}
}
export function getShowTrails() {
return showTrails;
}
export function getShowSatellites() {
return showSatellites;
}
@@ -711,7 +911,10 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
.normalize();
return scratchToCamera.dot(scratchToSatellite) > 0;
return (
scratchToCamera.dot(scratchToSatellite) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
}
function createBrighterDotCanvas() {
@@ -757,6 +960,7 @@ function createRingSprite(position, isLocked = false) {
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
}
@@ -776,6 +980,7 @@ function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
}
@@ -798,6 +1003,7 @@ export function showHoverRing(position, isLocked = false) {
lockedDotSprite = new THREE.Sprite(dotMaterial);
lockedDotSprite.position.copy(position);
lockedDotSprite.scale.set(4, 4, 1);
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
earthObjRef.add(lockedDotSprite);
return lockedRingSprite;
}
@@ -827,10 +1033,15 @@ export function hideLockedRing() {
export function updateLockedRingPosition(position) {
if (!position) return;
if (!lockedRingSprite || !lockedDotSprite) {
showHoverRing(position, true);
}
if (lockedRingSprite) {
lockedRingSprite.position.copy(position);
const ringPulse = getBreathingPulse(breathingPhase);
const breathScale =
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
1 +
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
lockedRingSprite.scale.set(
SATELLITE_CONFIG.ringSize * breathScale,
SATELLITE_CONFIG.ringSize * breathScale,
@@ -838,20 +1049,21 @@ export function updateLockedRingPosition(position) {
);
lockedRingSprite.material.opacity =
SATELLITE_CONFIG.breathingOpacityMin +
Math.sin(breathingPhase) *
ringPulse *
(SATELLITE_CONFIG.breathingOpacityMax -
SATELLITE_CONFIG.breathingOpacityMin);
}
if (lockedDotSprite) {
lockedDotSprite.position.copy(position);
const dotPulse = getBreathingPulse(breathingPhase);
const dotBreathScale =
1 +
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
(dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin +
Math.sin(breathingPhase) *
dotPulse *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
}
}
@@ -887,6 +1099,15 @@ export function setSatelliteRingState(index, state, position) {
}
}
function applyDimMaterialState(isDimmed) {
if (satellitePoints) {
satellitePoints.material.opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
}
if (satelliteBackdropPoints) {
satelliteBackdropPoints.material.opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
}
}
export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => {
if (item.sprite) {
@@ -894,12 +1115,16 @@ export function clearRelatedSatelliteHighlights() {
}
});
relatedSatelliteSprites = [];
highlightedSatelliteIndices = null;
applyDimMaterialState(false);
}
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return;
highlightedSatelliteIndices = new Set(indices);
applyDimMaterialState(true);
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
@@ -989,7 +1214,8 @@ function calculatePredictedOrbit(
if (points.length < samples * 0.5) {
points.length = 0;
const radius = CONFIG.earthRadius + 5;
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
@@ -1040,9 +1266,12 @@ export function showPredictedOrbit(satellite) {
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
});
predictedOrbitLine = new THREE.Line(geometry, material);
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(predictedOrbitLine);
}
@@ -1055,10 +1284,12 @@ export function hidePredictedOrbit() {
export function clearSatelliteData() {
satelliteData = [];
satelliteSatrecCache = new Map();
selectedSatellite = null;
lockedSatelliteIndex = null;
hoveredSatelliteIndex = null;
positionUpdateAccumulator = 0;
breathingPhase = 0;
satellitePositions.forEach((position) => {
position.current.set(0, 0, 0);
@@ -1081,6 +1312,16 @@ export function clearSatelliteData() {
satellitePoints.geometry.setDrawRange(0, 0);
}
if (satelliteBackdropPoints) {
const backdropPositionAttr =
satelliteBackdropPoints.geometry.attributes.position;
if (backdropPositionAttr?.array) {
backdropPositionAttr.array.fill(0);
backdropPositionAttr.needsUpdate = true;
}
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
}
if (satelliteTrails) {
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color;
@@ -1103,6 +1344,11 @@ export function clearSatelliteData() {
export function resetSatelliteState() {
clearSatelliteData();
if (satelliteBackdropPoints) {
disposeObject3D(satelliteBackdropPoints);
satelliteBackdropPoints = null;
}
if (satellitePoints) {
disposeObject3D(satellitePoints);
satellitePoints = null;
@@ -1115,6 +1361,7 @@ export function resetSatelliteState() {
satellitePositions = [];
satelliteCapacity = 0;
satelliteSatrecCache = new Map();
showSatellites = false;
showTrails = true;
}

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

@@ -0,0 +1,318 @@
import * as THREE from "three";
import { CONFIG, TERRAIN_CONFIG } from "./constants.js";
import { vector3ToLatLon } from "./utils.js";
const EARTH_RADIUS_METERS = 6371000;
const TERRAIN_COLOR_STOPS = [
{ height: 0, color: new THREE.Color(0x5f7f5b) },
{ height: 800, color: new THREE.Color(0x7f9564) },
{ height: 1800, color: new THREE.Color(0x9e956c) },
{ height: 3200, color: new THREE.Color(0x9f866a) },
{ height: 5200, color: new THREE.Color(0xc6c0b1) },
{ height: 7800, color: new THREE.Color(0xe8e5de) },
];
let terrainMesh = null;
let terrainLoadPromise = null;
let terrainReady = false;
let terrainFailed = false;
let terrainTileCache = new Map();
let resolvedTileCache = new Map();
let terrainVertexSamples = null;
let terrainOpacity = TERRAIN_CONFIG.opacity;
function clampLatitude(lat) {
return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878);
}
function buildTileUrl(z, x, y) {
return TERRAIN_CONFIG.urlTemplate
.replace("{z}", String(z))
.replace("{x}", String(x))
.replace("{y}", String(y));
}
function getTerrainCanvas(size) {
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(size, size);
}
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
return canvas;
}
async function decodeTerrainTile(z, x, y) {
const cacheKey = `${z}/${x}/${y}`;
if (terrainTileCache.has(cacheKey)) {
return terrainTileCache.get(cacheKey);
}
const tilePromise = (async () => {
const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" });
if (!response.ok) {
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
}
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
bitmap.close?.();
const { data, width, height } = ctx.getImageData(
0,
0,
TERRAIN_CONFIG.tileSize,
TERRAIN_CONFIG.tileSize,
);
const tileData = { data, width, height };
resolvedTileCache.set(cacheKey, tileData);
return tileData;
})();
terrainTileCache.set(cacheKey, tilePromise);
return tilePromise;
}
function decodeTerrariumHeight(tile, pixelX, pixelY) {
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
const index = (safeY * tile.width + safeX) * 4;
const r = tile.data[index];
const g = tile.data[index + 1];
const b = tile.data[index + 2];
return (r * 256 + g + b / 256) - 32768;
}
function latLonToTileSample(lat, lon, z, tileSize) {
const n = 2 ** z;
const clampedLat = clampLatitude(lat);
const latRad = THREE.MathUtils.degToRad(clampedLat);
const normalizedX = ((lon + 180) / 360) * n;
const normalizedY =
((1 -
Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
2) *
n;
const tileX = THREE.MathUtils.euclideanModulo(
Math.floor(normalizedX),
n,
);
const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1);
const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize);
const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize);
return {
tileX,
tileY,
pixelX,
pixelY,
};
}
function buildTerrainVertexSamples(positionAttribute) {
const z = TERRAIN_CONFIG.baseZoom;
const tileSize = TERRAIN_CONFIG.tileSize;
const samples = [];
for (let i = 0; i < positionAttribute.count; i++) {
const direction = new THREE.Vector3(
positionAttribute.getX(i),
positionAttribute.getY(i),
positionAttribute.getZ(i),
).normalize();
const { lat, lon } = vector3ToLatLon(direction);
const sample = latLonToTileSample(lat, lon, z, tileSize);
samples.push({
index: i,
direction,
...sample,
});
}
return samples;
}
function sampleTerrainColor(heightMeters) {
if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) {
return TERRAIN_COLOR_STOPS[0].color;
}
for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) {
const lower = TERRAIN_COLOR_STOPS[i - 1];
const upper = TERRAIN_COLOR_STOPS[i];
if (heightMeters <= upper.height) {
const t =
(heightMeters - lower.height) / Math.max(upper.height - lower.height, 1);
return lower.color.clone().lerp(upper.color, t);
}
}
return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color;
}
async function runWithConcurrency(items, limit, worker) {
const queue = [...items];
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift();
await worker(item);
}
});
await Promise.all(workers);
}
async function fetchRequiredTiles(samples) {
const uniqueKeys = Array.from(
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
);
const resolvedTiles = new Map();
await runWithConcurrency(
uniqueKeys,
TERRAIN_CONFIG.maxConcurrentRequests,
async (key) => {
const [z, x, y] = key.split("/").map(Number);
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
},
);
return resolvedTiles;
}
function applyTerrainDisplacement(samples, mesh, resolvedTiles) {
const geometry = mesh.geometry;
const positionAttribute = geometry.getAttribute("position");
let colorAttribute = geometry.getAttribute("color");
if (!colorAttribute || colorAttribute.itemSize !== 4) {
colorAttribute = new THREE.BufferAttribute(
new Float32Array(positionAttribute.count * 4),
4,
);
geometry.setAttribute("color", colorAttribute);
}
const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset;
samples.forEach((sample) => {
const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`;
const tile = resolvedTiles.get(tileKey);
if (!tile) return;
const rawElevationMeters = decodeTerrariumHeight(
tile,
sample.pixelX,
sample.pixelY,
);
const elevationMeters = Math.max(0, rawElevationMeters);
const heightWorld =
(elevationMeters / EARTH_RADIUS_METERS) *
CONFIG.earthRadius *
TERRAIN_CONFIG.exaggeration;
const radius = baseRadius + heightWorld;
const tint = sampleTerrainColor(elevationMeters);
const landAlpha = THREE.MathUtils.clamp(
elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1),
0,
1,
);
positionAttribute.setXYZ(
sample.index,
sample.direction.x * radius,
sample.direction.y * radius,
sample.direction.z * radius,
);
colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha);
});
positionAttribute.needsUpdate = true;
colorAttribute.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
}
export function registerTerrainMesh(mesh) {
terrainMesh = mesh;
terrainReady = false;
terrainFailed = false;
terrainLoadPromise = null;
terrainTileCache = new Map();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity;
if (terrainMesh?.material) {
terrainMesh.material.opacity = terrainOpacity;
terrainMesh.material.needsUpdate = true;
}
terrainVertexSamples = mesh
? buildTerrainVertexSamples(mesh.geometry.getAttribute("position"))
: null;
}
export function isTerrainReady() {
return terrainReady;
}
export async function ensureTerrainReady() {
if (!terrainMesh || !TERRAIN_CONFIG.enabled) {
return false;
}
if (terrainReady) {
return true;
}
if (terrainLoadPromise) {
return terrainLoadPromise;
}
terrainLoadPromise = (async () => {
try {
const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples);
applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles);
terrainReady = true;
terrainFailed = false;
return true;
} catch (error) {
terrainFailed = true;
console.error("加载真实地形失败:", error);
throw error;
} finally {
terrainLoadPromise = null;
}
})();
return terrainLoadPromise;
}
export function clearTerrainData() {
terrainMesh = null;
terrainLoadPromise = null;
terrainReady = false;
terrainFailed = false;
terrainVertexSamples = null;
terrainTileCache = new Map();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity;
}
export function sampleElevationAt(lat, lon) {
if (!terrainReady) return null;
const z = TERRAIN_CONFIG.baseZoom;
const { tileX, tileY, pixelX, pixelY } = latLonToTileSample(lat, lon, z, TERRAIN_CONFIG.tileSize);
const tile = resolvedTileCache.get(`${z}/${tileX}/${tileY}`);
if (!tile) return null;
return Math.max(0, decodeTerrariumHeight(tile, pixelX, pixelY));
}
export function setTerrainOpacity(nextOpacity) {
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
if (terrainMesh?.material) {
terrainMesh.material.opacity = terrainOpacity;
terrainMesh.material.needsUpdate = true;
}
return terrainOpacity;
}
export function getTerrainOpacity() {
return terrainOpacity;
}

View File

@@ -1,5 +1,11 @@
import Hls from "hls.js";
import { showStatusMessage } from "./ui.js";
import { createHUDPanel } from "./hud-panels.js";
// Naming convention:
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
// - #tv-panel is the inner live tab pane
// - #news-panel is the inner aggregation-news tab pane
const TV_STREAMS_API = "/api/v1/tv/streams";
const TV_PROXY_API = "/api/v1/tv/proxy";
@@ -21,8 +27,25 @@ let refreshPromise = null;
let hlsPlayer = null;
let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null;
let mediaPanel = null;
let activeTab = "live";
const failedSourceIds = new Set();
let probeTimer = null;
let reformCleanupTimer = null;
const tabPanelState = {
live: null,
news: null,
};
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const DEFAULT_HUD_OFFSET_PX = 20;
const MIN_NEWS_TAB_HEIGHT_PX = 280;
const PANEL_RESIZE_MARGIN_PX = 12;
const TV_PANEL_MIN_WIDTH_PX = 360;
const TV_PANEL_MIN_HEIGHT_PX = 340;
const REFORM_CLEANUP_MS = 280;
const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor";
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
@@ -33,60 +56,69 @@ const HLS_RETRY_CONFIG = {
};
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
panel: document.getElementById("tv-panel"),
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
resizeHandle: null,
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"),
metaPanel: document.getElementById("tv-panel-meta"),
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"),
newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"),
newsTabBtn: document.getElementById("tv-tab-news"),
// Inner tab panes.
livePane: document.getElementById("tv-panel"),
newsPane: document.getElementById("news-panel"),
};
}
function setMetaCollapsed(collapsed) {
const { metaWrap, metaToggle, panel } = getElements();
if (!metaWrap) return;
mediaPanel?.setCollapsed(collapsed);
}
const isDragged = panel?.dataset.dragged === "true" && panel.style.top;
if (isDragged) {
// Top-anchored panel: toggle instantly and compensate top so the player
// (panel bottom) stays visually fixed.
const bottomBefore = panel.getBoundingClientRect().bottom;
metaWrap.style.transition = "none";
metaWrap.classList.toggle("is-collapsed", collapsed);
metaToggle?.classList.toggle("is-collapsed", collapsed);
// Force synchronous reflow to get updated panel height
void panel.offsetHeight;
const delta = panel.getBoundingClientRect().bottom - bottomBefore;
if (delta !== 0) {
panel.style.top = `${parseFloat(panel.style.top) - delta}px`;
}
// Restore CSS transition after this paint
requestAnimationFrame(() => {
metaWrap.style.transition = "";
});
} else {
metaWrap.classList.toggle("is-collapsed", collapsed);
metaToggle?.classList.toggle("is-collapsed", collapsed);
function syncPanelActiveTab(tab = activeTab) {
const { panel } = getElements();
if (panel instanceof HTMLElement) {
panel.dataset.activeTab = tab;
}
}
function syncNewsDefaultMaxHeight() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
const earthStats = document.getElementById("earth-stats");
const hudOffset = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"),
);
const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX;
if (!(earthStats instanceof HTMLElement)) {
panel.style.removeProperty("--tv-news-default-max-height");
return;
}
const statsRect = earthStats.getBoundingClientRect();
const availableHeight = Math.max(
Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()),
Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom),
);
panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`);
}
function autoExpandMeta() {
clearTimeout(metaAutoCollapseTimer);
setMetaCollapsed(false);
@@ -102,6 +134,82 @@ function clearPanelPositioningForResize(panel) {
panel.dataset.dragged = "true";
}
function readPanelLayoutState(panel) {
return {
width: panel.style.width || "",
height: panel.style.height || "",
resized: panel.dataset.resized === "true",
};
}
function resetPanelLayoutState(panel) {
panel.style.width = "";
panel.style.height = "";
delete panel.dataset.resized;
}
function captureTabState(tab = activeTab) {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
};
}
function restoreTabState(tab, panel, container, anchor = null) {
if (!(panel instanceof HTMLElement)) return;
const snapshot = tabPanelState[tab];
if (!snapshot?.layout) {
resetPanelLayoutState(panel);
return;
}
const { layout } = snapshot;
panel.style.width = layout.width;
panel.style.height = layout.height;
if (layout.resized) {
panel.dataset.resized = "true";
} else {
delete panel.dataset.resized;
}
if (tab === "live" && snapshot.metaCollapsed !== null) {
setMetaCollapsed(snapshot.metaCollapsed);
}
requestAnimationFrame(() => {
if (anchor) {
const panelRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const targetLeft = anchor.right - containerRect.left - panelRect.width;
const targetTop = anchor.bottom - containerRect.top - panelRect.height;
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
} else {
panel.style.right = "";
panel.style.bottom = "";
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
delete panel.dataset.dragged;
}
});
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -109,6 +217,27 @@ function getHudScale() {
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function clampPanelToContainer(panel, container) {
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
if (panel.dataset.dragged !== "true") return;
const containerRect = container.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const currentLeft = panelRect.left - containerRect.left;
const currentTop = panelRect.top - containerRect.top;
const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft));
const clampedTop = Math.min(maxTop, Math.max(0, currentTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
}
function setupResizeHandle() {
const { panel } = getElements();
const container = document.getElementById("container");
@@ -116,12 +245,14 @@ function setupResizeHandle() {
let resizing = false;
let activeEdge = "";
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
let startLeft = 0;
let startTop = 0;
const resizeStart = {
pointerX: 0,
pointerY: 0,
width: 0,
height: 0,
left: 0,
top: 0,
};
const stopResize = () => {
resizing = false;
@@ -134,23 +265,23 @@ function setupResizeHandle() {
if (!resizing) return;
const containerRect = container.getBoundingClientRect();
const hudScale = getHudScale();
const minWidth = Math.max(320, Math.round(360 * hudScale));
const minHeight = Math.max(260, Math.round(340 * hudScale));
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const minWidth = Math.max(320, Math.round(TV_PANEL_MIN_WIDTH_PX * hudScale));
const minHeight = Math.max(260, Math.round(TV_PANEL_MIN_HEIGHT_PX * hudScale));
const dx = event.clientX - resizeStart.pointerX;
const dy = event.clientY - resizeStart.pointerY;
if (activeEdge.includes("r")) {
const maxW = containerRect.width - startLeft - 12;
panel.style.width = `${Math.min(maxW, Math.max(minWidth, startWidth + dx))}px`;
const maxW = containerRect.width - resizeStart.left - PANEL_RESIZE_MARGIN_PX;
panel.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
}
if (activeEdge.includes("l")) {
const newW = Math.max(minWidth, startWidth - dx);
const newW = Math.max(minWidth, resizeStart.width - dx);
panel.style.width = `${newW}px`;
panel.style.left = `${Math.max(0, startLeft + startWidth - newW)}px`;
panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
}
if (activeEdge.includes("b")) {
const maxH = containerRect.height - startTop - 12;
panel.style.minHeight = `${Math.min(maxH, Math.max(minHeight, startHeight + dy))}px`;
const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX;
panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
}
};
@@ -162,17 +293,21 @@ function setupResizeHandle() {
activeEdge = edgeEl.dataset.edge ?? "";
resizing = true;
startX = event.clientX;
startY = event.clientY;
resizeStart.pointerX = event.clientX;
resizeStart.pointerY = event.clientY;
clearPanelPositioningForResize(panel);
panel.dataset.resized = "true";
const rect = panel.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
startLeft = rect.left - cRect.left;
startTop = rect.top - cRect.top;
resizeStart.width = rect.width;
resizeStart.height = rect.height;
resizeStart.left = rect.left - cRect.left;
resizeStart.top = rect.top - cRect.top;
panel.style.width = `${resizeStart.width}px`;
panel.style.height = `${resizeStart.height}px`;
panel.style.minHeight = "";
panel.classList.add("is-resizing");
document.body.style.userSelect = "none";
@@ -189,15 +324,25 @@ function setupResizeHandle() {
function updateToggleButton(visible) {
const { toggleBtn } = getElements();
if (!toggleBtn) return;
const icon = toggleBtn.querySelector(".material-symbols-rounded");
const isLiveTab = activeTab === "live";
toggleBtn.classList.toggle("active", visible);
if (icon) {
icon.textContent = isLiveTab ? "live_tv" : "newspaper";
}
const title = visible
? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播")
: (isLiveTab ? "打开新闻直播" : "打开态势新闻");
toggleBtn.title = title;
toggleBtn.setAttribute("aria-label", title);
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
tooltip.textContent = title;
}
}
function syncSettingsToggle(visible) {
const input = document.querySelector('[data-settings-panel="tv-panel"]');
const input = document.querySelector('[data-settings-panel="media-panel"]');
if (input instanceof HTMLInputElement) {
input.checked = visible;
}
@@ -206,9 +351,203 @@ function syncSettingsToggle(visible) {
function setPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
mediaPanel?.setVisible(visible);
document.body.classList.toggle("earth-media-open", visible);
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
detail: { visible },
}));
}
export function setTVPanelVisible(visible) {
setPanelVisible(visible);
}
function clearReformState() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
panel.classList.remove("is-reforming");
panel.style.height = "";
if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") {
panel.style.top = "";
panel.style.bottom = "";
delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY];
}
if (reformCleanupTimer) {
clearTimeout(reformCleanupTimer);
reformCleanupTimer = null;
}
}
function animateTabReform(applyChange) {
const { panel } = getElements();
const container = document.getElementById("container");
if (!(panel instanceof HTMLElement)) {
applyChange();
return;
}
if (!(container instanceof HTMLElement)) {
applyChange();
return;
}
if (panel.dataset.resized === "true") {
applyChange();
requestAnimationFrame(() => {
clampPanelToContainer(panel, container);
});
return;
}
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
applyChange();
return;
}
clearReformState();
const reformStartRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const reformSnapshot = {
height: reformStartRect.height,
anchoredBottom: reformStartRect.bottom - containerRect.top,
shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true",
};
if (reformSnapshot.shouldRestoreDefaultAnchoring) {
panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true";
panel.style.bottom = "auto";
}
panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`;
panel.style.height = `${reformSnapshot.height}px`;
panel.classList.add("is-reforming");
void panel.offsetHeight;
applyChange();
panel.style.height = "auto";
const targetHeight = panel.getBoundingClientRect().height;
panel.style.height = `${reformSnapshot.height}px`;
void panel.offsetHeight;
const targetTop = reformSnapshot.anchoredBottom - targetHeight;
const finalizeReform = () => {
panel.removeEventListener("transitionend", handleReformTransitionEnd);
clearReformState();
};
const handleReformTransitionEnd = (event) => {
if (event.target === panel && event.propertyName === "height") {
finalizeReform();
}
};
panel.addEventListener("transitionend", handleReformTransitionEnd);
reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS);
requestAnimationFrame(() => {
panel.style.top = `${targetTop}px`;
panel.style.height = `${targetHeight}px`;
});
}
function updateTabState(target, isActive, activeClassName = "") {
if (!(target instanceof HTMLElement)) return;
target.hidden = !isActive;
if (activeClassName) {
target.classList.toggle(activeClassName, isActive);
}
}
function updateTabButtonState(button, isActive) {
if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle("media-panel-tab--active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
}
function setActiveTab(tab) {
const nextTab = tab === "news" ? "news" : "live";
if (activeTab === nextTab) return;
captureTabState(activeTab);
const { panel } = getElements();
const targetSnapshot = tabPanelState[nextTab];
const currentIsCustom =
panel instanceof HTMLElement && panel.dataset.resized === "true";
const targetIsCustom = Boolean(targetSnapshot?.layout?.resized);
const container = document.getElementById("container");
const currentAnchor =
panel instanceof HTMLElement && container instanceof HTMLElement
? (() => {
const panelRect = panel.getBoundingClientRect();
return {
right: panelRect.right,
bottom: panelRect.bottom,
};
})()
: null;
const applyTabSwitch = (restoreLayoutState = false) => {
activeTab = nextTab;
syncPanelActiveTab(nextTab);
syncNewsDefaultMaxHeight();
const {
liveTabBtn,
newsTabBtn,
liveHeaderControls,
newsHeaderControls,
livePane,
newsPane,
} = getElements();
updateTabButtonState(liveTabBtn, nextTab === "live");
updateTabButtonState(newsTabBtn, nextTab === "news");
updateTabState(liveHeaderControls, nextTab === "live");
updateTabState(newsHeaderControls, nextTab === "news");
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
if (
restoreLayoutState &&
panel instanceof HTMLElement &&
container instanceof HTMLElement
) {
restoreTabState(nextTab, panel, container, currentAnchor);
}
requestAnimationFrame(() => {
captureTabState(nextTab);
});
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: nextTab },
}));
};
if (currentIsCustom || targetIsCustom) {
applyTabSwitch(true);
return;
}
animateTabReform(() => applyTabSwitch(false));
}
export function openTVPanelTab(tab = "live") {
setPanelVisible(true);
if (activeTab === tab) return;
setActiveTab(tab);
}
export function isTVPanelVisible() {
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
}
export function getActiveTVTab() {
return activeTab;
}
function getEmbeddedUrl(source) {
@@ -392,7 +731,7 @@ function attachVideoSource(video, source) {
}
}
if (!showEmbeddedFallback(source)) {
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
@@ -425,6 +764,59 @@ function findSourceById(sourceId) {
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
}
function markSourceFailed(sourceId) {
if (!sourceId) return;
failedSourceIds.add(sourceId);
renderSourceOptions();
if (!probeTimer) {
probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
}
}
function clearSourceFailed(sourceId) {
if (!failedSourceIds.has(sourceId)) return;
failedSourceIds.delete(sourceId);
renderSourceOptions();
if (failedSourceIds.size === 0 && probeTimer) {
clearInterval(probeTimer);
probeTimer = null;
}
}
async function probeFailedSources() {
if (failedSourceIds.size === 0) {
clearInterval(probeTimer);
probeTimer = null;
return;
}
for (const sourceId of [...failedSourceIds]) {
const source = findSourceById(sourceId);
if (!source) { failedSourceIds.delete(sourceId); continue; }
const probeUrl = source.stream_url || source.embed_url;
if (!probeUrl) continue;
try {
const resp = await fetch(probeUrl, {
method: "HEAD",
signal: AbortSignal.timeout(5000),
});
if (resp.ok) clearSourceFailed(sourceId);
} catch {
// 仍然失效,保持标记
}
}
}
function tryFallbackSource() {
const fallback = tvPayload?.fallback_source;
if (!fallback || fallback.id === currentSourceId) return false;
markSourceFailed(currentSourceId);
currentSourceId = fallback.id;
const { select } = getElements();
if (select) select.value = currentSourceId;
renderSource(fallback);
return true;
}
function getCurrentSource() {
return findSourceById(currentSourceId);
}
@@ -487,10 +879,12 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment();
sources.forEach((source) => {
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
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}${marker}`;
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option);
});
@@ -522,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 || "支持后台配置默认源与采集器补充源。";
@@ -622,58 +1020,124 @@ export function initTVPanel() {
if (initialized) return;
initialized = true;
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
const {
select,
refreshBtn,
iframe,
video,
toggleBtn,
panel,
metaToggle,
liveTabBtn,
newsTabBtn,
} = getElements();
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
if (panel && metaToggle) {
mediaPanel = createHUDPanel({
panel,
header: ".hud-panel__header",
body: "#tv-meta-wrap",
collapseBtn: metaToggle,
bodyCollapsedClass: "is-collapsed",
preferredDirection: "up",
expandLabel: "展开新闻直播信息",
collapseLabel: "折叠新闻直播信息",
});
}
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
toggleBtn?.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
setPanelVisible(nextVisible);
if (nextVisible) {
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("新闻直播窗口已关闭", "info");
const currentlyVisible = mediaPanel?.isVisible() ?? false;
if (!currentlyVisible) {
setPanelVisible(true);
if (activeTab === "live") {
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("态势新闻窗口已打开", "info");
}
return;
}
const nextTab = activeTab === "live" ? "news" : "live";
setActiveTab(nextTab);
if (nextTab === "live") {
await ensureTVPanelReady();
showStatusMessage("已切换到新闻直播", "info");
return;
}
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));
});
});
const { metaToggle } = getElements();
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed");
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
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");
});
newsTabBtn?.addEventListener("click", () => {
setActiveTab("news");
});
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
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;
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)) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
videoEl?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
});
setupResizeHandle();
syncPanelActiveTab("live");
syncNewsDefaultMaxHeight();
updateTabButtonState(liveTabBtn, true);
updateTabButtonState(newsTabBtn, false);
captureTabState("live");
window.addEventListener("resize", syncNewsDefaultMaxHeight);
}

View File

@@ -2,13 +2,37 @@
let statusTimeoutId = null;
let statusHideTimeoutId = null;
let statusReplayTimeoutId = null;
const STATUS_BASE_CLASS = "earth-status-message";
const STATUS_DISPLAY_MS = 3000;
const STATUS_FADE_MS = 280;
let statusQueue = [];
let statusBusy = false;
let loadingActive = false;
let loadingLockedWidth = 0;
let pendingLoadingMessage = "";
function createStatusEntry(message, type = "info") {
return { message, type };
}
function getElement(id) {
return document.getElementById(id);
}
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";
@@ -19,53 +43,109 @@ function clearStatusTimers() {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
if (statusHideTimeoutId) {
clearTimeout(statusHideTimeoutId);
statusHideTimeoutId = null;
}
}
if (statusReplayTimeoutId) {
clearTimeout(statusReplayTimeoutId);
statusReplayTimeoutId = null;
function clearLoadingWidthLock(statusEl) {
loadingLockedWidth = 0;
if (statusEl) {
statusEl.style.minWidth = "";
}
}
// Show status message
export function showStatusMessage(message, type = "info") {
function updateLoadingWidthLock(statusEl) {
if (!statusEl || !loadingActive) return;
const nextWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
if (nextWidth <= 0) return;
loadingLockedWidth = Math.max(loadingLockedWidth, nextWidth);
statusEl.style.minWidth = `${loadingLockedWidth}px`;
}
function buildStatusContent(statusEl, message, type) {
statusEl.innerHTML = "";
const indicator = document.createElement("span");
indicator.className = "earth-status-indicator";
indicator.setAttribute("aria-hidden", "true");
const dotCount = type === "loading" ? 3 : 1;
for (let i = 0; i < dotCount; i++) {
const dot = document.createElement("span");
dot.className = "earth-status-dot";
indicator.appendChild(dot);
}
const text = document.createElement("span");
text.className = "earth-status-text";
text.textContent = message;
statusEl.appendChild(indicator);
statusEl.appendChild(text);
}
function buildPersistentErrorContent(errorEl, message) {
buildStatusContent(errorEl, message, "error");
}
function hideStatusElement(statusEl, onHidden) {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
if (!statusEl.classList.contains("visible")) {
setElementDisplay(statusEl, false);
statusEl.className = STATUS_BASE_CLASS;
statusEl.innerHTML = "";
}
statusHideTimeoutId = null;
if (typeof onHidden === "function") {
onHidden();
}
}, STATUS_FADE_MS);
}
function processStatusQueue() {
if (loadingActive || statusBusy || statusQueue.length === 0) return;
const next = statusQueue.shift();
if (!next) return;
startTransientStatus(next.message, next.type);
}
function startTransientStatus(message, type = "info") {
const statusEl = getElement("status-message");
if (!statusEl) return;
clearStatusTimers();
statusBusy = true;
const startShow = () => {
statusEl.textContent = message;
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
setElementDisplay(statusEl, true);
statusEl.offsetHeight;
statusEl.classList.add("visible");
buildStatusContent(statusEl, message, type);
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
setElementDisplay(statusEl, true, "inline-flex");
statusEl.offsetHeight;
statusEl.classList.add("visible");
statusTimeoutId = setTimeout(() => {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
setElementDisplay(statusEl, false);
statusEl.textContent = "";
statusHideTimeoutId = null;
}, 280);
statusTimeoutId = null;
}, 3000);
};
statusTimeoutId = setTimeout(() => {
hideStatusElement(statusEl, () => {
statusBusy = false;
processStatusQueue();
});
statusTimeoutId = null;
}, STATUS_DISPLAY_MS);
}
if (statusEl.classList.contains("visible")) {
statusEl.classList.remove("visible");
statusReplayTimeoutId = setTimeout(() => {
startShow();
statusReplayTimeoutId = null;
}, 180);
// Show status message
export function showStatusMessage(message, type = "info") {
if (loadingActive) {
statusQueue.unshift(createStatusEntry(message, type));
return;
}
startTransientStatus(message, type);
}
startShow();
export function queueStatusMessage(message, type = "info") {
statusQueue.push(createStatusEntry(message, type));
processStatusQueue();
}
// Update coordinates display
@@ -89,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";
@@ -97,46 +185,68 @@ 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
// Show/hide loading via status message
export function setLoading(loading) {
const loadingEl = getElement("loading");
if (!loadingEl) return;
setElementDisplay(loadingEl, loading);
const statusEl = getElement("status-message");
if (!statusEl) return;
if (loading) {
clearStatusTimers();
loadingActive = true;
statusBusy = false;
clearLoadingWidthLock(statusEl);
buildStatusContent(
statusEl,
pendingLoadingMessage || "正在加载...",
"loading",
);
pendingLoadingMessage = "";
statusEl.className = `${STATUS_BASE_CLASS} loading`;
setElementDisplay(statusEl, true, "inline-flex");
statusEl.offsetHeight;
statusEl.classList.add("visible");
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
});
} else {
pendingLoadingMessage = "";
if (!statusEl.classList.contains("loading")) {
loadingActive = false;
clearLoadingWidthLock(statusEl);
processStatusQueue();
return;
}
loadingActive = false;
hideStatusElement(statusEl, () => {
clearLoadingWidthLock(statusEl);
statusBusy = false;
processStatusQueue();
});
}
}
export function setLoadingMessage(title, subtitle = "") {
const titleEl = getElement("loading-title");
const subtitleEl = getElement("loading-subtitle");
if (titleEl) {
titleEl.textContent = title;
export function setLoadingMessage(title) {
const statusEl = getElement("status-message");
if (!statusEl || !statusEl.classList.contains("loading")) {
pendingLoadingMessage = title;
return;
}
if (subtitleEl) {
subtitleEl.textContent = subtitle;
const textEl = statusEl.querySelector(".earth-status-text");
if (textEl) {
textEl.textContent = title;
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
});
}
}
@@ -162,27 +272,37 @@ export function hideTooltip() {
export function showError(message) {
const errorEl = getElement("error-message");
if (!errorEl) return;
errorEl.textContent = message;
setElementDisplay(errorEl, true);
buildPersistentErrorContent(errorEl, message);
errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
setElementDisplay(errorEl, true, "inline-flex");
errorEl.offsetHeight;
errorEl.classList.add("visible");
}
// Hide error message
export function hideError() {
const errorEl = getElement("error-message");
if (errorEl) {
errorEl.classList.remove("visible");
setElementDisplay(errorEl, false);
errorEl.textContent = "";
errorEl.className = "earth-error-message";
errorEl.innerHTML = "";
}
}
export function clearUiState() {
clearStatusTimers();
statusQueue = [];
statusBusy = false;
loadingActive = false;
pendingLoadingMessage = "";
const statusEl = getElement("status-message");
if (statusEl) {
statusEl.className = STATUS_BASE_CLASS;
setElementDisplay(statusEl, false);
statusEl.textContent = "";
statusEl.innerHTML = "";
clearLoadingWidthLock(statusEl);
}
hideTooltip();

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)

Some files were not shown because too many files have changed in this diff Show More