Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe04030fb | ||
|
|
6a5f9f7ad4 | ||
|
|
439a512148 | ||
|
|
f73fa1ea6d | ||
|
|
5b623a6385 | ||
|
|
0082cf3fbd | ||
|
|
3ae4acdff8 | ||
|
|
437efc848c | ||
|
|
003a46ac30 | ||
|
|
4b0be4cb76 | ||
|
|
b7647379de |
91
.claude/commands/goal-driven.md
Normal file
91
.claude/commands/goal-driven.md
Normal 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
3
.codex/config.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
approval_policy = "never"
|
||||||
|
|
||||||
|
sandbox_mode = "danger-full-access"
|
||||||
101
.codex/skills/goal-driven/SKILL.md
Executable file
101
.codex/skills/goal-driven/SKILL.md
Executable 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).
|
||||||
7
.codex/skills/goal-driven/agents/openai.yaml
Normal file
7
.codex/skills/goal-driven/agents/openai.yaml
Normal 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
|
||||||
38
.codex/skills/goal-driven/references/prompt-template.md
Executable file
38
.codex/skills/goal-driven/references/prompt-template.md
Executable 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
124
README.md
@@ -227,6 +227,120 @@ bun run build
|
|||||||
|
|
||||||
启动服务后访问: `http://localhost:8000/docs`
|
启动服务后访问: `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。
|
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
||||||
@@ -328,11 +442,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
|||||||
|
|
||||||
详细文档:
|
详细文档:
|
||||||
|
|
||||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||||
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
|
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||||
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md)
|
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
|
||||||
|
|
||||||
## 前端页面布局规范
|
## 前端页面布局规范
|
||||||
|
|
||||||
@@ -346,7 +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)
|
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@@ -20,3 +20,7 @@
|
|||||||
- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector
|
- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector
|
||||||
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度
|
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度
|
||||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||||
|
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
||||||
|
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||||
|
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||||
|
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ async def list_datasources(
|
|||||||
collector_list.append(
|
collector_list.append(
|
||||||
{
|
{
|
||||||
"id": datasource.id,
|
"id": datasource.id,
|
||||||
|
"source": datasource.source,
|
||||||
"name": datasource.name,
|
"name": datasource.name,
|
||||||
"module": datasource.module,
|
"module": datasource.module,
|
||||||
"priority": datasource.priority,
|
"priority": datasource.priority,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy import select, func
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.core.collected_data_fields import get_record_field
|
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.satellite_tle import build_tle_lines_from_elements
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -363,6 +364,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
|
|||||||
return {"type": "FeatureCollection", "features": features}
|
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(
|
def convert_bgp_anomalies_to_geojson(
|
||||||
records: List[BGPAnomaly],
|
records: List[BGPAnomaly],
|
||||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||||
@@ -786,9 +996,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/geo/landing-points")
|
@router.get("/geo/landing-points")
|
||||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||||
try:
|
try:
|
||||||
records = await _load_current_collected_data(db, "arcgis_landing_points")
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation")
|
db,
|
||||||
cable_records = await _load_current_collected_data(db, "arcgis_cables")
|
[
|
||||||
|
"arcgis_landing_points",
|
||||||
|
"arcgis_cable_landing_relation",
|
||||||
|
"arcgis_cables",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
records = records_by_source.get("arcgis_landing_points", [])
|
||||||
|
relation_records = records_by_source.get(
|
||||||
|
"arcgis_cable_landing_relation",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
cable_records = records_by_source.get("arcgis_cables", [])
|
||||||
|
|
||||||
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
|
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
|
||||||
relation_records,
|
relation_records,
|
||||||
@@ -964,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")
|
@router.get("/geo/bgp-anomalies")
|
||||||
async def get_bgp_anomalies_geojson(
|
async def get_bgp_anomalies_geojson(
|
||||||
severity: Optional[str] = Query(None),
|
severity: Optional[str] = Query(None),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = {
|
|||||||
"iptoasn_prefix_geo": "iptoasn.combined_url",
|
"iptoasn_prefix_geo": "iptoasn.combined_url",
|
||||||
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
|
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
|
||||||
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
||||||
|
"news_live_streams": "news_live_streams.channels_url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,3 +86,11 @@ opengeofeed:
|
|||||||
nro:
|
nro:
|
||||||
# NRO delegated stats 下载地址
|
# NRO delegated stats 下载地址
|
||||||
delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/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"
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
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
|
from app.services.collectors.base import BaseCollector
|
||||||
|
|
||||||
|
|
||||||
@@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector):
|
|||||||
data_type = "news_live_stream"
|
data_type = "news_live_stream"
|
||||||
fail_on_empty = False
|
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]]:
|
async def fetch(self) -> list[dict[str, Any]]:
|
||||||
request_url = (self._resolved_url or "").strip()
|
request_url = (self._resolved_url or "").strip()
|
||||||
if not request_url:
|
if not request_url:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client:
|
datasource_config = await self._load_datasource_config()
|
||||||
response = await client.get(
|
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,
|
request_url,
|
||||||
headers={
|
headers=request_headers,
|
||||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
params=request_params or None,
|
||||||
"Accept": "application/json",
|
json=request_json,
|
||||||
},
|
data=request_data,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
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]]:
|
async def _load_datasource_config(self) -> DataSourceConfig | None:
|
||||||
if isinstance(response, dict):
|
if not self._db_session:
|
||||||
candidates = response.get("sources") or response.get("streams") or response.get("data") or []
|
return None
|
||||||
elif isinstance(response, list):
|
|
||||||
candidates = response
|
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:
|
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]] = []
|
normalized: list[dict[str, Any]] = []
|
||||||
for index, item in enumerate(candidates):
|
for index, item in enumerate(candidates):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}"
|
stream_id = (
|
||||||
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip()
|
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:
|
if not name:
|
||||||
continue
|
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 = {
|
metadata = {
|
||||||
"provider": item.get("provider") or item.get("publisher") or "Collector",
|
"provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector",
|
||||||
"region": item.get("region") or item.get("country") or "Global",
|
"region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global",
|
||||||
"language": item.get("language") or "und",
|
"language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und",
|
||||||
"source_type": item.get("source_type") or "iframe",
|
"source_type": source_type,
|
||||||
"embed_url": item.get("embed_url") or item.get("url") or "",
|
"embed_url": embed_url,
|
||||||
"stream_url": item.get("stream_url") or "",
|
"stream_url": stream_url,
|
||||||
"homepage_url": item.get("homepage_url") or item.get("source_url") or "",
|
"homepage_url": homepage_url,
|
||||||
"poster_url": item.get("poster_url") or "",
|
"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),
|
"sort_order": item.get("sort_order", 200 + index),
|
||||||
"notes": item.get("notes") or item.get("description") or "",
|
"notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")),
|
||||||
"is_enabled": item.get("is_enabled", True),
|
"is_enabled": self._parse_enabled(item),
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized.append(
|
normalized.append(
|
||||||
@@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
|||||||
"name": name,
|
"name": name,
|
||||||
"description": metadata["notes"],
|
"description": metadata["notes"],
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()),
|
"reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
|
|||||||
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
|
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
|
||||||
|
|
||||||
DEFAULT_TV_SETTINGS = {
|
DEFAULT_TV_SETTINGS = {
|
||||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||||
"auto_fallback": True,
|
"auto_fallback": True,
|
||||||
"sources": [
|
"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),
|
"sort_order": metadata.get("sort_order", 200 + index),
|
||||||
"collector_source": record.source,
|
"collector_source": record.source,
|
||||||
"notes": record.description or metadata.get("notes") or "",
|
"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,
|
index=index,
|
||||||
)
|
)
|
||||||
|
|||||||
217
backend/tests/test_visualization_compute_centers.py
Normal file
217
backend/tests/test_visualization_compute_centers.py
Normal 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()
|
||||||
@@ -8,7 +8,179 @@ This project follows the repository versioning rule:
|
|||||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
## [0.29.1] — 2026-04-20
|
## [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
|
||||||
|
- 抽屉把手区域缩小至 36px(collapsed 时仅露出把手,不遮挡地球操作区)
|
||||||
|
- 抽屉定期弹跳动画提示用户可上拉,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
|
## [0.30.0] — 2026-04-21
|
||||||
|
|
||||||
@@ -295,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 [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
|
||||||
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
|
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
|
||||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/technical/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/technical/earth-news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
@@ -381,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/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
|
||||||
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
|
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
|
||||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -419,7 +591,7 @@ Released: 2026-04-10
|
|||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
|
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
|
||||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||||
|
|
||||||
## 0.24.6
|
## 0.24.6
|
||||||
|
|
||||||
@@ -436,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/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
|
||||||
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
|
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
|
||||||
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
|
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
|
||||||
- Improved [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
- Improved [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -542,8 +714,8 @@ Released: 2026-04-09
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
|
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
|
||||||
- Added [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
- Added [docs/frontend/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||||
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
- Added [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
@@ -648,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 [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
|
||||||
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
||||||
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
||||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||||
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
@@ -774,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 a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
|
||||||
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
||||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/earth/prefix-geography-plan.md).
|
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-prefix-geography-plan.md).
|
||||||
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
||||||
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
||||||
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
||||||
@@ -789,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 event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
|
||||||
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
||||||
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
||||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -947,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 restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||||
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||||
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md).
|
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md).
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,8 @@
|
|||||||
- 明确写明“已完成”的计划,优先归档
|
- 明确写明“已完成”的计划,优先归档
|
||||||
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
||||||
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 一部分归档文档来自外部或临时工作流草案,例如 sisyphus 生成的初稿
|
||||||
|
- 这类文档如果有可用内容,应先吸收到 `docs/plans/` 或 `docs/technical/`,再归档保留来源记录
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||||
|
|
||||||
# 地球3D可视化架构重构计划
|
# 地球3D可视化架构重构计划
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||||
|
|
||||||
# 卫星预测轨道显示功能
|
# 卫星预测轨道显示功能
|
||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||||
|
|
||||||
# UE5 3D 大屏客户端开发计划
|
# UE5 3D 大屏客户端开发计划
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||||
|
|
||||||
# WebGL Instancing 卫星渲染优化计划
|
# WebGL Instancing 卫星渲染优化计划
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
37
docs/plans/README.md
Normal file
37
docs/plans/README.md
Normal 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)
|
||||||
@@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r
|
|||||||
|
|
||||||
Related documents:
|
Related documents:
|
||||||
|
|
||||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
- [aiprovider](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md)
|
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/plans/agents-datasource-health-plan.md)
|
||||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/agent-architecture-plan.md)
|
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/plans/agents-agent-architecture-plan.md)
|
||||||
|
|
||||||
|
|
||||||
## Big Picture
|
## Big Picture
|
||||||
@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
|
|||||||
|
|
||||||
## Why This Layer Exists
|
## Why This Layer Exists
|
||||||
|
|
||||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md):
|
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md):
|
||||||
|
|
||||||
- incident density is naturally low
|
- incident density is naturally low
|
||||||
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
||||||
@@ -290,7 +290,7 @@ Each feature should include:
|
|||||||
|
|
||||||
## Earth Rendering Plan
|
## Earth Rendering Plan
|
||||||
|
|
||||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md).
|
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-earth-rendering-plan.md).
|
||||||
|
|
||||||
### Layer Relationship
|
### Layer Relationship
|
||||||
|
|
||||||
312
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
312
docs/plans/earth-compute-center-bgp-style-plan.md
Normal 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 一级模块,再继续推进关系层和专题页。
|
||||||
400
docs/plans/earth-mobile-drawer-ui-plan.md
Normal file
400
docs/plans/earth-mobile-drawer-ui-plan.md
Normal 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 放进抽屉”,而是“以抽屉为载体,重做一套适合手机端的信息页面”。
|
||||||
|
|
||||||
|
后续开发必须以此为准:
|
||||||
|
|
||||||
|
- 复用数据
|
||||||
|
- 重做界面
|
||||||
|
- 清除桌面遗留心智
|
||||||
156
docs/plans/earth-news-source-configuration-and-collector-plan.md
Normal file
156
docs/plans/earth-news-source-configuration-and-collector-plan.md
Normal 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.
|
||||||
98
docs/plans/earth-predicted-orbit-plan.md
Normal file
98
docs/plans/earth-predicted-orbit-plan.md
Normal 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. 页面切换回来不会闪出旧轨道残留
|
||||||
111
docs/plans/earth-renderer-architecture-separation-plan.md
Normal file
111
docs/plans/earth-renderer-architecture-separation-plan.md
Normal 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. 这个视觉变化是否能在不改逻辑的情况下单独替换?
|
||||||
|
|
||||||
|
如果答不上来,就说明还在把状态、逻辑、渲染揉在一起。
|
||||||
82
docs/plans/earth-webgl-instancing-satellites-plan.md
Normal file
82
docs/plans/earth-webgl-instancing-satellites-plan.md
Normal 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. 图例、信息卡、相关卫星联动仍然成立
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py)
|
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py)
|
||||||
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
|
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
|
||||||
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
|
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
|
||||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||||
|
|
||||||
### 2. 本地运行与配置打通
|
### 2. 本地运行与配置打通
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
相关文件:
|
相关文件:
|
||||||
|
|
||||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
@@ -979,3 +979,37 @@ Content/
|
|||||||
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
||||||
|
|
||||||
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 附录:来自 sisyphus 草案的补充
|
||||||
|
|
||||||
|
> 这部分吸收自一个 sisyphus-created draft,原始草案已归档,不再单独维护为主计划。
|
||||||
|
|
||||||
|
## 1. 项目骨架建议
|
||||||
|
|
||||||
|
原草案给过一个更偏“工程初始化”的目录示意,适合拿来做一期的命名参考:
|
||||||
|
|
||||||
|
- `Levels/`
|
||||||
|
- `Blueprints/`
|
||||||
|
- `Materials/`
|
||||||
|
- `Widgets/`
|
||||||
|
- `Source/PlanetAPI/`
|
||||||
|
- `Source/CesiumIntegration/`
|
||||||
|
- `Source/Visualization/`
|
||||||
|
|
||||||
|
这不是强制结构,但对 UE 初期整理目录很有帮助。
|
||||||
|
|
||||||
|
## 2. API 契约意识
|
||||||
|
|
||||||
|
原草案有一个很对的提醒:
|
||||||
|
|
||||||
|
- 一期虽然可以先走 HTTP
|
||||||
|
- 但数据模型命名不应只服务于一次性演示
|
||||||
|
- 后续 WebSocket 接入时,字段设计最好能沿用
|
||||||
|
|
||||||
|
所以当前主计划继续建议:
|
||||||
|
|
||||||
|
- 先做 HTTP 拉取
|
||||||
|
- 尽量把 UE 侧数据模型定义清楚
|
||||||
|
- 不要在蓝图各处散写临时 JSON 字段解析
|
||||||
26
docs/technical/README.md
Normal file
26
docs/technical/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Technical Docs
|
||||||
|
|
||||||
|
这里放“当前实现和当前结构”的文档,重点回答:
|
||||||
|
|
||||||
|
- 现在代码是怎么组织的
|
||||||
|
- 当前入口在哪
|
||||||
|
- 状态和组件如何工作
|
||||||
|
- 后续改动应该沿着哪条实现边界继续走
|
||||||
|
|
||||||
|
适合放入这里的内容:
|
||||||
|
|
||||||
|
- 前端上下文
|
||||||
|
- Earth 前端结构
|
||||||
|
- 后端运行控制
|
||||||
|
- collector 现状
|
||||||
|
- 采集格式约定
|
||||||
|
|
||||||
|
不适合放入这里的内容:
|
||||||
|
|
||||||
|
- 尚未完成的 roadmap
|
||||||
|
- 未来迭代方案
|
||||||
|
- 大范围重构计划
|
||||||
|
|
||||||
|
这些应放入:
|
||||||
|
|
||||||
|
- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md)
|
||||||
@@ -187,7 +187,7 @@ Current reality:
|
|||||||
- that is expected, because incidents are aggregated and de-noised
|
- that is expected, because incidents are aggregated and de-noised
|
||||||
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
||||||
|
|
||||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-region-aggregation-plan.md).
|
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md).
|
||||||
|
|
||||||
So the immediate next milestone is:
|
So the immediate next milestone is:
|
||||||
|
|
||||||
381
docs/technical/earth-frontend-context.md
Normal file
381
docs/technical/earth-frontend-context.md
Normal 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)
|
||||||
@@ -95,3 +95,93 @@
|
|||||||
- 手工配置源
|
- 手工配置源
|
||||||
- `news_live_streams` 采集器采集源
|
- `news_live_streams` 采集器采集源
|
||||||
- 当前默认兜底源为 `CCTV-4 中文国际`
|
- 当前默认兜底源为 `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`
|
||||||
236
docs/technical/frontend-admin-frontend-context.md
Normal file
236
docs/technical/frontend-admin-frontend-context.md
Normal 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)
|
||||||
@@ -16,12 +16,22 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.30.0`
|
- `dev` 当前开发分支历史推导到:`0.36.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| 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.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||||
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
||||||
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.30.0",
|
"version": "0.36.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
"vite": "^5.0.10"
|
"vite": "^5.0.10"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "bun ./node_modules/vite/bin/vite.js",
|
||||||
"build": "tsc && vite build",
|
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
|
||||||
"preview": "vite preview"
|
"preview": "bun ./node_modules/vite/bin/vite.js preview"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--hud-scale: 1;
|
--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-offset: calc(20px * var(--hud-scale));
|
||||||
--hud-radius: calc(22px * var(--hud-scale));
|
--hud-radius: calc(22px * var(--hud-scale));
|
||||||
--hud-panel-padding: calc(18px * var(--hud-scale));
|
--hud-panel-padding: calc(18px * var(--hud-scale));
|
||||||
@@ -66,12 +70,23 @@ body.earth-page {
|
|||||||
overflow: hidden;
|
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 {
|
.earth-app {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.earth-app canvas {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
.earth-app.dragging {
|
.earth-app.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,3 +133,20 @@
|
|||||||
right: var(--hud-offset);
|
right: var(--hud-offset);
|
||||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + 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
@@ -161,6 +161,84 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
z-index: 49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link polyline {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(255, 255, 255, 0.98);
|
||||||
|
stroke-width: 2.15;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
|
||||||
|
drop-shadow(0 0 2px rgba(6, 14, 28, 0.56))
|
||||||
|
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link circle {
|
||||||
|
fill: rgba(255, 255, 255, 0.98);
|
||||||
|
stroke: rgba(7, 16, 32, 0.72);
|
||||||
|
stroke-width: 1.0;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
|
||||||
|
drop-shadow(0 0 2px rgba(6, 14, 28, 0.54))
|
||||||
|
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-animating polyline {
|
||||||
|
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-animating circle {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-animating circle:first-of-type {
|
||||||
|
animation: cruiseConnectorNodeIn 0.14s ease forwards;
|
||||||
|
animation-delay: 0.02s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-animating circle:last-of-type {
|
||||||
|
animation: cruiseConnectorNodeIn 0.16s ease forwards;
|
||||||
|
animation-delay: 0.34s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cruiseConnectorDraw {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: var(--connector-length, 0px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cruiseConnectorNodeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.72);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Info Card ────────────────────────────────────────────────── */
|
/* ── Info Card ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.info-card {
|
.info-card {
|
||||||
@@ -289,3 +367,39 @@
|
|||||||
.earth-app.layout-expanded .earth-left-column {
|
.earth-app.layout-expanded .earth-left-column {
|
||||||
transform: translate(calc(-100% + var(--hud-offset)), 0);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer-row-toggle-track {
|
.layer-row-toggle-track {
|
||||||
@@ -247,6 +248,11 @@
|
|||||||
transition: background 0.18s ease, border-color 0.18s ease;
|
transition: background 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layer-row-toggle:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Thumb */
|
/* Thumb */
|
||||||
.layer-row-toggle-track::after {
|
.layer-row-toggle-track::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -261,6 +267,24 @@
|
|||||||
transition: transform 0.18s ease, background 0.18s ease;
|
transition: transform 0.18s ease, background 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layer-row-toggle.is-loading .layer-row-toggle-track {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(104, 147, 221, 0.38),
|
||||||
|
rgba(143, 185, 255, 0.72),
|
||||||
|
rgba(104, 147, 221, 0.38)
|
||||||
|
);
|
||||||
|
background-size: 180% 100%;
|
||||||
|
border-color: rgba(223, 236, 252, 0.28);
|
||||||
|
animation: layer-toggle-loading-track 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row-toggle.is-loading .layer-row-toggle-track::after {
|
||||||
|
background: #f0f6ff;
|
||||||
|
transform: translateX(calc(7px * var(--hud-scale)));
|
||||||
|
animation: layer-toggle-loading-thumb 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Active (ON) state */
|
/* Active (ON) state */
|
||||||
.layer-row-toggle.active .layer-row-toggle-track {
|
.layer-row-toggle.active .layer-row-toggle-track {
|
||||||
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
||||||
@@ -272,5 +296,50 @@
|
|||||||
transform: translateX(calc(14px * var(--hud-scale)));
|
transform: translateX(calc(14px * var(--hud-scale)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes layer-toggle-loading-track {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 180% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes layer-toggle-loading-thumb {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 0 rgba(174, 205, 255, 0.18);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 calc(10px * var(--hud-scale)) rgba(174, 205, 255, 0.42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
||||||
individual rule needed since the whole column translates together. */
|
individual rule needed since the whole column translates together. */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,3 +138,24 @@
|
|||||||
bottom: var(--hud-offset);
|
bottom: var(--hud-offset);
|
||||||
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.earth-toolbar-group,
|
.earth-toolbar-group,
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.earth-toolbar-cluster {
|
.earth-toolbar-cluster {
|
||||||
@@ -103,6 +105,22 @@
|
|||||||
pointer-events: auto;
|
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 {
|
.earth-toolbar-orb > .liquid-glass-surface {
|
||||||
animation: floatDock 4.6s ease-in-out infinite;
|
animation: floatDock 4.6s ease-in-out infinite;
|
||||||
animation-delay: var(--orb-delay, 0s);
|
animation-delay: var(--orb-delay, 0s);
|
||||||
@@ -120,7 +138,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--hud-text-soft);
|
color: var(--hud-text-soft);
|
||||||
font-size: 14px;
|
font-size: calc(14px * var(--toolbar-scale));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -361,7 +379,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, 8px);
|
transform: translate(-50%, calc(8px * var(--toolbar-scale)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.earth-toolbar-popover::before {
|
.earth-toolbar-popover::before {
|
||||||
@@ -381,7 +399,7 @@
|
|||||||
top: auto;
|
top: auto;
|
||||||
right: auto;
|
right: auto;
|
||||||
bottom: calc(100% + (12px * var(--toolbar-scale)));
|
bottom: calc(100% + (12px * var(--toolbar-scale)));
|
||||||
transform: translate(-50%, 10px);
|
transform: translate(-50%, calc(10px * var(--toolbar-scale)));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -398,16 +416,16 @@
|
|||||||
|
|
||||||
.earth-zoom-toolbar .earth-zoom-btn,
|
.earth-zoom-toolbar .earth-zoom-btn,
|
||||||
.earth-zoom-toolbar .earth-zoom-value {
|
.earth-zoom-toolbar .earth-zoom-value {
|
||||||
width: 42px;
|
width: calc(42px * var(--toolbar-scale));
|
||||||
min-width: 42px;
|
min-width: calc(42px * var(--toolbar-scale));
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: var(--hud-text-soft);
|
color: var(--hud-text-soft);
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.earth-zoom-toolbar .earth-zoom-btn {
|
.earth-zoom-toolbar .earth-zoom-btn {
|
||||||
height: 42px;
|
height: calc(42px * var(--toolbar-scale));
|
||||||
font-size: 20px;
|
font-size: calc(20px * var(--toolbar-scale));
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -416,9 +434,9 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 42px;
|
height: calc(42px * var(--toolbar-scale));
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.68rem;
|
font-size: calc(11px * var(--toolbar-scale));
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,15 +447,15 @@
|
|||||||
|
|
||||||
.earth-toolbar-btn .earth-toolbar-tooltip {
|
.earth-toolbar-btn .earth-toolbar-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 56px;
|
bottom: calc(56px * var(--toolbar-scale));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
||||||
color: var(--hud-text);
|
color: var(--hud-text);
|
||||||
padding: 6px 12px;
|
padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
|
||||||
border-radius: 6px;
|
border-radius: calc(6px * var(--toolbar-scale));
|
||||||
font-size: 12px;
|
font-size: calc(12px * var(--toolbar-scale));
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -453,7 +471,7 @@
|
|||||||
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
|
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
bottom: 58px;
|
bottom: calc(58px * var(--toolbar-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
||||||
@@ -462,6 +480,6 @@
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 6px solid transparent;
|
border: calc(6px * var(--toolbar-scale)) solid transparent;
|
||||||
border-top-color: rgba(18, 31, 52, 0.96);
|
border-top-color: rgba(18, 31, 52, 0.96);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,8 +133,8 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
margin-top: calc(-1 * var(--hud-gap-sm));
|
margin-top: 0;
|
||||||
margin-bottom: calc(-1 * var(--hud-gap-sm));
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tv-panel-meta {
|
.tv-panel-meta {
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
|
|
||||||
.tv-panel-player {
|
.tv-panel-player {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 0 auto;
|
flex: 1 1 auto;
|
||||||
min-height: calc(220px * var(--hud-scale));
|
min-height: calc(220px * var(--hud-scale));
|
||||||
border-radius: calc(16px * var(--hud-scale));
|
border-radius: calc(16px * var(--hud-scale));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -285,6 +285,27 @@
|
|||||||
cursor: nesw-resize;
|
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 {
|
.tv-panel-edge[data-edge="br"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<span class="layer-row-label">地形</span>
|
<span class="layer-row-label">地形</span>
|
||||||
<span class="layer-row-meta">Terrain</span>
|
<span class="layer-row-meta">Terrain</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示">
|
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||||
<span class="layer-row-toggle-track"></span>
|
<span class="layer-row-toggle-track"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,6 +132,16 @@
|
|||||||
<span class="layer-row-toggle-track"></span>
|
<span class="layer-row-toggle-track"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="layer-row" data-layer-name="bgp观测 routing signals">
|
||||||
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
||||||
<div class="layer-row-copy">
|
<div class="layer-row-copy">
|
||||||
@@ -150,20 +160,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="error-message" class="hud-error-message"></div>
|
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
|
||||||
|
|
||||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||||
<div id="control-toolbar" class="earth-toolbar">
|
<div id="control-toolbar" class="earth-toolbar">
|
||||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded">
|
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
<button id="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="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">search</span>
|
<span class="material-symbols-rounded">search</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
|
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
|
||||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
<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="icon rotate-icon icon-pause" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">pause</span>
|
<span class="material-symbols-rounded">pause</span>
|
||||||
@@ -174,7 +192,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
|
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
|
||||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">live_tv</span>
|
<span class="material-symbols-rounded">live_tv</span>
|
||||||
@@ -182,7 +200,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
|
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
|
||||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">refresh</span>
|
<span class="material-symbols-rounded">refresh</span>
|
||||||
@@ -190,7 +208,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
|
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
|
||||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">zoom_in</span>
|
<span class="material-symbols-rounded">zoom_in</span>
|
||||||
@@ -203,7 +221,7 @@
|
|||||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
|
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
|
||||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">settings</span>
|
<span class="material-symbols-rounded">settings</span>
|
||||||
@@ -211,7 +229,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
|
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
|
||||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||||
<span class="icon" aria-hidden="true">
|
<span class="icon" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">my_location</span>
|
<span class="material-symbols-rounded">my_location</span>
|
||||||
@@ -219,7 +237,7 @@
|
|||||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
|
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
|
||||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
<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="icon layout-icon layout-expand" aria-hidden="true">
|
||||||
<span class="material-symbols-rounded">open_in_full</span>
|
<span class="material-symbols-rounded">open_in_full</span>
|
||||||
@@ -274,23 +292,27 @@
|
|||||||
<!-- 2-col KPI grid -->
|
<!-- 2-col KPI grid -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-cell">
|
<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>
|
<span class="stat-label">海缆系统</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<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>
|
<span class="stat-label">登陆点</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<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>
|
<span class="stat-label">在轨卫星</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<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>
|
<span class="stat-label">BGP 事件</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<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>
|
<span class="stat-label">BGP 观测站</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
@@ -302,12 +324,12 @@
|
|||||||
<!-- BGP status footer -->
|
<!-- BGP status footer -->
|
||||||
<div class="stats-footer">
|
<div class="stats-footer">
|
||||||
<span class="stats-footer-dot"></span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- hidden elements kept for JS compatibility -->
|
<!-- hidden elements kept for JS compatibility -->
|
||||||
<span id="terrain-status" hidden></span>
|
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
|
||||||
<span id="texture-quality" hidden></span>
|
<span id="texture-quality" data-earth-stat="texture-quality" hidden></span>
|
||||||
<span id="camera-distance" hidden></span>
|
<span id="camera-distance" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -412,6 +434,299 @@
|
|||||||
|
|
||||||
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></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="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-modal" class="earth-settings-modal" aria-hidden="true">
|
||||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||||
@@ -419,14 +734,57 @@
|
|||||||
<div class="hud-panel__title-group">
|
<div class="hud-panel__title-group">
|
||||||
<div class="earth-settings-kicker">设置</div>
|
<div class="earth-settings-kicker">设置</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="settings-reset" class="earth-settings-reset hud-panel__action" type="button" aria-label="重置设置">
|
||||||
|
<span class="material-symbols-rounded">restart_alt</span>
|
||||||
|
<span>重置</span>
|
||||||
|
</button>
|
||||||
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
|
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
|
||||||
<span class="material-symbols-rounded">close</span>
|
<span class="material-symbols-rounded">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="earth-settings-content hud-panel__body">
|
<div class="earth-settings-content hud-panel__body">
|
||||||
|
<section class="earth-settings-section">
|
||||||
|
<div class="earth-settings-section-title">旋转</div>
|
||||||
|
<div class="earth-settings-list">
|
||||||
|
<div class="earth-settings-item earth-settings-item--stacked">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">旋转模式</span>
|
||||||
|
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="earth-settings-segmented-btn is-active"
|
||||||
|
data-rotation-mode="rotate"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
|
旋转模式
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="earth-settings-segmented-btn"
|
||||||
|
data-rotation-mode="cruise"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
巡航模式
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="earth-settings-section">
|
<section class="earth-settings-section">
|
||||||
<div class="earth-settings-section-title">视图</div>
|
<div class="earth-settings-section-title">视图</div>
|
||||||
<div class="earth-settings-list">
|
<div class="earth-settings-list">
|
||||||
|
<label class="earth-settings-item" for="toggle-daynight">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">日夜模式</span>
|
||||||
|
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-settings-switch">
|
||||||
|
<input id="toggle-daynight" type="checkbox" checked>
|
||||||
|
<span class="earth-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label class="earth-settings-item" for="toggle-view-layers">
|
<label class="earth-settings-item" for="toggle-view-layers">
|
||||||
<div class="earth-settings-copy">
|
<div class="earth-settings-copy">
|
||||||
<span class="earth-settings-item-title">图层控制</span>
|
<span class="earth-settings-item-title">图层控制</span>
|
||||||
@@ -469,6 +827,30 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="earth-settings-section">
|
||||||
|
<div class="earth-settings-section-title">视图</div>
|
||||||
|
<div class="earth-settings-list">
|
||||||
|
<div class="earth-settings-item earth-settings-item--stacked">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">地球默认大小</span>
|
||||||
|
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
|
||||||
|
</div>
|
||||||
|
<div class="earth-settings-slider-row">
|
||||||
|
<input
|
||||||
|
id="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">
|
<section class="earth-settings-section">
|
||||||
<div class="earth-settings-section-title">地形</div>
|
<div class="earth-settings-section-title">地形</div>
|
||||||
<div class="earth-settings-list">
|
<div class="earth-settings-list">
|
||||||
|
|||||||
300
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
300
frontend/public/earth/js/bgp-cruise-adapter.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawWaveSymbol(context) {
|
function drawWaveSymbol(context) {
|
||||||
context.lineWidth = 12;
|
|
||||||
context.lineCap = "round";
|
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(18, 76);
|
context.moveTo(14, 100);
|
||||||
context.bezierCurveTo(34, 46, 46, 46, 64, 76);
|
context.lineTo(38, 26);
|
||||||
context.bezierCurveTo(80, 106, 94, 106, 110, 76);
|
context.lineTo(64, 100);
|
||||||
context.stroke();
|
context.lineTo(90, 26);
|
||||||
|
context.lineTo(114, 100);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawBurstSymbol(context) {
|
function drawBurstSymbol(context) {
|
||||||
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBGPAnomalies(scene, earth) {
|
export async function loadBGPAnomalies(scene, earth) {
|
||||||
clearBGPData(earth);
|
|
||||||
|
|
||||||
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
|
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
|
||||||
if (!collectorsResponse.ok) {
|
if (!collectorsResponse.ok) {
|
||||||
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
||||||
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
|
|||||||
? collectorsPayload.features
|
? collectorsPayload.features
|
||||||
: [];
|
: [];
|
||||||
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
|
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
|
||||||
|
|
||||||
|
clearBGPData(earth);
|
||||||
|
|
||||||
totalAnomalyCount = selectedEventData.totalAnomalyCount;
|
totalAnomalyCount = selectedEventData.totalAnomalyCount;
|
||||||
totalIncidentCount = selectedEventData.totalIncidentCount;
|
totalIncidentCount = selectedEventData.totalIncidentCount;
|
||||||
activeEventCountByCollector.clear();
|
activeEventCountByCollector.clear();
|
||||||
@@ -1351,7 +1353,7 @@ export async function loadBGPAnomalies(scene, earth) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
||||||
const hasLockedLayer = Boolean(
|
const hasLockedLayer = Boolean(
|
||||||
@@ -1459,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
|||||||
const isLinkedCollectorLocked =
|
const isLinkedCollectorLocked =
|
||||||
lockedObjectType === "bgp_collector" &&
|
lockedObjectType === "bgp_collector" &&
|
||||||
lockedObject?.userData?.collector === marker.userData.collector;
|
lockedObject?.userData?.collector === marker.userData.collector;
|
||||||
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
|
const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
|
||||||
|
const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
|
||||||
|
const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
|
||||||
|
const isActive = isLocked || isLinkedCollectorLocked || isCruise;
|
||||||
const isHovered = marker.userData.state === "hover";
|
const isHovered = marker.userData.state === "hover";
|
||||||
const pulse =
|
const pulse =
|
||||||
0.5 +
|
0.5 +
|
||||||
@@ -1477,18 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
|||||||
|
|
||||||
if (isLocked || isLinkedCollectorLocked) {
|
if (isLocked || isLinkedCollectorLocked) {
|
||||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||||
opacity =
|
opacity = 0.9 + 0.1 * pulse;
|
||||||
0.9 +
|
|
||||||
0.1 * pulse;
|
|
||||||
markerColor = 0xfff1a8;
|
markerColor = 0xfff1a8;
|
||||||
ringBaseOpacity *= 1.2;
|
ringBaseOpacity *= 1.2;
|
||||||
|
} else if (isCruise) {
|
||||||
|
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||||
|
opacity = 0.9 + 0.1 * pulse;
|
||||||
|
ringBaseOpacity *= 1.2;
|
||||||
} else if (isHovered) {
|
} else if (isHovered) {
|
||||||
scale *= BGP_CONFIG.marker.hoverScale;
|
scale *= BGP_CONFIG.marker.hoverScale;
|
||||||
opacity = 0.9;
|
opacity = 0.9;
|
||||||
ringBaseOpacity *= 1.05;
|
ringBaseOpacity *= 1.05;
|
||||||
} else if (isOtherLocked) {
|
} else if (isOtherLocked) {
|
||||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||||
opacity = 0.1;
|
opacity = 0.22;
|
||||||
markerColor = 0x7d8ca3;
|
markerColor = 0x7d8ca3;
|
||||||
ringBaseOpacity = 0.02;
|
ringBaseOpacity = 0.02;
|
||||||
} else {
|
} else {
|
||||||
@@ -1500,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
|||||||
marker.material.color.setHex(markerColor);
|
marker.material.color.setHex(markerColor);
|
||||||
marker.material.opacity = opacity;
|
marker.material.opacity = opacity;
|
||||||
marker.visible = showBGP;
|
marker.visible = showBGP;
|
||||||
|
marker.renderOrder = isActive ? 7 : 3;
|
||||||
|
|
||||||
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
||||||
const applyRingState = (ring, phase, maxScale) => {
|
const applyRingState = (ring, phase, maxScale) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
CABLE_CONFIG,
|
CABLE_CONFIG,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { latLonToVector3 } from "./utils.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 { showInfoCard } from "./info-card.js";
|
||||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export let lockedCable = null;
|
|||||||
let cableIdMap = new Map();
|
let cableIdMap = new Map();
|
||||||
let cableStates = new Map();
|
let cableStates = new Map();
|
||||||
let cablesVisible = true;
|
let cablesVisible = true;
|
||||||
|
let landingPointGeometry = null;
|
||||||
const landingPointWorldPosition = new THREE.Vector3();
|
const landingPointWorldPosition = new THREE.Vector3();
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
function clamp(value, min, max) {
|
||||||
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
|
|||||||
if (owner) {
|
if (owner) {
|
||||||
owner.remove(object);
|
owner.remove(object);
|
||||||
}
|
}
|
||||||
if (object.geometry) {
|
if (object.geometry && !object.userData?.sharedGeometry) {
|
||||||
object.geometry.dispose();
|
object.geometry.dispose();
|
||||||
}
|
}
|
||||||
if (object.material) {
|
if (object.material) {
|
||||||
@@ -335,9 +336,8 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
|||||||
feature.properties.status === "In Service"),
|
feature.properties.status === "In Service"),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const cableCountEl = document.getElementById("cable-count");
|
|
||||||
const statusEl = document.getElementById("cable-status-summary");
|
const statusEl = document.getElementById("cable-status-summary");
|
||||||
if (cableCountEl) cableCountEl.textContent = cableCount + "个";
|
setEarthStatValue("cable-count", `${cableCount}个`);
|
||||||
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
||||||
|
|
||||||
updateEarthStats({
|
updateEarthStats({
|
||||||
@@ -369,76 +369,72 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
|||||||
|
|
||||||
clearLandingPoints(earthObj);
|
clearLandingPoints(earthObj);
|
||||||
|
|
||||||
const sphereGeometry = new THREE.SphereGeometry(
|
if (!landingPointGeometry) {
|
||||||
CABLE_CONFIG.landingPoint.radius,
|
landingPointGeometry = new THREE.SphereGeometry(
|
||||||
CABLE_CONFIG.landingPoint.widthSegments,
|
CABLE_CONFIG.landingPoint.radius,
|
||||||
CABLE_CONFIG.landingPoint.heightSegments,
|
CABLE_CONFIG.landingPoint.widthSegments,
|
||||||
);
|
CABLE_CONFIG.landingPoint.heightSegments,
|
||||||
|
);
|
||||||
|
}
|
||||||
let validCount = 0;
|
let validCount = 0;
|
||||||
|
|
||||||
try {
|
for (const feature of data.features) {
|
||||||
for (const feature of data.features) {
|
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
|
||||||
|
|
||||||
const [lon, lat] = feature.geometry.coordinates;
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
const properties = feature.properties || {};
|
const properties = feature.properties || {};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof lon !== "number" ||
|
typeof lon !== "number" ||
|
||||||
typeof lat !== "number" ||
|
typeof lat !== "number" ||
|
||||||
Number.isNaN(lon) ||
|
Number.isNaN(lon) ||
|
||||||
Number.isNaN(lat) ||
|
Number.isNaN(lat) ||
|
||||||
Math.abs(lat) > 90 ||
|
Math.abs(lat) > 90 ||
|
||||||
Math.abs(lon) > 180
|
Math.abs(lon) > 180
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
const position = latLonToVector3(
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
Number.isNaN(position.x) ||
|
|
||||||
Number.isNaN(position.y) ||
|
|
||||||
Number.isNaN(position.z)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sphere = new THREE.Mesh(
|
|
||||||
sphereGeometry.clone(),
|
|
||||||
new THREE.MeshStandardMaterial({
|
|
||||||
color: CABLE_CONFIG.landingPoint.color,
|
|
||||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
|
||||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
|
||||||
transparent: true,
|
|
||||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
sphere.position.copy(position);
|
|
||||||
sphere.userData = {
|
|
||||||
type: "landingPoint",
|
|
||||||
name: properties.name || "未知登陆站",
|
|
||||||
cableNames: properties.cable_names || [],
|
|
||||||
country: properties.country || "未知国家",
|
|
||||||
status: properties.status || "Unknown",
|
|
||||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
|
||||||
};
|
|
||||||
|
|
||||||
earthObj.add(sphere);
|
|
||||||
landingPoints.push(sphere);
|
|
||||||
validCount++;
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
sphereGeometry.dispose();
|
const position = latLonToVector3(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Number.isNaN(position.x) ||
|
||||||
|
Number.isNaN(position.y) ||
|
||||||
|
Number.isNaN(position.z)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sphere = new THREE.Mesh(
|
||||||
|
landingPointGeometry,
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: CABLE_CONFIG.landingPoint.color,
|
||||||
|
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||||
|
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||||
|
transparent: true,
|
||||||
|
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sphere.position.copy(position);
|
||||||
|
sphere.userData = {
|
||||||
|
type: "landingPoint",
|
||||||
|
name: properties.name || "未知登陆站",
|
||||||
|
cableNames: properties.cable_names || [],
|
||||||
|
country: properties.country || "未知国家",
|
||||||
|
status: properties.status || "Unknown",
|
||||||
|
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||||
|
sharedGeometry: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
earthObj.add(sphere);
|
||||||
|
landingPoints.push(sphere);
|
||||||
|
validCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||||
if (landingPointCountEl) {
|
|
||||||
landingPointCountEl.textContent = validCount + "个";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||||
|
|||||||
185
frontend/public/earth/js/callout-connector.js
Normal file
185
frontend/public/earth/js/callout-connector.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import * as Astronomy from "astronomy-engine";
|
import * as Astronomy from "astronomy-engine";
|
||||||
|
|
||||||
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js";
|
import {
|
||||||
|
CELESTIAL_CONFIG,
|
||||||
|
EARTH_CONFIG,
|
||||||
|
SCENE_LIGHT_CONFIG,
|
||||||
|
} from "./constants.js";
|
||||||
import { latLonToVector3 } from "./utils.js";
|
import { latLonToVector3 } from "./utils.js";
|
||||||
|
|
||||||
const textureLoader = new THREE.TextureLoader();
|
const textureLoader = new THREE.TextureLoader();
|
||||||
@@ -21,7 +25,11 @@ let moonDirection = defaultMoonDirection.clone();
|
|||||||
let lastUpdatedAt = 0;
|
let lastUpdatedAt = 0;
|
||||||
let linkedSunLight = null;
|
let linkedSunLight = null;
|
||||||
let linkedBackLight = null;
|
let linkedBackLight = null;
|
||||||
|
let linkedAmbientLight = null;
|
||||||
|
let linkedPointLight = null;
|
||||||
|
let linkedCamera = null;
|
||||||
let linkedEarth = null;
|
let linkedEarth = null;
|
||||||
|
let dayNightLightingEnabled = true;
|
||||||
let brightStarSprites = [];
|
let brightStarSprites = [];
|
||||||
let celestialRotationQuaternion = new THREE.Quaternion();
|
let celestialRotationQuaternion = new THREE.Quaternion();
|
||||||
let celestialViewQuaternion = new THREE.Quaternion();
|
let celestialViewQuaternion = new THREE.Quaternion();
|
||||||
@@ -333,10 +341,71 @@ function updateSpritePositions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateLighting() {
|
function updateLighting() {
|
||||||
|
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
|
||||||
|
const inspection = CELESTIAL_CONFIG.inspectionLighting;
|
||||||
|
const cameraDirection = linkedCamera
|
||||||
|
? linkedCamera.position.clone().normalize()
|
||||||
|
: defaultSunDirection.clone();
|
||||||
|
const worldUp = new THREE.Vector3(0, 1, 0);
|
||||||
|
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
|
||||||
|
if (right.lengthSq() < 1e-6) {
|
||||||
|
right.set(1, 0, 0);
|
||||||
|
} else {
|
||||||
|
right.normalize();
|
||||||
|
}
|
||||||
|
const adjustedUp = new THREE.Vector3()
|
||||||
|
.crossVectors(cameraDirection, right)
|
||||||
|
.normalize();
|
||||||
|
|
||||||
|
const resolveInspectionDirection = (offset) =>
|
||||||
|
cameraDirection
|
||||||
|
.clone()
|
||||||
|
.multiplyScalar(offset.z)
|
||||||
|
.add(right.clone().multiplyScalar(offset.x))
|
||||||
|
.add(adjustedUp.clone().multiplyScalar(offset.y))
|
||||||
|
.normalize();
|
||||||
|
|
||||||
|
if (linkedAmbientLight) {
|
||||||
|
linkedAmbientLight.color.setHex(inspection.ambientColor);
|
||||||
|
linkedAmbientLight.intensity = inspection.ambientIntensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedSunLight) {
|
||||||
|
linkedSunLight.color.setHex(inspection.keyLightColor);
|
||||||
|
linkedSunLight.intensity = inspection.keyLightIntensity;
|
||||||
|
linkedSunLight.position
|
||||||
|
.copy(resolveInspectionDirection(inspection.keyLightOffset))
|
||||||
|
.multiplyScalar(inspection.keyLightDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedBackLight) {
|
||||||
|
linkedBackLight.color.setHex(inspection.backLightColor);
|
||||||
|
linkedBackLight.intensity = inspection.backLightIntensity;
|
||||||
|
linkedBackLight.position
|
||||||
|
.copy(resolveInspectionDirection(inspection.backLightOffset))
|
||||||
|
.multiplyScalar(inspection.backLightDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedPointLight) {
|
||||||
|
linkedPointLight.color.setHex(inspection.pointLightColor);
|
||||||
|
linkedPointLight.intensity = inspection.pointLightIntensity;
|
||||||
|
linkedPointLight.position
|
||||||
|
.copy(resolveInspectionDirection(inspection.pointLightOffset))
|
||||||
|
.multiplyScalar(inspection.pointLightDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const physicalSunDirection = getPhysicalSunDirection(
|
const physicalSunDirection = getPhysicalSunDirection(
|
||||||
new Date(lastUpdatedAt || Date.now()),
|
new Date(lastUpdatedAt || Date.now()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (linkedAmbientLight) {
|
||||||
|
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
|
||||||
|
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
|
||||||
|
}
|
||||||
|
|
||||||
if (linkedSunLight) {
|
if (linkedSunLight) {
|
||||||
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
|
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
|
||||||
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
|
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
|
||||||
@@ -352,6 +421,16 @@ function updateLighting() {
|
|||||||
.copy(physicalSunDirection)
|
.copy(physicalSunDirection)
|
||||||
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
|
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (linkedPointLight) {
|
||||||
|
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
|
||||||
|
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
|
||||||
|
linkedPointLight.position.set(
|
||||||
|
SCENE_LIGHT_CONFIG.point.position.x,
|
||||||
|
SCENE_LIGHT_CONFIG.point.position.y,
|
||||||
|
SCENE_LIGHT_CONFIG.point.position.z,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCelestialState(date = new Date()) {
|
function computeCelestialState(date = new Date()) {
|
||||||
@@ -364,14 +443,24 @@ function computeCelestialState(date = new Date()) {
|
|||||||
|
|
||||||
export function initCelestialLayer(
|
export function initCelestialLayer(
|
||||||
scene,
|
scene,
|
||||||
{ camera = null, sunLight = null, backLight = null, earth = null } = {},
|
{
|
||||||
|
camera = null,
|
||||||
|
sunLight = null,
|
||||||
|
backLight = null,
|
||||||
|
ambientLight = null,
|
||||||
|
pointLight = null,
|
||||||
|
earth = null,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
|
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
|
||||||
|
|
||||||
disposeCelestialLayer();
|
disposeCelestialLayer();
|
||||||
|
|
||||||
|
linkedCamera = camera;
|
||||||
linkedSunLight = sunLight;
|
linkedSunLight = sunLight;
|
||||||
linkedBackLight = backLight;
|
linkedBackLight = backLight;
|
||||||
|
linkedAmbientLight = ambientLight;
|
||||||
|
linkedPointLight = pointLight;
|
||||||
linkedEarth = earth;
|
linkedEarth = earth;
|
||||||
|
|
||||||
celestialRoot = new THREE.Group();
|
celestialRoot = new THREE.Group();
|
||||||
@@ -457,6 +546,10 @@ export function initCelestialLayer(
|
|||||||
export function updateCelestialLayer(date = new Date(), camera = null) {
|
export function updateCelestialLayer(date = new Date(), camera = null) {
|
||||||
if (!celestialRoot) return;
|
if (!celestialRoot) return;
|
||||||
|
|
||||||
|
if (camera) {
|
||||||
|
linkedCamera = camera;
|
||||||
|
}
|
||||||
|
|
||||||
refreshCelestialView();
|
refreshCelestialView();
|
||||||
|
|
||||||
const now = date.getTime();
|
const now = date.getTime();
|
||||||
@@ -503,6 +596,11 @@ export function setCelestialFollow(nextFollow = {}) {
|
|||||||
return getCelestialDebugState();
|
return getCelestialDebugState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setCelestialDayNightEnabled(enabled) {
|
||||||
|
dayNightLightingEnabled = enabled;
|
||||||
|
updateLighting();
|
||||||
|
}
|
||||||
|
|
||||||
export function disposeCelestialLayer() {
|
export function disposeCelestialLayer() {
|
||||||
if (celestialRoot?.parent) {
|
if (celestialRoot?.parent) {
|
||||||
celestialRoot.parent.remove(celestialRoot);
|
celestialRoot.parent.remove(celestialRoot);
|
||||||
@@ -531,7 +629,11 @@ export function disposeCelestialLayer() {
|
|||||||
lastUpdatedAt = 0;
|
lastUpdatedAt = 0;
|
||||||
linkedSunLight = null;
|
linkedSunLight = null;
|
||||||
linkedBackLight = null;
|
linkedBackLight = null;
|
||||||
|
linkedAmbientLight = null;
|
||||||
|
linkedPointLight = null;
|
||||||
|
linkedCamera = null;
|
||||||
linkedEarth = null;
|
linkedEarth = null;
|
||||||
|
dayNightLightingEnabled = true;
|
||||||
sunDirection.copy(defaultSunDirection);
|
sunDirection.copy(defaultSunDirection);
|
||||||
moonDirection.copy(defaultMoonDirection);
|
moonDirection.copy(defaultMoonDirection);
|
||||||
runtimeOrientationEuler = {
|
runtimeOrientationEuler = {
|
||||||
|
|||||||
334
frontend/public/earth/js/compute-centers.js
Normal file
334
frontend/public/earth/js/compute-centers.js
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scene configuration
|
// Scene configuration
|
||||||
export const CONFIG = {
|
export const CONFIG = {
|
||||||
defaultCameraZ: 300,
|
defaultCameraZ: 300,
|
||||||
|
defaultViewZoom: 1.0,
|
||||||
minZoom: 0.5,
|
minZoom: 0.5,
|
||||||
maxZoom: 5.0,
|
maxZoom: 5.0,
|
||||||
earthRadius: 100,
|
earthRadius: 100,
|
||||||
@@ -12,6 +13,26 @@ export const CONFIG = {
|
|||||||
dragRotationScaleMax: 2.0,
|
dragRotationScaleMax: 2.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ROTATION_MODE = {
|
||||||
|
ROTATE: "rotate",
|
||||||
|
CRUISE: "cruise",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CRUISE_CONFIG = {
|
||||||
|
dwellMs: 7_000,
|
||||||
|
focusDurationMs: 1_400,
|
||||||
|
pollIntervalMs: 15_000,
|
||||||
|
maxPolledEvents: 200,
|
||||||
|
cardAnchorXRatio: 0.68,
|
||||||
|
cardAnchorYRatio: 0.24,
|
||||||
|
linkMarkerGapPx: 18,
|
||||||
|
linkPanelGapPx: 12,
|
||||||
|
linkElbowOffsetPx: 72,
|
||||||
|
linkAnchorHeightRatio: 0.26,
|
||||||
|
linkForcedBendPx: 34,
|
||||||
|
linkElbowDropPx: 24,
|
||||||
|
};
|
||||||
|
|
||||||
export const HUD_CONFIG = {
|
export const HUD_CONFIG = {
|
||||||
scaleReferenceWidth: 1920,
|
scaleReferenceWidth: 1920,
|
||||||
scaleReferenceHeight: 1080,
|
scaleReferenceHeight: 1080,
|
||||||
@@ -72,6 +93,45 @@ export const CELESTIAL_CONFIG = {
|
|||||||
sunLightColor: 0xfff4df,
|
sunLightColor: 0xfff4df,
|
||||||
backLightIntensity: 0.3,
|
backLightIntensity: 0.3,
|
||||||
backLightColor: 0x2b4c78,
|
backLightColor: 0x2b4c78,
|
||||||
|
inspectionLighting: {
|
||||||
|
enabled: true,
|
||||||
|
ambientIntensity: 0.64,
|
||||||
|
ambientColor: 0x707070,
|
||||||
|
keyLightIntensity: 0.92,
|
||||||
|
keyLightColor: 0xfcfcfb,
|
||||||
|
keyLightDistance: 380,
|
||||||
|
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
|
||||||
|
backLightIntensity: 0.26,
|
||||||
|
backLightColor: 0x8f96a0,
|
||||||
|
backLightDistance: 260,
|
||||||
|
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
|
||||||
|
pointLightIntensity: 0.36,
|
||||||
|
pointLightColor: 0xfafcff,
|
||||||
|
pointLightDistance: 320,
|
||||||
|
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SCENE_LIGHT_CONFIG = {
|
||||||
|
ambient: {
|
||||||
|
color: 0x404060,
|
||||||
|
intensity: 1,
|
||||||
|
},
|
||||||
|
sun: {
|
||||||
|
color: 0xffffff,
|
||||||
|
intensity: 1.2,
|
||||||
|
position: { x: 5, y: 3, z: 5 },
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
color: 0x446688,
|
||||||
|
intensity: 0.3,
|
||||||
|
position: { x: -5, y: 0, z: -5 },
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
color: 0xffffff,
|
||||||
|
intensity: 0.4,
|
||||||
|
position: { x: 10, y: 10, z: 10 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TERRAIN_CONFIG = {
|
export const TERRAIN_CONFIG = {
|
||||||
@@ -96,11 +156,38 @@ export const TERRAIN_CONFIG = {
|
|||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
cablesApi: '/api/v1/visualization/geo/cables',
|
cablesApi: '/api/v1/visualization/geo/cables',
|
||||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||||
|
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
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
|
// Cable colors mapping
|
||||||
export const CABLE_COLORS = {
|
export const CABLE_COLORS = {
|
||||||
'Americas II': 0xff4444,
|
'Americas II': 0xff4444,
|
||||||
@@ -169,7 +256,12 @@ export const CABLE_STATE = {
|
|||||||
|
|
||||||
export const SATELLITE_CONFIG = {
|
export const SATELLITE_CONFIG = {
|
||||||
maxCount: -1,
|
maxCount: -1,
|
||||||
|
initialLoadCount: 2400,
|
||||||
|
hydrateFullAfterInitialLoad: true,
|
||||||
trailLength: 10,
|
trailLength: 10,
|
||||||
|
displayAltitudeOffset: 8,
|
||||||
|
frontFacingDotThreshold: 0.015,
|
||||||
|
overlayRenderOrder: 12,
|
||||||
dotSize: 4,
|
dotSize: 4,
|
||||||
ringSize: 0.07,
|
ringSize: 0.07,
|
||||||
apiPath: '/api/v1/visualization/geo/satellites',
|
apiPath: '/api/v1/visualization/geo/satellites',
|
||||||
|
|||||||
2088
frontend/public/earth/js/controls.js
vendored
2088
frontend/public/earth/js/controls.js
vendored
File diff suppressed because it is too large
Load Diff
229
frontend/public/earth/js/cruise-sequencer.js
Normal file
229
frontend/public/earth/js/cruise-sequencer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export let terrain = null;
|
|||||||
const textureLoader = new THREE.TextureLoader();
|
const textureLoader = new THREE.TextureLoader();
|
||||||
let _earthMaterial = null;
|
let _earthMaterial = null;
|
||||||
let _earthShader = null;
|
let _earthShader = null;
|
||||||
|
let _dayNightEnabled = true;
|
||||||
const _earthSunDirection = new THREE.Vector3(
|
const _earthSunDirection = new THREE.Vector3(
|
||||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
||||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
||||||
@@ -33,6 +34,7 @@ function applyEarthDayNightShader(material) {
|
|||||||
shader.uniforms.uTwilightColor = { value: twilightColor };
|
shader.uniforms.uTwilightColor = { value: twilightColor };
|
||||||
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
||||||
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
|
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
|
||||||
|
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
|
||||||
|
|
||||||
shader.vertexShader = shader.vertexShader.replace(
|
shader.vertexShader = shader.vertexShader.replace(
|
||||||
"#include <common>",
|
"#include <common>",
|
||||||
@@ -55,7 +57,8 @@ uniform float uTwilightWidth;
|
|||||||
uniform float uTwilightIntensity;
|
uniform float uTwilightIntensity;
|
||||||
uniform vec3 uTwilightColor;
|
uniform vec3 uTwilightColor;
|
||||||
uniform vec3 uNightTintColor;
|
uniform vec3 uNightTintColor;
|
||||||
uniform float uNightTintIntensity;`,
|
uniform float uNightTintIntensity;
|
||||||
|
uniform float uDayNightEnabled;`,
|
||||||
).replace(
|
).replace(
|
||||||
"#include <output_fragment>",
|
"#include <output_fragment>",
|
||||||
`
|
`
|
||||||
@@ -65,16 +68,27 @@ uniform float uNightTintIntensity;`,
|
|||||||
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
||||||
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
||||||
|
|
||||||
outgoingLight *= mix(uNightFloor, uDayBoost, daylight);
|
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
|
||||||
outgoingLight += uTwilightColor * twilight * uTwilightIntensity;
|
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
|
||||||
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
|
float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
|
||||||
|
float cameraBoost = mix(0.62, 1.08, nDotV);
|
||||||
|
|
||||||
|
float dn = uDayNightEnabled;
|
||||||
|
vec3 dnLight = outgoingLight;
|
||||||
|
dnLight *= mix(uNightFloor, uDayBoost, daylight);
|
||||||
|
dnLight += uTwilightColor * twilight * uTwilightIntensity;
|
||||||
|
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
|
||||||
|
|
||||||
|
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
|
||||||
|
// dn=1: full day/night solar lighting
|
||||||
|
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
|
||||||
|
|
||||||
#include <output_fragment>
|
#include <output_fragment>
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
material.customProgramCacheKey = () => "earth-day-night-v1";
|
material.customProgramCacheKey = () => "earth-day-night-v5";
|
||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +238,7 @@ export function createTerrain(earthObj) {
|
|||||||
specular: TERRAIN_CONFIG.specular,
|
specular: TERRAIN_CONFIG.specular,
|
||||||
shininess: TERRAIN_CONFIG.shininess,
|
shininess: TERRAIN_CONFIG.shininess,
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
|
vertexAlphas: true,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: TERRAIN_CONFIG.opacity,
|
opacity: TERRAIN_CONFIG.opacity,
|
||||||
flatShading: false,
|
flatShading: false,
|
||||||
@@ -347,6 +362,28 @@ export function setEarthSunDirection(direction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setDayNightEnabled(enabled) {
|
||||||
|
_dayNightEnabled = enabled;
|
||||||
|
if (_earthShader?.uniforms?.uDayNightEnabled) {
|
||||||
|
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
|
||||||
|
}
|
||||||
|
if (_earthMaterial) {
|
||||||
|
if (enabled) {
|
||||||
|
// Restore normal Phong lighting + custom day/night shader
|
||||||
|
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||||
|
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
|
||||||
|
_earthMaterial.emissiveMap = null;
|
||||||
|
} else {
|
||||||
|
// Full bright: zero diffuse so directional light has no effect;
|
||||||
|
// use original color as emissive map to show texture uniformly.
|
||||||
|
_earthMaterial.color.setRGB(0, 0, 0);
|
||||||
|
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||||
|
_earthMaterial.emissiveMap = _earthMaterial.map;
|
||||||
|
}
|
||||||
|
_earthMaterial.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function loadEarthTexture() {
|
export function loadEarthTexture() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!_earthMaterial) { resolve(); return; }
|
if (!_earthMaterial) { resolve(); return; }
|
||||||
@@ -367,6 +404,10 @@ export function loadEarthTexture() {
|
|||||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
texture.magFilter = THREE.LinearFilter;
|
texture.magFilter = THREE.LinearFilter;
|
||||||
_earthMaterial.map = texture;
|
_earthMaterial.map = texture;
|
||||||
|
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
|
||||||
|
if (!_dayNightEnabled) {
|
||||||
|
_earthMaterial.emissiveMap = texture;
|
||||||
|
}
|
||||||
_earthMaterial.needsUpdate = true;
|
_earthMaterial.needsUpdate = true;
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,189 @@ import { showStatusMessage } from './ui.js';
|
|||||||
let currentType = null;
|
let currentType = null;
|
||||||
let cardMounted = false;
|
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 = {
|
const CARD_CONFIG = {
|
||||||
cable: {
|
cable: {
|
||||||
icon: '🛥️',
|
icon: '🛥️',
|
||||||
@@ -18,6 +201,18 @@ const CARD_CONFIG = {
|
|||||||
{ key: 'rfs', label: '投入使用' }
|
{ 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: {
|
satellite: {
|
||||||
icon: '🛰️',
|
icon: '🛰️',
|
||||||
title: '卫星详情',
|
title: '卫星详情',
|
||||||
@@ -79,15 +274,22 @@ const CARD_CONFIG = {
|
|||||||
},
|
},
|
||||||
supercomputer: {
|
supercomputer: {
|
||||||
icon: '🖥️',
|
icon: '🖥️',
|
||||||
title: '超算详情',
|
title: '超算中心详情',
|
||||||
className: 'supercomputer',
|
className: 'supercomputer',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', label: '名称' },
|
{ key: 'name', label: '名称' },
|
||||||
|
{ key: 'site_type_label', label: '类型' },
|
||||||
{ key: 'rank', label: '排名' },
|
{ key: 'rank', label: '排名' },
|
||||||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
{ key: 'capacity', label: '实测算力' },
|
||||||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
{ key: 'vendor', label: '厂商' },
|
||||||
|
{ key: 'operator', label: '运营方' },
|
||||||
|
{ key: 'cores', label: '核心数' },
|
||||||
|
{ key: 'power', label: '功耗', unit: 'kW' },
|
||||||
{ key: 'country', label: '国家' },
|
{ key: 'country', label: '国家' },
|
||||||
{ key: 'city', label: '城市' }
|
{ key: 'city', label: '城市' },
|
||||||
|
{ key: 'location_precision_label', label: '位置精度' },
|
||||||
|
{ key: 'source', label: '来源' },
|
||||||
|
{ key: 'updated_at', label: '更新时间' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
gpu_cluster: {
|
gpu_cluster: {
|
||||||
@@ -96,8 +298,17 @@ const CARD_CONFIG = {
|
|||||||
className: 'gpu_cluster',
|
className: 'gpu_cluster',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'name', label: '名称' },
|
{ 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: '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;
|
if (!handle) return;
|
||||||
|
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
|
let activePointerId = null;
|
||||||
let startPointerX = 0;
|
let startPointerX = 0;
|
||||||
let startPointerY = 0;
|
let startPointerY = 0;
|
||||||
let startLeft = 0;
|
let startLeft = 0;
|
||||||
let startTop = 0;
|
let startTop = 0;
|
||||||
|
|
||||||
const stopDragging = () => {
|
const stopDragging = (event) => {
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
activePointerId !== null &&
|
||||||
|
"pointerId" in event &&
|
||||||
|
event.pointerId !== activePointerId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
activePointerId = null;
|
||||||
panel.classList.remove('is-dragging');
|
panel.classList.remove('is-dragging');
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMove = (event) => {
|
const onMove = (event) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
if (activePointerId !== null && event.pointerId !== activePointerId) return;
|
||||||
|
event.preventDefault();
|
||||||
const appRect = app.getBoundingClientRect();
|
const appRect = app.getBoundingClientRect();
|
||||||
const panelRect = panel.getBoundingClientRect();
|
const panelRect = panel.getBoundingClientRect();
|
||||||
const nextLeft = Math.min(
|
const nextLeft = Math.min(
|
||||||
@@ -143,7 +366,9 @@ function setupInfoCardDrag(panel) {
|
|||||||
|
|
||||||
handle.addEventListener('pointerdown', (event) => {
|
handle.addEventListener('pointerdown', (event) => {
|
||||||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||||
|
event.preventDefault();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
activePointerId = event.pointerId;
|
||||||
startPointerX = event.clientX;
|
startPointerX = event.clientX;
|
||||||
startPointerY = event.clientY;
|
startPointerY = event.clientY;
|
||||||
const appRect = app.getBoundingClientRect();
|
const appRect = app.getBoundingClientRect();
|
||||||
@@ -159,9 +384,11 @@ function setupInfoCardDrag(panel) {
|
|||||||
handle.setPointerCapture?.(event.pointerId);
|
handle.setPointerCapture?.(event.pointerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
handle.addEventListener('pointermove', onMove);
|
// Listen in capture phase so card-level stopPropagation used to shield the
|
||||||
handle.addEventListener('pointerup', stopDragging);
|
// globe canvas does not swallow the drag stream before we can reposition.
|
||||||
handle.addEventListener('pointercancel', stopDragging);
|
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
|
||||||
|
window.addEventListener('pointerup', stopDragging, { capture: true });
|
||||||
|
window.addEventListener('pointercancel', stopDragging, { capture: true });
|
||||||
handle.addEventListener('lostpointercapture', stopDragging);
|
handle.addEventListener('lostpointercapture', stopDragging);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,8 +465,15 @@ function mountCard() {
|
|||||||
cardMounted = true;
|
cardMounted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionPanel(panel, x, y) {
|
function positionPanel(panel, x, y, options = {}) {
|
||||||
if (!panel) return;
|
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 margin = 12;
|
||||||
const offset = 14;
|
const offset = 14;
|
||||||
const vpW = window.innerWidth;
|
const vpW = window.innerWidth;
|
||||||
@@ -251,6 +485,22 @@ function positionPanel(panel, x, y) {
|
|||||||
const estW = Math.min(300 * scale, vpW - 32);
|
const estW = Math.min(300 * scale, vpW - 32);
|
||||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||||
|
|
||||||
|
if (options.absolute === true) {
|
||||||
|
const clampedLeft = Math.min(
|
||||||
|
Math.max(margin, x),
|
||||||
|
Math.max(margin, vpW - estW - margin),
|
||||||
|
);
|
||||||
|
const clampedTop = Math.min(
|
||||||
|
Math.max(margin, y),
|
||||||
|
Math.max(margin, vpH - estH - margin),
|
||||||
|
);
|
||||||
|
panel.style.left = `${clampedLeft}px`;
|
||||||
|
panel.style.top = `${clampedTop}px`;
|
||||||
|
panel.style.right = 'auto';
|
||||||
|
panel.style.bottom = 'auto';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let left = x + offset;
|
let left = x + offset;
|
||||||
let top = y + offset;
|
let top = y + offset;
|
||||||
|
|
||||||
@@ -263,16 +513,24 @@ function positionPanel(panel, x, y) {
|
|||||||
panel.style.bottom = 'auto';
|
panel.style.bottom = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPanel(x, y) {
|
function showPanel(x, y, options = {}) {
|
||||||
const panel = getPanel();
|
const panel = getPanel();
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
if (x != null && y != null) positionPanel(panel, x, y);
|
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||||
panel.classList.add('is-visible');
|
panel.classList.add('is-visible');
|
||||||
|
document.body.classList.add('earth-info-open');
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hidePanel() {
|
function hidePanel() {
|
||||||
const panel = getPanel();
|
const panel = getPanel();
|
||||||
if (panel) panel.classList.remove('is-visible');
|
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()
|
// No-op: event binding now happens lazily in mountCard()
|
||||||
@@ -292,6 +550,51 @@ export function showInfoCard(type, data, options = {}) {
|
|||||||
return;
|
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();
|
mountCard();
|
||||||
|
|
||||||
currentType = type;
|
currentType = type;
|
||||||
@@ -327,10 +630,19 @@ export function showInfoCard(type, data, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
showPanel(options.x, options.y);
|
showPanel(options.x, options.y, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideInfoCard() {
|
export function hideInfoCard() {
|
||||||
|
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();
|
hidePanel();
|
||||||
currentType = null;
|
currentType = null;
|
||||||
}
|
}
|
||||||
|
|||||||
46
frontend/public/earth/js/layer-button-state.js
Normal file
46
frontend/public/earth/js/layer-button-state.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
frontend/public/earth/js/layer-startup-tasks.js
Normal file
201
frontend/public/earth/js/layer-startup-tasks.js
Normal 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();
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createHUDPanel } from "./hud-panels.js";
|
import { createHUDPanel } from "./hud-panels.js";
|
||||||
|
|
||||||
const LEGEND_MODES = {
|
const LEGEND_MODES = {
|
||||||
cables: { title: "海缆" },
|
cables: { title: "海缆" },
|
||||||
satellites: { title: "卫星" },
|
satellites: { title: "卫星" },
|
||||||
bgp: { title: "BGP" },
|
computeCenters: { title: "算力" },
|
||||||
|
bgp: { title: "BGP" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentLegendMode = "cables";
|
let currentLegendMode = "cables";
|
||||||
@@ -11,6 +12,7 @@ let legendPanel = null;
|
|||||||
let legendItemsByMode = {
|
let legendItemsByMode = {
|
||||||
cables: [],
|
cables: [],
|
||||||
satellites: [],
|
satellites: [],
|
||||||
|
computeCenters: [],
|
||||||
bgp: [],
|
bgp: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,17 +64,18 @@ export function setLegendItems(mode, items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncCurrentLabel(mode) {
|
function syncCurrentLabel(mode) {
|
||||||
const labelEl = document.getElementById("legend-current-label");
|
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||||
if (!labelEl) return;
|
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
|
||||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
.forEach((labelEl) => {
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = nextLabel;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLegend(mode) {
|
function renderLegend(mode) {
|
||||||
const listEl = document.querySelector("#legend .legend-list");
|
|
||||||
if (!listEl) return;
|
|
||||||
|
|
||||||
const items = legendItemsByMode[mode] || [];
|
const items = legendItemsByMode[mode] || [];
|
||||||
listEl.innerHTML = items
|
const html = items
|
||||||
.map(
|
.map(
|
||||||
(item) => `
|
(item) => `
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
@@ -81,4 +84,14 @@ function renderLegend(mode) {
|
|||||||
</div>`,
|
</div>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.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
@@ -18,17 +18,18 @@ let lastFocus = null;
|
|||||||
let lastFetchAt = 0;
|
let lastFetchAt = 0;
|
||||||
let lastRegionSwitchAt = 0;
|
let lastRegionSwitchAt = 0;
|
||||||
function getElements() {
|
function getElements() {
|
||||||
|
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||||
return {
|
return {
|
||||||
refreshBtn: document.getElementById("news-refresh"),
|
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
|
||||||
openBtn: document.getElementById("news-open-external"),
|
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
|
||||||
status: document.getElementById("news-board-status"),
|
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
|
||||||
focusLabel: document.getElementById("news-focus-label"),
|
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
|
||||||
focusCoords: document.getElementById("news-focus-coords"),
|
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
|
||||||
sourceCount: document.getElementById("news-source-count"),
|
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
|
||||||
regionChip: document.getElementById("news-region-chip"),
|
regionChip: document.getElementById("news-region-chip"),
|
||||||
board: document.getElementById("news-board-list"),
|
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
|
||||||
empty: document.getElementById("news-board-empty"),
|
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
|
||||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,18 +82,33 @@ function renderPayload(nextPayload) {
|
|||||||
openBtn,
|
openBtn,
|
||||||
feedAnchor,
|
feedAnchor,
|
||||||
} = getElements();
|
} = getElements();
|
||||||
|
|
||||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||||
const focus = nextPayload?.focus || {};
|
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 || "全球焦点";
|
focusLabel.textContent = focus.label || "全球焦点";
|
||||||
regionChip.textContent = focus.region || "global";
|
|
||||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
|
||||||
|
|
||||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||||
@@ -275,8 +291,6 @@ export function initNewsPanel() {
|
|||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
const { refreshBtn, openBtn } = getElements();
|
|
||||||
|
|
||||||
updateNewsToggleUI(isTVPanelVisible());
|
updateNewsToggleUI(isTVPanelVisible());
|
||||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||||
|
|
||||||
@@ -287,16 +301,22 @@ export function initNewsPanel() {
|
|||||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshBtn?.addEventListener("click", async () => {
|
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||||
try {
|
const refreshBtn = document.getElementById(id);
|
||||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
refreshBtn?.addEventListener("click", async () => {
|
||||||
showStatusMessage("态势新闻已刷新", "info");
|
try {
|
||||||
} catch {
|
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||||
showStatusMessage("态势新闻刷新失败", "error");
|
showStatusMessage("态势新闻已刷新", "info");
|
||||||
}
|
} catch {
|
||||||
|
showStatusMessage("态势新闻刷新失败", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
["news-open-external", "mobile-news-open-external"].forEach((id) => {
|
||||||
|
const openBtn = document.getElementById(id);
|
||||||
|
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||||
|
});
|
||||||
|
|
||||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
|||||||
import { latLonToVector3 } from "./utils.js";
|
import { latLonToVector3 } from "./utils.js";
|
||||||
|
|
||||||
let satellitePoints = null;
|
let satellitePoints = null;
|
||||||
|
let satelliteBackdropPoints = null;
|
||||||
let satelliteTrails = null;
|
let satelliteTrails = null;
|
||||||
let satelliteData = [];
|
let satelliteData = [];
|
||||||
let showSatellites = false;
|
let showSatellites = false;
|
||||||
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
|
|||||||
let lockedDotSprite = null;
|
let lockedDotSprite = null;
|
||||||
let predictedOrbitLine = null;
|
let predictedOrbitLine = null;
|
||||||
let relatedSatelliteSprites = [];
|
let relatedSatelliteSprites = [];
|
||||||
|
let highlightedSatelliteIndices = null;
|
||||||
let earthObjRef = null;
|
let earthObjRef = null;
|
||||||
let sceneRef = null;
|
let sceneRef = null;
|
||||||
let cameraRef = null;
|
let cameraRef = null;
|
||||||
@@ -24,10 +26,15 @@ let lockedSatelliteIndex = null;
|
|||||||
let hoveredSatelliteIndex = null;
|
let hoveredSatelliteIndex = null;
|
||||||
let positionUpdateAccumulator = 0;
|
let positionUpdateAccumulator = 0;
|
||||||
let satelliteCapacity = 0;
|
let satelliteCapacity = 0;
|
||||||
|
let satelliteSatrecCache = new Map();
|
||||||
|
|
||||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||||
const DOT_TEXTURE_SIZE = 32;
|
const DOT_TEXTURE_SIZE = 32;
|
||||||
const POSITION_UPDATE_INTERVAL_MS = 250;
|
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 scratchWorldSatellitePosition = new THREE.Vector3();
|
||||||
const scratchToCamera = new THREE.Vector3();
|
const scratchToCamera = new THREE.Vector3();
|
||||||
@@ -117,6 +124,10 @@ export function updateBreathingPhase(deltaTime = 16) {
|
|||||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBreathingPulse(phase) {
|
||||||
|
return 0.5 + 0.5 * Math.sin(phase);
|
||||||
|
}
|
||||||
|
|
||||||
export function getSatelliteLegendItems() {
|
export function getSatelliteLegendItems() {
|
||||||
const presentKeys = new Set();
|
const presentKeys = new Set();
|
||||||
|
|
||||||
@@ -204,6 +215,37 @@ function createDotTexture() {
|
|||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBackdropDotTexture() {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = DOT_TEXTURE_SIZE;
|
||||||
|
canvas.height = DOT_TEXTURE_SIZE;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const center = DOT_TEXTURE_SIZE / 2;
|
||||||
|
const radius = center - 1;
|
||||||
|
|
||||||
|
const gradient = ctx.createRadialGradient(
|
||||||
|
center,
|
||||||
|
center,
|
||||||
|
0,
|
||||||
|
center,
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
|
||||||
|
gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
|
||||||
|
gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
|
||||||
|
gradient.addColorStop(1, "rgba(7, 14, 27, 0)");
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(center, center, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||||
const size = DOT_TEXTURE_SIZE * 2;
|
const size = DOT_TEXTURE_SIZE * 2;
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@@ -226,8 +268,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
|||||||
export function createSatellites(scene, earthObj) {
|
export function createSatellites(scene, earthObj) {
|
||||||
initSatelliteScene(scene, earthObj);
|
initSatelliteScene(scene, earthObj);
|
||||||
const dotTexture = createDotTexture();
|
const dotTexture = createDotTexture();
|
||||||
|
const backdropTexture = createBackdropDotTexture();
|
||||||
|
|
||||||
const pointsGeometry = new THREE.BufferGeometry();
|
const pointsGeometry = new THREE.BufferGeometry();
|
||||||
|
const backdropGeometry = new THREE.BufferGeometry();
|
||||||
|
|
||||||
|
const backdropMaterial = new THREE.PointsMaterial({
|
||||||
|
size: SATELLITE_CONFIG.dotSize * 1.28,
|
||||||
|
map: backdropTexture,
|
||||||
|
color: 0x0b1626,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
sizeAttenuation: false,
|
||||||
|
alphaTest: 0.04,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
const pointsMaterial = new THREE.PointsMaterial({
|
const pointsMaterial = new THREE.PointsMaterial({
|
||||||
size: SATELLITE_CONFIG.dotSize,
|
size: SATELLITE_CONFIG.dotSize,
|
||||||
@@ -237,29 +292,45 @@ export function createSatellites(scene, earthObj) {
|
|||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
sizeAttenuation: false,
|
sizeAttenuation: false,
|
||||||
alphaTest: 0.1,
|
alphaTest: 0.1,
|
||||||
|
depthWrite: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
|
||||||
|
satelliteBackdropPoints.visible = false;
|
||||||
|
satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
|
||||||
|
satelliteBackdropPoints.renderOrder = 5;
|
||||||
|
|
||||||
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
|
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
|
||||||
satellitePoints.visible = false;
|
satellitePoints.visible = false;
|
||||||
satellitePoints.userData = { type: "satellitePoints" };
|
satellitePoints.userData = { type: "satellitePoints" };
|
||||||
|
satellitePoints.renderOrder = 6;
|
||||||
|
|
||||||
const originalScale = { x: 1, y: 1, z: 1 };
|
const originalScale = { x: 1, y: 1, z: 1 };
|
||||||
satellitePoints.onBeforeRender = () => {
|
const syncPointScale = () => {
|
||||||
if (earthObj && earthObj.scale.x !== 1) {
|
if (earthObj && earthObj.scale.x !== 1) {
|
||||||
satellitePoints.scale.set(
|
const scaleX = originalScale.x / earthObj.scale.x;
|
||||||
originalScale.x / earthObj.scale.x,
|
const scaleY = originalScale.y / earthObj.scale.y;
|
||||||
originalScale.y / earthObj.scale.y,
|
const scaleZ = originalScale.z / earthObj.scale.z;
|
||||||
originalScale.z / earthObj.scale.z,
|
satellitePoints.scale.set(scaleX, scaleY, scaleZ);
|
||||||
);
|
if (satelliteBackdropPoints) {
|
||||||
|
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
satellitePoints.scale.set(
|
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
|
||||||
originalScale.x,
|
if (satelliteBackdropPoints) {
|
||||||
originalScale.y,
|
satelliteBackdropPoints.scale.set(
|
||||||
originalScale.z,
|
originalScale.x,
|
||||||
);
|
originalScale.y,
|
||||||
|
originalScale.z,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
satelliteBackdropPoints.onBeforeRender = syncPointScale;
|
||||||
|
satellitePoints.onBeforeRender = syncPointScale;
|
||||||
|
|
||||||
|
earthObj.add(satelliteBackdropPoints);
|
||||||
earthObj.add(satellitePoints);
|
earthObj.add(satellitePoints);
|
||||||
|
|
||||||
const trailGeometry = new THREE.BufferGeometry();
|
const trailGeometry = new THREE.BufferGeometry();
|
||||||
@@ -281,7 +352,12 @@ export function createSatellites(scene, earthObj) {
|
|||||||
return satellitePoints;
|
return satellitePoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestedSatelliteLimit() {
|
function getRequestedSatelliteLimit(limitOverride) {
|
||||||
|
if (limitOverride === null) return null;
|
||||||
|
if (Number.isFinite(limitOverride) && limitOverride > 0) {
|
||||||
|
return Math.floor(limitOverride);
|
||||||
|
}
|
||||||
|
|
||||||
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,13 +371,50 @@ function createSatellitePositionState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureSatelliteCapacity(count) {
|
function ensureSatelliteCapacity(count) {
|
||||||
if (!satellitePoints || !satelliteTrails) return;
|
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
|
||||||
|
|
||||||
const nextCapacity = Math.max(count, 0);
|
const nextCapacity = Math.max(count, 0);
|
||||||
if (nextCapacity === satelliteCapacity) return;
|
if (nextCapacity === satelliteCapacity) return;
|
||||||
|
|
||||||
|
const previousPointPositions =
|
||||||
|
satellitePoints.geometry.attributes.position?.array || null;
|
||||||
|
const previousBackdropPositions =
|
||||||
|
satelliteBackdropPoints.geometry.attributes.position?.array || null;
|
||||||
|
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
|
||||||
|
const previousTrailPositions =
|
||||||
|
satelliteTrails.geometry.attributes.position?.array || null;
|
||||||
|
const previousTrailColors =
|
||||||
|
satelliteTrails.geometry.attributes.color?.array || null;
|
||||||
|
const previousSatellitePositions = satellitePositions;
|
||||||
|
const previousCapacity = satelliteCapacity;
|
||||||
|
|
||||||
const positions = new Float32Array(nextCapacity * 3);
|
const positions = new Float32Array(nextCapacity * 3);
|
||||||
|
const backdropPositions = new Float32Array(nextCapacity * 3);
|
||||||
const colors = new Float32Array(nextCapacity * 3);
|
const colors = new Float32Array(nextCapacity * 3);
|
||||||
|
if (previousPointPositions) {
|
||||||
|
positions.set(
|
||||||
|
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (previousBackdropPositions) {
|
||||||
|
backdropPositions.set(
|
||||||
|
previousBackdropPositions.subarray(
|
||||||
|
0,
|
||||||
|
Math.min(previousBackdropPositions.length, backdropPositions.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (previousColors) {
|
||||||
|
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
|
||||||
|
}
|
||||||
|
satelliteBackdropPoints.geometry.setAttribute(
|
||||||
|
"position",
|
||||||
|
new THREE.BufferAttribute(backdropPositions, 3),
|
||||||
|
);
|
||||||
|
satelliteBackdropPoints.geometry.setDrawRange(
|
||||||
|
0,
|
||||||
|
Math.min(previousCapacity, nextCapacity),
|
||||||
|
);
|
||||||
satellitePoints.geometry.setAttribute(
|
satellitePoints.geometry.setAttribute(
|
||||||
"position",
|
"position",
|
||||||
new THREE.BufferAttribute(positions, 3),
|
new THREE.BufferAttribute(positions, 3),
|
||||||
@@ -310,10 +423,26 @@ function ensureSatelliteCapacity(count) {
|
|||||||
"color",
|
"color",
|
||||||
new THREE.BufferAttribute(colors, 3),
|
new THREE.BufferAttribute(colors, 3),
|
||||||
);
|
);
|
||||||
satellitePoints.geometry.setDrawRange(0, 0);
|
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
|
||||||
|
|
||||||
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||||
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||||
|
if (previousTrailPositions) {
|
||||||
|
trailPositions.set(
|
||||||
|
previousTrailPositions.subarray(
|
||||||
|
0,
|
||||||
|
Math.min(previousTrailPositions.length, trailPositions.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (previousTrailColors) {
|
||||||
|
trailColors.set(
|
||||||
|
previousTrailColors.subarray(
|
||||||
|
0,
|
||||||
|
Math.min(previousTrailColors.length, trailColors.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
satelliteTrails.geometry.setAttribute(
|
satelliteTrails.geometry.setAttribute(
|
||||||
"position",
|
"position",
|
||||||
new THREE.BufferAttribute(trailPositions, 3),
|
new THREE.BufferAttribute(trailPositions, 3),
|
||||||
@@ -323,10 +452,19 @@ function ensureSatelliteCapacity(count) {
|
|||||||
new THREE.BufferAttribute(trailColors, 3),
|
new THREE.BufferAttribute(trailColors, 3),
|
||||||
);
|
);
|
||||||
|
|
||||||
satellitePositions = Array.from(
|
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
||||||
{ length: nextCapacity },
|
const previousState = previousSatellitePositions[index];
|
||||||
createSatellitePositionState,
|
if (!previousState) {
|
||||||
);
|
return createSatellitePositionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: previousState.current.clone(),
|
||||||
|
trail: previousState.trail.slice(),
|
||||||
|
trailIndex: previousState.trailIndex,
|
||||||
|
trailCount: previousState.trailCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
satelliteCapacity = nextCapacity;
|
satelliteCapacity = nextCapacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +475,7 @@ function computeSatellitePosition(satellite, time) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const satrec = buildSatrecFromProperties(props, time);
|
const satrec = getOrBuildSatrec(props, time);
|
||||||
if (!satrec || satrec.error) {
|
if (!satrec || satrec.error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -356,7 +494,8 @@ function computeSatellitePosition(satellite, time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const r = Math.sqrt(x * x + y * y + z * z);
|
const r = Math.sqrt(x * x + y * y + z * z);
|
||||||
const displayRadius = CONFIG.earthRadius * 1.05;
|
const displayRadius =
|
||||||
|
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||||
const scale = displayRadius / r;
|
const scale = displayRadius / r;
|
||||||
|
|
||||||
return new THREE.Vector3(x * scale, y * scale, z * scale);
|
return new THREE.Vector3(x * scale, y * scale, z * scale);
|
||||||
@@ -382,6 +521,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
|
|||||||
return twoline2satrec(tleLines.line1, tleLines.line2);
|
return twoline2satrec(tleLines.line1, tleLines.line2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSatelliteSatrecCacheKey(props) {
|
||||||
|
if (!props?.norad_cat_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.tle_line1 && props.tle_line2) {
|
||||||
|
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.epoch) {
|
||||||
|
return [
|
||||||
|
"elements",
|
||||||
|
props.norad_cat_id,
|
||||||
|
props.epoch,
|
||||||
|
props.inclination,
|
||||||
|
props.raan,
|
||||||
|
props.eccentricity,
|
||||||
|
props.arg_of_perigee,
|
||||||
|
props.mean_anomaly,
|
||||||
|
props.mean_motion,
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrBuildSatrec(props, fallbackTime) {
|
||||||
|
const cacheKey = getSatelliteSatrecCacheKey(props);
|
||||||
|
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
|
||||||
|
return satelliteSatrecCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const satrec = buildSatrecFromProperties(props, fallbackTime);
|
||||||
|
if (cacheKey && satrec && !satrec.error) {
|
||||||
|
satelliteSatrecCache.set(cacheKey, satrec);
|
||||||
|
}
|
||||||
|
return satrec;
|
||||||
|
}
|
||||||
|
|
||||||
function computeTleChecksum(line) {
|
function computeTleChecksum(line) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
|
|
||||||
@@ -464,7 +642,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateFallbackPosition(satellite, index, total) {
|
function generateFallbackPosition(satellite, index, total) {
|
||||||
const radius = CONFIG.earthRadius + 5;
|
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||||
|
|
||||||
const noradId = satellite.properties?.norad_cat_id || index;
|
const noradId = satellite.properties?.norad_cat_id || index;
|
||||||
const inclination = satellite.properties?.inclination || 53;
|
const inclination = satellite.properties?.inclination || 53;
|
||||||
@@ -491,8 +669,8 @@ function generateFallbackPosition(satellite, index, total) {
|
|||||||
return new THREE.Vector3(x, y, z);
|
return new THREE.Vector3(x, y, z);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSatellites() {
|
export async function loadSatellites(options = {}) {
|
||||||
const limit = getRequestedSatelliteLimit();
|
const limit = getRequestedSatelliteLimit(options.limit);
|
||||||
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
|
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
|
||||||
if (limit !== null) {
|
if (limit !== null) {
|
||||||
url.searchParams.set("limit", String(limit));
|
url.searchParams.set("limit", String(limit));
|
||||||
@@ -505,13 +683,17 @@ export async function loadSatellites() {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
satelliteData = data.features || [];
|
satelliteData = data.features || [];
|
||||||
|
satelliteSatrecCache = new Map();
|
||||||
ensureSatelliteCapacity(satelliteData.length);
|
ensureSatelliteCapacity(satelliteData.length);
|
||||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||||
return satelliteData.length;
|
return {
|
||||||
|
count: satelliteData.length,
|
||||||
|
requestedLimit: limit,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSatellitePositions(deltaTime = 0, force = false) {
|
export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||||
if (!satellitePoints || satelliteData.length === 0) return;
|
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
|
||||||
|
|
||||||
const shouldUpdateTrails =
|
const shouldUpdateTrails =
|
||||||
showSatellites || showTrails || lockedSatelliteIndex !== null;
|
showSatellites || showTrails || lockedSatelliteIndex !== null;
|
||||||
@@ -528,6 +710,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
|||||||
positionUpdateAccumulator = 0;
|
positionUpdateAccumulator = 0;
|
||||||
|
|
||||||
const positions = satellitePoints.geometry.attributes.position.array;
|
const positions = satellitePoints.geometry.attributes.position.array;
|
||||||
|
const backdropPositions =
|
||||||
|
satelliteBackdropPoints.geometry.attributes.position.array;
|
||||||
const colors = satellitePoints.geometry.attributes.color.array;
|
const colors = satellitePoints.geometry.attributes.color.array;
|
||||||
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
||||||
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
||||||
@@ -559,13 +743,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
|||||||
positions[i * 3] = pos.x;
|
positions[i * 3] = pos.x;
|
||||||
positions[i * 3 + 1] = pos.y;
|
positions[i * 3 + 1] = pos.y;
|
||||||
positions[i * 3 + 2] = pos.z;
|
positions[i * 3 + 2] = pos.z;
|
||||||
|
backdropPositions[i * 3] = pos.x;
|
||||||
|
backdropPositions[i * 3 + 1] = pos.y;
|
||||||
|
backdropPositions[i * 3 + 2] = pos.z;
|
||||||
|
|
||||||
const rule = getSatelliteLegendRule(props);
|
const rule = getSatelliteLegendRule(props);
|
||||||
const { r, g, b } = getSatelliteRuleColor(rule);
|
const { r, g, b } = getSatelliteRuleColor(rule);
|
||||||
|
|
||||||
colors[i * 3] = r;
|
const isNonFocusDimmed =
|
||||||
colors[i * 3 + 1] = g;
|
highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i);
|
||||||
colors[i * 3 + 2] = b;
|
const pointBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_BRIGHTNESS : 1;
|
||||||
|
const trailBrightness = isNonFocusDimmed
|
||||||
|
? DIMMED_SATELLITE_TRAIL_BRIGHTNESS
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
colors[i * 3] = r * pointBrightness;
|
||||||
|
colors[i * 3 + 1] = g * pointBrightness;
|
||||||
|
colors[i * 3 + 2] = b * pointBrightness;
|
||||||
|
|
||||||
const satPosition = satellitePositions[i];
|
const satPosition = satellitePositions[i];
|
||||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||||
@@ -581,9 +775,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
|||||||
trailPositions[trailIdx + 1] = trailPoint.y;
|
trailPositions[trailIdx + 1] = trailPoint.y;
|
||||||
trailPositions[trailIdx + 2] = trailPoint.z;
|
trailPositions[trailIdx + 2] = trailPoint.z;
|
||||||
const alpha = (j + 1) / satPosition.trailCount;
|
const alpha = (j + 1) / satPosition.trailCount;
|
||||||
trailColors[trailIdx] = r * alpha;
|
trailColors[trailIdx] = r * alpha * trailBrightness;
|
||||||
trailColors[trailIdx + 1] = g * alpha;
|
trailColors[trailIdx + 1] = g * alpha * trailBrightness;
|
||||||
trailColors[trailIdx + 2] = b * alpha;
|
trailColors[trailIdx + 2] = b * alpha * trailBrightness;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,6 +795,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
|||||||
positions[i * 3] = 0;
|
positions[i * 3] = 0;
|
||||||
positions[i * 3 + 1] = 0;
|
positions[i * 3 + 1] = 0;
|
||||||
positions[i * 3 + 2] = 0;
|
positions[i * 3 + 2] = 0;
|
||||||
|
backdropPositions[i * 3] = 0;
|
||||||
|
backdropPositions[i * 3 + 1] = 0;
|
||||||
|
backdropPositions[i * 3 + 2] = 0;
|
||||||
|
|
||||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||||
@@ -613,6 +810,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
|||||||
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
||||||
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
||||||
satellitePoints.geometry.setDrawRange(0, count);
|
satellitePoints.geometry.setDrawRange(0, count);
|
||||||
|
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
|
||||||
|
satelliteBackdropPoints.geometry.setDrawRange(0, count);
|
||||||
|
|
||||||
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
||||||
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
||||||
@@ -631,6 +830,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
|||||||
|
|
||||||
export function toggleSatellites(visible) {
|
export function toggleSatellites(visible) {
|
||||||
showSatellites = visible;
|
showSatellites = visible;
|
||||||
|
if (satelliteBackdropPoints) {
|
||||||
|
satelliteBackdropPoints.visible = visible;
|
||||||
|
}
|
||||||
if (satellitePoints) {
|
if (satellitePoints) {
|
||||||
satellitePoints.visible = visible;
|
satellitePoints.visible = visible;
|
||||||
}
|
}
|
||||||
@@ -646,6 +848,10 @@ export function toggleTrails(visible) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShowTrails() {
|
||||||
|
return showTrails;
|
||||||
|
}
|
||||||
|
|
||||||
export function getShowSatellites() {
|
export function getShowSatellites() {
|
||||||
return showSatellites;
|
return showSatellites;
|
||||||
}
|
}
|
||||||
@@ -705,7 +911,10 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
|
|||||||
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
|
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
|
||||||
.normalize();
|
.normalize();
|
||||||
|
|
||||||
return scratchToCamera.dot(scratchToSatellite) > 0;
|
return (
|
||||||
|
scratchToCamera.dot(scratchToSatellite) >
|
||||||
|
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrighterDotCanvas() {
|
function createBrighterDotCanvas() {
|
||||||
@@ -751,6 +960,7 @@ function createRingSprite(position, isLocked = false) {
|
|||||||
const sprite = new THREE.Sprite(spriteMaterial);
|
const sprite = new THREE.Sprite(spriteMaterial);
|
||||||
sprite.position.copy(position);
|
sprite.position.copy(position);
|
||||||
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
|
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
|
||||||
|
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||||
earthObjRef.add(sprite);
|
earthObjRef.add(sprite);
|
||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
@@ -770,6 +980,7 @@ function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
|
|||||||
const sprite = new THREE.Sprite(spriteMaterial);
|
const sprite = new THREE.Sprite(spriteMaterial);
|
||||||
sprite.position.copy(position);
|
sprite.position.copy(position);
|
||||||
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
|
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
|
||||||
|
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||||
earthObjRef.add(sprite);
|
earthObjRef.add(sprite);
|
||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
@@ -792,6 +1003,7 @@ export function showHoverRing(position, isLocked = false) {
|
|||||||
lockedDotSprite = new THREE.Sprite(dotMaterial);
|
lockedDotSprite = new THREE.Sprite(dotMaterial);
|
||||||
lockedDotSprite.position.copy(position);
|
lockedDotSprite.position.copy(position);
|
||||||
lockedDotSprite.scale.set(4, 4, 1);
|
lockedDotSprite.scale.set(4, 4, 1);
|
||||||
|
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
|
||||||
earthObjRef.add(lockedDotSprite);
|
earthObjRef.add(lockedDotSprite);
|
||||||
return lockedRingSprite;
|
return lockedRingSprite;
|
||||||
}
|
}
|
||||||
@@ -821,10 +1033,15 @@ export function hideLockedRing() {
|
|||||||
|
|
||||||
export function updateLockedRingPosition(position) {
|
export function updateLockedRingPosition(position) {
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
|
if (!lockedRingSprite || !lockedDotSprite) {
|
||||||
|
showHoverRing(position, true);
|
||||||
|
}
|
||||||
if (lockedRingSprite) {
|
if (lockedRingSprite) {
|
||||||
lockedRingSprite.position.copy(position);
|
lockedRingSprite.position.copy(position);
|
||||||
|
const ringPulse = getBreathingPulse(breathingPhase);
|
||||||
const breathScale =
|
const breathScale =
|
||||||
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
1 +
|
||||||
|
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||||
lockedRingSprite.scale.set(
|
lockedRingSprite.scale.set(
|
||||||
SATELLITE_CONFIG.ringSize * breathScale,
|
SATELLITE_CONFIG.ringSize * breathScale,
|
||||||
SATELLITE_CONFIG.ringSize * breathScale,
|
SATELLITE_CONFIG.ringSize * breathScale,
|
||||||
@@ -832,20 +1049,21 @@ export function updateLockedRingPosition(position) {
|
|||||||
);
|
);
|
||||||
lockedRingSprite.material.opacity =
|
lockedRingSprite.material.opacity =
|
||||||
SATELLITE_CONFIG.breathingOpacityMin +
|
SATELLITE_CONFIG.breathingOpacityMin +
|
||||||
Math.sin(breathingPhase) *
|
ringPulse *
|
||||||
(SATELLITE_CONFIG.breathingOpacityMax -
|
(SATELLITE_CONFIG.breathingOpacityMax -
|
||||||
SATELLITE_CONFIG.breathingOpacityMin);
|
SATELLITE_CONFIG.breathingOpacityMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lockedDotSprite) {
|
if (lockedDotSprite) {
|
||||||
lockedDotSprite.position.copy(position);
|
lockedDotSprite.position.copy(position);
|
||||||
|
const dotPulse = getBreathingPulse(breathingPhase);
|
||||||
const dotBreathScale =
|
const dotBreathScale =
|
||||||
1 +
|
1 +
|
||||||
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
(dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||||
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
|
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
|
||||||
lockedDotSprite.material.opacity =
|
lockedDotSprite.material.opacity =
|
||||||
SATELLITE_CONFIG.dotOpacityMin +
|
SATELLITE_CONFIG.dotOpacityMin +
|
||||||
Math.sin(breathingPhase) *
|
dotPulse *
|
||||||
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
|
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -881,6 +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() {
|
export function clearRelatedSatelliteHighlights() {
|
||||||
relatedSatelliteSprites.forEach((item) => {
|
relatedSatelliteSprites.forEach((item) => {
|
||||||
if (item.sprite) {
|
if (item.sprite) {
|
||||||
@@ -888,12 +1115,16 @@ export function clearRelatedSatelliteHighlights() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
relatedSatelliteSprites = [];
|
relatedSatelliteSprites = [];
|
||||||
|
highlightedSatelliteIndices = null;
|
||||||
|
applyDimMaterialState(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
|
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
|
||||||
clearRelatedSatelliteHighlights();
|
clearRelatedSatelliteHighlights();
|
||||||
if (!Array.isArray(indices) || indices.length === 0) return;
|
if (!Array.isArray(indices) || indices.length === 0) return;
|
||||||
|
|
||||||
|
highlightedSatelliteIndices = new Set(indices);
|
||||||
|
applyDimMaterialState(true);
|
||||||
indices.forEach((index) => {
|
indices.forEach((index) => {
|
||||||
const pos = satellitePositions?.[index]?.current;
|
const pos = satellitePositions?.[index]?.current;
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
@@ -983,7 +1214,8 @@ function calculatePredictedOrbit(
|
|||||||
|
|
||||||
if (points.length < samples * 0.5) {
|
if (points.length < samples * 0.5) {
|
||||||
points.length = 0;
|
points.length = 0;
|
||||||
const radius = CONFIG.earthRadius + 5;
|
const radius =
|
||||||
|
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||||
const inclination = satellite.properties?.inclination || 53;
|
const inclination = satellite.properties?.inclination || 53;
|
||||||
const raan = satellite.properties?.raan || 0;
|
const raan = satellite.properties?.raan || 0;
|
||||||
|
|
||||||
@@ -1034,9 +1266,12 @@ export function showPredictedOrbit(satellite) {
|
|||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
blending: THREE.AdditiveBlending,
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthTest: true,
|
||||||
|
depthWrite: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
predictedOrbitLine = new THREE.Line(geometry, material);
|
predictedOrbitLine = new THREE.Line(geometry, material);
|
||||||
|
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||||
earthObjRef.add(predictedOrbitLine);
|
earthObjRef.add(predictedOrbitLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,10 +1284,12 @@ export function hidePredictedOrbit() {
|
|||||||
|
|
||||||
export function clearSatelliteData() {
|
export function clearSatelliteData() {
|
||||||
satelliteData = [];
|
satelliteData = [];
|
||||||
|
satelliteSatrecCache = new Map();
|
||||||
selectedSatellite = null;
|
selectedSatellite = null;
|
||||||
lockedSatelliteIndex = null;
|
lockedSatelliteIndex = null;
|
||||||
hoveredSatelliteIndex = null;
|
hoveredSatelliteIndex = null;
|
||||||
positionUpdateAccumulator = 0;
|
positionUpdateAccumulator = 0;
|
||||||
|
breathingPhase = 0;
|
||||||
|
|
||||||
satellitePositions.forEach((position) => {
|
satellitePositions.forEach((position) => {
|
||||||
position.current.set(0, 0, 0);
|
position.current.set(0, 0, 0);
|
||||||
@@ -1075,6 +1312,16 @@ export function clearSatelliteData() {
|
|||||||
satellitePoints.geometry.setDrawRange(0, 0);
|
satellitePoints.geometry.setDrawRange(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (satelliteBackdropPoints) {
|
||||||
|
const backdropPositionAttr =
|
||||||
|
satelliteBackdropPoints.geometry.attributes.position;
|
||||||
|
if (backdropPositionAttr?.array) {
|
||||||
|
backdropPositionAttr.array.fill(0);
|
||||||
|
backdropPositionAttr.needsUpdate = true;
|
||||||
|
}
|
||||||
|
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (satelliteTrails) {
|
if (satelliteTrails) {
|
||||||
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
|
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
|
||||||
const trailColorAttr = satelliteTrails.geometry.attributes.color;
|
const trailColorAttr = satelliteTrails.geometry.attributes.color;
|
||||||
@@ -1097,6 +1344,11 @@ export function clearSatelliteData() {
|
|||||||
export function resetSatelliteState() {
|
export function resetSatelliteState() {
|
||||||
clearSatelliteData();
|
clearSatelliteData();
|
||||||
|
|
||||||
|
if (satelliteBackdropPoints) {
|
||||||
|
disposeObject3D(satelliteBackdropPoints);
|
||||||
|
satelliteBackdropPoints = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (satellitePoints) {
|
if (satellitePoints) {
|
||||||
disposeObject3D(satellitePoints);
|
disposeObject3D(satellitePoints);
|
||||||
satellitePoints = null;
|
satellitePoints = null;
|
||||||
@@ -1109,6 +1361,7 @@ export function resetSatelliteState() {
|
|||||||
|
|
||||||
satellitePositions = [];
|
satellitePositions = [];
|
||||||
satelliteCapacity = 0;
|
satelliteCapacity = 0;
|
||||||
|
satelliteSatrecCache = new Map();
|
||||||
showSatellites = false;
|
showSatellites = false;
|
||||||
showTrails = true;
|
showTrails = true;
|
||||||
}
|
}
|
||||||
|
|||||||
282
frontend/public/earth/js/search.js
Normal file
282
frontend/public/earth/js/search.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ let terrainLoadPromise = null;
|
|||||||
let terrainReady = false;
|
let terrainReady = false;
|
||||||
let terrainFailed = false;
|
let terrainFailed = false;
|
||||||
let terrainTileCache = new Map();
|
let terrainTileCache = new Map();
|
||||||
|
let resolvedTileCache = new Map();
|
||||||
let terrainVertexSamples = null;
|
let terrainVertexSamples = null;
|
||||||
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||||
|
|
||||||
@@ -67,7 +68,9 @@ async function decodeTerrainTile(z, x, y) {
|
|||||||
TERRAIN_CONFIG.tileSize,
|
TERRAIN_CONFIG.tileSize,
|
||||||
TERRAIN_CONFIG.tileSize,
|
TERRAIN_CONFIG.tileSize,
|
||||||
);
|
);
|
||||||
return { data, width, height };
|
const tileData = { data, width, height };
|
||||||
|
resolvedTileCache.set(cacheKey, tileData);
|
||||||
|
return tileData;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
terrainTileCache.set(cacheKey, tilePromise);
|
terrainTileCache.set(cacheKey, tilePromise);
|
||||||
@@ -236,6 +239,7 @@ export function registerTerrainMesh(mesh) {
|
|||||||
terrainFailed = false;
|
terrainFailed = false;
|
||||||
terrainLoadPromise = null;
|
terrainLoadPromise = null;
|
||||||
terrainTileCache = new Map();
|
terrainTileCache = new Map();
|
||||||
|
resolvedTileCache = new Map();
|
||||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||||
if (terrainMesh?.material) {
|
if (terrainMesh?.material) {
|
||||||
terrainMesh.material.opacity = terrainOpacity;
|
terrainMesh.material.opacity = terrainOpacity;
|
||||||
@@ -287,9 +291,19 @@ export function clearTerrainData() {
|
|||||||
terrainFailed = false;
|
terrainFailed = false;
|
||||||
terrainVertexSamples = null;
|
terrainVertexSamples = null;
|
||||||
terrainTileCache = new Map();
|
terrainTileCache = new Map();
|
||||||
|
resolvedTileCache = new Map();
|
||||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
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) {
|
export function setTerrainOpacity(nextOpacity) {
|
||||||
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||||
if (terrainMesh?.material) {
|
if (terrainMesh?.material) {
|
||||||
|
|||||||
@@ -56,21 +56,22 @@ const HLS_RETRY_CONFIG = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getElements() {
|
function getElements() {
|
||||||
|
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||||
return {
|
return {
|
||||||
// Outer media shell node.
|
// Outer media shell node.
|
||||||
panel: document.getElementById("media-panel"),
|
panel: document.getElementById("media-panel"),
|
||||||
toggleBtn: document.getElementById("toggle-tv"),
|
toggleBtn: document.getElementById("toggle-tv"),
|
||||||
select: document.getElementById("tv-source-select"),
|
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
|
||||||
title: document.getElementById("tv-source-title"),
|
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
|
||||||
meta: document.getElementById("tv-source-meta"),
|
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
|
||||||
catalog: document.getElementById("tv-source-catalog"),
|
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
|
||||||
status: document.getElementById("tv-source-status"),
|
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
|
||||||
notes: document.getElementById("tv-source-notes"),
|
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
|
||||||
iframe: document.getElementById("tv-iframe"),
|
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
|
||||||
video: document.getElementById("tv-video"),
|
video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
|
||||||
empty: document.getElementById("tv-empty-state"),
|
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
|
||||||
refreshBtn: document.getElementById("tv-refresh"),
|
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
|
||||||
openBtn: document.getElementById("tv-open-external"),
|
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
|
||||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||||
@@ -351,6 +352,7 @@ function setPanelVisible(visible) {
|
|||||||
const { panel } = getElements();
|
const { panel } = getElements();
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
mediaPanel?.setVisible(visible);
|
mediaPanel?.setVisible(visible);
|
||||||
|
document.body.classList.toggle("earth-media-open", visible);
|
||||||
updateToggleButton(visible);
|
updateToggleButton(visible);
|
||||||
syncSettingsToggle(visible);
|
syncSettingsToggle(visible);
|
||||||
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
||||||
@@ -877,11 +879,12 @@ function renderSourceOptions() {
|
|||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
sources.forEach((source) => {
|
sources.forEach((source) => {
|
||||||
|
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
|
||||||
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = source.id;
|
option.value = source.id;
|
||||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
|
||||||
fragment.appendChild(option);
|
fragment.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -913,8 +916,12 @@ function renderSource(source) {
|
|||||||
const latestLabel = latestUpdatedAt
|
const latestLabel = latestUpdatedAt
|
||||||
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
|
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
|
||||||
: "尚未同步";
|
: "尚未同步";
|
||||||
const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : "";
|
const sourceOriginLabel = source?.collector_source
|
||||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`;
|
? `采集源 ${source.collector_source}`
|
||||||
|
: source
|
||||||
|
? "内置源"
|
||||||
|
: "";
|
||||||
|
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
|
||||||
}
|
}
|
||||||
if (notes) {
|
if (notes) {
|
||||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||||
@@ -1066,12 +1073,16 @@ export function initTVPanel() {
|
|||||||
showStatusMessage("已切换到态势新闻", "info");
|
showStatusMessage("已切换到态势新闻", "info");
|
||||||
});
|
});
|
||||||
|
|
||||||
select?.addEventListener("change", (event) => {
|
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
|
||||||
const target = event.currentTarget;
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
if (!(target instanceof HTMLSelectElement)) return;
|
.forEach((selectEl) => {
|
||||||
currentSourceId = target.value;
|
selectEl?.addEventListener("change", (event) => {
|
||||||
renderSource(findSourceById(currentSourceId));
|
const target = event.currentTarget;
|
||||||
});
|
if (!(target instanceof HTMLSelectElement)) return;
|
||||||
|
currentSourceId = target.value;
|
||||||
|
renderSource(findSourceById(currentSourceId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
metaToggle?.addEventListener("click", () => {
|
metaToggle?.addEventListener("click", () => {
|
||||||
clearTimeout(metaAutoCollapseTimer);
|
clearTimeout(metaAutoCollapseTimer);
|
||||||
@@ -1079,9 +1090,13 @@ export function initTVPanel() {
|
|||||||
setMetaCollapsed(isNowCollapsed);
|
setMetaCollapsed(isNowCollapsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshBtn?.addEventListener("click", () => {
|
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
|
||||||
refreshTVPanel();
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
});
|
.forEach((refreshEl) => {
|
||||||
|
refreshEl?.addEventListener("click", () => {
|
||||||
|
refreshTVPanel();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
liveTabBtn?.addEventListener("click", () => {
|
liveTabBtn?.addEventListener("click", () => {
|
||||||
setActiveTab("live");
|
setActiveTab("live");
|
||||||
@@ -1090,24 +1105,32 @@ export function initTVPanel() {
|
|||||||
setActiveTab("news");
|
setActiveTab("news");
|
||||||
});
|
});
|
||||||
|
|
||||||
iframe?.addEventListener("load", () => {
|
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
|
||||||
if (iframe.hidden) return;
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
clearSourceFailed(currentSourceId);
|
.forEach((iframeEl) => {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
iframeEl?.addEventListener("load", () => {
|
||||||
});
|
if (iframeEl.hidden) return;
|
||||||
|
clearSourceFailed(currentSourceId);
|
||||||
|
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
video?.addEventListener("loadedmetadata", () => {
|
[video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
|
||||||
if (video.hidden) return;
|
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||||
clearSourceFailed(currentSourceId);
|
.forEach((videoEl) => {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
videoEl?.addEventListener("loadedmetadata", () => {
|
||||||
});
|
if (videoEl.hidden) return;
|
||||||
|
clearSourceFailed(currentSourceId);
|
||||||
|
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||||
|
});
|
||||||
|
|
||||||
video?.addEventListener("error", () => {
|
videoEl?.addEventListener("error", () => {
|
||||||
const currentSource = getCurrentSource();
|
const currentSource = getCurrentSource();
|
||||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
setupResizeHandle();
|
setupResizeHandle();
|
||||||
syncPanelActiveTab("live");
|
syncPanelActiveTab("live");
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ function getElement(id) {
|
|||||||
return document.getElementById(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") {
|
function setElementDisplay(element, visible, displayValue = "block") {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.style.display = visible ? displayValue : "none";
|
element.style.display = visible ? displayValue : "none";
|
||||||
@@ -72,6 +86,10 @@ function buildStatusContent(statusEl, message, type) {
|
|||||||
statusEl.appendChild(text);
|
statusEl.appendChild(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPersistentErrorContent(errorEl, message) {
|
||||||
|
buildStatusContent(errorEl, message, "error");
|
||||||
|
}
|
||||||
|
|
||||||
function hideStatusElement(statusEl, onHidden) {
|
function hideStatusElement(statusEl, onHidden) {
|
||||||
statusEl.classList.remove("visible");
|
statusEl.classList.remove("visible");
|
||||||
statusHideTimeoutId = setTimeout(() => {
|
statusHideTimeoutId = setTimeout(() => {
|
||||||
@@ -151,7 +169,15 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
|||||||
const slider = getElement("zoom-slider");
|
const slider = getElement("zoom-slider");
|
||||||
const cameraDistanceEl = getElement("camera-distance");
|
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 (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
|
||||||
if (slider) slider.value = zoomLevel;
|
if (slider) slider.value = zoomLevel;
|
||||||
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
|
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
|
||||||
@@ -159,27 +185,14 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
|||||||
|
|
||||||
// Update earth stats
|
// Update earth stats
|
||||||
export function updateEarthStats(stats) {
|
export function updateEarthStats(stats) {
|
||||||
const cableCountEl = getElement("cable-count");
|
setEarthStatValue("cable-count", String(stats.cableCount || 0));
|
||||||
const landingPointCountEl = getElement("landing-point-count");
|
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
|
||||||
const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
|
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
|
||||||
const bgpCollectorCountEl = getElement("bgp-collector-count");
|
setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
|
||||||
const bgpStatusSummaryEl = getElement("bgp-status-summary");
|
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||||
const terrainStatusEl = getElement("terrain-status");
|
setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
|
||||||
const textureQualityEl = getElement("texture-quality");
|
setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
|
||||||
|
setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
|
||||||
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 卫星图";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide loading via status message
|
// Show/hide loading via status message
|
||||||
@@ -259,16 +272,21 @@ export function hideTooltip() {
|
|||||||
export function showError(message) {
|
export function showError(message) {
|
||||||
const errorEl = getElement("error-message");
|
const errorEl = getElement("error-message");
|
||||||
if (!errorEl) return;
|
if (!errorEl) return;
|
||||||
errorEl.textContent = message;
|
buildPersistentErrorContent(errorEl, message);
|
||||||
setElementDisplay(errorEl, true);
|
errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
|
||||||
|
setElementDisplay(errorEl, true, "inline-flex");
|
||||||
|
errorEl.offsetHeight;
|
||||||
|
errorEl.classList.add("visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide error message
|
// Hide error message
|
||||||
export function hideError() {
|
export function hideError() {
|
||||||
const errorEl = getElement("error-message");
|
const errorEl = getElement("error-message");
|
||||||
if (errorEl) {
|
if (errorEl) {
|
||||||
|
errorEl.classList.remove("visible");
|
||||||
setElementDisplay(errorEl, false);
|
setElementDisplay(errorEl, false);
|
||||||
errorEl.textContent = "";
|
errorEl.className = "earth-error-message";
|
||||||
|
errorEl.innerHTML = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,13 +132,10 @@ function Scrollbar({
|
|||||||
resizeObserver.observe(viewport)
|
resizeObserver.observe(viewport)
|
||||||
resizeObserver.observe(trackX)
|
resizeObserver.observe(trackX)
|
||||||
resizeObserver.observe(trackY)
|
resizeObserver.observe(trackY)
|
||||||
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
|
|
||||||
|
|
||||||
mutationObserver.observe(viewport, {
|
mutationObserver.observe(viewport, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
attributes: true,
|
|
||||||
characterData: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
viewport.addEventListener('scroll', scheduleUpdate, { passive: true })
|
viewport.addEventListener('scroll', scheduleUpdate, { passive: true })
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ function ScrollbarOverlay({
|
|||||||
scheduleUpdate()
|
scheduleUpdate()
|
||||||
})
|
})
|
||||||
resizeObserver.observe(target)
|
resizeObserver.observe(target)
|
||||||
Array.from(target.children).forEach((child) => resizeObserver?.observe(child))
|
|
||||||
scheduleUpdate()
|
scheduleUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +168,6 @@ function ScrollbarOverlay({
|
|||||||
mutationObserver.observe(container, {
|
mutationObserver.observe(container, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
attributes: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('resize', scheduleUpdate)
|
window.addEventListener('resize', scheduleUpdate)
|
||||||
|
|||||||
@@ -1562,6 +1562,14 @@ body {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-source-drawer-collapse {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-source-drawer-collapse:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useWebSocket } from '../../hooks/useWebSocket'
|
|||||||
|
|
||||||
interface BuiltInDataSource {
|
interface BuiltInDataSource {
|
||||||
id: number
|
id: number
|
||||||
|
source: string
|
||||||
name: string
|
name: string
|
||||||
module: string
|
module: string
|
||||||
priority: string
|
priority: string
|
||||||
@@ -180,6 +181,19 @@ interface CustomDataSource {
|
|||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditableDataSourceConfig {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
source_type: string
|
||||||
|
endpoint: string
|
||||||
|
auth_type: string
|
||||||
|
auth_config: Record<string, any>
|
||||||
|
headers: Record<string, string>
|
||||||
|
config: Record<string, any>
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ViewDataSource {
|
interface ViewDataSource {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -205,6 +219,7 @@ function DataSources() {
|
|||||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||||
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
|
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
|
||||||
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
|
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
|
||||||
|
const [builtinEditingSource, setBuiltinEditingSource] = useState<BuiltInDataSource | null>(null)
|
||||||
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
|
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
|
||||||
const [recordCount, setRecordCount] = useState<number>(0)
|
const [recordCount, setRecordCount] = useState<number>(0)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
@@ -219,6 +234,81 @@ function DataSources() {
|
|||||||
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
|
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const headersMapToList = useCallback((headers?: Record<string, string> | null) => {
|
||||||
|
return Object.entries(headers || {})
|
||||||
|
.filter(([key, value]) => key && value !== undefined && value !== null && String(value).trim() !== '')
|
||||||
|
.map(([key, value]) => ({ key, value }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const headersListToMap = useCallback((headers?: Array<{ key?: string; value?: string }> | Record<string, string>) => {
|
||||||
|
if (!headers) return {}
|
||||||
|
if (!Array.isArray(headers)) return headers
|
||||||
|
|
||||||
|
return headers.reduce<Record<string, string>>((acc, item) => {
|
||||||
|
const key = item?.key?.trim()
|
||||||
|
const value = item?.value?.trim()
|
||||||
|
if (!key || value === undefined) return acc
|
||||||
|
acc[key] = value
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyConfigToForm = useCallback((config?: Partial<EditableDataSourceConfig> | null) => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: config?.name || '',
|
||||||
|
description: config?.description || '',
|
||||||
|
source_type: config?.source_type || 'http',
|
||||||
|
endpoint: config?.endpoint || '',
|
||||||
|
auth_type: config?.auth_type || 'none',
|
||||||
|
auth_config: config?.auth_config || {},
|
||||||
|
headers: headersMapToList(config?.headers || {}),
|
||||||
|
config: config?.config || { timeout: 30, retry: 3 },
|
||||||
|
})
|
||||||
|
}, [form, headersMapToList])
|
||||||
|
|
||||||
|
const loadConfigDetail = useCallback(async (configId: number) => {
|
||||||
|
const res = await axios.get<EditableDataSourceConfig>(`/api/v1/datasources/configs/${configId}`)
|
||||||
|
return res.data
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createDefaultConfigDraft = useCallback((overrides?: Partial<EditableDataSourceConfig>) => ({
|
||||||
|
source_type: 'http',
|
||||||
|
auth_type: 'none',
|
||||||
|
headers: {},
|
||||||
|
config: { timeout: 30, retry: 3 },
|
||||||
|
...overrides,
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const getBuiltinOverrideDescription = useCallback(
|
||||||
|
(source?: Pick<BuiltInDataSource, 'name'> | null) =>
|
||||||
|
source ? `Built-in datasource override for ${source.name}` : undefined,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const createFormPayload = useCallback((values: any) => ({
|
||||||
|
...values,
|
||||||
|
name: builtinEditingSource ? builtinEditingSource.source : values.name,
|
||||||
|
description:
|
||||||
|
values.description ||
|
||||||
|
getBuiltinOverrideDescription(builtinEditingSource),
|
||||||
|
source_type: builtinEditingSource ? 'http' : values.source_type,
|
||||||
|
headers: headersListToMap(values.headers),
|
||||||
|
}), [builtinEditingSource, getBuiltinOverrideDescription, headersListToMap])
|
||||||
|
|
||||||
|
const closeDrawerAfterLoadError = useCallback((
|
||||||
|
errorMessage: string,
|
||||||
|
options?: { clearBuiltin?: boolean; clearEditingConfig?: boolean },
|
||||||
|
) => {
|
||||||
|
messageApi.error(errorMessage)
|
||||||
|
setDrawerVisible(false)
|
||||||
|
if (options?.clearBuiltin) {
|
||||||
|
setBuiltinEditingSource(null)
|
||||||
|
}
|
||||||
|
if (options?.clearEditingConfig) {
|
||||||
|
setEditingConfig(null)
|
||||||
|
}
|
||||||
|
}, [messageApi])
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -711,9 +801,11 @@ function DataSources() {
|
|||||||
|
|
||||||
const handleViewSource = async (source: BuiltInDataSource) => {
|
const handleViewSource = async (source: BuiltInDataSource) => {
|
||||||
try {
|
try {
|
||||||
const [res, statsRes] = await Promise.all([
|
const existingOverride = customSources.find((item) => item.name === source.source)
|
||||||
|
const [res, statsRes, overrideDetail] = await Promise.all([
|
||||||
axios.get(`/api/v1/datasources/${source.id}`),
|
axios.get(`/api/v1/datasources/${source.id}`),
|
||||||
axios.get(`/api/v1/datasources/${source.id}/stats`)
|
axios.get(`/api/v1/datasources/${source.id}/stats`),
|
||||||
|
existingOverride ? loadConfigDetail(existingOverride.id) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
const data = res.data
|
const data = res.data
|
||||||
setViewingSource({
|
setViewingSource({
|
||||||
@@ -721,10 +813,10 @@ function DataSources() {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
description: null,
|
description: null,
|
||||||
source_type: data.collector_class,
|
source_type: data.collector_class,
|
||||||
endpoint: data.endpoint || '',
|
endpoint: overrideDetail?.endpoint || data.endpoint || '',
|
||||||
auth_type: 'none',
|
auth_type: overrideDetail?.auth_type || 'none',
|
||||||
headers: {},
|
headers: overrideDetail?.headers || {},
|
||||||
config: {},
|
config: overrideDetail?.config || {},
|
||||||
collector_class: data.collector_class,
|
collector_class: data.collector_class,
|
||||||
module: data.module,
|
module: data.module,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
@@ -753,7 +845,8 @@ function DataSources() {
|
|||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
const res = await axios.post('/api/v1/datasources/configs/test', values)
|
const payload = createFormPayload(values)
|
||||||
|
const res = await axios.post('/api/v1/datasources/configs/test', payload)
|
||||||
setTestResult(res.data)
|
setTestResult(res.data)
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
messageApi.success('连接测试成功')
|
messageApi.success('连接测试成功')
|
||||||
@@ -771,16 +864,18 @@ function DataSources() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
|
const payload = createFormPayload(values)
|
||||||
if (editingConfig) {
|
if (editingConfig) {
|
||||||
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values)
|
await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, payload)
|
||||||
messageApi.success('配置已更新')
|
messageApi.success('配置已更新')
|
||||||
} else {
|
} else {
|
||||||
await axios.post('/api/v1/datasources/configs', values)
|
await axios.post('/api/v1/datasources/configs', payload)
|
||||||
messageApi.success('配置已创建')
|
messageApi.success('配置已创建')
|
||||||
}
|
}
|
||||||
setDrawerVisible(false)
|
setDrawerVisible(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setEditingConfig(null)
|
setEditingConfig(null)
|
||||||
|
setBuiltinEditingSource(null)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -800,6 +895,23 @@ function DataSources() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetBuiltinOverride = async () => {
|
||||||
|
if (!builtinEditingSource || !editingConfig) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/v1/datasources/configs/${editingConfig.id}`)
|
||||||
|
messageApi.success(`已恢复 ${builtinEditingSource.name} 的默认配置`)
|
||||||
|
setDrawerVisible(false)
|
||||||
|
form.resetFields()
|
||||||
|
setEditingConfig(null)
|
||||||
|
setBuiltinEditingSource(null)
|
||||||
|
setTestResult(null)
|
||||||
|
fetchData()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { detail?: string } } }
|
||||||
|
messageApi.error(err.response?.data?.detail || '恢复默认失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleCustom = async (id: number, current: boolean) => {
|
const handleToggleCustom = async (id: number, current: boolean) => {
|
||||||
try {
|
try {
|
||||||
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
|
await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current })
|
||||||
@@ -811,24 +923,53 @@ function DataSources() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openDrawer = (config?: CustomDataSource) => {
|
const openDrawer = async (config?: CustomDataSource) => {
|
||||||
|
setBuiltinEditingSource(null)
|
||||||
setEditingConfig(config || null)
|
setEditingConfig(config || null)
|
||||||
if (config) {
|
|
||||||
form.setFieldsValue({
|
|
||||||
...config,
|
|
||||||
auth_config: {},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
form.resetFields()
|
|
||||||
form.setFieldsValue({
|
|
||||||
source_type: 'http',
|
|
||||||
auth_type: 'none',
|
|
||||||
config: { timeout: 30, retry: 3 },
|
|
||||||
headers: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setDrawerVisible(true)
|
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
|
setDrawerVisible(true)
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
const detail = await loadConfigDetail(config.id)
|
||||||
|
applyConfigToForm(detail)
|
||||||
|
} catch {
|
||||||
|
closeDrawerAfterLoadError('获取配置详情失败', { clearEditingConfig: true })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.resetFields()
|
||||||
|
applyConfigToForm(createDefaultConfigDraft())
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBuiltinConfigDrawer = async (source: BuiltInDataSource) => {
|
||||||
|
setBuiltinEditingSource(source)
|
||||||
|
setTestResult(null)
|
||||||
|
setDrawerVisible(true)
|
||||||
|
|
||||||
|
const existingOverride = customSources.find((item) => item.name === source.source)
|
||||||
|
setEditingConfig(existingOverride || null)
|
||||||
|
|
||||||
|
if (existingOverride) {
|
||||||
|
try {
|
||||||
|
const detail = await loadConfigDetail(existingOverride.id)
|
||||||
|
applyConfigToForm(detail)
|
||||||
|
} catch {
|
||||||
|
closeDrawerAfterLoadError('获取内置数据源配置失败', {
|
||||||
|
clearBuiltin: true,
|
||||||
|
clearEditingConfig: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.resetFields()
|
||||||
|
applyConfigToForm(createDefaultConfigDraft({
|
||||||
|
name: source.source,
|
||||||
|
description: getBuiltinOverrideDescription(source),
|
||||||
|
endpoint: source.endpoint || '',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyLink = async (value: string, successText: string) => {
|
const handleCopyLink = async (value: string, successText: string) => {
|
||||||
@@ -945,12 +1086,18 @@ function DataSources() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
fixed: 'right' as const,
|
fixed: 'right' as const,
|
||||||
width: builtinActionsCollapsed ? 40 : 164,
|
width: builtinActionsCollapsed ? 40 : 228,
|
||||||
onCell: () => actionCellProps,
|
onCell: () => actionCellProps,
|
||||||
render: (_: unknown, record: BuiltInDataSource) => (
|
render: (_: unknown, record: BuiltInDataSource) => (
|
||||||
<TableActions
|
<TableActions
|
||||||
collapsed={builtinActionsCollapsed}
|
collapsed={builtinActionsCollapsed}
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
onClick: () => { void openBuiltinConfigDrawer(record) },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'trigger',
|
key: 'trigger',
|
||||||
label: '触发',
|
label: '触发',
|
||||||
@@ -967,6 +1114,14 @@ function DataSources() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => { void openBuiltinConfigDrawer(record) }}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1035,7 +1190,7 @@ function DataSources() {
|
|||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: '编辑',
|
label: '编辑',
|
||||||
icon: <EditOutlined />,
|
icon: <EditOutlined />,
|
||||||
onClick: () => openDrawer(record),
|
onClick: () => { void openDrawer(record) },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'toggle',
|
key: 'toggle',
|
||||||
@@ -1059,7 +1214,7 @@ function DataSources() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}>编辑</Button>
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { void openDrawer(record) }}>编辑</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1171,7 +1326,7 @@ function DataSources() {
|
|||||||
children: (
|
children: (
|
||||||
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
|
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
|
||||||
<div className="data-source-custom-toolbar">
|
<div className="data-source-custom-toolbar">
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => { void openDrawer() }}>
|
||||||
添加数据源
|
添加数据源
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1215,24 +1370,40 @@ function DataSources() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={editingConfig ? '编辑数据源' : '添加数据源'}
|
title={builtinEditingSource ? `编辑内置数据源配置 · ${builtinEditingSource.name}` : editingConfig ? '编辑数据源' : '添加数据源'}
|
||||||
width={600}
|
width={600}
|
||||||
open={drawerVisible}
|
open={drawerVisible}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDrawerVisible(false)
|
setDrawerVisible(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setEditingConfig(null)
|
setEditingConfig(null)
|
||||||
|
setBuiltinEditingSource(null)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Button
|
<Space>
|
||||||
icon={<ExperimentOutlined />}
|
{builtinEditingSource && editingConfig ? (
|
||||||
loading={testing}
|
<Popconfirm
|
||||||
onClick={handleTest}
|
title="恢复内置默认配置?"
|
||||||
>
|
description="这会删除当前 override,并重新使用代码内置默认配置。"
|
||||||
测试连接
|
okText="恢复默认"
|
||||||
</Button>
|
cancelText="取消"
|
||||||
|
onConfirm={handleResetBuiltinOverride}
|
||||||
|
>
|
||||||
|
<Button danger icon={<ClearOutlined />}>
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
icon={<ExperimentOutlined />}
|
||||||
|
loading={testing}
|
||||||
|
onClick={handleTest}
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||||
<Button type="primary" onClick={handleSave}>
|
<Button type="primary" onClick={handleSave}>
|
||||||
@@ -1243,29 +1414,46 @@ function DataSources() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item
|
{builtinEditingSource ? (
|
||||||
name="name"
|
<Card size="small" bordered={false} style={{ marginBottom: 16, background: '#fafafa' }}>
|
||||||
label="名称"
|
<Row gutter={[12, 12]}>
|
||||||
rules={[{ required: true, message: '请输入名称' }]}
|
<Col span={12}>
|
||||||
>
|
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>内置数据源</div>
|
||||||
<Input placeholder="My API Data Source" />
|
<Input value={builtinEditingSource.name} disabled />
|
||||||
</Form.Item>
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>Collector Key</div>
|
||||||
|
<Input value={builtinEditingSource.source} disabled />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="名称"
|
||||||
|
rules={[{ required: true, message: '请输入名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="My API Data Source" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item name="description" label="描述">
|
<Form.Item name="description" label="描述">
|
||||||
<Input.TextArea rows={2} placeholder="数据源描述" />
|
<Input.TextArea rows={2} placeholder="数据源描述" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
{builtinEditingSource ? null : (
|
||||||
name="source_type"
|
<Form.Item
|
||||||
label="数据源类型"
|
name="source_type"
|
||||||
rules={[{ required: true, message: '请选择类型' }]}
|
label="数据源类型"
|
||||||
>
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
<Select>
|
>
|
||||||
<Select.Option value="http">HTTP API</Select.Option>
|
<Select>
|
||||||
<Select.Option value="api">REST API</Select.Option>
|
<Select.Option value="http">HTTP API</Select.Option>
|
||||||
<Select.Option value="database">数据库</Select.Option>
|
<Select.Option value="api">REST API</Select.Option>
|
||||||
</Select>
|
<Select.Option value="database">数据库</Select.Option>
|
||||||
</Form.Item>
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="endpoint"
|
name="endpoint"
|
||||||
@@ -1276,6 +1464,7 @@ function DataSources() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
|
className="data-source-drawer-collapse"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
@@ -1311,6 +1500,12 @@ function DataSources() {
|
|||||||
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
|
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
|
||||||
<Input placeholder="X-API-Key" />
|
<Input placeholder="X-API-Key" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name={['auth_config', 'in']} label="传递位置" initialValue="header">
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="header">Header</Select.Option>
|
||||||
|
<Select.Option value="query">Query Param</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name={['auth_config', 'api_key']} label="API Key">
|
<Form.Item name={['auth_config', 'api_key']} label="API Key">
|
||||||
<Input.Password placeholder="API Key" />
|
<Input.Password placeholder="API Key" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -1345,6 +1540,7 @@ function DataSources() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
|
className="data-source-drawer-collapse"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'headers',
|
key: 'headers',
|
||||||
@@ -1376,6 +1572,7 @@ function DataSources() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
|
className="data-source-drawer-collapse"
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'config',
|
key: 'config',
|
||||||
|
|||||||
@@ -21,5 +21,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.tooling.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
123
planet.sh
123
planet.sh
@@ -59,6 +59,7 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
|
|||||||
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
||||||
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
|
||||||
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
|
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
|
||||||
|
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
|
||||||
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
|
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
|
||||||
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
||||||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
||||||
@@ -371,6 +372,41 @@ log_success() {
|
|||||||
log_line "done" "$GREEN" "$1"
|
log_line "done" "$GREEN" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_recommended_lan_ipv4() {
|
||||||
|
local candidate
|
||||||
|
|
||||||
|
while read -r candidate; do
|
||||||
|
[ -z "$candidate" ] && continue
|
||||||
|
case "$candidate" in
|
||||||
|
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
|
||||||
|
printf "%s" "$candidate"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<EOF
|
||||||
|
$(hostname -I 2>/dev/null | tr ' ' '\n')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log_lan_access_notes() {
|
||||||
|
local frontend_port="$1"
|
||||||
|
local backend_port="$2"
|
||||||
|
local recommended_lan_ip=""
|
||||||
|
|
||||||
|
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
|
||||||
|
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
|
||||||
|
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
|
||||||
|
else
|
||||||
|
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
|
||||||
|
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
print_splash() {
|
print_splash() {
|
||||||
clear_wait_spinner
|
clear_wait_spinner
|
||||||
printf "%b" "$CYAN"
|
printf "%b" "$CYAN"
|
||||||
@@ -484,12 +520,13 @@ print_database_failure_diagnostics() {
|
|||||||
compute_ai_provider_build_fingerprint() {
|
compute_ai_provider_build_fingerprint() {
|
||||||
(
|
(
|
||||||
cd "$SCRIPT_DIR" || exit 1
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
tar -cf - \
|
{
|
||||||
aiprovider \
|
tar -cf - \
|
||||||
pyproject.toml \
|
aiprovider \
|
||||||
uv.lock \
|
docker-compose.yml \
|
||||||
docker-compose.yml \
|
docker-compose.simple.yml 2>/dev/null
|
||||||
docker-compose.simple.yml 2>/dev/null
|
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py"
|
||||||
|
}
|
||||||
) | sha256sum | awk '{print $1}'
|
) | sha256sum | awk '{print $1}'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,8 +803,8 @@ ensure_frontend_deps() {
|
|||||||
|
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
|
|
||||||
set_wait_detail "检查 vite 是否已安装"
|
set_wait_detail "检查 Vite Bun 入口是否已安装"
|
||||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||||
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
||||||
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
||||||
if ! run_with_retry \
|
if ! run_with_retry \
|
||||||
@@ -780,9 +817,9 @@ ensure_frontend_deps() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||||
close_wait_session_context "$owns_wait_session"
|
close_wait_session_context "$owns_wait_session"
|
||||||
log_error "前端依赖安装失败,未找到 vite"
|
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1054,6 +1091,14 @@ start_ai_provider_service() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ai_provider_service_healthy() {
|
||||||
|
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||||
|
|
||||||
|
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
|
||||||
|
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
|
||||||
|
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
ensure_database_services_healthy() {
|
ensure_database_services_healthy() {
|
||||||
local retry=1
|
local retry=1
|
||||||
|
|
||||||
@@ -1130,7 +1175,15 @@ start_backend_service() {
|
|||||||
log_success "启动数据库已就绪"
|
log_success "启动数据库已就绪"
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
start_ai_provider_service "$ai_provider_port"
|
# Backend depends on AI Provider reachability, but a backend-only restart
|
||||||
|
# should reuse the existing healthy provider instead of rebuilding or
|
||||||
|
# restarting it.
|
||||||
|
if ai_provider_service_healthy "$ai_provider_port"; then
|
||||||
|
log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
|
||||||
|
else
|
||||||
|
log_note "AI Provider 当前不健康,先执行托底启动"
|
||||||
|
start_ai_provider_service "$ai_provider_port"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$backend_port_requested" -eq 1 ]; then
|
if [ "$backend_port_requested" -eq 1 ]; then
|
||||||
kill_port_if_requested "$backend_port" "后端"
|
kill_port_if_requested "$backend_port" "后端"
|
||||||
@@ -1308,15 +1361,15 @@ cleanup_frontend_processes() {
|
|||||||
rm -f "$FRONTEND_PID_FILE"
|
rm -f "$FRONTEND_PID_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true
|
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||||
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true
|
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
|
||||||
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true
|
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
|
||||||
pkill -f "bun run dev" 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start_frontend_with_retry() {
|
start_frontend_with_retry() {
|
||||||
local frontend_port="$1"
|
local frontend_port="$1"
|
||||||
local frontend_port_requested="${2:-0}"
|
local frontend_port_requested="${2:-0}"
|
||||||
|
local frontend_lan_enabled="${3:-0}"
|
||||||
local retry=1
|
local retry=1
|
||||||
|
|
||||||
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
|
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
|
||||||
@@ -1326,7 +1379,13 @@ start_frontend_with_retry() {
|
|||||||
fi
|
fi
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
: > /tmp/planet_frontend.log
|
: > /tmp/planet_frontend.log
|
||||||
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 &
|
local -a frontend_args
|
||||||
|
frontend_args=("$FRONTEND_VITE_ENTRY")
|
||||||
|
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||||||
|
frontend_args+=(--host 0.0.0.0)
|
||||||
|
fi
|
||||||
|
frontend_args+=(--port "$frontend_port" --strictPort)
|
||||||
|
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
||||||
|
|
||||||
@@ -1352,6 +1411,7 @@ start_frontend_with_retry() {
|
|||||||
start_frontend_service() {
|
start_frontend_service() {
|
||||||
local frontend_port="$1"
|
local frontend_port="$1"
|
||||||
local frontend_port_requested="$2"
|
local frontend_port_requested="$2"
|
||||||
|
local frontend_lan_enabled="${3:-0}"
|
||||||
|
|
||||||
if [ "$frontend_port_requested" -eq 1 ]; then
|
if [ "$frontend_port_requested" -eq 1 ]; then
|
||||||
kill_port_if_requested "$frontend_port" "前端"
|
kill_port_if_requested "$frontend_port" "前端"
|
||||||
@@ -1363,8 +1423,12 @@ start_frontend_service() {
|
|||||||
log_success "前端依赖已就绪"
|
log_success "前端依赖已就绪"
|
||||||
|
|
||||||
start_wait_session "启动前端服务"
|
start_wait_session "启动前端服务"
|
||||||
set_wait_detail "启动 Vite 开发服务器"
|
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||||||
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
|
set_wait_detail "启动 Vite 开发服务器(局域网开放)"
|
||||||
|
else
|
||||||
|
set_wait_detail "启动 Vite 开发服务器"
|
||||||
|
fi
|
||||||
|
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
|
||||||
stop_wait_session
|
stop_wait_session
|
||||||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||||
tail -10 /tmp/planet_frontend.log
|
tail -10 /tmp/planet_frontend.log
|
||||||
@@ -1383,6 +1447,7 @@ parse_service_args() {
|
|||||||
FRONTEND_PORT_REQUESTED=0
|
FRONTEND_PORT_REQUESTED=0
|
||||||
AI_PROVIDER_REQUESTED=0
|
AI_PROVIDER_REQUESTED=0
|
||||||
DATABASE_REQUESTED=0
|
DATABASE_REQUESTED=0
|
||||||
|
FRONTEND_LAN_ENABLED=0
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -1417,6 +1482,10 @@ parse_service_args() {
|
|||||||
DATABASE_REQUESTED=1
|
DATABASE_REQUESTED=1
|
||||||
shift 1
|
shift 1
|
||||||
;;
|
;;
|
||||||
|
--allow-lan)
|
||||||
|
FRONTEND_LAN_ENABLED=1
|
||||||
|
shift 1
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "未知参数: $1"
|
log_error "未知参数: $1"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1463,7 +1532,7 @@ stop_ai_provider_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_frontend_service() {
|
stop_frontend_service() {
|
||||||
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
||||||
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
|
||||||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||||||
cleanup_frontend_processes "$FRONTEND_PORT"
|
cleanup_frontend_processes "$FRONTEND_PORT"
|
||||||
@@ -1591,13 +1660,16 @@ start() {
|
|||||||
print_splash
|
print_splash
|
||||||
|
|
||||||
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
||||||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
|
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
|
||||||
|
|
||||||
log_success "启动完成"
|
log_success "启动完成"
|
||||||
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
||||||
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
||||||
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
|
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
|
||||||
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
|
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
|
||||||
|
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||||
|
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -1617,7 +1689,7 @@ restart() {
|
|||||||
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
|
||||||
stop
|
stop
|
||||||
sleep 1
|
sleep 1
|
||||||
start
|
start "$@"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1642,7 +1714,7 @@ restart() {
|
|||||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
stop_frontend_service
|
stop_frontend_service
|
||||||
sleep 1
|
sleep 1
|
||||||
start_frontend_service "$FRONTEND_PORT" 1
|
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1658,6 +1730,9 @@ restart() {
|
|||||||
fi
|
fi
|
||||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||||
|
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
|
||||||
|
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,9 +1813,9 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
||||||
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
|
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
|
||||||
log_note "stop 停止服务"
|
log_note "stop 停止服务"
|
||||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
|
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
|
||||||
log_note "createuser 交互创建用户"
|
log_note "createuser 交互创建用户"
|
||||||
log_note "health 检查健康状态"
|
log_note "health 检查健康状态"
|
||||||
log_note "log 查看日志"
|
log_note "log 查看日志"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.30.0"
|
version = "0.36.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
186
scripts/compute_aiprovider_dependency_fingerprint.py
Normal file
186
scripts/compute_aiprovider_dependency_fingerprint.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT_TO_DISTRIBUTION = {
|
||||||
|
"pydantic_settings": "pydantic-settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Some runtime dependencies are referenced indirectly:
|
||||||
|
# - uvicorn is launched by the container command rather than imported.
|
||||||
|
# - python-dotenv is used by pydantic-settings when loading the local .env file.
|
||||||
|
EXTRA_RUNTIME_DISTRIBUTIONS = {
|
||||||
|
"python-dotenv",
|
||||||
|
"uvicorn",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(value: str) -> str:
|
||||||
|
return value.strip().lower().replace("_", "-").replace(".", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_table(text: str, table_name: str) -> str:
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"(?ms)^\[{re.escape(table_name)}\]\s*$\n(.*?)(?=^\[[^\]]+\]\s*$|\Z)"
|
||||||
|
)
|
||||||
|
match = pattern.search(text)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_string_value(text: str, key: str) -> str | None:
|
||||||
|
match = re.search(rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', text)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_string_list_value(text: str, key: str) -> list[str]:
|
||||||
|
match = re.search(rf"(?ms)^{re.escape(key)}\s*=\s*\[(.*?)\]\s*$", text)
|
||||||
|
if not match:
|
||||||
|
return []
|
||||||
|
return re.findall(r'"([^"]+)"', match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def project_dependency_map(pyproject_text: str) -> tuple[str | None, dict[str, str]]:
|
||||||
|
project_block = extract_table(pyproject_text, "project")
|
||||||
|
requires_python = extract_string_value(project_block, "requires-python")
|
||||||
|
dependencies = extract_string_list_value(project_block, "dependencies")
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for spec in dependencies:
|
||||||
|
name = spec.split(";", 1)[0].strip()
|
||||||
|
for marker in ("<", ">", "=", "!", "~"):
|
||||||
|
if marker in name:
|
||||||
|
name = name.split(marker, 1)[0].strip()
|
||||||
|
if "[" in name:
|
||||||
|
name = name.split("[", 1)[0].strip()
|
||||||
|
mapping[normalize_name(name)] = spec.strip()
|
||||||
|
return requires_python, mapping
|
||||||
|
|
||||||
|
|
||||||
|
def collect_runtime_imports(aiprovider_dir: Path) -> set[str]:
|
||||||
|
imports: set[str] = set()
|
||||||
|
stdlib = set(sys.stdlib_module_names)
|
||||||
|
|
||||||
|
for path in sorted(aiprovider_dir.rglob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
imports.add(alias.name.split(".", 1)[0])
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
imports.add(node.module.split(".", 1)[0])
|
||||||
|
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for name in imports
|
||||||
|
if name not in stdlib and name != "aiprovider"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_relevant_root_dependencies(
|
||||||
|
pyproject_deps: dict[str, str],
|
||||||
|
imported_modules: set[str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
relevant_names: set[str] = set(EXTRA_RUNTIME_DISTRIBUTIONS)
|
||||||
|
|
||||||
|
for module_name in imported_modules:
|
||||||
|
mapped_name = IMPORT_TO_DISTRIBUTION.get(module_name, module_name)
|
||||||
|
normalized_name = normalize_name(mapped_name)
|
||||||
|
if normalized_name in pyproject_deps:
|
||||||
|
relevant_names.add(normalized_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: pyproject_deps[name]
|
||||||
|
for name in sorted(relevant_names)
|
||||||
|
if name in pyproject_deps
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_metadata(lock_text: str) -> dict[str, str | None]:
|
||||||
|
return {
|
||||||
|
"version": extract_string_value(lock_text, "version") or re.search(r"(?m)^version\s*=\s*(\d+)\s*$", lock_text).group(1),
|
||||||
|
"revision": extract_string_value(lock_text, "revision") or re.search(r"(?m)^revision\s*=\s*(\d+)\s*$", lock_text).group(1),
|
||||||
|
"requires_python": extract_string_value(lock_text, "requires-python"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_package_map(lock_text: str) -> dict[str, dict[str, Any]]:
|
||||||
|
mapping: dict[str, dict[str, Any]] = {}
|
||||||
|
sections = re.split(r"(?m)^\[\[package\]\]\s*$\n?", lock_text)
|
||||||
|
for section in sections[1:]:
|
||||||
|
raw_section = section.strip()
|
||||||
|
name = extract_string_value(raw_section, "name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
version = extract_string_value(raw_section, "version")
|
||||||
|
dependency_names = [
|
||||||
|
normalize_name(dep_name)
|
||||||
|
for dep_name in re.findall(r'\{\s*name\s*=\s*"([^"]+)"', raw_section)
|
||||||
|
]
|
||||||
|
mapping[normalize_name(name)] = {
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"dependencies": dependency_names,
|
||||||
|
"raw": raw_section,
|
||||||
|
}
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def dependency_closure(
|
||||||
|
lock_packages: dict[str, dict[str, Any]],
|
||||||
|
root_dependencies: dict[str, str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
pending = list(root_dependencies.keys())
|
||||||
|
visited: set[str] = set()
|
||||||
|
resolved: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
|
||||||
|
package = lock_packages.get(current)
|
||||||
|
if package is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved.append(package)
|
||||||
|
for dep_name in package.get("dependencies", []):
|
||||||
|
pending.append(dep_name)
|
||||||
|
|
||||||
|
resolved.sort(key=lambda pkg: (normalize_name(pkg["name"]), pkg.get("version", "")))
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
repo_root = Path(__file__).resolve().parents[1]
|
||||||
|
pyproject_path = repo_root / "pyproject.toml"
|
||||||
|
uv_lock_path = repo_root / "uv.lock"
|
||||||
|
aiprovider_dir = repo_root / "aiprovider"
|
||||||
|
|
||||||
|
pyproject_text = pyproject_path.read_text(encoding="utf-8")
|
||||||
|
uv_lock_text = uv_lock_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
requires_python, pyproject_deps = project_dependency_map(pyproject_text)
|
||||||
|
imported_modules = collect_runtime_imports(aiprovider_dir)
|
||||||
|
relevant_roots = resolve_relevant_root_dependencies(pyproject_deps, imported_modules)
|
||||||
|
relevant_packages = dependency_closure(lock_package_map(uv_lock_text), relevant_roots)
|
||||||
|
|
||||||
|
fingerprint_payload = {
|
||||||
|
"requires_python": requires_python,
|
||||||
|
"relevant_root_dependencies": relevant_roots,
|
||||||
|
"lock": {**lock_metadata(uv_lock_text), "packages": relevant_packages},
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(fingerprint_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user