Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195a8bf71c | ||
|
|
987c378f99 | ||
|
|
67f82dc41c | ||
|
|
abe04030fb | ||
|
|
6a5f9f7ad4 | ||
|
|
439a512148 | ||
|
|
f73fa1ea6d | ||
|
|
5b623a6385 | ||
|
|
0082cf3fbd | ||
|
|
3ae4acdff8 | ||
|
|
437efc848c | ||
|
|
003a46ac30 | ||
|
|
4b0be4cb76 | ||
|
|
b7647379de | ||
|
|
0f89372d71 | ||
|
|
2b0d4cfc49 | ||
|
|
e6d0332fba | ||
|
|
fe45a99cbd | ||
|
|
ae77b06c3c | ||
|
|
b5dd4f12f8 | ||
|
|
75cb214f23 | ||
|
|
4c21973197 | ||
|
|
51ae5e6ec9 | ||
|
|
1cf1f32ddd | ||
|
|
8f3ab88743 | ||
|
|
f8b43a995b | ||
|
|
d9adaf4134 | ||
|
|
40e51d5b20 | ||
|
|
93c1c1e550 | ||
|
|
48eb13b993 | ||
|
|
11179e7e67 | ||
|
|
07e26d6d5a |
120
.claude/commands/cleanup.md
Normal file
120
.claude/commands/cleanup.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
|
||||
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
# /cleanup — 垃圾代码审查与清理
|
||||
|
||||
分析当前工作区(git diff)中的未提交代码,找出并修复常见垃圾代码,**不得改变任何运行逻辑**。
|
||||
|
||||
## 检查范围
|
||||
|
||||
若 `$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
|
||||
|
||||
## 审查清单
|
||||
|
||||
按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题):
|
||||
|
||||
### 1. 重复逻辑 (Duplicate Logic)
|
||||
- 完全相同或高度相似的代码块在多处出现
|
||||
- 同一函数/方法被多个地方各自实现,已有公共版本未被复用
|
||||
- 相同的 DOM 查询、正则、模板字符串在同一文件重复
|
||||
|
||||
### 2. Magic Numbers / Magic Strings
|
||||
- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量
|
||||
- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中
|
||||
- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算
|
||||
|
||||
### 3. 命名问题
|
||||
- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`)
|
||||
- 命名与实际用途不符
|
||||
- 同一概念在不同地方用不同名字表达
|
||||
|
||||
### 4. 死代码 / 无效代码
|
||||
- 注释掉的旧代码块(3行以上)
|
||||
- 声明后从未使用的变量/参数/导入
|
||||
- 永远不会执行的条件分支
|
||||
|
||||
### 5. 代码风格问题
|
||||
- 尾部空白字符(trailing whitespace)
|
||||
- 同一文件内风格不一致(如混用单双引号、缩进不统一)
|
||||
- 空行使用不一致(连续多个空行等)
|
||||
|
||||
### 6. 其他常见问题
|
||||
- 私有辅助函数应被 export 但没有,导致调用方重复实现
|
||||
- 类型/接口重复定义
|
||||
- 过于冗长的条件表达式可以简化(不改逻辑)
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### Step 1 — 获取待检查文件列表
|
||||
|
||||
```bash
|
||||
# 无参数时:获取所有未提交修改
|
||||
git diff HEAD --name-only
|
||||
|
||||
# 有参数时:用 $ARGUMENTS 过滤
|
||||
```
|
||||
|
||||
### Step 2 — 逐文件阅读并分析
|
||||
|
||||
- 用 Read 工具读取完整文件(不只读 diff)
|
||||
- 对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式
|
||||
|
||||
### Step 3 — 报告问题清单
|
||||
|
||||
在修改前,先以列表形式输出所有发现的问题:
|
||||
|
||||
```
|
||||
发现 N 个问题:
|
||||
|
||||
[文件] js/foo.js
|
||||
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
|
||||
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
|
||||
|
||||
[文件] js/bar.js
|
||||
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
|
||||
...
|
||||
```
|
||||
|
||||
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
|
||||
|
||||
### Step 4 — 执行修复
|
||||
|
||||
对每个问题,使用 Edit 工具进行**最小化修改**:
|
||||
|
||||
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
|
||||
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
|
||||
- **命名问题**:重命名变量,更新所有使用处
|
||||
- **死代码**:直接删除
|
||||
- **尾部空白/风格**:修正
|
||||
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
|
||||
|
||||
**修复原则:**
|
||||
- 只改在审查清单中发现的问题,不做额外优化
|
||||
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
|
||||
- 改完后用 `grep` 验证旧的坏代码已消失
|
||||
|
||||
### Step 5 — 输出总结
|
||||
|
||||
```
|
||||
清理完成:
|
||||
|
||||
修复了 N 个问题:
|
||||
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
|
||||
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 14(4处引用)
|
||||
✓ controls.js — export updateLayerButtonState,移除 main.js 中的重复实现
|
||||
...
|
||||
|
||||
未修改的问题(需人工确认):
|
||||
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
|
||||
```
|
||||
|
||||
## 约束
|
||||
|
||||
- **禁止**改变函数签名、接口定义、导出 API(除非问题正是私有函数应被 export)
|
||||
- **禁止**添加新功能、新抽象、新参数
|
||||
- **禁止**修改注释内容(只删除注释掉的死代码)
|
||||
- **禁止**修改测试文件逻辑
|
||||
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"
|
||||
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. 直到满足标准或用户明确停止
|
||||
```
|
||||
146
.claude/commands/release.md
Normal file
146
.claude/commands/release.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
description: 发版工作流:根据变更类型决定版本号,更新所有版本文件和 changelog,运行验证,commit 并 push
|
||||
argument-hint: 可选:feature | bugfix | 或直接描述本次发布内容
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Glob", "Grep"]
|
||||
---
|
||||
|
||||
# /release — Planet 发版工作流
|
||||
|
||||
## 版本号规则
|
||||
|
||||
| 变更类型 | 版本跳动 | 适用场景 |
|
||||
|---------|---------|---------|
|
||||
| `feature` | `+0.1.0` | 纯新功能,无 bugfix |
|
||||
| `improvement` | `+0.0.1` | UI 调整、小功能增强、bugfix 混合,或以 UI/体验改进为主的迭代 |
|
||||
| `bugfix` | `+0.0.1` | 纯 bug 修复,无新功能 |
|
||||
| `docs` / `maintenance` / `refactor` | 默认不发版,除非用户明确要求 |
|
||||
|
||||
意图混合时以用户明确描述为准;bugfix + 小 feature 混合默认判定为 `improvement`(`+0.0.1`)。
|
||||
|
||||
## 必须同步更新的文件
|
||||
|
||||
使用 `git rev-parse --show-toplevel` 获取仓库根目录,以下路径均相对于根目录:
|
||||
|
||||
- `VERSION`
|
||||
- `frontend/package.json`(`"version"` 字段)
|
||||
- `pyproject.toml`(`version =` 字段)
|
||||
- `uv.lock`(**不要手动编辑**,通过 `uv lock` 重新生成)
|
||||
- `docs/CHANGELOG.md`
|
||||
- `docs/version-history.md`
|
||||
|
||||
## 执行步骤
|
||||
|
||||
### Step 1 — 环境检查
|
||||
|
||||
```bash
|
||||
git branch --show-current # 确认在 dev 分支
|
||||
git status --short # 检查是否有无关的未暂存修改
|
||||
cat VERSION # 读取当前版本
|
||||
```
|
||||
|
||||
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
|
||||
|
||||
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
|
||||
|
||||
### Step 2 — 确定发版类型与新版本号
|
||||
|
||||
- 若 `$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
|
||||
- 否则根据当前 `git diff HEAD` 和 `git log` 推断
|
||||
- 计算新版本号(例:`0.26.2` → bugfix → `0.26.3`)
|
||||
- **先输出发版计划供用户确认**:
|
||||
|
||||
```
|
||||
发版计划:
|
||||
类型:bugfix
|
||||
版本:0.26.2 → 0.26.3
|
||||
分支:dev
|
||||
将更新:VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
|
||||
```
|
||||
|
||||
### Step 3 — 更新版本号文件
|
||||
|
||||
按顺序更新(每步用 Edit 工具,精确替换,不要重写整个文件):
|
||||
|
||||
1. `VERSION` — 直接替换全部内容为新版本号
|
||||
2. `frontend/package.json` — 替换 `"version": "x.x.x"` 行
|
||||
3. `pyproject.toml` — 替换 `version = "x.x.x"` 行
|
||||
4. 运行 `uv lock` 重新生成 `uv.lock`(在仓库根目录下执行)
|
||||
|
||||
### Step 4 — 更新 CHANGELOG.md
|
||||
|
||||
在文件顶部插入新条目,格式:
|
||||
|
||||
```markdown
|
||||
## [x.x.x] — YYYY-MM-DD
|
||||
|
||||
### ✨ Features / 🐛 Fixes / 🔧 Improvements
|
||||
- ...(只列高信号条目,最多 5 条)
|
||||
- ...
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
日期使用 `date +%Y-%m-%d` 获取今天的日期。
|
||||
|
||||
### Step 5 — 更新 docs/version-history.md
|
||||
|
||||
- 更新文件头部的"当前开发版本"字段
|
||||
- 在时间线表格顶部插入新行:`| vx.x.x | YYYY-MM-DD | 一句话摘要 |`
|
||||
|
||||
### Step 6 — 验证
|
||||
|
||||
针对本次变更范围做最小验证:
|
||||
|
||||
- Python 文件有修改:`python3 -m py_compile <changed_files>`
|
||||
- Frontend 文件有修改:运行项目标准检查(若无则跳过并说明)
|
||||
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
|
||||
|
||||
```bash
|
||||
grep -h "version" VERSION frontend/package.json pyproject.toml
|
||||
```
|
||||
|
||||
### Step 7 — 提交前预览
|
||||
|
||||
展示将要提交的文件列表:
|
||||
|
||||
```bash
|
||||
git diff --stat HEAD
|
||||
```
|
||||
|
||||
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
|
||||
|
||||
### Step 8 — Commit & Push(用户确认后)
|
||||
|
||||
```bash
|
||||
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
|
||||
# 若有代码变更也一并 stage
|
||||
git add <code_files>
|
||||
|
||||
git commit -m "release: bump version to x.x.x"
|
||||
git tag vx.x.x
|
||||
git push origin dev
|
||||
git push origin vx.x.x
|
||||
```
|
||||
|
||||
commit message 固定格式:`release: bump version to x.x.x`
|
||||
|
||||
### Step 9 — 完成确认
|
||||
|
||||
输出摘要:
|
||||
|
||||
```
|
||||
✓ 版本号已更新:0.26.2 → 0.26.3
|
||||
✓ CHANGELOG 已更新
|
||||
✓ version-history 已更新
|
||||
✓ uv.lock 已重新生成
|
||||
✓ 验证通过
|
||||
✓ commit: release: bump version to 0.26.3
|
||||
✓ tag: v0.26.3
|
||||
✓ 已 push 到 origin/dev
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
|
||||
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
|
||||
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行
|
||||
3
.codex/config.toml
Normal file
3
.codex/config.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
approval_policy = "never"
|
||||
|
||||
sandbox_mode = "danger-full-access"
|
||||
124
.codex/skills/cleanup/SKILL.md
Normal file
124
.codex/skills/cleanup/SKILL.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: cleanup
|
||||
description: Use when the user asks to clean up, lint, or review uncommitted code for common code smells — duplicate logic, magic numbers, unclear naming, dead code, style inconsistencies. Fixes issues without changing any runtime behavior.
|
||||
---
|
||||
|
||||
# Cleanup
|
||||
|
||||
Review and fix code quality issues in the current working tree without altering any logic or behavior.
|
||||
|
||||
## When To Use
|
||||
|
||||
- The user asks to clean up, tidy, or lint uncommitted changes
|
||||
- The user wants a code smell review before releasing or committing
|
||||
- The user mentions magic numbers, duplicate logic, dead code, or naming issues
|
||||
|
||||
Do not refactor architecture, add features, or change behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
If the user specifies a file or directory, check only that. Otherwise check all uncommitted changes (`git diff HEAD`).
|
||||
|
||||
Only report issues present in **newly added or modified** lines of this diff — do not audit unchanged code.
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Duplicate Logic
|
||||
- Identical or near-identical code blocks appearing in multiple places
|
||||
- A function/helper that already exists but is re-implemented elsewhere instead of being reused
|
||||
- Repeated DOM queries, regex literals, or template strings within the same file
|
||||
|
||||
### 2. Magic Numbers / Magic Strings
|
||||
- Bare numeric literals used in calculations (offsets, timeouts, sizes, thresholds) without a named constant
|
||||
- Hardcoded strings (IDs, status values, URL fragments) scattered through logic
|
||||
- Exceptions: `0`, `1`, `-1`, `100`, `""` and other idiomatically clear values are fine
|
||||
|
||||
### 3. Naming Issues
|
||||
- Cryptic abbreviations (`or_`, `tmp2`, `x2`)
|
||||
- Names that do not match actual behavior
|
||||
- The same concept referred to by different names in different places
|
||||
|
||||
### 4. Dead Code
|
||||
- Commented-out code blocks (3+ lines)
|
||||
- Variables, parameters, or imports declared but never used
|
||||
- Branches that can never execute
|
||||
|
||||
### 5. Style Inconsistencies
|
||||
- Trailing whitespace
|
||||
- Mixed quote styles or indentation within the same file
|
||||
- Inconsistent blank-line usage (multiple consecutive blank lines, etc.)
|
||||
|
||||
### 6. Other
|
||||
- Private helper functions that should be exported but are not, causing callers to duplicate the implementation
|
||||
- Overly verbose conditions that can be simplified without changing logic
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1 — Get the file list
|
||||
|
||||
```bash
|
||||
git diff HEAD --name-only
|
||||
```
|
||||
|
||||
Filter to the user-specified path if one was provided.
|
||||
|
||||
### Step 2 — Read and analyze each file
|
||||
|
||||
Read the full file (not just the diff) with the Read tool. For each file, record every issue found: filename, line number, category, and suggested fix.
|
||||
|
||||
### Step 3 — Report findings before touching anything
|
||||
|
||||
Print a structured list:
|
||||
|
||||
```
|
||||
Found N issues:
|
||||
|
||||
[file] js/foo.js
|
||||
· L34, L78: Duplicate logic — same DOM query implemented twice; extract to getPanel()
|
||||
· L91: Magic number — bare 14 used as pixel offset; name it TOOLTIP_OFFSET
|
||||
|
||||
[file] js/bar.js
|
||||
· L12: Naming — variable `or_` is unclear; rename to outerR, outerG, outerB
|
||||
...
|
||||
```
|
||||
|
||||
If no issues are found, output "No code smells detected. Code quality looks good." and stop.
|
||||
|
||||
### Step 4 — Fix each issue
|
||||
|
||||
Use the Edit tool for **minimal, targeted changes**:
|
||||
|
||||
- **Duplicate logic**: extract to a shared constant or function; update all call sites
|
||||
- **Magic number/string**: declare `const NAME = value` near the top of the relevant scope; replace all usages
|
||||
- **Naming**: rename the variable/function; update all references
|
||||
- **Dead code**: delete it
|
||||
- **Trailing whitespace / style**: fix in place
|
||||
- **Unexported helper**: add `export`; update callers to import instead of re-implementing
|
||||
|
||||
Principles:
|
||||
- Only fix issues identified in the checklist — no extra improvements
|
||||
- Keep each Edit as small as possible
|
||||
- After fixing, verify the old bad pattern is gone with grep
|
||||
|
||||
### Step 5 — Summary
|
||||
|
||||
```
|
||||
Cleanup complete:
|
||||
|
||||
Fixed N issues:
|
||||
✓ earth.js — extracted duplicate vertexShader into ATMOS_VERTEX_SHADER constant
|
||||
✓ main.js — extracted TOOLTIP_CURSOR_OFFSET = 14 (4 references updated)
|
||||
✓ controls.js — exported updateLayerButtonState; removed duplicate implementation in main.js
|
||||
...
|
||||
|
||||
Skipped (needs manual review):
|
||||
! foo.js L45 — large commented-out block; confirm it is safe to delete
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Do not** change function signatures, exported interfaces, or public APIs (unless the issue is a missing export)
|
||||
- **Do not** add new features, abstractions, or parameters
|
||||
- **Do not** rewrite comments (only delete commented-out dead code)
|
||||
- **Do not** touch test file logic
|
||||
- If a magic number's intent is uncertain, skip it and flag it in the summary
|
||||
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.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: release-workflow
|
||||
description: Use when the user asks to release, bump version, update changelog/version files, or commit/push a repository release for the Planet repo. Applies the repo's versioning rules, updates all required version-bearing files, updates changelog/version-history, runs minimal relevant validation, and then commits/pushes when requested.
|
||||
---
|
||||
|
||||
# Release Workflow
|
||||
|
||||
Use this skill for release-oriented work in this repository.
|
||||
|
||||
## When To Use
|
||||
|
||||
- The user asks to `发版`
|
||||
- The user asks to bump a version
|
||||
- The user asks to update `CHANGELOG`, `version-history`, or version files as part of a release
|
||||
- The user asks to commit/push a release or a publishable bugfix/feature bundle
|
||||
|
||||
Do not use this skill for ordinary commits that are not being released.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- `feature` -> bump `+0.1.0`
|
||||
- `bugfix` -> bump `+0.0.1`
|
||||
- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release
|
||||
|
||||
When intent is mixed, prefer the user’s stated release intent. If they ask to release a bugfix bundle, use a patch bump.
|
||||
|
||||
## Required Files
|
||||
|
||||
Every release bump must update these files together:
|
||||
|
||||
- `/home/ray/dev/linkong/planet/VERSION`
|
||||
- `/home/ray/dev/linkong/planet/frontend/package.json`
|
||||
- `/home/ray/dev/linkong/planet/pyproject.toml`
|
||||
- `/home/ray/dev/linkong/planet/uv.lock`
|
||||
- `/home/ray/dev/linkong/planet/docs/CHANGELOG.md`
|
||||
- `/home/ray/dev/linkong/planet/docs/version-history.md`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current worktree and current version.
|
||||
2. Decide the release type from the user request:
|
||||
- feature
|
||||
- bugfix
|
||||
- release without code changes
|
||||
3. Compute the next version.
|
||||
4. Update all required version-bearing files.
|
||||
5. Add a concise but specific changelog entry:
|
||||
- highlights
|
||||
- important added/improved/fixed items
|
||||
- mention the highest-signal files only
|
||||
6. Update `docs/version-history.md`:
|
||||
- current dev version
|
||||
- new timeline row with summary
|
||||
7. Run the smallest relevant validation available.
|
||||
8. Before commit, verify the target version is present in all required files.
|
||||
9. If the user asked for commit/push:
|
||||
- stage the release files and code changes
|
||||
- commit with a conventional message
|
||||
- push to the requested branch, usually `dev`
|
||||
|
||||
## Validation Guidance
|
||||
|
||||
- Prefer scope-matched validation over broad expensive checks
|
||||
- Typical examples:
|
||||
- Python backend edits: `python3 -m py_compile ...`
|
||||
- Frontend edits: use the project-standard frontend build/check if available
|
||||
- If the environment prevents a check, say that explicitly in the final summary
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Before closing the task, confirm:
|
||||
|
||||
- version bump applied consistently
|
||||
- changelog updated
|
||||
- version history updated
|
||||
- generated/runtime artifacts are not accidentally staged
|
||||
- validation status recorded
|
||||
- commit and push completed if requested
|
||||
157
.codex/skills/release/SKILL.md
Normal file
157
.codex/skills/release/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: release
|
||||
description: Use when the user asks to release, bump version, update changelog/version files, or commit/push a repository release for the Planet repo. Determines version bump type from changes, updates all required version-bearing files, updates changelog and version-history, runs minimal validation, then commits, tags, and pushes.
|
||||
---
|
||||
|
||||
# Release Workflow
|
||||
|
||||
Use this skill for release-oriented work in this repository.
|
||||
|
||||
## When To Use
|
||||
|
||||
- The user asks to `发版`
|
||||
- The user asks to bump a version
|
||||
- The user asks to update `CHANGELOG`, `version-history`, or version files as part of a release
|
||||
- The user asks to commit/push a release or a publishable bugfix/feature bundle
|
||||
|
||||
Do not use this skill for ordinary commits that are not being released.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- `feature` -> bump `+0.1.0`
|
||||
- `bugfix` -> bump `+0.0.1`
|
||||
- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release
|
||||
|
||||
When intent is mixed, prefer the user's stated release intent.
|
||||
|
||||
## Required Files
|
||||
|
||||
Use `git rev-parse --show-toplevel` to get the repo root. All paths are relative to it:
|
||||
|
||||
- `VERSION`
|
||||
- `frontend/package.json` (`"version"` field)
|
||||
- `pyproject.toml` (`version =` field)
|
||||
- `uv.lock` (**never edit manually** — regenerate by running `uv lock`)
|
||||
- `docs/CHANGELOG.md`
|
||||
- `docs/version-history.md`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Environment check
|
||||
|
||||
```bash
|
||||
git branch --show-current # must be on dev
|
||||
git status --short # check for unrelated uncommitted changes
|
||||
cat VERSION # read current version
|
||||
```
|
||||
|
||||
If not on `dev`, stop and tell the user. Do not proceed.
|
||||
|
||||
If unrelated uncommitted changes exist, list them and ask the user whether to include them or stash first.
|
||||
|
||||
### Step 2 — Determine release type and next version
|
||||
|
||||
- If the user provided an explicit type (`feature` / `bugfix`), use it
|
||||
- Otherwise infer from `git diff HEAD` and recent `git log`
|
||||
- Compute the next version (e.g. `0.26.2` → bugfix → `0.26.3`)
|
||||
- **Show the release plan before making any changes:**
|
||||
|
||||
```
|
||||
Release plan:
|
||||
Type: bugfix
|
||||
Version: 0.26.2 → 0.26.3
|
||||
Branch: dev
|
||||
Will update: VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
|
||||
```
|
||||
|
||||
### Step 3 — Update version files
|
||||
|
||||
Update in order (use Edit for precise replacement, never rewrite whole files):
|
||||
|
||||
1. `VERSION` — replace entire content with new version string
|
||||
2. `frontend/package.json` — replace `"version": "x.x.x"` line
|
||||
3. `pyproject.toml` — replace `version = "x.x.x"` line
|
||||
4. Run `uv lock` at repo root to regenerate `uv.lock`
|
||||
|
||||
### Step 4 — Update CHANGELOG.md
|
||||
|
||||
Insert a new entry at the top of the file:
|
||||
|
||||
```markdown
|
||||
## x.x.x
|
||||
|
||||
Released: YYYY-MM-DD
|
||||
|
||||
### Highlights
|
||||
|
||||
- ...
|
||||
|
||||
### Added / Fixed / Improved
|
||||
|
||||
- ... (high-signal items only, max 5)
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Get today's date with `date +%Y-%m-%d`.
|
||||
|
||||
### Step 5 — Update docs/version-history.md
|
||||
|
||||
- Update the "current dev version" field in the file header
|
||||
- Insert a new row at the top of the timeline table: `| vx.x.x | YYYY-MM-DD | one-line summary |`
|
||||
|
||||
### Step 6 — Validate
|
||||
|
||||
Run the smallest relevant validation for the changes in scope:
|
||||
|
||||
- Python files changed: `python3 -m py_compile <changed_files>`
|
||||
- Frontend files changed: run the project-standard check if available; otherwise skip and say so
|
||||
- Version consistency: confirm VERSION, package.json, pyproject.toml, and uv.lock all show the same version
|
||||
|
||||
```bash
|
||||
grep -h "version" VERSION frontend/package.json pyproject.toml
|
||||
```
|
||||
|
||||
### Step 7 — Pre-commit preview
|
||||
|
||||
Show what will be committed:
|
||||
|
||||
```bash
|
||||
git diff --stat HEAD
|
||||
```
|
||||
|
||||
Confirm all required files are present and no unexpected files (debug files, `.env`, etc.) are included.
|
||||
|
||||
### Step 8 — Commit, tag, and push
|
||||
|
||||
```bash
|
||||
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
|
||||
# also stage any code changes included in this release
|
||||
git add <code_files>
|
||||
|
||||
git commit -m "release: bump version to x.x.x"
|
||||
git tag vx.x.x
|
||||
git push origin dev
|
||||
git push origin vx.x.x
|
||||
```
|
||||
|
||||
Commit message format is fixed: `release: bump version to x.x.x`
|
||||
|
||||
### Step 9 — Completion summary
|
||||
|
||||
```
|
||||
✓ Version bumped: 0.26.2 → 0.26.3
|
||||
✓ CHANGELOG updated
|
||||
✓ version-history updated
|
||||
✓ uv.lock regenerated
|
||||
✓ Validation passed
|
||||
✓ commit: release: bump version to 0.26.3
|
||||
✓ tag: v0.26.3
|
||||
✓ Pushed to origin/dev
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `uv.lock` must only be updated by running `uv lock`, never manually
|
||||
- The release commit should include only version files + the code for this release — no unrelated changes
|
||||
- If `uv` is unavailable in the environment, say so explicitly and remind the user to run it manually
|
||||
124
README.md
124
README.md
@@ -227,6 +227,120 @@ bun run build
|
||||
|
||||
启动服务后访问: `http://localhost:8000/docs`
|
||||
|
||||
## WSL / Windows 局域网访问
|
||||
|
||||
如果服务运行在 WSL 中,而你希望:
|
||||
|
||||
- Windows 本机浏览器访问开发服务
|
||||
- 同一局域网内的手机或其他电脑访问开发服务
|
||||
|
||||
推荐按下面顺序排查和配置。
|
||||
|
||||
### 1. 在 WSL 中启动服务
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
|
||||
|
||||
### 2. 先确认 WSL 内部服务正常
|
||||
|
||||
在 WSL 中执行:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `3000` 返回前端 HTML
|
||||
- `8000/health` 返回健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
|
||||
|
||||
如果这一步不通,先不要继续做 Windows 转发。
|
||||
|
||||
### 3. 在 Windows 本机验证 localhost 直通
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
||||
|
||||
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
|
||||
|
||||
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
|
||||
|
||||
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
```
|
||||
|
||||
再放行 Windows 防火墙:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
```
|
||||
|
||||
检查转发规则是否生效:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy show all
|
||||
```
|
||||
|
||||
预期能看到:
|
||||
|
||||
- `0.0.0.0:3000 -> 127.0.0.1:3000`
|
||||
- `0.0.0.0:8000 -> 127.0.0.1:8000`
|
||||
|
||||
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
ipconfig
|
||||
```
|
||||
|
||||
找到当前联网网卡的 IPv4 地址,例如 `192.168.8.228`。
|
||||
|
||||
局域网其他设备可访问:
|
||||
|
||||
- `http://<Windows局域网IP>:3000/earth`
|
||||
- `http://<Windows局域网IP>:3000/admin`
|
||||
|
||||
例如:
|
||||
|
||||
- `http://192.168.8.228:3000/earth`
|
||||
|
||||
### 6. 常见现象与判断
|
||||
|
||||
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
|
||||
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||
|
||||
### 7. 本项目一次性验证顺序
|
||||
|
||||
建议固定按这个顺序验证:
|
||||
|
||||
1. WSL 中执行 `curl http://localhost:3000`
|
||||
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||
3. Windows 中执行 `curl http://localhost:3000`
|
||||
4. Windows 中执行 `curl http://localhost:8000/health`
|
||||
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
||||
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
||||
|
||||
## 启动容错参数
|
||||
|
||||
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
||||
@@ -328,11 +442,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
|
||||
详细文档:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md)
|
||||
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
@@ -346,7 +460,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
当前推荐参考实现:
|
||||
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
12
TODO.md
12
TODO.md
@@ -20,3 +20,15 @@
|
||||
- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector
|
||||
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度
|
||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
||||
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
||||
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
|
||||
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败
|
||||
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
||||
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
|
||||
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
完整使用说明见:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
|
||||
当前支持:
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api.v1 import (
|
||||
collected_data,
|
||||
visualization,
|
||||
bgp,
|
||||
news,
|
||||
system_control,
|
||||
tv,
|
||||
)
|
||||
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
|
||||
@@ -420,6 +420,7 @@ async def list_datasources(
|
||||
collector_list.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
|
||||
13
backend/app/api/v1/news.py
Normal file
13
backend/app/api/v1/news.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.services.earth_news import get_earth_news_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/earth-feed")
|
||||
async def get_earth_feed(
|
||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||
):
|
||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
||||
@@ -6,12 +6,14 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import math
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.countries import get_country_centroid
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
@@ -23,6 +25,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
|
||||
router = APIRouter()
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
@@ -359,6 +364,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _parse_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||
("el capitan", 37.6819, -121.7681),
|
||||
("livermore", 37.6819, -121.7681),
|
||||
("llnl", 37.6819, -121.7681),
|
||||
("lawrence livermore", 37.6819, -121.7681),
|
||||
("frontier", 35.9319, -84.3107),
|
||||
("oak ridge", 35.9319, -84.3107),
|
||||
("ornl", 35.9319, -84.3107),
|
||||
("aurora", 41.7130, -87.9820),
|
||||
("argonne", 41.7130, -87.9820),
|
||||
("anl", 41.7130, -87.9820),
|
||||
("fugaku", 34.6953, 135.1974),
|
||||
("kobe", 34.6953, 135.1974),
|
||||
("riken", 34.6953, 135.1974),
|
||||
("summit", 35.9319, -84.3107),
|
||||
("leonardo", 44.4949, 11.3426),
|
||||
("bologna", 44.4949, 11.3426),
|
||||
("alps", 46.0037, 8.9511),
|
||||
("lugano", 46.0037, 8.9511),
|
||||
("sunway taihulight", 31.4912, 120.3119),
|
||||
("wuxi", 31.4912, 120.3119),
|
||||
("tianhe-2", 23.1291, 113.2644),
|
||||
("tianhe-2a", 23.1291, 113.2644),
|
||||
("guangzhou", 23.1291, 113.2644),
|
||||
("colossus", 35.1495, -90.0490),
|
||||
("memphis", 35.1495, -90.0490),
|
||||
("xai", 35.1495, -90.0490),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hint_text(*parts: Any) -> str:
|
||||
return " ".join(
|
||||
str(part).strip().lower()
|
||||
for part in parts
|
||||
if part not in (None, "")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compute_center_coordinates(
|
||||
record: CollectedData,
|
||||
metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "precise",
|
||||
"geography_mode": "source_coordinates",
|
||||
"is_estimated": False,
|
||||
"estimated_reason": None,
|
||||
}
|
||||
|
||||
hint_text = _normalize_hint_text(
|
||||
record.name,
|
||||
get_record_field(record, "city"),
|
||||
get_record_field(record, "country"),
|
||||
metadata.get("site"),
|
||||
metadata.get("organization"),
|
||||
metadata.get("operator"),
|
||||
)
|
||||
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||
if needle in hint_text:
|
||||
return {
|
||||
"latitude": resolved_latitude,
|
||||
"longitude": resolved_longitude,
|
||||
"location_precision": "estimated_site",
|
||||
"geography_mode": "site_hint",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": f"Matched known site hint: {needle}",
|
||||
}
|
||||
|
||||
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||
if centroid:
|
||||
return {
|
||||
"latitude": centroid.get("latitude"),
|
||||
"longitude": centroid.get("longitude"),
|
||||
"location_precision": "estimated_country",
|
||||
"geography_mode": "country_centroid",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "Estimated from country centroid",
|
||||
}
|
||||
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "unknown",
|
||||
"geography_mode": "unknown",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "No resolvable location hints",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||
if capacity_value is None:
|
||||
return "unknown"
|
||||
|
||||
unit = str(capacity_unit or "").strip().lower()
|
||||
if unit in {"pflop/s", "pflops", "pflop"}:
|
||||
normalized_tflops = capacity_value * 1000
|
||||
elif unit in {"gflop/s", "gflops", "gflop"}:
|
||||
normalized_tflops = capacity_value / 1000
|
||||
else:
|
||||
normalized_tflops = capacity_value
|
||||
|
||||
if normalized_tflops >= 1_000_000:
|
||||
return "exascale"
|
||||
if normalized_tflops >= 100_000:
|
||||
return "ultra"
|
||||
if normalized_tflops >= 10_000:
|
||||
return "large"
|
||||
if normalized_tflops > 0:
|
||||
return "regional"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||
latitude = coordinate_info.get("latitude")
|
||||
longitude = coordinate_info.get("longitude")
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
|
||||
if site_type == "supercomputer":
|
||||
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||
capacity_unit = "GFlops"
|
||||
else:
|
||||
capacity_value = _parse_float(get_record_field(record, "value"))
|
||||
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
|
||||
|
||||
vendor = (
|
||||
metadata.get("manufacturer")
|
||||
or metadata.get("vendor")
|
||||
or metadata.get("gpu_type")
|
||||
)
|
||||
operator = (
|
||||
metadata.get("organization")
|
||||
or metadata.get("operator")
|
||||
or metadata.get("owner")
|
||||
)
|
||||
rank = metadata.get("rank")
|
||||
if rank in (None, "") and site_type == "supercomputer":
|
||||
rank = get_record_field(record, "rank")
|
||||
|
||||
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": record.id,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [longitude or 0, latitude or 0],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"site_type": site_type,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"operator": operator,
|
||||
"vendor": vendor,
|
||||
"capacity_value": capacity_value,
|
||||
"capacity_unit": capacity_unit,
|
||||
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
|
||||
"rank": rank,
|
||||
"gpu_count": metadata.get("gpu_count"),
|
||||
"gpu_type": metadata.get("gpu_type"),
|
||||
"cores": get_record_field(record, "cores"),
|
||||
"power": get_record_field(record, "power"),
|
||||
"source": record.source,
|
||||
"updated_at": updated_at,
|
||||
"status": "observed",
|
||||
"location_precision": coordinate_info.get("location_precision"),
|
||||
"geography_mode": coordinate_info.get("geography_mode"),
|
||||
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||
"data_type": "compute_center",
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_bgp_anomalies_to_geojson(
|
||||
records: List[BGPAnomaly],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
@@ -782,9 +996,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/geo/landing-points")
|
||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
records = await _load_current_collected_data(db, "arcgis_landing_points")
|
||||
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation")
|
||||
cable_records = await _load_current_collected_data(db, "arcgis_cables")
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
[
|
||||
"arcgis_landing_points",
|
||||
"arcgis_cable_landing_relation",
|
||||
"arcgis_cables",
|
||||
],
|
||||
)
|
||||
records = records_by_source.get("arcgis_landing_points", [])
|
||||
relation_records = records_by_source.get(
|
||||
"arcgis_cable_landing_relation",
|
||||
[],
|
||||
)
|
||||
cable_records = records_by_source.get("arcgis_cables", [])
|
||||
|
||||
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
|
||||
relation_records,
|
||||
@@ -804,6 +1029,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Terrain tile upstream error: {exc.response.status_code}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
@@ -916,6 +1185,53 @@ async def get_gpu_clusters_geojson(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/compute-centers")
|
||||
async def get_compute_centers_geojson(
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取统一算力中心 GeoJSON 数据"""
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
["top500", "epoch_ai_gpu"],
|
||||
)
|
||||
records = _filter_known_records(
|
||||
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
|
||||
)
|
||||
if limit is not None:
|
||||
records = records[:limit]
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
},
|
||||
}
|
||||
|
||||
geojson = convert_compute_centers_to_geojson(records)
|
||||
features = geojson.get("features", [])
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(features),
|
||||
"stats": {
|
||||
"total": len(features),
|
||||
"supercomputers": sum(
|
||||
1 for feature in features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_clusters": sum(
|
||||
1 for feature in features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/bgp-anomalies")
|
||||
async def get_bgp_anomalies_geojson(
|
||||
severity: Optional[str] = Query(None),
|
||||
|
||||
@@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = {
|
||||
"iptoasn_prefix_geo": "iptoasn.combined_url",
|
||||
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
|
||||
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
||||
"news_live_streams": "news_live_streams.channels_url",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -86,3 +86,11 @@ opengeofeed:
|
||||
nro:
|
||||
# NRO delegated stats 下载地址
|
||||
delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats"
|
||||
|
||||
news_live_streams:
|
||||
# IPTV-org 频道元数据 JSON
|
||||
channels_url: "https://iptv-org.github.io/api/channels.json"
|
||||
# IPTV-org 频道播放流 JSON
|
||||
streams_url: "https://iptv-org.github.io/api/streams.json"
|
||||
# IPTV-org 台标 JSON
|
||||
logos_url: "https://iptv-org.github.io/api/logos.json"
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.services.collectors.base import BaseCollector
|
||||
|
||||
|
||||
@@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
data_type = "news_live_stream"
|
||||
fail_on_empty = False
|
||||
|
||||
DEFAULT_TIMEOUT = 45.0
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
RESPONSE_CANDIDATE_KEYS = ("sources", "streams", "channels", "items", "results", "data")
|
||||
DEFAULT_ADAPTER = "iptv_org"
|
||||
DEFAULT_IPTV_ORG_STREAMS_URL = "https://iptv-org.github.io/api/streams.json"
|
||||
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
|
||||
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
|
||||
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
|
||||
DEFAULT_IPTV_ORG_MAX_SOURCES = 120
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
request_url = (self._resolved_url or "").strip()
|
||||
if not request_url:
|
||||
return []
|
||||
|
||||
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
datasource_config = await self._load_datasource_config()
|
||||
effective_config = self._get_effective_config(datasource_config)
|
||||
adapter = str(effective_config.get("adapter") or "").strip().lower()
|
||||
if adapter == "iptv_org":
|
||||
return await self._fetch_iptv_org(request_url, effective_config)
|
||||
|
||||
request_headers = self._build_request_headers(datasource_config)
|
||||
request_config = self._get_request_config(datasource_config)
|
||||
request_params = self._build_request_params(datasource_config)
|
||||
request_json = self._build_request_json_body(datasource_config)
|
||||
request_data = self._build_request_form_body(datasource_config)
|
||||
timeout = self._get_timeout(datasource_config)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
response = await client.request(
|
||||
request_config["method"],
|
||||
request_url,
|
||||
headers={
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
headers=request_headers,
|
||||
params=request_params or None,
|
||||
json=request_json,
|
||||
data=request_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self.parse_response(response.json())
|
||||
return self.parse_response(
|
||||
response.json(),
|
||||
response_path=request_config["response_path"],
|
||||
)
|
||||
|
||||
def parse_response(self, response: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(response, dict):
|
||||
candidates = response.get("sources") or response.get("streams") or response.get("data") or []
|
||||
elif isinstance(response, list):
|
||||
candidates = response
|
||||
async def _load_datasource_config(self) -> DataSourceConfig | None:
|
||||
if not self._db_session:
|
||||
return None
|
||||
|
||||
result = await self._db_session.execute(
|
||||
select(DataSourceConfig)
|
||||
.where(DataSourceConfig.name == self.name)
|
||||
.where(DataSourceConfig.is_active.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _get_effective_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
|
||||
payload = dict(datasource_config.config or {}) if datasource_config else {}
|
||||
if payload:
|
||||
return payload
|
||||
|
||||
yaml_config = get_data_sources_config()
|
||||
return {
|
||||
"adapter": self.DEFAULT_ADAPTER,
|
||||
"streams_url": yaml_config.get_yaml_value("news_live_streams.streams_url")
|
||||
or self.DEFAULT_IPTV_ORG_STREAMS_URL,
|
||||
"logos_url": yaml_config.get_yaml_value("news_live_streams.logos_url")
|
||||
or self.DEFAULT_IPTV_ORG_LOGOS_URL,
|
||||
"news_categories": list(self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES),
|
||||
"exclude_categories": list(self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES),
|
||||
"max_sources": self.DEFAULT_IPTV_ORG_MAX_SOURCES,
|
||||
}
|
||||
|
||||
def _get_request_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
|
||||
payload = self._get_effective_config(datasource_config)
|
||||
raw_method = payload.get("method") or payload.get("request_method") or "GET"
|
||||
method = str(raw_method).strip().upper() or "GET"
|
||||
if method not in {"GET", "POST"}:
|
||||
method = "GET"
|
||||
|
||||
response_path = payload.get("response_path") or payload.get("payload_path") or payload.get("items_path")
|
||||
if isinstance(response_path, str):
|
||||
response_path = response_path.strip()
|
||||
else:
|
||||
candidates = []
|
||||
response_path = None
|
||||
|
||||
return {
|
||||
"method": method,
|
||||
"response_path": response_path or None,
|
||||
}
|
||||
|
||||
def _get_timeout(self, datasource_config: DataSourceConfig | None) -> float:
|
||||
payload = self._get_effective_config(datasource_config)
|
||||
try:
|
||||
return float(payload.get("timeout", self.DEFAULT_TIMEOUT))
|
||||
except (TypeError, ValueError):
|
||||
return self.DEFAULT_TIMEOUT
|
||||
|
||||
def _build_request_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
|
||||
headers = dict(self.DEFAULT_HEADERS)
|
||||
if datasource_config:
|
||||
headers.update(self._normalize_headers(datasource_config.headers))
|
||||
headers.update(self._build_auth_headers(datasource_config))
|
||||
return headers
|
||||
|
||||
def _build_request_params(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if not datasource_config:
|
||||
return params
|
||||
|
||||
payload = datasource_config.config or {}
|
||||
candidate = payload.get("params") or payload.get("query_params")
|
||||
if isinstance(candidate, dict):
|
||||
params.update(candidate)
|
||||
|
||||
if datasource_config.auth_type == "api_key":
|
||||
auth_config = datasource_config.auth_config or {}
|
||||
if str(auth_config.get("in") or auth_config.get("location") or "header").lower() == "query":
|
||||
api_key = auth_config.get("api_key")
|
||||
key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key"
|
||||
if api_key and key_name:
|
||||
params[str(key_name)] = api_key
|
||||
|
||||
return params
|
||||
|
||||
def _build_request_json_body(self, datasource_config: DataSourceConfig | None) -> Any:
|
||||
if not datasource_config:
|
||||
return None
|
||||
|
||||
payload = datasource_config.config or {}
|
||||
body = payload.get("json_body")
|
||||
if body is None and str(payload.get("body_type") or "").lower() in {"json", ""}:
|
||||
candidate = payload.get("body")
|
||||
if isinstance(candidate, (dict, list)):
|
||||
body = candidate
|
||||
return body
|
||||
|
||||
def _build_request_form_body(self, datasource_config: DataSourceConfig | None) -> Any:
|
||||
if not datasource_config:
|
||||
return None
|
||||
|
||||
payload = datasource_config.config or {}
|
||||
form_body = payload.get("form_body")
|
||||
if form_body is not None:
|
||||
return form_body
|
||||
|
||||
if str(payload.get("body_type") or "").lower() == "form":
|
||||
candidate = payload.get("body")
|
||||
if isinstance(candidate, dict):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _normalize_headers(self, headers: Any) -> dict[str, str]:
|
||||
if not isinstance(headers, dict):
|
||||
return {}
|
||||
normalized: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
header_name = str(key).strip()
|
||||
if not header_name or value is None:
|
||||
continue
|
||||
normalized[header_name] = str(value)
|
||||
return normalized
|
||||
|
||||
def _build_auth_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
|
||||
if not datasource_config:
|
||||
return {}
|
||||
|
||||
auth_type = str(datasource_config.auth_type or "none").lower()
|
||||
auth_config = datasource_config.auth_config or {}
|
||||
if auth_type == "bearer" and auth_config.get("token"):
|
||||
return {"Authorization": f"Bearer {auth_config['token']}"}
|
||||
|
||||
if auth_type == "api_key" and auth_config.get("api_key"):
|
||||
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
|
||||
if location == "query":
|
||||
return {}
|
||||
key_name = auth_config.get("key_name") or "X-API-Key"
|
||||
return {str(key_name): str(auth_config["api_key"])}
|
||||
|
||||
if auth_type == "basic":
|
||||
username = str(auth_config.get("username") or "")
|
||||
password = str(auth_config.get("password") or "")
|
||||
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
return {}
|
||||
|
||||
def _extract_candidates(self, response: Any, response_path: str | None) -> list[Any]:
|
||||
if response_path:
|
||||
extracted = self._extract_from_path(response, response_path)
|
||||
if isinstance(extracted, list):
|
||||
return extracted
|
||||
if isinstance(extracted, dict):
|
||||
for key in self.RESPONSE_CANDIDATE_KEYS:
|
||||
nested = extracted.get(key)
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
return [extracted]
|
||||
|
||||
if isinstance(response, dict):
|
||||
for key in self.RESPONSE_CANDIDATE_KEYS:
|
||||
nested = response.get(key)
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
return []
|
||||
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
return []
|
||||
|
||||
def _extract_from_path(self, payload: Any, path: str) -> Any:
|
||||
current = payload
|
||||
for segment in (part.strip() for part in path.split(".") if part.strip()):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(segment)
|
||||
continue
|
||||
if isinstance(current, list):
|
||||
try:
|
||||
current = current[int(segment)]
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
return current
|
||||
|
||||
def _infer_source_type(self, item: dict[str, Any]) -> str:
|
||||
explicit = str(item.get("source_type") or item.get("type") or "").strip().lower()
|
||||
if explicit in {"iframe", "hls", "video", "external", "youtube"}:
|
||||
return explicit
|
||||
|
||||
youtube_video_id = self._clean_text(
|
||||
item.get("youtube_video_id")
|
||||
or item.get("video_id")
|
||||
or item.get("youtubeVideoId")
|
||||
)
|
||||
youtube_channel = self._clean_text(item.get("youtube_channel") or item.get("channel_handle"))
|
||||
embed_url = self._clean_url(item.get("embed_url") or item.get("embed") or item.get("page_url"))
|
||||
stream_url = self._clean_url(item.get("stream_url") or item.get("stream") or item.get("playback_url") or item.get("hls_url"))
|
||||
homepage_url = self._clean_url(item.get("homepage_url") or item.get("source_url") or item.get("website"))
|
||||
|
||||
if youtube_video_id or youtube_channel:
|
||||
return "youtube"
|
||||
if stream_url.endswith(".m3u8"):
|
||||
return "hls"
|
||||
if stream_url:
|
||||
return "video"
|
||||
if embed_url:
|
||||
parsed = urlparse(embed_url)
|
||||
if "youtube.com" in (parsed.netloc or "") or "youtu.be" in (parsed.netloc or ""):
|
||||
return "youtube"
|
||||
return "iframe"
|
||||
if homepage_url:
|
||||
return "external"
|
||||
return "iframe"
|
||||
|
||||
def _parse_enabled(self, item: dict[str, Any]) -> bool:
|
||||
if "is_enabled" in item:
|
||||
return self._to_bool(item.get("is_enabled"), default=True)
|
||||
if "enabled" in item:
|
||||
return self._to_bool(item.get("enabled"), default=True)
|
||||
if "active" in item:
|
||||
return self._to_bool(item.get("active"), default=True)
|
||||
if "status" in item:
|
||||
status = str(item.get("status") or "").strip().lower()
|
||||
if status in {"disabled", "inactive", "offline"}:
|
||||
return False
|
||||
if status in {"enabled", "active", "online", "live"}:
|
||||
return True
|
||||
return True
|
||||
|
||||
def _to_bool(self, value: Any, *, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on", "enabled", "active", "online", "live"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off", "disabled", "inactive", "offline"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
def _clean_text(self, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
def _clean_url(self, value: Any) -> str:
|
||||
text = self._clean_text(value)
|
||||
if not text:
|
||||
return ""
|
||||
parsed = urlparse(text)
|
||||
if parsed.scheme and parsed.scheme not in {"http", "https"}:
|
||||
return ""
|
||||
if parsed.scheme and not parsed.netloc:
|
||||
return ""
|
||||
return text
|
||||
|
||||
async def _fetch_iptv_org(self, channels_url: str, collector_config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
streams_url = self._clean_url(collector_config.get("streams_url")) or self.DEFAULT_IPTV_ORG_STREAMS_URL
|
||||
logos_url = self._clean_url(collector_config.get("logos_url")) or self.DEFAULT_IPTV_ORG_LOGOS_URL
|
||||
news_categories = {
|
||||
self._clean_text(value).lower()
|
||||
for value in (collector_config.get("news_categories") or self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES)
|
||||
if self._clean_text(value)
|
||||
}
|
||||
exclude_categories = {
|
||||
self._clean_text(value).lower()
|
||||
for value in (collector_config.get("exclude_categories") or self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES)
|
||||
if self._clean_text(value)
|
||||
}
|
||||
try:
|
||||
max_sources = int(collector_config.get("max_sources", self.DEFAULT_IPTV_ORG_MAX_SOURCES))
|
||||
except (TypeError, ValueError):
|
||||
max_sources = self.DEFAULT_IPTV_ORG_MAX_SOURCES
|
||||
|
||||
timeout = self.DEFAULT_TIMEOUT
|
||||
try:
|
||||
timeout = float(collector_config.get("timeout", self.DEFAULT_TIMEOUT))
|
||||
except (TypeError, ValueError):
|
||||
timeout = self.DEFAULT_TIMEOUT
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
channels_payload, streams_payload, logos_payload = await self._gather_iptv_org_payloads(
|
||||
client,
|
||||
channels_url,
|
||||
streams_url,
|
||||
logos_url,
|
||||
)
|
||||
|
||||
channels = channels_payload if isinstance(channels_payload, list) else []
|
||||
streams = streams_payload if isinstance(streams_payload, list) else []
|
||||
logos = logos_payload if isinstance(logos_payload, list) else []
|
||||
|
||||
logo_by_channel = {
|
||||
self._clean_text(item.get("channel")): self._clean_url(item.get("url"))
|
||||
for item in logos
|
||||
if isinstance(item, dict) and self._clean_text(item.get("channel")) and self._clean_url(item.get("url"))
|
||||
}
|
||||
|
||||
streams_by_channel: dict[str, list[dict[str, Any]]] = {}
|
||||
for stream in streams:
|
||||
if not isinstance(stream, dict):
|
||||
continue
|
||||
channel_id = self._clean_text(stream.get("channel"))
|
||||
if not channel_id:
|
||||
continue
|
||||
streams_by_channel.setdefault(channel_id, []).append(stream)
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for channel in channels:
|
||||
if not isinstance(channel, dict):
|
||||
continue
|
||||
|
||||
categories = [
|
||||
self._clean_text(value).lower()
|
||||
for value in (channel.get("categories") or [])
|
||||
if self._clean_text(value)
|
||||
]
|
||||
if news_categories and not any(category in news_categories for category in categories):
|
||||
continue
|
||||
if exclude_categories and any(category in exclude_categories for category in categories):
|
||||
continue
|
||||
if channel.get("is_nsfw") is True:
|
||||
continue
|
||||
if channel.get("closed"):
|
||||
continue
|
||||
|
||||
channel_id = self._clean_text(channel.get("id"))
|
||||
if not channel_id:
|
||||
continue
|
||||
|
||||
stream = self._pick_iptv_org_stream(streams_by_channel.get(channel_id) or [])
|
||||
if not stream:
|
||||
continue
|
||||
|
||||
stream_url = self._clean_url(stream.get("url"))
|
||||
if not stream_url:
|
||||
continue
|
||||
|
||||
name = self._clean_text(channel.get("name")) or channel_id
|
||||
notes_parts = [
|
||||
f"Imported from IPTV-org catalog ({channel_id})",
|
||||
f"Categories: {', '.join(categories)}" if categories else "",
|
||||
f"Quality: {self._clean_text(stream.get('quality'))}" if self._clean_text(stream.get("quality")) else "",
|
||||
]
|
||||
metadata = {
|
||||
"provider": self._clean_text(channel.get("network")) or "IPTV-org",
|
||||
"region": self._clean_text(channel.get("country")) or "Global",
|
||||
"language": "und",
|
||||
"source_type": "hls" if stream_url.endswith(".m3u8") else "video",
|
||||
"embed_url": "",
|
||||
"stream_url": stream_url,
|
||||
"homepage_url": self._clean_url(channel.get("website")),
|
||||
"poster_url": logo_by_channel.get(channel_id, ""),
|
||||
"youtube_video_id": "",
|
||||
"youtube_channel": "",
|
||||
"sort_order": 400 + len(normalized),
|
||||
"notes": "; ".join(part for part in notes_parts if part),
|
||||
"is_enabled": True,
|
||||
"collector_adapter": "iptv_org",
|
||||
"channel_id": channel_id,
|
||||
"categories": categories,
|
||||
"quality": self._clean_text(stream.get("quality")),
|
||||
"stream_label": self._clean_text(stream.get("label") or stream.get("title")),
|
||||
"stream_referrer": self._clean_text(stream.get("referrer")),
|
||||
"stream_user_agent": self._clean_text(stream.get("user_agent")),
|
||||
}
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"source_id": channel_id,
|
||||
"name": name,
|
||||
"description": metadata["notes"],
|
||||
"metadata": metadata,
|
||||
"reference_date": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
if len(normalized) >= max_sources:
|
||||
break
|
||||
|
||||
return normalized
|
||||
|
||||
async def _gather_iptv_org_payloads(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
channels_url: str,
|
||||
streams_url: str,
|
||||
logos_url: str,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
headers = dict(self.DEFAULT_HEADERS)
|
||||
channels_payload, streams_payload, logos_payload = await asyncio.gather(
|
||||
client.get(channels_url, headers=headers),
|
||||
client.get(streams_url, headers=headers),
|
||||
client.get(logos_url, headers=headers),
|
||||
)
|
||||
channels_payload.raise_for_status()
|
||||
streams_payload.raise_for_status()
|
||||
logos_payload.raise_for_status()
|
||||
return channels_payload.json(), streams_payload.json(), logos_payload.json()
|
||||
|
||||
def _pick_iptv_org_stream(self, streams: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
def score(stream: dict[str, Any]) -> tuple[int, int]:
|
||||
url = self._clean_url(stream.get("url"))
|
||||
quality = self._clean_text(stream.get("quality")).lower()
|
||||
quality_score = 0
|
||||
if quality.endswith("p"):
|
||||
try:
|
||||
quality_score = int(quality[:-1])
|
||||
except ValueError:
|
||||
quality_score = 0
|
||||
stream_score = 1000 if url.endswith(".m3u8") else 0
|
||||
return stream_score, quality_score
|
||||
|
||||
sorted_streams = sorted(streams, key=score, reverse=True)
|
||||
return sorted_streams[0]
|
||||
|
||||
def parse_response(self, response: Any, *, response_path: str | None = None) -> list[dict[str, Any]]:
|
||||
candidates = self._extract_candidates(response, response_path)
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(candidates):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}"
|
||||
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip()
|
||||
stream_id = (
|
||||
item.get("id")
|
||||
or item.get("source_id")
|
||||
or item.get("slug")
|
||||
or item.get("channel_id")
|
||||
or item.get("code")
|
||||
or f"news-live-{index + 1}"
|
||||
)
|
||||
name = self._clean_text(
|
||||
item.get("name")
|
||||
or item.get("title")
|
||||
or item.get("channel")
|
||||
or item.get("display_name")
|
||||
or f"News Live {index + 1}"
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
source_type = self._infer_source_type(item)
|
||||
stream_url = self._clean_url(
|
||||
item.get("stream_url")
|
||||
or item.get("stream")
|
||||
or item.get("playback_url")
|
||||
or item.get("hls_url")
|
||||
or item.get("m3u8_url")
|
||||
)
|
||||
embed_url = self._clean_url(
|
||||
item.get("embed_url")
|
||||
or item.get("embed")
|
||||
or item.get("page_url")
|
||||
or (item.get("url") if source_type == "iframe" else "")
|
||||
)
|
||||
homepage_url = self._clean_url(
|
||||
item.get("homepage_url")
|
||||
or item.get("source_url")
|
||||
or item.get("website")
|
||||
or item.get("url")
|
||||
)
|
||||
metadata = {
|
||||
"provider": item.get("provider") or item.get("publisher") or "Collector",
|
||||
"region": item.get("region") or item.get("country") or "Global",
|
||||
"language": item.get("language") or "und",
|
||||
"source_type": item.get("source_type") or "iframe",
|
||||
"embed_url": item.get("embed_url") or item.get("url") or "",
|
||||
"stream_url": item.get("stream_url") or "",
|
||||
"homepage_url": item.get("homepage_url") or item.get("source_url") or "",
|
||||
"poster_url": item.get("poster_url") or "",
|
||||
"provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector",
|
||||
"region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global",
|
||||
"language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und",
|
||||
"source_type": source_type,
|
||||
"embed_url": embed_url,
|
||||
"stream_url": stream_url,
|
||||
"homepage_url": homepage_url,
|
||||
"poster_url": self._clean_url(item.get("poster_url") or item.get("thumbnail_url") or item.get("logo_url")),
|
||||
"youtube_video_id": self._clean_text(
|
||||
item.get("youtube_video_id")
|
||||
or item.get("video_id")
|
||||
or item.get("youtubeVideoId")
|
||||
),
|
||||
"youtube_channel": self._clean_text(
|
||||
item.get("youtube_channel")
|
||||
or item.get("channel_handle")
|
||||
or item.get("youtubeChannel")
|
||||
),
|
||||
"sort_order": item.get("sort_order", 200 + index),
|
||||
"notes": item.get("notes") or item.get("description") or "",
|
||||
"is_enabled": item.get("is_enabled", True),
|
||||
"notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")),
|
||||
"is_enabled": self._parse_enabled(item),
|
||||
}
|
||||
|
||||
normalized.append(
|
||||
@@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
"name": name,
|
||||
"description": metadata["notes"],
|
||||
"metadata": metadata,
|
||||
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()),
|
||||
"reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
490
backend/app/services/earth_news.py
Normal file
490
backend/app/services/earth_news.py
Normal file
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
|
||||
REQUEST_TIMEOUT = 12.0
|
||||
MAX_ITEMS_PER_SOURCE = 6
|
||||
MAX_ITEMS_TOTAL = 12
|
||||
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionProfile:
|
||||
key: str
|
||||
label: str
|
||||
query: str
|
||||
accent: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsFeedSource:
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
feed_url: str
|
||||
homepage_url: str
|
||||
source_type: str = "rss"
|
||||
priority: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
url: str
|
||||
source: str
|
||||
feed_name: str
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedRegionFeed:
|
||||
region: str
|
||||
fetched_at: datetime
|
||||
items: list[ParsedNewsItem]
|
||||
sources: list[NewsFeedSource]
|
||||
|
||||
|
||||
REGION_PROFILES: dict[str, RegionProfile] = {
|
||||
"americas": RegionProfile(
|
||||
key="americas",
|
||||
label="美洲焦点",
|
||||
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
|
||||
accent="#79d3ff",
|
||||
),
|
||||
"europe": RegionProfile(
|
||||
key="europe",
|
||||
label="欧洲焦点",
|
||||
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
|
||||
accent="#8fd4ff",
|
||||
),
|
||||
"middle-east-africa": RegionProfile(
|
||||
key="middle-east-africa",
|
||||
label="中东与非洲焦点",
|
||||
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
|
||||
accent="#ffb56a",
|
||||
),
|
||||
"asia-pacific": RegionProfile(
|
||||
key="asia-pacific",
|
||||
label="亚太焦点",
|
||||
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
|
||||
accent="#78f2cf",
|
||||
),
|
||||
"global": RegionProfile(
|
||||
key="global",
|
||||
label="全球焦点",
|
||||
query='"world news" OR geopolitics OR "global affairs"',
|
||||
accent="#d6e6ff",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||
return (
|
||||
"https://news.google.com/rss/search?q="
|
||||
+ quote(query, safe="")
|
||||
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
|
||||
)
|
||||
|
||||
|
||||
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
|
||||
NewsFeedSource(
|
||||
id="bbc-world",
|
||||
name="BBC World",
|
||||
region="global",
|
||||
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
homepage_url="https://www.bbc.com/news/world",
|
||||
priority=10,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="dw-top",
|
||||
name="DW Top Stories",
|
||||
region="europe",
|
||||
feed_url="https://rss.dw.com/rdf/rss-en-top",
|
||||
homepage_url="https://www.dw.com/en/top-stories/s-9097",
|
||||
priority=20,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="global-scan",
|
||||
name="Global Monitor / World",
|
||||
region="global",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["global"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=30,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-americas",
|
||||
name="Global Monitor / Americas",
|
||||
region="americas",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["americas"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-europe",
|
||||
name="Global Monitor / Europe",
|
||||
region="europe",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["europe"].query,
|
||||
hl="en-GB",
|
||||
gl="GB",
|
||||
ceid="GB:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-mea",
|
||||
name="Global Monitor / MEA",
|
||||
region="middle-east-africa",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["middle-east-africa"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-apac",
|
||||
name="Global Monitor / APAC",
|
||||
region="asia-pacific",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["asia-pacific"].query,
|
||||
hl="en-SG",
|
||||
gl="SG",
|
||||
ceid="SG:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
|
||||
|
||||
|
||||
def determine_focus_region(lat: float | None, lon: float | None) -> str:
|
||||
if lat is None or lon is None:
|
||||
return "global"
|
||||
if -170 <= lon <= -30:
|
||||
return "americas"
|
||||
if -30 < lon <= 45:
|
||||
return "europe" if lat >= 30 else "middle-east-africa"
|
||||
if 45 < lon <= 150:
|
||||
return "middle-east-africa" if lat < 10 else "asia-pacific"
|
||||
return "asia-pacific"
|
||||
|
||||
|
||||
def get_region_profile(region: str) -> RegionProfile:
|
||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
key=lambda source: (source.priority, source.name),
|
||||
)
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
soup = BeautifulSoup(value, "html.parser")
|
||||
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
|
||||
|
||||
|
||||
def _truncate(value: str, limit: int = 180) -> str:
|
||||
text = value.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _normalize_source_name(raw: str, fallback: str) -> str:
|
||||
text = html.unescape((raw or "").strip())
|
||||
if " - " in text:
|
||||
return text.split(" - ")[-1].strip() or fallback
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _parse_datetime(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
for parser in (
|
||||
lambda value: parsedate_to_datetime(value),
|
||||
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
|
||||
):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_item_text(element: ET.Element, *names: str) -> str:
|
||||
for name in names:
|
||||
node = element.find(name)
|
||||
if node is not None and node.text:
|
||||
return node.text.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
|
||||
root = ET.fromstring(xml_text)
|
||||
items: list[ParsedNewsItem] = []
|
||||
|
||||
rss_items = root.findall("./channel/item")
|
||||
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
|
||||
nodes = rss_items or atom_entries
|
||||
|
||||
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
|
||||
if node.tag.endswith("entry"):
|
||||
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
|
||||
summary = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}summary",
|
||||
"{http://www.w3.org/2005/Atom}content",
|
||||
)
|
||||
link_node = node.find("{http://www.w3.org/2005/Atom}link")
|
||||
link = link_node.get("href", "").strip() if link_node is not None else ""
|
||||
published = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}updated",
|
||||
"{http://www.w3.org/2005/Atom}published",
|
||||
)
|
||||
else:
|
||||
title = _extract_item_text(node, "title")
|
||||
summary = _extract_item_text(node, "description", "content")
|
||||
link = _extract_item_text(node, "link")
|
||||
published = _extract_item_text(node, "pubDate", "published", "updated")
|
||||
|
||||
clean_title = html.unescape(title).strip()
|
||||
clean_summary = _truncate(_strip_html(summary), 180)
|
||||
if not clean_title or not link:
|
||||
continue
|
||||
|
||||
item_source = _normalize_source_name(clean_title, source.name)
|
||||
display_title = clean_title
|
||||
if source.source_type == "aggregated" and " - " in clean_title:
|
||||
parts = clean_title.rsplit(" - ", 1)
|
||||
display_title = parts[0].strip()
|
||||
item_source = _normalize_source_name(parts[1], source.name)
|
||||
|
||||
items.append(
|
||||
ParsedNewsItem(
|
||||
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
|
||||
title=display_title,
|
||||
summary=clean_summary,
|
||||
url=link,
|
||||
source=item_source,
|
||||
feed_name=source.name,
|
||||
feed_region=source.region,
|
||||
homepage_url=source.homepage_url,
|
||||
published_at=_parse_datetime(published),
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
"region": source.region,
|
||||
"homepage_url": source.homepage_url,
|
||||
}
|
||||
for source in sources
|
||||
]
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
active_region: str,
|
||||
items: list[ParsedNewsItem],
|
||||
sources: list[NewsFeedSource],
|
||||
errors: list[str],
|
||||
stale: bool,
|
||||
generated_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile = get_region_profile(active_region)
|
||||
timestamp = generated_at or datetime.now(UTC)
|
||||
return {
|
||||
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
|
||||
"focus": {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
"items": [_serialize_item(item, active_region=active_region) for item in items],
|
||||
"errors": errors,
|
||||
"stale": stale,
|
||||
}
|
||||
|
||||
|
||||
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
|
||||
deduped: dict[str, ParsedNewsItem] = {}
|
||||
for item in items:
|
||||
key = item.url.strip() or item.title.strip().lower()
|
||||
if key not in deduped:
|
||||
deduped[key] = item
|
||||
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: (
|
||||
item.feed_region != active_region,
|
||||
item.published_at is None,
|
||||
-(item.published_at.timestamp() if item.published_at else 0),
|
||||
item.feed_name,
|
||||
),
|
||||
)[:MAX_ITEMS_TOTAL]
|
||||
|
||||
|
||||
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
|
||||
cached = _REGION_CACHE.get(region)
|
||||
if not cached:
|
||||
return None
|
||||
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
|
||||
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
|
||||
return None
|
||||
return cached
|
||||
|
||||
|
||||
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
|
||||
_REGION_CACHE[region] = CachedRegionFeed(
|
||||
region=region,
|
||||
fetched_at=datetime.now(UTC),
|
||||
items=list(items),
|
||||
sources=list(sources),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_source(
|
||||
client: httpx.AsyncClient,
|
||||
source: NewsFeedSource,
|
||||
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
|
||||
try:
|
||||
response = await client.get(source.feed_url)
|
||||
response.raise_for_status()
|
||||
return source, _parse_feed_entries(response.text, source), None
|
||||
except Exception as exc:
|
||||
return source, [], str(exc)
|
||||
|
||||
|
||||
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as client:
|
||||
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
|
||||
|
||||
fetched_items: list[ParsedNewsItem] = []
|
||||
for source, items, error in results:
|
||||
if error:
|
||||
errors.append(f"{source.name}: {error}")
|
||||
continue
|
||||
fetched_items.extend(items)
|
||||
|
||||
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
if ranked_items:
|
||||
_store_region_cache(active_region, items=ranked_items, sources=sources)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=ranked_items,
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
|
||||
cached = _get_cached_region_feed(active_region)
|
||||
if cached:
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=cached.items,
|
||||
sources=cached.sources,
|
||||
errors=errors,
|
||||
stale=True,
|
||||
generated_at=cached.fetched_at,
|
||||
)
|
||||
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=[],
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
@@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
|
||||
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
|
||||
|
||||
DEFAULT_TV_SETTINGS = {
|
||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||
"auto_fallback": True,
|
||||
"sources": [
|
||||
{
|
||||
@@ -362,7 +362,7 @@ def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, A
|
||||
"sort_order": metadata.get("sort_order", 200 + index),
|
||||
"collector_source": record.source,
|
||||
"notes": record.description or metadata.get("notes") or "",
|
||||
"updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)),
|
||||
"updated_at": to_iso8601_utc(record.collected_at or record.reference_date or datetime.now(UTC)),
|
||||
},
|
||||
index=index,
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -5,8 +5,440 @@ All notable changes to `planet` are documented here.
|
||||
This project follows the repository versioning rule:
|
||||
|
||||
- `feature` -> `+0.1.0`
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.37.2] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
|
||||
|
||||
### 🔧 Improvements
|
||||
- 经纬线正式接入 Earth layer registry,复用现有图层切换、移动端图层卡片与设置持久化流
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.37.1] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
|
||||
|
||||
### 🔧 Improvements
|
||||
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.37.0] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 连线系统正式从巡航里解耦成通用 callout connector:桌面端和移动端统一支持对象级锚点、四边切换与临界区边缘滑动
|
||||
- BGP 巡航展示继续收口为稳定的“先定位卡片、再连真实锚点、再展示卡片”链路,移动端 popup 与桌面 info panel 的路线规则统一
|
||||
|
||||
### 🔧 Improvements
|
||||
- connector 配置从 `CRUISE_CONFIG` 拆到独立 `CONNECTOR_CONFIG`,默认类名、动画名和实例命名也全部去 cruise 语义
|
||||
- 移动端 popup 增加更稳定的 dock/obstacle 处理,拖动卡片时连线起终点会持续按几何关系自适应刷新
|
||||
- Earth 多个图层与控制逻辑继续收口,补充算力中心/BGP 风格对齐、layer panel 与相关交互细节调整
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复巡航模式下终点只像“视觉锚点”而不是真实绑定对象的问题,卡片拖动后终点现在会跟随
|
||||
- 修复移动端与桌面端多类连线路线异常:压线、反向、临界区折返、起点遮挡事件点等问题
|
||||
- 修复对象矩形临界区内连线仍强制中点到中点导致路线像“先钻进 source 内部”再出去的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.36.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新增统一“算力中心”图层:接入超算与 GPU 集群,支持搜索、统计、图例、详情卡与独立图层开关
|
||||
- 算力中心支持精确位置与估算位置两种状态,估算点会以问号角标区分,避免数据不全时整批节点在地图上消失
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth 详情卡拖拽与地球拖拽交互继续收口,减少拖动卡片和旋转地球时的选中文本与 pointer 竞争
|
||||
- `planet.sh` 改为通过独立脚本计算 AI Provider 依赖指纹,降低与根仓库依赖版本文件的无关耦合
|
||||
- README 补充 WSL / Windows 局域网访问排查与转发配置说明,便于开发环境联调
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Earth 算力中心图层在无原始坐标时无法显示的问题,支持站点提示和国家级估算回退
|
||||
- 修复信息卡拖拽事件可能被卡片级 stopPropagation 吞掉,导致拖拽流中断的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 统计展示改为统一 `data-earth-stat` 绑定机制,桌面 HUD 和移动端抽屉复用同一套状态更新入口
|
||||
|
||||
### 🔧 Improvements
|
||||
- 收口海缆、登陆点、卫星、BGP 事件与 BGP 状态的统计写入逻辑,减少后续继续补桌面/移动双写分支的成本
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复移动端态势抽屉中的海缆、登陆点与 BGP 统计在图层切换后可能停留旧值的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 移动端底部抽屉系统全面上线:响应式布局自动切换、Tab 导航、手势上拉/下滑开合、惯性速度判定
|
||||
- 移动端点击可交互物件(海缆、登陆点、卫星、BGP)后弹出智能定位悬浮卡片,可拖动,点击跳转详情
|
||||
|
||||
### 🔧 Improvements
|
||||
- 抽屉把手区域缩小至 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
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增真实地形图层:后端代理 Terrarium DEM 瓦片(`/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png`),前端新增 `terrain.js` 负责瓦片拉取、顶点位移与按海拔着色
|
||||
- 设置弹窗新增"地形"分组,支持通过滑块实时调整地形图层透明度
|
||||
|
||||
### 🔧 Improvements
|
||||
- 地形按钮改为异步加载,首次点击显示进度提示并在失败时自动回退
|
||||
- 启动阶段改用 `applyImmediateView` 直接应用初始视角,`showStatusMessage` / `queueStatusMessage` 区分即时与队列态状态消息,加载中不再被临时状态打断
|
||||
- 控制面板抽取 `applyTerrainUiState` / `getViewRotation` 收敛地形切换与视角旋转的重复 UI 同步逻辑
|
||||
|
||||
---
|
||||
|
||||
## [0.29.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 继续收口 HUD 交互与设置面板表现,设置弹窗改成更接近从按钮展开的窗口感,同时加入系统级 admin 入口
|
||||
- 修正天球太阳方向与地球受光解耦后的日照逻辑,地表昼夜判断改为按太阳直射点经纬度落到地球贴图坐标
|
||||
|
||||
### 🔧 Improvements
|
||||
- toolbar 进一步收成更贴近 hub 的浅弓形排列,并统一成与 HUD panel 一致的液态玻璃配色与透明度
|
||||
- 设置弹窗与各 HUD panel 继续统一样式、等比缩放和头部基线,设置列表补充系统分组与 admin 跳转
|
||||
- 所有 HUD panel 增加更统一的液态玻璃高光与 hover / press 反馈
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复设置弹窗仍像旧圆角矩形、标题文案重复和从底边直直飞出的动画问题
|
||||
- 修复天球与太阳方向混用显示校准导致中国白天仍落在夜面的日照错误
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 加载状态条改成单一队列式通知面板,加载阶段不再因为步骤文案变化而回缩,也不会被其他通知打断
|
||||
- 调整 brand panel 的呈现方式与昼夜/选中态可读性,让品牌区更自然、交互高亮在白天和黑夜里都更稳定
|
||||
|
||||
### 🔧 Improvements
|
||||
- 移除旧的地球加载浮层结构,统一由 HUD 状态消息承载三点脉冲加载过程
|
||||
- brand panel 改为无边框品牌层,仅保留轻微氛围光,不再因为非常规尺寸显得像第五块功能面板
|
||||
- 温和收敛地球昼夜材质与主背光强度,保留昼夜辨识度的同时提升白天地表纹理和夜面交互可见性
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复加载地球时通知条在步骤切换中反复缩短、其他状态消息抢占加载流程的问题
|
||||
- 修复海缆、登陆点和 BGP 选中高亮在黑夜中过暗、在高光中过亮导致难以辨识的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.29.0] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新增天球层第一版:引入真实全天星图、亮星层与太阳/月亮位置计算,地球场景首次具备可校准的天文背景
|
||||
- 地球昼夜分隔升级为更明显的日夜增强效果,夜面、晨昏带和太阳方向联动更容易直接读出来
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 `celestial.js` 模块和 `assets/celestial/` 资源目录,统一管理星图、亮星数据以及太阳/月亮与光照同步
|
||||
- 卫星图例改为按倾角分组,严格固定为“赤道轨道 → 低倾角轨道 → 中倾角轨道 → 高倾角轨道 → 逆行轨道”顺序,并全部中文化
|
||||
- 图层面板补齐关闭按钮,拖拽脱离左列后不再被流布局 margin 影响,能够真正贴到品牌面板下沿
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复天球球壳放大后被相机 far plane 裁剪导致的外层黑环问题
|
||||
- 修复图层面板在左侧上移时始终与 brand panel 保持额外间距的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.28.2] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 修正媒体情报面板在 `电视直播 / 态势聚合` tab 间切换时的尺寸记忆逻辑,切回原 tab 后可恢复各自大小状态
|
||||
- 清理 `docs/` 根目录遗留的旧路径文档,只保留新的分组目录和归档目录,结束同一文档双路径并存状态
|
||||
|
||||
### 🔧 Improvements
|
||||
- `media-panel` 切换逻辑改成按 tab 分别记忆尺寸状态,避免 `A -> B -> A` 时继续共用同一套外层尺寸
|
||||
- 目录整理真正完成收尾:旧的 `docs/*.md` 平铺计划文档删除,继续以 `docs/agents / earth / backend / frontend / ops / ue5 / deprecated` 为唯一入口
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复拉伸 `media-panel` 后切换 tab 时,`news-panel` 高度回退到旧默认值的问题
|
||||
- 修复拉伸后切换 tab 导致面板视觉锚点异常的问题,切换时改为围绕当前卡片自身右下角进行尺寸恢复
|
||||
|
||||
---
|
||||
|
||||
## [0.28.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界
|
||||
- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated`
|
||||
|
||||
### 🔧 Improvements
|
||||
- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合”
|
||||
- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义
|
||||
- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位
|
||||
- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签
|
||||
|
||||
---
|
||||
|
||||
## [0.28.0] — 2026-04-20
|
||||
|
||||
### ✨ Features
|
||||
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
|
||||
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
|
||||
|
||||
说明:
|
||||
- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel` 和 `news-panel`
|
||||
|
||||
### 🔧 Improvements
|
||||
- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口
|
||||
- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀
|
||||
- `tv.js`、`news.js` 进一步清理共享 HUDPanel 迁移后的残留逻辑,收紧 tab / resize / reform 相关局部 helper
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板右下角缩放时高度异常抬升、标题栏被顶出视口的问题
|
||||
- 修复直播/聚合 tab 切换时按钮高亮、内容切换和底边基准表现不一致的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
## [0.27.8] — 2026-04-20
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机
|
||||
- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题
|
||||
- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
### 🔧 Improvements
|
||||
- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
|
||||
- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
|
||||
- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
|
||||
- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
|
||||
- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
|
||||
- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.6] — 2026-04-15
|
||||
|
||||
### 🔧 Improvements
|
||||
- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链,tabs content-holder 正确撑满剩余高度
|
||||
- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部
|
||||
- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口
|
||||
- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer
|
||||
|
||||
---
|
||||
|
||||
## [0.27.4] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- info-card 改为懒加载动态挂载:页面初始 DOM 不再含隐藏的 `#info-panel` 节点,仅首次点击交互元素时创建
|
||||
|
||||
---
|
||||
|
||||
## [0.27.5] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- 统一控制台多页面滚动体验:BGP、alerts、采集数据、用户管理、任务、设置、Playground 等区域接入自定义滚动条与表格滚动容器
|
||||
- 优化 BGP 与 alerts 页响应式布局:顶部概览卡在窄宽度下优先重排,必要时才启用横向滚动,避免卡片裁切和全局滚动条接管
|
||||
- 调整 `situational alerts` 布局策略:统计卡按宽度在单行、两列和横滚之间切换,下方详情卡保持单行高度优先
|
||||
- 实时采集进度优化:一键采集完成后在未刷新页面时保留 100% 完成态,不再错误归零
|
||||
- 补充 UE5 MVP 融合方案文档,完善后续集成规划沉淀
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 BGP summary 与 alerts 顶部卡片在无真实溢出时误出现横向滚动的问题
|
||||
- 修复 alerts 页面缩窄后外层全局竖向滚动被接管的问题,恢复“一屏内、内部滚动”的布局逻辑
|
||||
- 修复 `situational alerts` 在两排布局下详情卡竖向溢出的问题,改为更稳定的分区响应式排版
|
||||
- 修复自定义滚动条交互反馈,悬停、聚焦、拖拽时颜色加深但不再显示多余外圈
|
||||
|
||||
---
|
||||
|
||||
## [0.27.3] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- TV panel meta 折叠展开方向稳定:底部锚定时向上生长,拖拽后(顶部锚定)通过 JS 补偿 top 保持播放器底部位置不变
|
||||
- 修复 TV panel 展开/折叠时视频区域跳动问题:移除面板 min-height,使播放器高度在两种状态下保持一致
|
||||
- 修正 TV panel meta toggle 箭头方向:展开朝下,折叠朝上
|
||||
- 修复图例面板折叠按钮失效(legend-bar-btn 补充进拖拽排除列表)
|
||||
- 调整图层搜索框图标尺寸为 20px,BR 缩放角标改为直角 L 形
|
||||
|
||||
---
|
||||
|
||||
## [0.27.2] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- 修复 brand copy 宽度不随内容收缩的问题,现在与 title 图片宽度保持一致
|
||||
- 提取 `--brand-copy-width` CSS 自定义属性,消除 160px / 172px 魔法数字重复
|
||||
|
||||
---
|
||||
|
||||
## [0.27.1] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
- 面板拖拽新增 L 形边界约束,其他面板无法覆盖 brand 面板区域,并从右侧/底部自然卡边
|
||||
- brand 组件引入 `--brand-scale` 整体缩放变量,padding 与内容尺寸独立控制
|
||||
- 图层控制面板宽度收窄(260px),与 brand 面板错落排列,间距调大
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复搜索框 `type="search"` 导致清除按钮重复显示的问题
|
||||
- 修复 `[hidden]` 属性被组件 `display` 规则覆盖的问题
|
||||
|
||||
---
|
||||
|
||||
## 0.27.0
|
||||
|
||||
Released: 2026-04-14
|
||||
@@ -80,7 +512,7 @@ Released: 2026-04-12
|
||||
|
||||
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
|
||||
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
|
||||
- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/technical/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/technical/earth-news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -166,7 +598,7 @@ Released: 2026-04-10
|
||||
|
||||
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
|
||||
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -204,7 +636,7 @@ Released: 2026-04-10
|
||||
### Improved
|
||||
|
||||
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
|
||||
## 0.24.6
|
||||
|
||||
@@ -221,7 +653,7 @@ Released: 2026-04-10
|
||||
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
|
||||
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
|
||||
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
|
||||
- Improved [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
- Improved [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -327,8 +759,8 @@ Released: 2026-04-09
|
||||
### Added
|
||||
|
||||
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
|
||||
- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
- Added [docs/frontend/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -433,7 +865,7 @@ Released: 2026-04-07
|
||||
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
|
||||
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
||||
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
||||
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
|
||||
### Improved
|
||||
@@ -559,7 +991,7 @@ Released: 2026-04-02
|
||||
|
||||
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
|
||||
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md).
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-prefix-geography-plan.md).
|
||||
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
||||
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
||||
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
||||
@@ -574,7 +1006,7 @@ Released: 2026-04-02
|
||||
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
|
||||
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
||||
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -732,7 +1164,7 @@ Released: 2026-03-31
|
||||
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md).
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -935,7 +1367,7 @@ Released: 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-module-plan.md).
|
||||
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
|
||||
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.
|
||||
|
||||
|
||||
22
docs/deprecated/README.md
Normal file
22
docs/deprecated/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Deprecated Docs
|
||||
|
||||
这个目录用于存放两类文档:
|
||||
|
||||
1. 已经完成、主要保留为历史记录的实施计划
|
||||
2. 已被现有实现或新方案替代的旧计划
|
||||
|
||||
放到这里并不代表这些文档“错误”,而是表示:
|
||||
|
||||
- 它们不再适合作为当前开发的主指导文档
|
||||
- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考
|
||||
|
||||
当前归档原则:
|
||||
|
||||
- 明确写明“已完成”的计划,优先归档
|
||||
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
||||
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
||||
|
||||
补充说明:
|
||||
|
||||
- 一部分归档文档来自外部或临时工作流草案,例如 sisyphus 生成的初稿
|
||||
- 这类文档如果有可用内容,应先吸收到 `docs/plans/` 或 `docs/technical/`,再归档保留来源记录
|
||||
165
docs/deprecated/hud-panel-component-plan.md
Normal file
165
docs/deprecated/hud-panel-component-plan.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# HUD Panel Component Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Unify Earth HUD panels into a reusable component layer so new panels can share:
|
||||
|
||||
- a consistent shell
|
||||
- a consistent header
|
||||
- a consistent action-button system
|
||||
- a consistent body and collapse pattern
|
||||
|
||||
## Scope
|
||||
|
||||
Target panels:
|
||||
|
||||
- `tv-panel`
|
||||
- `news-panel`
|
||||
- `legend`
|
||||
- `layer-panel`
|
||||
- `earth-stats`
|
||||
- `info-card`
|
||||
- settings modal header/actions
|
||||
|
||||
## Component Model
|
||||
|
||||
### Base shell
|
||||
|
||||
- `.hud-panel`
|
||||
- `.hud-panel--compact`
|
||||
- `.hud-panel--media`
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel-hidden`
|
||||
- `.hud-panel.is-dragging`
|
||||
- `.hud-panel.is-layout-animating`
|
||||
|
||||
### Header
|
||||
|
||||
- `.hud-panel__header`
|
||||
- `.hud-panel__title-group`
|
||||
- `.hud-panel__title`
|
||||
- `.hud-panel__subtitle`
|
||||
- `.hud-panel__chip`
|
||||
- `.hud-panel__actions`
|
||||
|
||||
Header baseline rule:
|
||||
|
||||
- Header title styling is fixed by the component layer and should not drift per panel
|
||||
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
|
||||
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
|
||||
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
|
||||
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
|
||||
|
||||
### Actions
|
||||
|
||||
- `.hud-panel__action`
|
||||
- `.hud-panel__action--icon`
|
||||
- `.hud-panel__action--collapse`
|
||||
- `.hud-panel__action--close`
|
||||
- `.hud-panel__action--refresh`
|
||||
- `.hud-panel__action--external`
|
||||
|
||||
Action-button baseline rule:
|
||||
|
||||
- Header action buttons must have one fixed default style baseline across all HUD panels
|
||||
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
|
||||
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
|
||||
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
|
||||
|
||||
### Body
|
||||
|
||||
- `.hud-panel__body`
|
||||
- `.hud-panel__body--scroll`
|
||||
- `.hud-panel__body--collapsible`
|
||||
|
||||
### Collapse behavior
|
||||
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel--expand-up`
|
||||
- `.hud-panel--expand-down`
|
||||
|
||||
Adaptive collapse / expand rule:
|
||||
|
||||
- HUD panels support two expansion directions:
|
||||
- top-to-bottom expansion
|
||||
- bottom-to-top expansion
|
||||
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
|
||||
- Use:
|
||||
- `d` = available distance from the header anchor to the viewport bottom edge
|
||||
- `h` = expected expanded panel height
|
||||
- buffer = `20px`
|
||||
- Collapsed-state direction rule:
|
||||
- if `d > h + 20px`, the next action direction is `expand-up`
|
||||
- if `d <= h + 20px`, the next action direction is `expand-down`
|
||||
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
|
||||
- if the current direction is already `up`, keep it until `d <= h`
|
||||
- if the current direction is already `down`, keep it until `d > h + 20px`
|
||||
- The opposite edge is still a safety guard:
|
||||
- if the chosen side cannot fit at all, fall back to the other side if it can fit
|
||||
- if neither side fully fits, choose the side with more space and let the body scroll
|
||||
- If neither direction fully fits, choose the direction with more available space and let the body scroll
|
||||
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
|
||||
- The collapse icon describes the next action, not the current state
|
||||
- This mapping is fixed component behavior and must not drift per panel:
|
||||
- collapsed + expand-down => `expand_more`
|
||||
- expanded + expand-down => `expand_less`
|
||||
- collapsed + expand-up => `expand_less`
|
||||
- expanded + expand-up => `expand_more`
|
||||
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
|
||||
- Expansion direction and icon direction must come from one shared source of truth in the component controller
|
||||
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
|
||||
|
||||
## Tokens
|
||||
|
||||
Promote panel differences into CSS variables instead of duplicating selectors:
|
||||
|
||||
- `--hud-panel-padding`
|
||||
- `--hud-header-padding`
|
||||
- `--hud-header-gap`
|
||||
- `--hud-action-padding`
|
||||
- `--hud-action-gap`
|
||||
- `--hud-action-icon-size`
|
||||
- `--hud-body-gap`
|
||||
- `--hud-body-max-height`
|
||||
- `--hud-chip-radius`
|
||||
- `--hud-title-font-size`
|
||||
- `--hud-title-font-weight`
|
||||
- `--hud-title-letter-spacing`
|
||||
- `--hud-title-line-height`
|
||||
- `--hud-title-color`
|
||||
- `--hud-header-border-color`
|
||||
- `--hud-header-divider-opacity`
|
||||
- `--hud-expand-direction`
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
|
||||
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
|
||||
3. Migrate `legend` and `layer-panel` into a compact variant
|
||||
4. Migrate `earth-stats` and `info-card`
|
||||
5. Align settings modal header/actions with the same action system
|
||||
6. Remove legacy one-off button selectors after verification
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change panel behavior and data flow during the first pass
|
||||
- Keep old class names temporarily as compatibility hooks
|
||||
- Prefer variable overrides over per-panel reimplementation
|
||||
- Treat header action-button default styling as fixed component API, not per-panel design space
|
||||
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
|
||||
- Treat collapse direction as a component behavior contract, not a one-off panel trick
|
||||
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
|
||||
- Verify header alignment and drag/collapse behavior after each migration batch
|
||||
|
||||
## First Implementation Batch
|
||||
|
||||
Batch 1 should only do:
|
||||
|
||||
- shared header structure
|
||||
- shared action-button system
|
||||
- shared title typography and header border/divider baseline
|
||||
- shared collapsible body pattern
|
||||
- adaptive collapse direction logic and direction-aware collapse icons
|
||||
- migration of `tv-panel` and `news-panel`
|
||||
|
||||
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.
|
||||
@@ -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可视化架构重构计划
|
||||
|
||||
## 背景
|
||||
@@ -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
|
||||
@@ -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 大屏客户端开发计划
|
||||
|
||||
## 项目概述
|
||||
@@ -1,3 +1,5 @@
|
||||
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||
|
||||
# WebGL Instancing 卫星渲染优化计划
|
||||
|
||||
## 背景
|
||||
37
docs/plans/README.md
Normal file
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:
|
||||
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agent-architecture-plan.md)
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/plans/agents-datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/plans/agents-agent-architecture-plan.md)
|
||||
|
||||
|
||||
## Big Picture
|
||||
@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
|
||||
|
||||
## Why This Layer Exists
|
||||
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md):
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md):
|
||||
|
||||
- incident density is naturally low
|
||||
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
||||
@@ -290,7 +290,7 @@ Each feature should include:
|
||||
|
||||
## Earth Rendering Plan
|
||||
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/bgp-earth-rendering-plan.md).
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-earth-rendering-plan.md).
|
||||
|
||||
### Layer Relationship
|
||||
|
||||
715
docs/plans/earth-celestial-background-plan.md
Normal file
715
docs/plans/earth-celestial-background-plan.md
Normal file
@@ -0,0 +1,715 @@
|
||||
# Earth 天球背景与日月位置实施方案
|
||||
|
||||
## 目标
|
||||
|
||||
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
|
||||
|
||||
1. 用真实天球背景替换当前随机星点
|
||||
2. 在当前时间下显示太阳与月亮的相对位置
|
||||
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
|
||||
|
||||
本方案优先追求:
|
||||
|
||||
- 与当前 Three.js Earth 架构兼容
|
||||
- 风险可控
|
||||
- 先落地一版真实感明显提升的 V1
|
||||
- 为后续更严格的天文参考系升级预留余地
|
||||
|
||||
## 当前现状
|
||||
|
||||
当前 Earth 的基础条件已经具备:
|
||||
|
||||
- 地球、云层、地形、网格都基于 Three.js,主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
|
||||
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不需要重写 Earth
|
||||
- 可以在现有 scene/world 层新增一个 celestial layer
|
||||
- 第一阶段不必拆 Earth / satellite / cable 的参考系
|
||||
|
||||
## 总体策略
|
||||
|
||||
采用“两层现实”设计:
|
||||
|
||||
### 1. 世界层(world-space celestial layer)
|
||||
|
||||
用于放置:
|
||||
|
||||
- 天球背景
|
||||
- 太阳
|
||||
- 月亮
|
||||
- 太阳光方向
|
||||
|
||||
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
|
||||
|
||||
### 2. 地球层(earth-fixed layer)
|
||||
|
||||
继续保持当前结构:
|
||||
|
||||
- 海缆
|
||||
- 登陆点
|
||||
- 卫星点与轨迹
|
||||
- BGP 覆盖
|
||||
- 地球纹理、云层、地形
|
||||
|
||||
这些对象继续挂在 `earthObj` 下,不打断现有交互。
|
||||
|
||||
## 为什么先这样做
|
||||
|
||||
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
|
||||
如果现在直接做严格惯性参考系改造,会同时影响:
|
||||
|
||||
- `earthObj.rotation`
|
||||
- 卫星轨迹与锁定逻辑
|
||||
- 海缆与登陆点附着关系
|
||||
- resetView / autoRotate / hover / click 等交互链路
|
||||
|
||||
所以第一阶段只做:
|
||||
|
||||
- 真正的天空
|
||||
- 真正的日月方向
|
||||
- 不碰现有 Earth 附着对象的语义
|
||||
|
||||
## 推荐技术选型
|
||||
|
||||
### 天文计算库
|
||||
|
||||
推荐:
|
||||
|
||||
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
|
||||
原因:
|
||||
|
||||
- 有 JavaScript 版本
|
||||
- 支持 Sun / Moon 的矢量与坐标变换
|
||||
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
|
||||
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
|
||||
|
||||
不作为主选的库:
|
||||
|
||||
- [SunCalc](https://github.com/mourner/suncalc)
|
||||
|
||||
原因:
|
||||
|
||||
- 更偏本地观察者视角的太阳/月亮高度角
|
||||
- 用于“地面日出日落”很好
|
||||
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
|
||||
|
||||
### Three.js 表现层
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 天球:内翻球壳 + 星图纹理
|
||||
- 太阳:`THREE.Sprite`
|
||||
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
|
||||
- 太阳光:`THREE.DirectionalLight`
|
||||
|
||||
参考:
|
||||
|
||||
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
|
||||
|
||||
## 天球背景资源与星体数据来源
|
||||
|
||||
为避免把“视觉背景”和“可计算天体位置”混为一谈,本方案明确分成两类资源:
|
||||
|
||||
### 1. 背景资源:全天星图贴图
|
||||
|
||||
用于 Phase 1 的“真实天空背景”。
|
||||
|
||||
推荐优先来源:
|
||||
|
||||
- NASA SVS 的 Tycho 全天星图
|
||||
- [The Tycho Catalog Skymap - Version 2.0](https://svs.gsfc.nasa.gov/3572/)
|
||||
- NASA Deep Star Maps 2020
|
||||
- SatelliteMap.space 在 credits 中明确提到其使用了 `NASA Deep Star Maps 2020 - High-resolution star field (1.7 billion stars from Gaia DR2)` 作为星空视觉资源
|
||||
- 这说明行业内成熟实现并不一定直接渲染全部星表点,而很可能先使用一张高质量官方深空星图作为背景层
|
||||
- 如需后续替换,也可评估 ESA / Gaia 的全天 sky map 资源
|
||||
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
|
||||
|
||||
建议要求:
|
||||
|
||||
- 使用官方来源或官方衍生可复用资源
|
||||
- 等距矩形投影(equirectangular)
|
||||
- 坐标定义尽量明确为赤道坐标展开
|
||||
- 分辨率建议至少 `4k`
|
||||
- 颜色不要过亮,避免压过 Earth HUD 前景
|
||||
- 尽量优先选择官方天文机构已经生产好的深空图,而不是自行拼接低质量星空纹理
|
||||
|
||||
建议本地资源目录:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
### 2. 位置数据:星表与天体计算
|
||||
|
||||
用于 Phase 2+ 的“位置正确的星体”。
|
||||
|
||||
推荐来源分两层:
|
||||
|
||||
- 太阳、月亮位置
|
||||
- 使用 [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
- 恒星位置
|
||||
- 第一优先:Hipparcos / Tycho
|
||||
- [Hipparcos overview](https://www.cosmos.esa.int/web/Hipparcos)
|
||||
- [Hipparcos catalogues](https://www.cosmos.esa.int/web/hipparcos/catalogues)
|
||||
- 第二优先:Gaia
|
||||
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
|
||||
|
||||
建议策略:
|
||||
|
||||
- V1:背景球壳只用全天星图,不立即生成全量恒星点
|
||||
- V2:只挑选亮星(例如星等 `< 5.5`)生成恒星点层
|
||||
- V3:如果确实需要更丰富的星场,再逐步扩展到更深星等
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 背景球壳负责“天球真实感”
|
||||
- 亮星点负责“位置正确、可后续标注和高亮”
|
||||
- 不需要一开始就处理数十万甚至数百万颗星
|
||||
|
||||
### 3. 对外部成熟实现的参考结论
|
||||
|
||||
`SatelliteMap.space` 的公开 credits 提供了一个很有价值的参考样板:
|
||||
|
||||
- 图形渲染使用 `TWGL.js`
|
||||
- 天文计算使用 `Skyfield` 与 `Astronomia`
|
||||
- 星空/天球视觉资源使用 `NASA Deep Star Maps 2020`
|
||||
|
||||
这给本项目的启发是:
|
||||
|
||||
- “真实感强的天球背景”完全可以先依赖官方高质量深空图
|
||||
- “位置正确的动态天体”则应依赖单独的天文计算链路
|
||||
- 没有必要在第一版就直接渲染完整星表
|
||||
|
||||
因此本项目推荐继续坚持两层拆分:
|
||||
|
||||
- 背景层:官方深空图 / 全天星图
|
||||
- 计算层:太阳、月亮与后续亮星点
|
||||
|
||||
## 如何保证星体位置正确
|
||||
|
||||
位置正确不是只看“图看起来像”,而是要统一参考系和转换链路。
|
||||
|
||||
### 1. 统一坐标基准
|
||||
|
||||
本方案推荐统一使用:
|
||||
|
||||
- `J2000` 赤道坐标系作为恒星位置基准
|
||||
|
||||
原因:
|
||||
|
||||
- Hipparcos / Tycho 资料和大量天文可视化都容易映射到该基准
|
||||
- 太阳、月亮也可以通过 Astronomy Engine 转到同一坐标系
|
||||
- 这样背景、恒星点、太阳、月亮就能共用一套 sky orientation
|
||||
|
||||
### 2. 背景贴图与点位必须使用同一展开逻辑
|
||||
|
||||
如果背景球壳使用赤道坐标全天图,那么:
|
||||
|
||||
- 亮星点也必须按赤道坐标贴到同一球面方向
|
||||
- 太阳/月亮 sprite 也必须按赤道坐标转换后落到同一 world-space
|
||||
|
||||
否则会出现:
|
||||
|
||||
- 背景银河带是对的
|
||||
- 但太阳/月亮或亮星点飘到不匹配的位置
|
||||
|
||||
### 3. RA / Dec 到 Three.js 坐标的落点方式
|
||||
|
||||
亮星点和日月方向最终都要转成单位球面向量。
|
||||
|
||||
概念步骤:
|
||||
|
||||
1. 读取赤经 `RA`
|
||||
2. 读取赤纬 `Dec`
|
||||
3. 转成弧度
|
||||
4. 映射到单位球面向量
|
||||
5. 再根据 Three.js 当前世界坐标定义做轴向映射
|
||||
|
||||
参考公式:
|
||||
|
||||
```text
|
||||
x = cos(dec) * cos(ra)
|
||||
y = sin(dec)
|
||||
z = cos(dec) * sin(ra)
|
||||
```
|
||||
|
||||
实际接入 Three.js 时,需要做一次项目内坐标轴校准:
|
||||
|
||||
- 验证 `RA = 0h`
|
||||
- 验证 `RA = 6h`
|
||||
- 验证北天极
|
||||
- 验证银河带主方向
|
||||
|
||||
然后确定最终的:
|
||||
|
||||
- `x/y/z` 对应 Three.js 哪个轴
|
||||
- 是否需要 `z` 取反
|
||||
- 是否需要整体再做一个固定 `rotation`
|
||||
|
||||
建议把这层显式封装在:
|
||||
|
||||
```js
|
||||
function equatorialToWorldVector(raRad, decRad)
|
||||
```
|
||||
|
||||
不要把轴映射散落在不同模块里。
|
||||
|
||||
### 4. 背景球壳与恒星点的关系
|
||||
|
||||
推荐最终组合:
|
||||
|
||||
- 背景层:全天星图球壳
|
||||
- 点位层:亮星点
|
||||
- 动态层:太阳 / 月亮
|
||||
|
||||
这样有三个好处:
|
||||
|
||||
- 背景层提供密集真实的天空纹理
|
||||
- 亮星点提供位置正确、可扩展的标注基础
|
||||
- 太阳/月亮提供与时间相关的真实动态对象
|
||||
|
||||
## 数据与资源建议清单
|
||||
|
||||
### 推荐首批引入资源
|
||||
|
||||
1. 全天星图
|
||||
- 来源:NASA Tycho all-sky map
|
||||
- 用途:背景球壳纹理
|
||||
|
||||
2. 月亮纹理
|
||||
- 用途:Phase 4 月相表现
|
||||
- 路径建议:
|
||||
- `frontend/public/earth/assets/celestial/moon_albedo_2k.jpg`
|
||||
|
||||
3. 太阳 glow 贴图
|
||||
- 用途:太阳 sprite halo
|
||||
- 路径建议:
|
||||
- `frontend/public/earth/assets/celestial/sun_glow.png`
|
||||
|
||||
### 推荐首批数据文件
|
||||
|
||||
如果要上亮星层,建议新增一个预处理后的轻量数据文件:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/bright-stars.json`
|
||||
|
||||
建议字段:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 32349,
|
||||
"name": "Sirius",
|
||||
"raDeg": 101.2875,
|
||||
"decDeg": -16.7161,
|
||||
"mag": -1.46,
|
||||
"colorIndex": 0.00
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
建议不要在浏览器里直接吞原始 Gaia 大表,而是先离线裁剪成:
|
||||
|
||||
- 只保留亮星
|
||||
- 只保留渲染必需字段
|
||||
- JSON 或二进制轻量格式
|
||||
|
||||
## 资源与数据实施路线
|
||||
|
||||
### 路线 A:先做可用版本(推荐)
|
||||
|
||||
1. 引入 NASA Tycho 全天图
|
||||
- 或评估替换为更接近 SatelliteMap.space 路线的 `NASA Deep Star Maps 2020`
|
||||
2. 实现背景球壳
|
||||
3. 用 Astronomy Engine 计算太阳/月亮方向
|
||||
4. 暂不做亮星点
|
||||
|
||||
优点:
|
||||
|
||||
- 最快见效
|
||||
- 风险最低
|
||||
- 就能明显提升天球真实感
|
||||
|
||||
### 路线 B:在 A 基础上增强
|
||||
|
||||
1. 离线生成 `bright-stars.json`
|
||||
2. 浏览器端渲染亮星点
|
||||
3. 后续可加:
|
||||
- 星座线
|
||||
- 亮星名称
|
||||
- 特定星体高亮
|
||||
|
||||
优点:
|
||||
|
||||
- 背景真实感和“位置正确的可交互星体”同时兼顾
|
||||
|
||||
## 代码模块建议细化
|
||||
|
||||
### 新增模块
|
||||
|
||||
- `frontend/public/earth/js/celestial.js`
|
||||
- 管理天球背景
|
||||
- 管理太阳/月亮
|
||||
- 管理亮星层(后续)
|
||||
|
||||
- `frontend/public/earth/js/celestial-data.js`
|
||||
- 资源路径
|
||||
- 星图方向配置
|
||||
- 亮星数据加载(后续)
|
||||
|
||||
### 建议函数设计
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date)
|
||||
export function setCelestialVisibility(visible)
|
||||
export function disposeCelestialLayer()
|
||||
|
||||
function loadStarMapTexture()
|
||||
function createSkySphere(texture)
|
||||
function createSunSprite()
|
||||
function createMoonSprite()
|
||||
function getSunEquatorialPosition(date)
|
||||
function getMoonEquatorialPosition(date)
|
||||
function equatorialToWorldVector(raRad, decRad)
|
||||
```
|
||||
|
||||
### 推荐后续预处理脚本
|
||||
|
||||
如要引入亮星层,建议单独做离线脚本:
|
||||
|
||||
- `scripts/build_bright_stars.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 Hipparcos / Tycho 源数据读取
|
||||
- 过滤亮星
|
||||
- 生成 `bright-stars.json`
|
||||
|
||||
这样浏览器端只消费轻量结果,不承担大表解析成本。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
## Phase 1:真实天球背景
|
||||
|
||||
### 目标
|
||||
|
||||
用真实全天星图替换当前随机星点背景。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 新增一张全天星图纹理
|
||||
|
||||
建议路径:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
纹理要求:
|
||||
|
||||
- 等距矩形投影
|
||||
- 赤经/赤纬坐标展开
|
||||
- 无地平线、无地景遮挡
|
||||
- 尽量深色、弱干扰,适合大屏 HUD 叠加
|
||||
|
||||
2. 新增天球球壳
|
||||
|
||||
新增模块:
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
建议接口:
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date, camera, earth)
|
||||
export function disposeCelestialLayer()
|
||||
```
|
||||
|
||||
3. 实现一个大半径内翻球体
|
||||
|
||||
建议参数:
|
||||
|
||||
- 半径:`600 ~ 900`
|
||||
- 材质:`MeshBasicMaterial`
|
||||
- `side: THREE.BackSide`
|
||||
- 不受场景光照影响
|
||||
- 始终围绕场景中心
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 初始加载后背景不再是随机星点
|
||||
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
|
||||
- 不明显干扰海缆/卫星/BGP 的前景识别
|
||||
|
||||
## Phase 2:太阳与月亮真实位置
|
||||
|
||||
### 目标
|
||||
|
||||
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 在 `celestial.js` 内封装天体位置计算
|
||||
|
||||
建议函数:
|
||||
|
||||
```js
|
||||
function getSunDirection(date)
|
||||
function getMoonDirection(date)
|
||||
```
|
||||
|
||||
输出统一为 world-space `THREE.Vector3`
|
||||
|
||||
2. 太阳显示
|
||||
|
||||
- 一个暖色发光 sprite
|
||||
- 比月亮更大、更亮
|
||||
- 可选添加柔和 halo
|
||||
|
||||
3. 月亮显示
|
||||
|
||||
- 一个较小 sprite 或 sphere
|
||||
- 灰白偏冷色
|
||||
- 后续 Phase 3 再做月相
|
||||
|
||||
4. 更新频率
|
||||
|
||||
不要每帧重新做完整天文计算,建议:
|
||||
|
||||
- 每 30 秒或 60 秒重算一次真实位置
|
||||
- 渲染帧内做平滑过渡
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面可见太阳与月亮两个对象
|
||||
- 时间变化时位置会更新
|
||||
- 日月不会跟随地球局部旋转而错误附着
|
||||
|
||||
## Phase 3:太阳驱动地球受光
|
||||
|
||||
### 目标
|
||||
|
||||
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 替换或接管当前主定向光
|
||||
|
||||
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`。
|
||||
|
||||
建议改为:
|
||||
|
||||
- 保留环境补光
|
||||
- 主太阳光方向由 `sunDirection` 决定
|
||||
|
||||
2. 太阳光参数建议
|
||||
|
||||
- `DirectionalLight` 颜色偏暖白
|
||||
- 强度略高于当前主光
|
||||
- 保留一个弱背光作为氛围补偿,避免背面过死黑
|
||||
|
||||
3. 先不做物理级大气散射
|
||||
|
||||
第一版只要求:
|
||||
|
||||
- 亮面与暗面方向真实
|
||||
- 云层和大气仍保持当前风格
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 地球明暗面会随太阳方向改变
|
||||
- 太阳 sprite 和地球亮面方向一致
|
||||
- 不破坏现有海缆、卫星、BGP 的可见性
|
||||
|
||||
## Phase 4:月相与天文细节增强
|
||||
|
||||
### 目标
|
||||
|
||||
在日月真实位置基础上增加更强的“天文可信度”。
|
||||
|
||||
### 可选项
|
||||
|
||||
1. 月相
|
||||
|
||||
- 根据日月夹角计算 illuminated fraction
|
||||
- 用月相纹理或 shader 表达盈亏
|
||||
|
||||
2. 赤道/黄道辅助线
|
||||
|
||||
- 可作为开发调试层,不默认显示
|
||||
|
||||
3. 太阳 terminator 增强
|
||||
|
||||
- 给地球夜面加入更自然的 night tint
|
||||
- 未来可叠加城市夜光纹理
|
||||
|
||||
4. 天文时间入口
|
||||
|
||||
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 月亮不再只是一个静态圆点
|
||||
- 后续扩展行星或观测模式时无需推倒重来
|
||||
|
||||
## Phase 5:严格参考系升级(可选,不作为 V1 必做)
|
||||
|
||||
### 目标
|
||||
|
||||
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
|
||||
|
||||
### 需要处理的问题
|
||||
|
||||
- 地球自转角与 UTC 的一致性
|
||||
- 赤道坐标系、地固坐标系、相机交互层分离
|
||||
- 卫星轨道显示与 Earth 旋转同步关系
|
||||
- resetView 和 autoRotate 的语义重定
|
||||
|
||||
### 风险
|
||||
|
||||
这一步会影响:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
因此不建议与 V1 同时推进。
|
||||
|
||||
## 代码改造清单
|
||||
|
||||
## 1. 新增文件
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 管理天球背景、太阳、月亮
|
||||
- 对外暴露 init/update/dispose
|
||||
|
||||
## 2. 修改 `constants.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
|
||||
新增:
|
||||
|
||||
```js
|
||||
export const CELESTIAL_CONFIG = {
|
||||
sphereRadius: 800,
|
||||
updateIntervalMs: 60000,
|
||||
sunSpriteScale: 28,
|
||||
moonSpriteScale: 16,
|
||||
sunLightIntensity: 1.25,
|
||||
ambientIntensity: 0.28,
|
||||
backLightIntensity: 0.18,
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 修改 `main.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
1. `init()` 中:
|
||||
- 初始化 celestial layer
|
||||
2. `addLights()` 中:
|
||||
- 把固定太阳光改成可更新的 celestial sun light
|
||||
3. `animate()` 中:
|
||||
- 每帧调 `updateCelestialLayer()`
|
||||
4. `destroy()` 中:
|
||||
- 清理 celestial 资源
|
||||
|
||||
## 4. 修改 `earth.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
- `createStars()` 逐步退役
|
||||
- 第一阶段可先保留作为 fallback
|
||||
- 当真实星图加载成功后,不再显示随机星点
|
||||
|
||||
## 5. 新增资源
|
||||
|
||||
目录建议:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/`
|
||||
|
||||
建议至少包含:
|
||||
|
||||
- `starmap_equatorial_4k.jpg`
|
||||
- `sun_glow.png`
|
||||
- `moon_albedo_2k.jpg`
|
||||
|
||||
## 数据流设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
|
||||
B --> C["创建天球球壳"]
|
||||
B --> D["创建太阳 sprite + 主定向光"]
|
||||
B --> E["创建月亮 sprite"]
|
||||
|
||||
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
|
||||
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
|
||||
H --> I["更新 sun sprite / moon sprite 位置"]
|
||||
H --> J["更新太阳 DirectionalLight 方向"]
|
||||
J --> K["地球昼夜方向变化"]
|
||||
```
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
### 1. 星图投影方向容易反
|
||||
|
||||
这会表现为:
|
||||
|
||||
- 星图左右镜像
|
||||
- 赤经方向颠倒
|
||||
- 日月位置和背景对不上
|
||||
|
||||
建议:
|
||||
|
||||
- 先做一个开发调试模式
|
||||
- 显示赤经/赤纬参考点,快速校正纹理朝向
|
||||
|
||||
### 2. 不要让天球跟随 Earth 旋转
|
||||
|
||||
天球背景和日月必须属于 scene/world,而不是 `earthObj`。
|
||||
|
||||
### 3. 不要每帧做重型天文计算
|
||||
|
||||
真实位置更新应节流,否则会浪费 CPU。
|
||||
|
||||
### 4. 月亮先求“方向正确”,再求“月相精致”
|
||||
|
||||
月相属于第二步优化,不应阻塞 V1 上线。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 新建 `celestial.js`
|
||||
2. 用星图球壳替换随机星点
|
||||
3. 接入 Astronomy Engine
|
||||
4. 加太阳/月亮 sprite
|
||||
5. 用太阳方向驱动主光
|
||||
6. 再决定要不要做月相和更严格参考系
|
||||
|
||||
## 最终建议
|
||||
|
||||
对于当前 Planet Earth,最稳妥的方案是:
|
||||
|
||||
- 先做真实天球背景
|
||||
- 再做真实太阳/月亮方向
|
||||
- 再让太阳驱动地球受光
|
||||
- 暂时不做 Earth 参考系重构
|
||||
|
||||
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。
|
||||
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# Earth Compute Center BGP-Style Plan
|
||||
|
||||
## Goal
|
||||
|
||||
这份文档定义如何按照 BGP 模块的产品方式,把“算力中心”提升为 Earth 上的一级能力。
|
||||
|
||||
这里的“按 BGP 方式”指的是:
|
||||
|
||||
- 有独立的数据语义和接口入口
|
||||
- 有独立的 Earth 图层与图例
|
||||
- 有独立的 hover / click / 选中态 / 详情卡
|
||||
- 有独立的统计口径与后续专题页扩展空间
|
||||
|
||||
这里的“按 BGP 方式”不指:
|
||||
|
||||
- 机械复制 BGP 的 anomaly / incident / collector 三层事件模型
|
||||
- 为静态算力设施强行引入不必要的复杂告警语义
|
||||
|
||||
算力中心本质上更接近“长期基础设施分布层”,不是“高频动态异常层”。
|
||||
因此应该复用 BGP 的模块化方法,而不是照搬 BGP 的事件结构。
|
||||
|
||||
## Why
|
||||
|
||||
当前仓库里已经有算力相关基础:
|
||||
|
||||
- 后端已有 `top500` 和 `epoch_ai_gpu` 数据采集
|
||||
- 可视化接口已有 `/api/v1/visualization/geo/supercomputers` 和 `/api/v1/visualization/geo/gpu-clusters`
|
||||
- Earth 信息卡已对 `supercomputer` 和 `gpu_cluster` 做了基础类型兼容
|
||||
|
||||
但当前能力还停留在“数据可取到”的阶段,没有形成像 BGP 那样完整的可视化模块:
|
||||
|
||||
- Earth 缺少独立的算力图层加载模块
|
||||
- 缺少算力 marker 体系和视觉层级
|
||||
- 缺少算力图例、统计、开关和搜索接入
|
||||
- 缺少与海缆、BGP、卫星的关系表达
|
||||
- 缺少算力专题页和后续告警/研判扩展入口
|
||||
|
||||
所以当前真正的缺口不是“有没有数据”,而是“有没有产品级模块”。
|
||||
|
||||
## Core Principle
|
||||
|
||||
算力中心应当采用和 BGP 一致的模块化分层:
|
||||
|
||||
1. 数据层:稳定的数据契约和 GeoJSON 输出
|
||||
2. 渲染层:独立的 Earth 图层、marker 和视觉状态管理
|
||||
3. 交互层:hover、click、锁定态、详情卡、图例和统计
|
||||
4. 扩展层:后续专题页、关系分析、告警和 AI 研判
|
||||
|
||||
但语义上必须保持算力中心自身的特点:
|
||||
|
||||
- `site / center` 是主对象,不是事件
|
||||
- `capacity / rank / vendor / operator / status` 是主信息,不是异常严重度
|
||||
- `distribution / concentration / dependency` 是后续分析方向,不是第一阶段必须项
|
||||
|
||||
## Recommended Scope
|
||||
|
||||
第一版“算力中心”建议统一承载两类对象:
|
||||
|
||||
- `supercomputer`
|
||||
- `gpu_cluster`
|
||||
|
||||
并在 Earth 上收口为一个主题层:`compute_centers`
|
||||
|
||||
这样做有几个好处:
|
||||
|
||||
- 用户看到的是统一的“算力基础设施”语义,而不是零散数据源
|
||||
- 后端仍可保留 `top500` 和 `epoch_ai_gpu` 的来源差异
|
||||
- 前端可以在一个图层里再细分两种 marker 语言
|
||||
|
||||
## Current Gap
|
||||
|
||||
和 BGP 对比,当前差距主要在下面几层。
|
||||
|
||||
### 1. Data Contract Gap
|
||||
|
||||
现在的算力 GeoJSON 还是通用 `collected_data` 输出思路,字段较轻:
|
||||
|
||||
- `gpu_cluster` 只有基础名称和地点
|
||||
- `supercomputer` 只暴露一部分性能字段
|
||||
- 缺少统一的 `site_type / operator / capacity_band / source / updated_at / confidence`
|
||||
- 缺少统一的算力层聚合出口
|
||||
|
||||
### 2. Earth Rendering Gap
|
||||
|
||||
当前 Earth 里没有类似 `bgp.js` 的算力模块:
|
||||
|
||||
- `constants.js` 没有算力 API 路径和视觉配置
|
||||
- `main.js` 没有算力加载、拾取、状态同步和 HUD 更新
|
||||
- `controls.js` 没有算力图层开关和启动加载优先级
|
||||
- `layer-startup-tasks.js` 没有算力启动任务
|
||||
- `legend.js` / `ui.js` 没有算力统计与图例模式
|
||||
|
||||
### 3. Interaction Gap
|
||||
|
||||
虽然 `info-card.js` 支持基础字段,但还没有形成 BGP 那种完整交互链路:
|
||||
|
||||
- 没有 hover / selected / dimmed 的视觉状态
|
||||
- 没有算力对象专属 tooltip 与摘要文案
|
||||
- 没有锁定后与其他基础设施的联动高亮
|
||||
- 没有搜索、统计卡和详情组织方式
|
||||
|
||||
### 4. Product Expansion Gap
|
||||
|
||||
当前还没有“算力中心”专题页与分析语义:
|
||||
|
||||
- 没有全球分布/国家聚合/厂商聚合视图
|
||||
- 没有算力与海缆/BGP/区域的关系表达
|
||||
- 没有 AI brief / assessment 的后续落点
|
||||
|
||||
## Architecture Direction
|
||||
|
||||
推荐把算力中心做成“BGP 同级能力”,但采用更适合静态基础设施的结构。
|
||||
|
||||
### Backend
|
||||
|
||||
建议新增统一聚合接口,例如:
|
||||
|
||||
- `/api/v1/visualization/geo/compute-centers`
|
||||
|
||||
它的职责是把:
|
||||
|
||||
- `top500`
|
||||
- `epoch_ai_gpu`
|
||||
|
||||
统一转换成一个主题层输出,同时保留对象细分类型:
|
||||
|
||||
- `site_type: supercomputer | gpu_cluster`
|
||||
|
||||
建议统一字段至少包括:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `site_type`
|
||||
- `country`
|
||||
- `city`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `operator`
|
||||
- `vendor`
|
||||
- `capacity_value`
|
||||
- `capacity_unit`
|
||||
- `capacity_band`
|
||||
- `rank`
|
||||
- `source`
|
||||
- `updated_at`
|
||||
- `location_precision`
|
||||
- `geography_mode`
|
||||
- `is_estimated`
|
||||
- `estimated_reason`
|
||||
- `metadata`
|
||||
|
||||
这里建议优先做“统一聚合出口”,而不是一开始就新增独立数据库表。
|
||||
|
||||
原因:
|
||||
|
||||
- 当前源数据更新频率低,先复用 `collected_data` 成本更低
|
||||
- 可以先把 Earth 产品体验做完整
|
||||
- 如果后续要做历史趋势、关系推断、告警,再评估是否拆成独立模型
|
||||
|
||||
### Frontend Earth
|
||||
|
||||
建议新增独立模块,例如:
|
||||
|
||||
- `frontend/public/earth/js/compute-centers.js`
|
||||
|
||||
职责参照 `bgp.js`:
|
||||
|
||||
- 拉取算力中心 GeoJSON
|
||||
- 创建 marker
|
||||
- 管理 hover / selected / dimmed 状态
|
||||
- 输出图例项
|
||||
- 输出统计摘要
|
||||
- 提供 overlay 和详情格式化辅助函数
|
||||
|
||||
推荐视觉分层:
|
||||
|
||||
1. `supercomputer` 用更稳定、更规整的设施型符号
|
||||
2. `gpu_cluster` 用更活跃、更现代的密度型符号
|
||||
3. 选中态通过 halo / ring / related infrastructure highlight 表达
|
||||
|
||||
视觉上应避免把算力中心做成“BGP 事件点”那种高频脉冲风格。
|
||||
它应该更像长期存在的高价值设施。
|
||||
|
||||
## Phases
|
||||
|
||||
## Phase 1: Unified Earth Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 先把算力中心做成 Earth 上可用、可点、可解释的一级图层
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增统一算力 GeoJSON 接口
|
||||
- 新增 `compute-centers.js`
|
||||
- 在 `constants.js` 增加 API 路径和视觉配置
|
||||
- 在 `controls.js` 增加算力图层开关与启动元数据
|
||||
- 在 `layer-startup-tasks.js` 增加算力启动加载任务
|
||||
- 在 `main.js` 接入算力拾取、hover、click、锁定态和 HUD 统计
|
||||
- 在 `ui.js` / `legend.js` / `index.html` 增加算力统计与图例入口
|
||||
- 在 `info-card.js` 提升算力详情字段组织
|
||||
- 对无法精确定位、但可按国家或弱线索推测的大概位置,仍然生成地图点位
|
||||
- 这类对象必须带显式“估算位置”状态,例如图标问号角标与详情说明
|
||||
|
||||
完成标准:
|
||||
|
||||
- Earth 上能独立显示/隐藏算力中心
|
||||
- 两类对象有可区分的视觉表达
|
||||
- hover / click / 详情卡 / 图例 / 统计全部打通
|
||||
- 精确位置与估算位置在图标或文案上可区分,不会误导为同一精度
|
||||
- 不干扰现有海缆、卫星、BGP 的交互链路
|
||||
|
||||
## Phase 2: Relationship Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 让算力中心不只是“点”,而是和其他基础设施产生上下文关系
|
||||
|
||||
工作项:
|
||||
|
||||
- 建立算力中心与国家/区域聚合摘要
|
||||
- 增加与附近海缆登陆点的关系提示
|
||||
- 增加与 BGP 事件/观测范围的空间邻近提示
|
||||
- 增加与卫星覆盖或区域连通性的实验性提示
|
||||
|
||||
完成标准:
|
||||
|
||||
- 点击算力中心时,用户能看到“它和哪些基础设施相关”
|
||||
- 信息表达以辅助判断为主,不做夸张推断
|
||||
|
||||
## Phase 3: Compute Center Observatory
|
||||
|
||||
目标:
|
||||
|
||||
- 把算力中心从 Earth 图层扩展成独立专题观测能力
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增算力中心专题页
|
||||
- 提供国家/厂商/类型/容量分布统计
|
||||
- 支持列表、筛选、详情和历史快照
|
||||
- 预留 AI brief / assessment 入口
|
||||
|
||||
完成标准:
|
||||
|
||||
- 算力中心不再只是 Earth 上的视觉点位
|
||||
- 能作为独立业务上下文进入日常观察与研判
|
||||
|
||||
## Phase 4: Alerts And Assessment
|
||||
|
||||
目标:
|
||||
|
||||
- 在不滥造“假动态告警”的前提下,引入真正有价值的变化感知
|
||||
|
||||
候选方向:
|
||||
|
||||
- 新增大规模算力中心
|
||||
- 既有中心容量显著变化
|
||||
- 国家/区域集中度显著变化
|
||||
- 高价值中心与关键网络基础设施关系变化
|
||||
|
||||
完成标准:
|
||||
|
||||
- 告警来自可解释的结构变化
|
||||
- 不把静态数据硬做成噪声式实时事件流
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
建议按下面顺序推进:
|
||||
|
||||
1. 先统一 GeoJSON 契约
|
||||
2. 再做 Earth 独立模块和图层开关
|
||||
3. 再补详情卡、图例和统计
|
||||
4. 最后才做关系层和专题页
|
||||
|
||||
这样可以避免一开始把范围摊得过大。
|
||||
|
||||
## Unknown Location Strategy
|
||||
|
||||
由于部分算力数据源不会直接提供经纬度,未知位置补全不能只依赖“继续找 API 字段”。
|
||||
更稳妥的方式是做成一条分层富化链路,而不是单一猜测规则。
|
||||
|
||||
推荐按下面优先级推进:
|
||||
|
||||
1. 直接源信息
|
||||
|
||||
- 源记录显式给出 `latitude / longitude`
|
||||
- 源记录给出 `city / region / facility / campus / operator`
|
||||
- 源页面详情、内嵌 JSON、结构化元数据、新闻稿链接里能抽出地点线索
|
||||
|
||||
2. 名称与机构归一化
|
||||
|
||||
- 建立 `canonical_name / aliases / operator / facility` 归一化表
|
||||
- 把 `cluster name`、`operator`、`campus name` 归一到同一个实体
|
||||
- 优先解决同一对象多写法导致的命中失败,而不是先扩大猜测范围
|
||||
|
||||
3. 本地位置注册表
|
||||
|
||||
- 用仓库内可维护的 registry 保存高价值对象的位置知识
|
||||
- 每条记录至少包含:`canonical_name`、`aliases`、`operator`、`country`、`region`、`city`、`lat`、`lon`、`confidence`、`source_note`
|
||||
- 转换层优先读取 registry,避免地点知识长期散落在转换代码里
|
||||
|
||||
4. 分层回退定位
|
||||
|
||||
- `precise`
|
||||
- `estimated_site`
|
||||
- `estimated_city`
|
||||
- `estimated_region`
|
||||
- `estimated_national_hub`
|
||||
- `estimated_country`
|
||||
|
||||
这里建议把“国家内主要算力城市”作为国家质心之前的一层。
|
||||
例如没有美国精确位置时,优先考虑已知的主要算力/数据中心城市候选,而不是直接落在几何质心。
|
||||
|
||||
5. 候选证据富化
|
||||
|
||||
- 如果源 API 无地点信息,可以允许采集链路读取公开辅助证据
|
||||
- 例如机构官网、数据中心介绍页、新闻稿、百科型页面、公开 PDF
|
||||
- 但只提取“地点线索”,不把外部页面上的经纬度当真值直接写回
|
||||
|
||||
6. 人工校验闭环
|
||||
|
||||
- 对高价值且仍然未知的对象输出待核验清单
|
||||
- 把人工确认结果回写到位置注册表
|
||||
- 后续采集继续优先复用这层人工确认结果
|
||||
|
||||
### Additional Solution Paths
|
||||
|
||||
除了静态映射表,还可以考虑下面这些办法:
|
||||
|
||||
- 基于国家和运营方建立“主要园区候选集”,用稳定散列把同国未知节点分散到若干可信城市,而不是全部压到一个点
|
||||
- 基于数据中心/云厂商公开 region 列表建立 `operator -> city set` 候选映射,用于云 GPU 集群类对象
|
||||
- 把“估算依据”结构化,例如 `matched_alias`、`matched_operator`、`matched_city_text`、`fallback_country_hub`
|
||||
- 给位置补全增加 `last_verified_at`,便于后续按时间重新校验老旧映射
|
||||
- 单独维护“不可可靠定位”状态;这类对象仍可在国家级聚合统计中出现,但可以允许用户在地图上过滤掉
|
||||
- 后续如果你们愿意投入更多,可把这条链路做成小型 enrichment pipeline,而不是仅在 API 转换时临时判断
|
||||
|
||||
## Non-Goals
|
||||
|
||||
第一阶段不建议做这些内容:
|
||||
|
||||
- 不复制 BGP 巡航模式到算力中心
|
||||
- 不先做复杂实时 websocket 推送
|
||||
- 不先引入独立 `compute_center_incident` 一类模型
|
||||
- 不先做全量 AI 分析面板
|
||||
|
||||
原因是算力中心的第一需求是“被看清楚”,不是“被实时播报”。
|
||||
但“被看清楚”不等于“只显示精确坐标对象”。
|
||||
对于没有精确经纬度、但能推测到国家或区域级位置的算力中心,应优先以上图并标注估算状态的方式处理,而不是直接在地图上消失。
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- 后端存在统一的算力中心 GeoJSON 出口
|
||||
- Earth 有独立算力图层模块,而不是散落在 `main.js`
|
||||
- 页面上有清晰的算力开关、图例和统计
|
||||
- `supercomputer` 和 `gpu_cluster` 在视觉和详情上都可区分
|
||||
- 估算位置对象在地图和详情中都有明确状态提示
|
||||
- 现有 BGP / 海缆 / 卫星功能无回归
|
||||
- 代码结构上为后续专题页和关系分析留出了明确扩展点
|
||||
|
||||
## Summary
|
||||
|
||||
这项工作的本质不是“再多画几个点”。
|
||||
|
||||
它应该把算力中心从已有数据源,升级成与 BGP 同级的 Earth 观测主题:
|
||||
|
||||
- 有独立语义
|
||||
- 有独立图层
|
||||
- 有独立交互
|
||||
- 有后续分析扩展能力
|
||||
|
||||
推荐先完成 Phase 1,把算力中心做成真正可用的 Earth 一级模块,再继续推进关系层和专题页。
|
||||
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. 页面切换回来不会闪出旧轨道残留
|
||||
472
docs/plans/earth-real-terrain-plan.md
Normal file
472
docs/plans/earth-real-terrain-plan.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Earth Real Terrain Plan
|
||||
|
||||
## Goal
|
||||
|
||||
将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。
|
||||
|
||||
当前占位实现位于:
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
具体问题:
|
||||
|
||||
- `createTerrain()` 直接对球体顶点应用 `simplex noise`
|
||||
- 没有真实海拔数据来源
|
||||
- 没有分辨率分层
|
||||
- 没有和当前相机/视角配套的性能控制
|
||||
|
||||
## Constraints
|
||||
|
||||
本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎:
|
||||
|
||||
- 地球主体仍然是一个 Three.js sphere
|
||||
- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上
|
||||
- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换
|
||||
- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形
|
||||
|
||||
## Recommended Data Source
|
||||
|
||||
### Primary recommendation
|
||||
|
||||
使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入:
|
||||
|
||||
- Mapzen/AWS `Terrarium` elevation tiles
|
||||
参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
|
||||
原因:
|
||||
|
||||
- 已经是全球瓦片化高程
|
||||
- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe
|
||||
- 编码简单稳定:
|
||||
- `heightMeters = (R * 256 + G + B / 256) - 32768`
|
||||
- 不需要我们先离线拼整球 DEM
|
||||
|
||||
### Data quality upgrade path
|
||||
|
||||
如果后面第一阶段效果确认可用,再逐步升级到底层源:
|
||||
|
||||
- Copernicus DEM GLO-30
|
||||
参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles
|
||||
|
||||
这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。
|
||||
|
||||
## Why Not Replace the Engine
|
||||
|
||||
不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因:
|
||||
|
||||
- 现有 Earth 业务对象都依附当前球面坐标
|
||||
- 切引擎会同时波及:
|
||||
- 海缆绘制
|
||||
- 卫星/轨迹
|
||||
- BGP 标记
|
||||
- HUD 与交互
|
||||
- 这是“重做一页”,不是“给地形层接真实数据”
|
||||
|
||||
所以推荐路线是:
|
||||
|
||||
- 保持当前 sphere globe
|
||||
- 为 sphere 增加真实高度位移层
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
分三期推进。
|
||||
|
||||
### Phase 1 — Global Heightmap Terrain Overlay
|
||||
|
||||
目标:
|
||||
|
||||
- 地形层切换后显示真实海拔起伏
|
||||
- 全球范围可用
|
||||
- 性能可控
|
||||
|
||||
做法:
|
||||
|
||||
1. 新增 terrain 数据模块
|
||||
|
||||
建议文件:
|
||||
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
职责:
|
||||
|
||||
- 选择 DEM zoom level
|
||||
- 请求 Terrarium tiles
|
||||
- 解码 tile 高程
|
||||
- 将高程重采样到当前地形球体网格
|
||||
|
||||
2. 替换 `createTerrain()`
|
||||
|
||||
当前:
|
||||
|
||||
- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形
|
||||
|
||||
调整后:
|
||||
|
||||
- `createTerrain()` 只负责创建 terrain mesh 骨架
|
||||
- 真正的顶点位移由 terrain 模块异步注入
|
||||
|
||||
3. 第一阶段采用“整球低分辨率位移”
|
||||
|
||||
不要一上来做动态 patch stitching。第一阶段更稳的办法是:
|
||||
|
||||
- 保留一张全球 terrain sphere
|
||||
- 使用较低分辨率几何
|
||||
- 例如 `SphereGeometry(radius, 192, 192)` 或 `256/256`
|
||||
- 运行时按一个固定地形 zoom(如 `z=4` 或 `z=5`)抓取覆盖全球的 Terrarium tiles
|
||||
- 将 tile 解码后重投影到经纬度采样网格
|
||||
- 将每个球面顶点按真实高度抬升
|
||||
|
||||
这样第一阶段就能做到:
|
||||
|
||||
- 有真实地形
|
||||
- 不需要复杂的局部 LOD
|
||||
- 不会让现有球体对象体系爆炸
|
||||
|
||||
### Phase 2 — View-Aware Refinement
|
||||
|
||||
目标:
|
||||
|
||||
- 正面可见区域更精细
|
||||
- 背面与远处维持低成本
|
||||
|
||||
做法:
|
||||
|
||||
- 引入“基础全球地形 + 当前视角高分局部补丁”
|
||||
- 正面区域额外抓更高 zoom 的高程 tile
|
||||
- 只替换局部顶点位移或局部 overlay mesh
|
||||
|
||||
这一阶段适合在第一阶段稳定后做。
|
||||
|
||||
### Phase 3 — Normals / Shading / Terrain UX
|
||||
|
||||
目标:
|
||||
|
||||
- 地形不仅有起伏,还更好看、更可读
|
||||
|
||||
包括:
|
||||
|
||||
- 根据高度生成更合理的 normals
|
||||
- 调整 terrain material,使山脉/高原更易读
|
||||
- 可选加入:
|
||||
- hillshade
|
||||
- contour lines
|
||||
- snowline / bathymetry tint
|
||||
|
||||
## Calibration Overlay Before More Terrain Tuning
|
||||
|
||||
在当前项目里,terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。
|
||||
|
||||
没有清晰的海岸线、国界线和地表分层时,人眼很难判断:
|
||||
|
||||
- 山脉是不是在应该高的地方高
|
||||
- terrain 是否真的贴在正确的大陆位置上
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否存在偏移
|
||||
|
||||
这里要明确区分两件事:
|
||||
|
||||
- 国界线不会修好错误的 terrain
|
||||
- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准
|
||||
|
||||
所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。
|
||||
|
||||
### Recommended order for the calibration layer
|
||||
|
||||
1. 海岸线
|
||||
2. 国界线
|
||||
3. 再继续调 terrain
|
||||
|
||||
原因:
|
||||
|
||||
- 海岸线比国界线更基础,也更接近真实地表边界
|
||||
- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系
|
||||
- 国界线更多是政治边界,只能作为辅助参照
|
||||
|
||||
如果只加国界线,不加海岸线,效果仍然可能会怪,因为:
|
||||
|
||||
- 很多国界线本来就是人为直线
|
||||
- 它们并不总是跟真实地形走
|
||||
|
||||
### Suggested layer order during debugging
|
||||
|
||||
建议调试期临时把地球层次明确成:
|
||||
|
||||
1. base earth texture
|
||||
2. coastline / borders overlay
|
||||
3. terrain relief
|
||||
4. cables / landing points / bgp / satellites
|
||||
|
||||
这样会比现在更容易判断:
|
||||
|
||||
- 山脉是否位于正确区域
|
||||
- terrain 是否和地表对齐
|
||||
- 国界/海岸是否漂移
|
||||
|
||||
### Suggested data source for the calibration overlay
|
||||
|
||||
优先用 `Natural Earth` 的轻量全球矢量数据:
|
||||
|
||||
- 海岸线(coastline)
|
||||
- Admin 0 国界线(country borders)
|
||||
|
||||
优点:
|
||||
|
||||
- 全球一致
|
||||
- 轻量
|
||||
- 很适合当前 Three.js globe 做 overlay
|
||||
|
||||
### Recommended execution path
|
||||
|
||||
#### Phase A — Add reference overlays
|
||||
|
||||
先加两层可开关的参考线:
|
||||
|
||||
- 海岸线
|
||||
- 国界线
|
||||
|
||||
这两层的目标不是最终美术表现,而是调试 / 校准。
|
||||
|
||||
#### Phase B — Recalibrate terrain against coastline
|
||||
|
||||
有了海岸线以后,再重新看 terrain:
|
||||
|
||||
- terrain 是否和大陆边缘错位
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移
|
||||
|
||||
#### Phase C — Decide whether to keep the current terrain path
|
||||
|
||||
这时再决定后面的路线:
|
||||
|
||||
- 如果发现真实高程整体是对的,只是缺少 shading / readability
|
||||
继续保留当前 DEM + terrain overlay 路线
|
||||
- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭
|
||||
再考虑重做 terrain pipeline
|
||||
|
||||
### Practical recommendation
|
||||
|
||||
当前阶段不建议“从头开始重做 terrain”。
|
||||
|
||||
更稳的策略是:
|
||||
|
||||
- 暂停继续盲调 terrain 参数
|
||||
- 先补海岸线 / 国界线作为校准参照层
|
||||
- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移”
|
||||
|
||||
## Recommended Geometry Model
|
||||
|
||||
### First usable model
|
||||
|
||||
保留一层独立 terrain sphere:
|
||||
|
||||
- base earth sphere:贴纹理、昼夜、海洋
|
||||
- terrain sphere:略高于地球半径,真实高程位移
|
||||
|
||||
建议:
|
||||
|
||||
- `terrainBaseRadius = CONFIG.earthRadius + 0.2`
|
||||
- 高度缩放使用真实米制换算,再乘一个可调 exaggeration
|
||||
|
||||
示例关系:
|
||||
|
||||
- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration`
|
||||
|
||||
建议第一阶段 `exaggeration = 1.3 ~ 1.8`
|
||||
|
||||
因为完全真实比例在全球球体上会太平,看不出来。
|
||||
|
||||
## Tile Decoding Plan
|
||||
|
||||
### Terrarium decode
|
||||
|
||||
对于每个高程 tile 像素:
|
||||
|
||||
```text
|
||||
heightMeters = (R * 256 + G + B / 256) - 32768
|
||||
```
|
||||
|
||||
### Sampling path
|
||||
|
||||
对于 terrain mesh 上每个顶点:
|
||||
|
||||
1. 将顶点方向转成经纬度
|
||||
2. 将经纬度映射到 Web Mercator tile 坐标
|
||||
3. 找到对应的 tile 和像素
|
||||
4. 解码高程
|
||||
5. 将顶点沿法线方向抬升
|
||||
|
||||
### Needed helpers
|
||||
|
||||
建议新增:
|
||||
|
||||
- `latLonToTileXY(lat, lon, z)`
|
||||
- `tilePixelFromLatLon(lat, lon, z, tileSize)`
|
||||
- `decodeTerrariumHeight(r, g, b)`
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
为了不让地形开关每次重开都重新抓全量 tile:
|
||||
|
||||
- terrain tile 按 `z/x/y` 存到内存缓存
|
||||
- terrain mesh 结果也缓存一份
|
||||
- 当用户关闭/开启 terrain:
|
||||
- 直接复用已有位移结果
|
||||
|
||||
建议:
|
||||
|
||||
- `Map<string, Float32Array | ImageBitmap>`
|
||||
|
||||
## Material Strategy
|
||||
|
||||
第一阶段不要复杂化。
|
||||
|
||||
建议 terrain material:
|
||||
|
||||
- 半透明低饱和地形色
|
||||
- 比 base earth 稍亮或稍偏冷
|
||||
- 保留当前 HUD 风格下的可读性
|
||||
|
||||
第一阶段不需要:
|
||||
|
||||
- 真实土地覆被纹理
|
||||
- 独立卫星影像贴 terrain
|
||||
|
||||
因为那会和现有地球纹理、云层、昼夜 shader 打架。
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files to change
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 重写 `createTerrain()`
|
||||
- 删除 simplex noise 占位逻辑
|
||||
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 初始化 terrain 数据加载
|
||||
- 控制 terrain readiness / loading message
|
||||
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- `toggleTerrain` 逻辑保持,但应能区分:
|
||||
- mesh 已就绪
|
||||
- 正在加载
|
||||
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 新增 `TERRAIN_CONFIG`
|
||||
- 新文件:
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
### Suggested new config
|
||||
|
||||
建议新增:
|
||||
|
||||
```js
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
baseRadiusOffset: 0.2,
|
||||
exaggeration: 1.5,
|
||||
opacity: 0.55,
|
||||
color: 0x6c876f,
|
||||
maxConcurrentRequests: 8,
|
||||
cacheEnabled: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Loading UX
|
||||
|
||||
地形第一次开启时,不能像现在一样瞬时切换。
|
||||
|
||||
建议:
|
||||
|
||||
- 如果地形数据尚未准备:
|
||||
- 顶部状态条显示:`正在加载真实地形数据...`
|
||||
- 完成后:
|
||||
- `真实地形已就绪`
|
||||
|
||||
如果加载失败:
|
||||
|
||||
- 保留 base earth
|
||||
- 显示轻量错误提示
|
||||
- 不要让 terrain 开关卡死在“开”状态
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Global tile count too high
|
||||
|
||||
即使 `z=5` 全球 tile 数也不少。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段限定低 zoom
|
||||
- 并发上限
|
||||
- 缓存
|
||||
|
||||
### 2. Mesh resolution too low
|
||||
|
||||
如果球面分段太低,山脉会被抹平。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段先选一个中等分辨率
|
||||
- 用 exaggeration 保证可见性
|
||||
|
||||
### 3. Existing overlays may z-fight with terrain
|
||||
|
||||
海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。
|
||||
|
||||
缓解:
|
||||
|
||||
- terrain sphere 单独作为 overlay
|
||||
- overlay 保持略低或略高的固定 offset
|
||||
- 必要时局部调整 landing point / cable altitude offset
|
||||
|
||||
### 4. Mercator sampling distortion near poles
|
||||
|
||||
Web Mercator 在高纬会有失真。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段接受
|
||||
- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
第一阶段完成后,应满足:
|
||||
|
||||
1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声
|
||||
2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认
|
||||
3. 关闭/重新开启 terrain 不重复全量请求
|
||||
4. 不破坏:
|
||||
- 海缆
|
||||
- 卫星
|
||||
- BGP
|
||||
- 地球昼夜
|
||||
- 天球层
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
1. 引入 `TERRAIN_CONFIG`
|
||||
2. 新建 `terrain.js`
|
||||
3. 实现 Terrarium tile 请求与 decode
|
||||
4. 用低 zoom 全球 tile 构建真实 terrain sphere
|
||||
5. 接管 `toggleTerrain()`
|
||||
6. 调整 terrain material 和高度 exaggeration
|
||||
7. 做缓存
|
||||
8. 再考虑第二阶段局部高分 refinement
|
||||
|
||||
## Source References
|
||||
|
||||
- Mapzen Terrarium / AWS terrain tiles
|
||||
[Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
- Terrarium tile experiments / format background
|
||||
[mapzen/terrarium](https://github.com/mapzen/terrarium)
|
||||
- Copernicus DEM overview
|
||||
[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
|
||||
## Recommendation Summary
|
||||
|
||||
如果现在就要开始做,我建议直接按这条路线开工:
|
||||
|
||||
- 第一阶段接入 Terrarium 全球高程 tile
|
||||
- 替换掉当前 simplex 假地形
|
||||
- 先做一层真实可见的全球 terrain overlay
|
||||
- 等第一阶段稳定,再做视角高分 refinement
|
||||
|
||||
这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。
|
||||
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)
|
||||
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
|
||||
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
|
||||
### 2. 本地运行与配置打通
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
相关文件:
|
||||
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
|
||||
## 当前限制
|
||||
1015
docs/plans/ue5-mvp-fused-plan.md
Normal file
1015
docs/plans/ue5-mvp-fused-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
||||
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/bgp-region-aggregation-plan.md).
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md).
|
||||
|
||||
So the immediate next milestone is:
|
||||
|
||||
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` 采集器采集源
|
||||
- 当前默认兜底源为 `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,40 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.37.2`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
|
||||
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh` 在 `uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
|
||||
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector,统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
|
||||
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
|
||||
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
|
||||
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
|
||||
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
|
||||
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override,并修复 TV 合并采集源后默认频道消失的问题 |
|
||||
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源,并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
|
||||
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
|
||||
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
|
||||
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
|
||||
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
|
||||
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
||||
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
||||
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
|
||||
| `0.28.2` | bugfix | `dev` | `pending` | 修正媒体情报 tab 尺寸记忆与切换锚点逻辑,并清理 docs 根目录遗留旧路径文档 |
|
||||
| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 |
|
||||
| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab,并稳定 TV/news 的 reform、resize 与共享 HUD 行为 |
|
||||
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
|
||||
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
|
||||
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复,Playground 响应式按钮与输入框收起优化 |
|
||||
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
|
||||
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
|
||||
| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
|
||||
| `0.27.2` | improvement | `dev` | — | 修复 brand copy 宽度问题,提取 --brand-copy-width CSS 变量 |
|
||||
| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 |
|
||||
| `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 |
|
||||
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |
|
||||
| `0.1.0` | feature | `main` | `6cb4398f` | Modularize 3D Earth page with ES Modules |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.0",
|
||||
"version": "0.37.2",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -25,8 +25,8 @@
|
||||
"vite": "^5.0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "bun ./node_modules/vite/bin/vite.js",
|
||||
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "bun ./node_modules/vite/bin/vite.js preview"
|
||||
}
|
||||
}
|
||||
|
||||
30
frontend/public/earth/assets/celestial/bright-stars.json
Normal file
30
frontend/public/earth/assets/celestial/bright-stars.json
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{ "id": 32349, "name": "Sirius", "raDeg": 101.2875, "decDeg": -16.7161, "mag": -1.46, "colorIndex": 0.00 },
|
||||
{ "id": 30438, "name": "Canopus", "raDeg": 95.9879, "decDeg": -52.6957, "mag": -0.74, "colorIndex": 0.15 },
|
||||
{ "id": 69673, "name": "Arcturus", "raDeg": 213.9153, "decDeg": 19.1824, "mag": -0.05, "colorIndex": 1.23 },
|
||||
{ "id": 71683, "name": "Alpha Centauri", "raDeg": 219.9021, "decDeg": -60.8339, "mag": -0.01, "colorIndex": 0.71 },
|
||||
{ "id": 91262, "name": "Vega", "raDeg": 279.2347, "decDeg": 38.7837, "mag": 0.03, "colorIndex": 0.00 },
|
||||
{ "id": 24608, "name": "Capella", "raDeg": 79.1723, "decDeg": 45.9979, "mag": 0.08, "colorIndex": 0.80 },
|
||||
{ "id": 24436, "name": "Rigel", "raDeg": 78.6345, "decDeg": -8.2016, "mag": 0.12, "colorIndex": -0.03 },
|
||||
{ "id": 37279, "name": "Procyon", "raDeg": 114.8255, "decDeg": 5.2250, "mag": 0.34, "colorIndex": 0.42 },
|
||||
{ "id": 7588, "name": "Achernar", "raDeg": 24.4286, "decDeg": -57.2368, "mag": 0.46, "colorIndex": -0.16 },
|
||||
{ "id": 27989, "name": "Betelgeuse", "raDeg": 88.7929, "decDeg": 7.4071, "mag": 0.50, "colorIndex": 1.85 },
|
||||
{ "id": 68702, "name": "Hadar", "raDeg": 210.9559, "decDeg": -60.3731, "mag": 0.61, "colorIndex": -0.23 },
|
||||
{ "id": 97649, "name": "Altair", "raDeg": 297.6958, "decDeg": 8.8683, "mag": 0.76, "colorIndex": 0.22 },
|
||||
{ "id": 60718, "name": "Acrux", "raDeg": 186.6490, "decDeg": -63.0991, "mag": 0.77, "colorIndex": -0.24 },
|
||||
{ "id": 21421, "name": "Aldebaran", "raDeg": 68.9800, "decDeg": 16.5093, "mag": 0.85, "colorIndex": 1.54 },
|
||||
{ "id": 65474, "name": "Spica", "raDeg": 201.2983, "decDeg": -11.1614, "mag": 0.98, "colorIndex": -0.23 },
|
||||
{ "id": 80763, "name": "Antares", "raDeg": 247.3519, "decDeg": -26.4320, "mag": 1.06, "colorIndex": 1.83 },
|
||||
{ "id": 37826, "name": "Pollux", "raDeg": 116.3289, "decDeg": 28.0262, "mag": 1.14, "colorIndex": 1.00 },
|
||||
{ "id": 113368, "name": "Fomalhaut", "raDeg": 344.4128, "decDeg": -29.6222, "mag": 1.16, "colorIndex": 0.09 },
|
||||
{ "id": 102098, "name": "Deneb", "raDeg": 310.3579, "decDeg": 45.2803, "mag": 1.25, "colorIndex": 0.09 },
|
||||
{ "id": 49669, "name": "Regulus", "raDeg": 152.0929, "decDeg": 11.9672, "mag": 1.35, "colorIndex": -0.11 },
|
||||
{ "id": 65477, "name": "Mimosa", "raDeg": 191.9303, "decDeg": -59.6888, "mag": 1.25, "colorIndex": -0.23 },
|
||||
{ "id": 33579, "name": "Alphard", "raDeg": 141.8969, "decDeg": -8.6586, "mag": 1.98, "colorIndex": 1.44 },
|
||||
{ "id": 21444, "name": "Bellatrix", "raDeg": 81.2828, "decDeg": 6.3497, "mag": 1.64, "colorIndex": -0.22 },
|
||||
{ "id": 25336, "name": "Elnath", "raDeg": 81.5729, "decDeg": 28.6075, "mag": 1.65, "colorIndex": -0.13 },
|
||||
{ "id": 26311, "name": "Alnilam", "raDeg": 84.0534, "decDeg": -1.2019, "mag": 1.69, "colorIndex": -0.19 },
|
||||
{ "id": 26727, "name": "Alnitak", "raDeg": 85.1897, "decDeg": -1.9426, "mag": 1.77, "colorIndex": -0.19 },
|
||||
{ "id": 25930, "name": "Saiph", "raDeg": 86.9391, "decDeg": -9.6696, "mag": 2.06, "colorIndex": -0.20 },
|
||||
{ "id": 58001, "name": "Alioth", "raDeg": 193.5073, "decDeg": 55.9598, "mag": 1.76, "colorIndex": -0.02 }
|
||||
]
|
||||
BIN
frontend/public/earth/assets/celestial/starmap_deep_8k.jpg
Normal file
BIN
frontend/public/earth/assets/celestial/starmap_deep_8k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 MiB |
BIN
frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg
Normal file
BIN
frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -8,6 +8,10 @@
|
||||
|
||||
:root {
|
||||
--hud-scale: 1;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--hud-offset: calc(20px * var(--hud-scale));
|
||||
--hud-radius: calc(22px * var(--hud-scale));
|
||||
--hud-panel-padding: calc(18px * var(--hud-scale));
|
||||
@@ -19,6 +23,7 @@
|
||||
--hud-font-size: calc(0.88rem * var(--hud-scale));
|
||||
--hud-font-size-sm: calc(0.75rem * var(--hud-scale));
|
||||
--hud-title-size: calc(1.02rem * var(--hud-scale));
|
||||
--hud-panel-header-title-size: calc(0.82rem * var(--hud-scale));
|
||||
--hud-kicker-size: calc(0.68rem * var(--hud-scale));
|
||||
--hud-surface-top: rgba(17, 31, 53, 0.84);
|
||||
--hud-surface-bottom: rgba(7, 17, 31, 0.76);
|
||||
@@ -55,6 +60,9 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Ensure [hidden] always wins over component display rules */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body.earth-page {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #0a0a1a;
|
||||
@@ -62,12 +70,23 @@ body.earth-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.is-globe-dragging,
|
||||
body.earth-page.is-globe-dragging,
|
||||
body.earth-page.is-globe-dragging * {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
.earth-app {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.earth-app canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.earth-app.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -79,59 +98,17 @@ body.earth-page {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 240;
|
||||
min-width: min(calc(320px * var(--hud-scale)), 78vw);
|
||||
padding: calc(26px * var(--hud-scale));
|
||||
border-radius: calc(18px * var(--hud-scale));
|
||||
border: 1px solid rgba(77, 184, 255, 0.34);
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.12), transparent 35%),
|
||||
linear-gradient(180deg, rgba(13, 24, 46, 0.95), rgba(7, 14, 28, 0.94));
|
||||
box-shadow:
|
||||
0 0 30px rgba(77, 184, 255, 0.22),
|
||||
0 16px 40px rgba(0, 0, 0, 0.28);
|
||||
text-align: center;
|
||||
color: #4db8ff;
|
||||
}
|
||||
|
||||
.earth-loading-text {
|
||||
color: #4db8ff;
|
||||
}
|
||||
|
||||
.earth-loading-title {
|
||||
font-size: calc(1.15rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.earth-loading-subtitle {
|
||||
margin-top: calc(10px * var(--hud-scale));
|
||||
color: #9ab7d4;
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.earth-loading-spinner {
|
||||
width: calc(40px * var(--hud-scale));
|
||||
height: calc(40px * var(--hud-scale));
|
||||
margin: 0 auto calc(15px * var(--hud-scale));
|
||||
border: 4px solid rgba(77, 184, 255, 0.28);
|
||||
border-top: 4px solid #4db8ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@keyframes earthLoadingPulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
transform: scale(0.78);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,20 +28,22 @@
|
||||
|
||||
.stats-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Reuse hud-panel-close — just override size to match kicker line */
|
||||
.stats-drag-bar .hud-panel-close {
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.stats-drag-bar .hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(12px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── 2-column KPI grid ────────────────────────────────────────── */
|
||||
@@ -131,3 +133,20 @@
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-stats {
|
||||
position: fixed;
|
||||
top: calc(8px + var(--safe-top));
|
||||
right: 8px;
|
||||
width: min(180px, calc(100vw - 16px));
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
.layout-mode-mobile.earth-search-open .hud-panel-stats,
|
||||
.layout-mode-mobile.earth-settings-open .hud-panel-stats,
|
||||
.layout-mode-mobile.earth-media-open .hud-panel-stats,
|
||||
.layout-mode-mobile.earth-info-open .hud-panel-stats {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,64 +24,87 @@
|
||||
/* ── Brand panel ──────────────────────────────────────────────── */
|
||||
|
||||
.hud-panel-brand {
|
||||
border-radius: 0;
|
||||
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
|
||||
--brand-scale: 0.88;
|
||||
--brand-copy-width: 160px;
|
||||
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
/* Reserve full panel height before brand images load */
|
||||
min-height: calc(66px * var(--hud-scale));
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hud-panel-brand::before,
|
||||
.hud-panel-brand::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(4px * var(--hud-scale)) calc(-10px * var(--hud-scale)) calc(6px * var(--hud-scale)) calc(-10px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 18% 50%, rgba(145, 186, 255, 0.14), transparent 32%),
|
||||
linear-gradient(90deg, rgba(145, 186, 255, 0.05), transparent 58%);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
filter: blur(14px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__logo {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: calc(128px * var(--hud-scale));
|
||||
height: calc(128px * var(--hud-scale));
|
||||
width: calc(128px * var(--hud-scale) * var(--brand-scale));
|
||||
height: calc(128px * var(--hud-scale) * var(--brand-scale));
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__copy {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
gap: calc(5px * var(--hud-scale) * var(--brand-scale));
|
||||
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__title {
|
||||
display: block;
|
||||
width: min(100%, calc(160px * var(--hud-scale)));
|
||||
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: calc(20px * var(--hud-scale));
|
||||
min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
gap: calc(2px * var(--hud-scale) * var(--brand-scale));
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__subtitle {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
font-size: calc(0.74rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
/* Prevent text from pushing brand wider than logo column */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -89,18 +112,24 @@
|
||||
|
||||
.hud-panel-brand .earth-brand__description {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.6rem * var(--hud-scale));
|
||||
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.08em;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en {
|
||||
--brand-copy-width: 172px;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__title {
|
||||
width: min(100%, calc(172px * var(--hud-scale)));
|
||||
width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__copy {
|
||||
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
||||
@@ -124,12 +153,103 @@
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
visibility: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.hud-panel-info.is-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hud-panel-info.hud-panel-info--anchor-stable {
|
||||
transform: none;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel-info.hud-panel-info--anchor-stable.is-visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.callout-connector {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease;
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
.callout-connector 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));
|
||||
}
|
||||
|
||||
.callout-connector 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;
|
||||
}
|
||||
|
||||
.callout-connector.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.callout-connector.is-animating polyline {
|
||||
animation: calloutConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.callout-connector.is-animating circle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.callout-connector.is-animating circle:first-of-type {
|
||||
animation: calloutConnectorNodeIn 0.14s ease forwards;
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
|
||||
.callout-connector.is-animating circle:last-of-type {
|
||||
animation: calloutConnectorNodeIn 0.16s ease forwards;
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
@keyframes calloutConnectorDraw {
|
||||
from {
|
||||
stroke-dashoffset: var(--connector-length, 0px);
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes calloutConnectorNodeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Info Card ────────────────────────────────────────────────── */
|
||||
@@ -155,7 +275,7 @@
|
||||
.info-card-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: calc(0.92rem * var(--hud-scale));
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
color: var(--hud-title);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -165,6 +285,15 @@
|
||||
|
||||
.info-card-close {
|
||||
flex-shrink: 0;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-content {
|
||||
@@ -174,6 +303,8 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar {
|
||||
@@ -211,6 +342,8 @@
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.18s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-label:hover {
|
||||
@@ -225,6 +358,8 @@
|
||||
text-align: right;
|
||||
max-width: calc(180px * var(--hud-scale));
|
||||
word-break: break-word;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Type-specific header accent colors */
|
||||
@@ -251,3 +386,39 @@
|
||||
.earth-app.layout-expanded .earth-left-column {
|
||||
transform: translate(calc(-100% + var(--hud-offset)), 0);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-left-column {
|
||||
top: calc(8px + var(--safe-top));
|
||||
left: 8px;
|
||||
max-width: min(300px, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-info {
|
||||
position: fixed;
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
top: auto !important;
|
||||
bottom: calc(84px + var(--safe-bottom)) !important;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: min(58vh, 520px);
|
||||
z-index: 240;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-content {
|
||||
max-height: min(46vh, 420px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-property {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-value {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* layer-panel.css — layer toggle panel (below brand, in left column) */
|
||||
|
||||
.hud-panel-layers {
|
||||
/* Lives inside .earth-left-column — position is relative via column rule */
|
||||
/* Lives inside .earth-left-column — narrower than brand panel intentionally */
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
width: calc(260px * var(--hud-scale));
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
margin-top: calc(6px * var(--hud-scale));
|
||||
margin-top: calc(12px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Header / drag handle ─────────────────────────────────────── */
|
||||
@@ -38,8 +38,8 @@
|
||||
.layer-panel-title {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
@@ -51,55 +51,71 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(22px * var(--hud-scale));
|
||||
height: calc(22px * var(--hud-scale));
|
||||
min-width: calc(22px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-panel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* Chevron rotates when collapsed */
|
||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
transition: color 0.18s ease;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
/* ── Search bar ───────────────────────────────────────────────── */
|
||||
|
||||
.layer-panel-search {
|
||||
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.layer-panel-search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
padding: calc(5px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||
border-radius: calc(8px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transition: border-color 0.18s ease;
|
||||
box-sizing: border-box;
|
||||
height: calc(38px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.layer-panel-search-box:focus-within {
|
||||
border-color: rgba(201, 225, 247, 0.28);
|
||||
}
|
||||
|
||||
.layer-panel-search-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-panel-search-icon.material-symbols-rounded {
|
||||
font-size: calc(20px * var(--hud-scale));
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
.layer-panel-search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -144,6 +160,23 @@
|
||||
.layer-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(5 * (56px * var(--hud-scale)));
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
@@ -153,6 +186,7 @@
|
||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
transition: background 0.14s ease;
|
||||
min-height: calc(56px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.layer-row:last-child {
|
||||
@@ -219,6 +253,7 @@
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row-toggle-track {
|
||||
@@ -231,6 +266,11 @@
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row-toggle:disabled {
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.layer-row-toggle-track::after {
|
||||
content: "";
|
||||
@@ -245,6 +285,24 @@
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-loading .layer-row-toggle-track {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(104, 147, 221, 0.38),
|
||||
rgba(143, 185, 255, 0.72),
|
||||
rgba(104, 147, 221, 0.38)
|
||||
);
|
||||
background-size: 180% 100%;
|
||||
border-color: rgba(223, 236, 252, 0.28);
|
||||
animation: layer-toggle-loading-track 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-loading .layer-row-toggle-track::after {
|
||||
background: #f0f6ff;
|
||||
transform: translateX(calc(7px * var(--hud-scale)));
|
||||
animation: layer-toggle-loading-thumb 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Active (ON) state */
|
||||
.layer-row-toggle.active .layer-row-toggle-track {
|
||||
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
||||
@@ -256,5 +314,55 @@
|
||||
transform: translateX(calc(14px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
@keyframes layer-toggle-loading-track {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 180% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes layer-toggle-loading-thumb {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 0 rgba(174, 205, 255, 0.18);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 calc(10px * var(--hud-scale)) rgba(174, 205, 255, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
||||
individual rule needed since the whole column translates together. */
|
||||
|
||||
.layout-mode-mobile .hud-panel-layers {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(88px + var(--safe-bottom));
|
||||
width: auto;
|
||||
max-height: min(60vh, 520px);
|
||||
margin-top: 0;
|
||||
z-index: 220;
|
||||
transform: translateY(calc(100% + 28px));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.24s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-layers.is-mobile-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .layer-panel-body {
|
||||
max-height: min(52vh, 460px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .layer-panel-list {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
@@ -27,38 +27,38 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Mode tabs ────────────────────────────────────────────────── */
|
||||
/* ── Current mode label ───────────────────────────────────────── */
|
||||
|
||||
.legend-tabs {
|
||||
.legend-current {
|
||||
display: flex;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
align-items: center;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-tab {
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
.legend-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-tab--active {
|
||||
.legend-current-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(120, 180, 255, 0.2);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
border-color: rgba(120, 180, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Bar action buttons ───────────────────────────────────────── */
|
||||
@@ -66,7 +66,7 @@
|
||||
.legend-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -74,54 +74,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
|
||||
.legend-bar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-bar-btn .material-symbols-rounded {
|
||||
font-size: calc(13px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Collapse chevron */
|
||||
#legend-collapse .material-symbols-rounded {
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
.legend--collapsed #legend-collapse .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ── Collapsible list body ────────────────────────────────────── */
|
||||
|
||||
.legend-body {
|
||||
max-height: calc(220px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend--collapsed .legend-body {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
--hud-body-collapse-gap: calc(4px * var(--hud-scale));
|
||||
--hud-body-max-height: calc(220px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Item list ────────────────────────────────────────────────── */
|
||||
@@ -175,3 +138,24 @@
|
||||
bottom: var(--hud-offset);
|
||||
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-legend {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: calc(84px + var(--safe-bottom));
|
||||
width: min(172px, calc(100vw - 16px));
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .legend-list {
|
||||
max-height: min(20vh, 180px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile.earth-search-open .hud-panel-legend,
|
||||
.layout-mode-mobile.earth-settings-open .hud-panel-legend,
|
||||
.layout-mode-mobile.earth-media-open .hud-panel-legend,
|
||||
.layout-mode-mobile.earth-info-open .hud-panel-legend {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
182
frontend/public/earth/css/news-panel.css
Normal file
182
frontend/public/earth/css/news-panel.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* news-panel.css */
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.news-region-chip {
|
||||
--news-accent: #d6e6ff;
|
||||
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
color: color-mix(in srgb, var(--news-accent) 82%, white);
|
||||
background: color-mix(in srgb, var(--news-accent) 12%, transparent);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-panel-subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-panel-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-panel-focus {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 16% 18%, rgba(123, 205, 255, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(104, 166, 232, 0.04));
|
||||
border: 1px solid rgba(205, 231, 255, 0.08);
|
||||
}
|
||||
|
||||
.news-focus-kicker,
|
||||
.news-board-status {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-focus-label {
|
||||
margin-top: calc(4px * var(--hud-scale));
|
||||
color: var(--hud-text);
|
||||
font-size: calc(1rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.news-focus-coords {
|
||||
margin-top: calc(3px * var(--hud-scale));
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-source-count {
|
||||
align-self: start;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-board {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-board-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 237, 255, 0.24), rgba(110, 176, 255, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.news-story-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
text-decoration: none;
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(92, 151, 218, 0.03));
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.news-story-card:hover {
|
||||
border-color: rgba(214, 235, 255, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.news-story-card--focus {
|
||||
border-color: rgba(127, 219, 255, 0.22);
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.news-story-source,
|
||||
.news-story-time,
|
||||
.news-story-tag {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-story-source {
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.news-story-title {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.news-story-summary {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.news-story-tag {
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.news-board-empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
line-height: 1.5;
|
||||
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* toolbar.css - bottom dock and floating toolbar primitives */
|
||||
/* toolbar.css - orbital hub toolbar */
|
||||
|
||||
.earth-toolbar-group {
|
||||
position: absolute;
|
||||
@@ -6,10 +6,10 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-group,
|
||||
@@ -24,119 +24,170 @@
|
||||
}
|
||||
|
||||
.earth-toolbar {
|
||||
--toolbar-scale: 1;
|
||||
--toolbar-orb-size: calc(46px * var(--toolbar-scale));
|
||||
--toolbar-hub-size: calc(58px * var(--toolbar-scale));
|
||||
--toolbar-arc-width: calc(420px * var(--toolbar-scale));
|
||||
--toolbar-arc-height: calc(160px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale));
|
||||
position: relative;
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
height: calc(200px * var(--toolbar-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-items {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: 56px;
|
||||
height: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: calc(100% + 12px);
|
||||
transform: translate(-50%, 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn {
|
||||
.earth-toolbar-cluster {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(14px * var(--toolbar-scale));
|
||||
width: var(--toolbar-arc-width);
|
||||
height: var(--toolbar-arc-height);
|
||||
transform: translateX(-50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(145, 186, 255, 0.08);
|
||||
border-bottom-color: transparent;
|
||||
background:
|
||||
radial-gradient(circle at 50% 100%, rgba(145, 186, 255, 0.05), transparent 58%);
|
||||
opacity: 0.9;
|
||||
mask: linear-gradient(180deg, rgba(0, 0, 0, 0.82), transparent 86%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(30px * var(--toolbar-scale));
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-hub {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(8px * var(--toolbar-scale));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.36s cubic-bezier(0.34, 1.15, 0.64, 1),
|
||||
opacity 0.24s ease;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-orb {
|
||||
transform: translate(calc(-50% + var(--orb-x)), calc(-50% + var(--orb-y)));
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb {
|
||||
transform: translate(-50%, -50%) scale(0.42);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > *,
|
||||
.earth-toolbar-hub > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb:has(#layer-action) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-toolbar-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation: floatDock 4.6s ease-in-out infinite;
|
||||
animation-delay: var(--orb-delay, 0s);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-dock-engaged .earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn,
|
||||
.earth-toolbar-hub-btn {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #4db8ff;
|
||||
font-size: 14px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(14px * var(--toolbar-scale));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn.floating-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
min-height: 42px;
|
||||
width: var(--toolbar-orb-size);
|
||||
height: var(--toolbar-orb-size);
|
||||
min-width: var(--toolbar-orb-size);
|
||||
min-height: var(--toolbar-orb-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:not(.liquid-glass-surface)::after {
|
||||
content: none;
|
||||
.earth-toolbar-hub-btn {
|
||||
width: var(--toolbar-hub-size);
|
||||
height: var(--toolbar-hub-size);
|
||||
min-width: var(--toolbar-hub-size);
|
||||
min-height: var(--toolbar-hub-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transform: translateZ(0);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .material-symbols-rounded {
|
||||
font-size: 21px;
|
||||
.earth-toolbar-btn .material-symbols-rounded,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
font-size: calc(21px * var(--toolbar-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
@@ -154,28 +205,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
shape-rendering: geometricPrecision;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.earth-toolbar-items > :nth-child(2n).floating-btn,
|
||||
.earth-toolbar-items > :nth-child(2n) .floating-btn {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.earth-toolbar-items > :nth-child(3n).floating-btn,
|
||||
.earth-toolbar-items > :nth-child(3n) .floating-btn {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.liquid-glass-surface {
|
||||
--elastic-x: 0px;
|
||||
--elastic-y: 0px;
|
||||
@@ -190,12 +219,14 @@
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center center;
|
||||
will-change: transform, box-shadow;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)),
|
||||
rgba(8, 20, 38, 0.22);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.05), transparent 30%),
|
||||
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)),
|
||||
rgba(8, 20, 38, 0.12);
|
||||
border: 1px solid var(--hud-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
@@ -205,7 +236,11 @@
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
transform:
|
||||
translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0)
|
||||
translate3d(
|
||||
var(--elastic-x),
|
||||
calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)),
|
||||
0
|
||||
)
|
||||
scale(var(--btn-scale));
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
@@ -213,17 +248,16 @@
|
||||
background 0.22s ease,
|
||||
opacity 0.18s ease,
|
||||
border-color 0.22s ease;
|
||||
animation: floatDock 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px 1px 18px 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%);
|
||||
opacity: 0.5;
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04) 28%, transparent 68%);
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
perspective(120px)
|
||||
@@ -234,14 +268,14 @@
|
||||
}
|
||||
|
||||
.liquid-glass-surface::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
padding: 1.35px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
|
||||
opacity: 0.82;
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
filter: url(#liquid-glass-distortion) blur(0.35px);
|
||||
transform:
|
||||
@@ -260,15 +294,28 @@
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 50% 24%, rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
linear-gradient(180deg, rgba(28, 54, 90, 0.26), rgba(11, 24, 43, 0.22)),
|
||||
rgba(10, 28, 52, 0.16);
|
||||
border-color: var(--hud-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 16px 30px rgba(0, 0, 0, 0.24),
|
||||
0 0 30px rgba(104, 181, 247, 0.18);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover {
|
||||
--btn-scale: 1.035;
|
||||
--btn-scale: 1.04;
|
||||
--press-offset: -1px;
|
||||
--glow-opacity: 0.32;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)),
|
||||
rgba(8, 20, 38, 0.2);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(128, 198, 255, 0.06)),
|
||||
rgba(8, 20, 38, 0.14);
|
||||
border-color: var(--hud-border-hover);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
@@ -289,52 +336,16 @@
|
||||
|
||||
.liquid-glass-surface:active,
|
||||
.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 0.942;
|
||||
--btn-scale: 0.95;
|
||||
--press-offset: 2px;
|
||||
--glow-opacity: 0.2;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)),
|
||||
rgba(10, 24, 44, 0.24);
|
||||
border-color: rgba(240, 249, 255, 0.58);
|
||||
box-shadow:
|
||||
inset 0 2px 10px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||
0 4px 10px rgba(0, 0, 0, 0.18),
|
||||
0 0 14px rgba(176, 226, 255, 0.18);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active::before,
|
||||
.liquid-glass-surface.is-pressed::before {
|
||||
opacity: 0.46;
|
||||
transform: translateY(2px) scale(0.985);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active::after,
|
||||
.liquid-glass-surface.is-pressed::after {
|
||||
opacity: 0.78;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active .icon,
|
||||
.liquid-glass-surface.is-pressed .icon {
|
||||
transform: translateY(1.5px);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active img,
|
||||
.liquid-glass-surface.is-pressed img,
|
||||
.liquid-glass-surface:active .material-symbols-rounded,
|
||||
.liquid-glass-surface.is-pressed .material-symbols-rounded {
|
||||
transform: translateY(1.5px);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active {
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)),
|
||||
rgba(11, 34, 58, 0.26);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)),
|
||||
rgba(11, 34, 58, 0.18);
|
||||
border-color: var(--hud-border-active);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
@@ -357,151 +368,64 @@
|
||||
|
||||
.earth-zoom-group:hover > .earth-zoom-toolbar,
|
||||
.earth-zoom-group:focus-within > .earth-zoom-toolbar,
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar,
|
||||
.earth-info-group:hover > .earth-info-toolbar,
|
||||
.earth-info-group:focus-within > .earth-info-toolbar,
|
||||
.earth-info-group.open > .earth-info-toolbar {
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar,
|
||||
.earth-info-group.force-closed > .earth-info-toolbar {
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px);
|
||||
transform: translate(-50%, calc(8px * var(--toolbar-scale)));
|
||||
}
|
||||
|
||||
.earth-zoom-group > .earth-zoom-toolbar,
|
||||
.earth-info-group > .earth-info-toolbar {
|
||||
.earth-toolbar-popover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(56px * var(--toolbar-scale));
|
||||
height: calc(16px * var(--toolbar-scale));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 12px);
|
||||
bottom: calc(100% + (12px * var(--toolbar-scale)));
|
||||
transform: translate(-50%, calc(10px * var(--toolbar-scale)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-info-toolbar {
|
||||
width: min(280px, calc(100vw - 36px));
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94));
|
||||
border: 1px solid rgba(211, 228, 246, 0.14);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(135%);
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-header {
|
||||
width: 100%;
|
||||
padding: 2px 4px 8px;
|
||||
border-bottom: 1px solid rgba(201, 225, 247, 0.08);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-title {
|
||||
display: block;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-subtitle {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-layer-btn__copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-layer-btn__label {
|
||||
color: var(--hud-text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.earth-layer-btn__meta {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-layer-btn__state {
|
||||
flex: 0 0 auto;
|
||||
min-width: 42px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-layer-btn.active .earth-layer-btn__state {
|
||||
color: #dff4ff;
|
||||
border-color: rgba(220, 240, 255, 0.24);
|
||||
background: rgba(131, 197, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-layer-btn .earth-toolbar-tooltip {
|
||||
display: none;
|
||||
gap: calc(8px * var(--toolbar-scale));
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn,
|
||||
.earth-zoom-toolbar .earth-zoom-value {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
width: calc(42px * var(--toolbar-scale));
|
||||
min-width: calc(42px * var(--toolbar-scale));
|
||||
border-radius: 50%;
|
||||
color: #4db8ff;
|
||||
animation: floatDock 3.8s ease-in-out infinite;
|
||||
color: var(--hud-text-soft);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn {
|
||||
height: 42px;
|
||||
font-size: 20px;
|
||||
height: calc(42px * var(--toolbar-scale));
|
||||
font-size: calc(20px * var(--toolbar-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -510,41 +434,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
height: calc(42px * var(--toolbar-scale));
|
||||
padding: 0;
|
||||
font-size: 0.68rem;
|
||||
font-size: calc(11px * var(--toolbar-scale));
|
||||
letter-spacing: normal;
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:active,
|
||||
.earth-zoom-toolbar .earth-zoom-btn.is-pressed,
|
||||
.earth-zoom-toolbar .earth-zoom-value:active,
|
||||
.earth-zoom-toolbar .earth-zoom-value.is-pressed {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:nth-child(3) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-toolbar-tooltip {
|
||||
bottom: calc(100% + 10px);
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-toolbar-tooltip::after {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.earth-app.layout-expanded .earth-toolbar-group {
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
@@ -552,21 +447,23 @@
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
bottom: calc(56px * var(--toolbar-scale));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 30, 0.95);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
||||
color: var(--hud-text);
|
||||
padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
|
||||
border-radius: calc(6px * var(--toolbar-scale));
|
||||
font-size: calc(12px * var(--toolbar-scale));
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(77, 184, 255, 0.4);
|
||||
border: 1px solid var(--hud-border);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: var(--hud-shadow-soft);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:hover .earth-toolbar-tooltip,
|
||||
@@ -574,15 +471,15 @@
|
||||
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
bottom: 58px;
|
||||
bottom: calc(58px * var(--toolbar-scale));
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
border: calc(6px * var(--toolbar-scale)) solid transparent;
|
||||
border-top-color: rgba(18, 31, 52, 0.96);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,98 @@
|
||||
/* tv-panel */
|
||||
/* media-panel
|
||||
* Outer HUD shell: #media-panel
|
||||
* Inner live pane: #tv-panel
|
||||
* Inner news pane: #news-panel
|
||||
*/
|
||||
|
||||
.hud-panel-tv {
|
||||
.hud-panel-media {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
width: calc(420px * var(--hud-scale));
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - (2 * var(--hud-offset)));
|
||||
min-width: calc(300px * var(--hud-scale));
|
||||
min-height: calc(340px * var(--hud-scale));
|
||||
padding: calc(18px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
.tv-panel-header-copy {
|
||||
display: grid;
|
||||
gap: calc(3px * var(--hud-scale));
|
||||
.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) {
|
||||
max-height: min(
|
||||
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
|
||||
calc(100vh - (2 * var(--hud-offset)))
|
||||
);
|
||||
}
|
||||
|
||||
.hud-panel-media.is-reforming {
|
||||
transition:
|
||||
height 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: height, top;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__title-group {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-title {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header .hud-panel__action,
|
||||
.hud-panel-media .hud-panel__header .hud-panel-close {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header .tv-panel-select,
|
||||
.hud-panel-media .hud-panel__header .media-panel-tab {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls--news {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tv-panel-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-tab-pane {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-panel-toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tv-panel-status {
|
||||
@@ -26,13 +102,6 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tv-panel-controls {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-sm);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -53,48 +122,19 @@
|
||||
color: #eef5fc;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-xs);
|
||||
.tv-panel-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: calc(120px * var(--hud-scale));
|
||||
opacity: 1;
|
||||
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--hud-text);
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action--icon {
|
||||
padding: calc(9px * var(--hud-scale));
|
||||
border-radius: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-action--icon .material-symbols-rounded {
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
.tv-panel-meta-wrap.is-collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-panel-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tv-panel-meta {
|
||||
@@ -163,45 +203,152 @@
|
||||
background: #050a14;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle {
|
||||
position: absolute;
|
||||
right: calc(8px * var(--hud-scale));
|
||||
bottom: calc(8px * var(--hud-scale));
|
||||
width: calc(18px * var(--hud-scale));
|
||||
height: calc(18px * var(--hud-scale));
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: nwse-resize;
|
||||
z-index: 2;
|
||||
.media-panel-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle::before {
|
||||
.media-panel-tab {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--hud-text-muted);
|
||||
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.media-panel-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(214, 235, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.media-panel-tab--active {
|
||||
color: var(--hud-accent-strong);
|
||||
border-color: rgba(120, 180, 255, 0.24);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.tv-tab-pane[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Multi-edge resize handles ───────────────────────────────── */
|
||||
|
||||
.tv-panel-edge {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="r"] {
|
||||
right: 0;
|
||||
top: calc(12px * var(--hud-scale));
|
||||
bottom: calc(12px * var(--hud-scale));
|
||||
width: calc(6px * var(--hud-scale));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="b"] {
|
||||
bottom: 0;
|
||||
left: calc(12px * var(--hud-scale));
|
||||
right: calc(12px * var(--hud-scale));
|
||||
height: calc(6px * var(--hud-scale));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="l"] {
|
||||
left: 0;
|
||||
top: calc(12px * var(--hud-scale));
|
||||
bottom: calc(12px * var(--hud-scale));
|
||||
width: calc(6px * var(--hud-scale));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="br"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.tv-panel-edge[data-edge="bl"] {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-media {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: calc(8px + var(--safe-top));
|
||||
bottom: calc(84px + var(--safe-bottom));
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
z-index: 230;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .tv-panel-player {
|
||||
min-height: min(42vh, 360px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .tv-panel-edge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 右下角视觉标记 */
|
||||
.tv-panel-edge[data-edge="br"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.46);
|
||||
border-bottom-right-radius: calc(10px * var(--hud-scale));
|
||||
opacity: 0.78;
|
||||
transition: opacity 0.18s ease, border-color 0.18s ease;
|
||||
inset: calc(4px * var(--hud-scale));
|
||||
border-right: 2px solid rgba(223, 235, 248, 0.4);
|
||||
border-bottom: 2px solid rgba(223, 235, 248, 0.4);
|
||||
transition: border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle:hover::before {
|
||||
opacity: 1;
|
||||
border-color: rgba(244, 249, 255, 0.78);
|
||||
.tv-panel-edge[data-edge="br"]:hover::before {
|
||||
border-color: rgba(244, 249, 255, 0.75);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-resizing {
|
||||
.hud-panel-media.is-resizing {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||
/* Whole panel is draggable; player overrides back to default */
|
||||
.hud-panel-media:not(.is-resizing) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.hud-panel-media.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel-media .tv-panel-player {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
|
||||
.hud-panel-media.is-dragging .tv-panel-iframe,
|
||||
.hud-panel-media.is-dragging .tv-panel-video {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
/* TV panel keeps its fixed width on all screen sizes.
|
||||
/* Media panel keeps its fixed width on all screen sizes.
|
||||
Responsive stretching removed — width only changes if user manually resizes. */
|
||||
|
||||
@@ -10,11 +10,28 @@
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15"
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15",
|
||||
"astronomy-engine": "https://esm.sh/astronomy-engine@2.1.19"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function applyInitialEarthViewportMode() {
|
||||
var width = window.innerWidth;
|
||||
var height = window.innerHeight;
|
||||
var mode = "desktop";
|
||||
|
||||
if (width <= 820) {
|
||||
mode = "mobile";
|
||||
} else if (width <= 1080 || height <= 760) {
|
||||
mode = "compact";
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle("layout-mode-mobile", mode === "mobile");
|
||||
document.documentElement.classList.toggle("layout-mode-compact", mode === "compact");
|
||||
document.documentElement.dataset.earthLayoutMode = mode;
|
||||
})();
|
||||
|
||||
(function applyInitialHudScale() {
|
||||
var referenceWidth = 1920;
|
||||
var referenceHeight = 1080;
|
||||
@@ -35,6 +52,7 @@
|
||||
<link rel="stylesheet" href="css/legend.css">
|
||||
<link rel="stylesheet" href="css/earth-stats.css">
|
||||
<link rel="stylesheet" href="css/tv-panel.css">
|
||||
<link rel="stylesheet" href="css/news-panel.css">
|
||||
<link rel="stylesheet" href="css/layer-panel.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||
@@ -50,7 +68,7 @@
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="container" class="earth-app">
|
||||
<div class="earth-left-column">
|
||||
<div id="left-column" class="earth-left-column">
|
||||
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
||||
<div id="brand-root"></div>
|
||||
</div>
|
||||
@@ -61,7 +79,10 @@
|
||||
<span class="material-symbols-rounded layer-panel-icon">layers</span>
|
||||
<span class="layer-panel-title">图层</span>
|
||||
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="layer-panel-btn hud-panel-close" type="button" data-close-panel="layer-toggles" aria-label="关闭图层面板" title="关闭">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -69,18 +90,20 @@
|
||||
<div class="layer-panel-body" id="layer-panel-body">
|
||||
<!-- Search -->
|
||||
<div class="layer-panel-search">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="search"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
<div class="layer-panel-search-box">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="text"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer rows -->
|
||||
@@ -91,7 +114,17 @@
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示">
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">经纬线</span>
|
||||
<span class="layer-row-meta">Graticule</span>
|
||||
</div>
|
||||
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,6 +158,16 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="算力中心 compute centers">
|
||||
<span class="material-symbols-rounded layer-row-icon">memory</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">算力中心</span>
|
||||
<span class="layer-row-meta">Compute Centers</span>
|
||||
</div>
|
||||
<button id="toggle-compute-centers" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换算力中心显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="bgp观测 routing signals">
|
||||
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
||||
<div class="layer-row-copy">
|
||||
@@ -143,52 +186,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating detail panel — positioned near click by JS -->
|
||||
<div id="info-panel" class="hud-panel hud-panel-info hud-panel-draggable" aria-live="polite">
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
</div>
|
||||
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div class="earth-toolbar-items">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
</button>
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
</span>
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
<div id="zoom-control-group" class="earth-toolbar-popover earth-zoom-group">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">layers</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">图层</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
</span>
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
|
||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">zoom_in</span>
|
||||
@@ -201,51 +247,61 @@
|
||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
</span>
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
</span>
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-hub">
|
||||
<button id="toolbar-hub" class="earth-toolbar-hub-btn liquid-glass-surface" title="工具菜单" aria-label="展开工具菜单">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">tune</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
|
||||
<!-- Drag bar: mode tabs + collapse + close -->
|
||||
<!-- Drag bar: current mode + collapse + close -->
|
||||
<div class="legend-bar hud-panel-drag-handle">
|
||||
<div class="legend-tabs" id="legend-tabs">
|
||||
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button>
|
||||
<button class="legend-tab" data-legend-mode="satellites">卫星</button>
|
||||
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
|
||||
<div class="legend-current" id="legend-current">
|
||||
<span class="legend-title">图例</span>
|
||||
<span id="legend-current-label" class="legend-current-label">海缆</span>
|
||||
</div>
|
||||
<div class="legend-bar-actions">
|
||||
<button id="legend-collapse" class="legend-bar-btn" title="折叠">
|
||||
<button id="legend-collapse" class="legend-bar-btn hud-panel__action hud-panel__action--collapse" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<button class="legend-bar-btn hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible list -->
|
||||
<div id="legend-body" class="legend-body">
|
||||
<div id="legend-body" class="legend-body hud-panel__body hud-panel__body--collapsible">
|
||||
<div class="legend-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,23 +318,27 @@
|
||||
<!-- 2-col KPI grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="cable-count">—</span>
|
||||
<span class="stat-num" id="cable-count" data-earth-stat="cable-count">—</span>
|
||||
<span class="stat-label">海缆系统</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="landing-point-count">—</span>
|
||||
<span class="stat-num" id="landing-point-count" data-earth-stat="landing-point-count">—</span>
|
||||
<span class="stat-label">登陆点</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="satellite-count">—</span>
|
||||
<span class="stat-num" id="satellite-count" data-earth-stat="satellite-count">—</span>
|
||||
<span class="stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-anomaly-count">—</span>
|
||||
<span class="stat-num" id="compute-center-count" data-earth-stat="compute-center-count">—</span>
|
||||
<span class="stat-label">算力中心</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-anomaly-count" data-earth-stat="bgp-anomaly-count">—</span>
|
||||
<span class="stat-label">BGP 事件</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-collector-count">—</span>
|
||||
<span class="stat-num" id="bgp-collector-count" data-earth-stat="bgp-collector-count">—</span>
|
||||
<span class="stat-label">BGP 观测站</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
@@ -290,86 +350,468 @@
|
||||
<!-- BGP status footer -->
|
||||
<div class="stats-footer">
|
||||
<span class="stats-footer-dot"></span>
|
||||
<span id="bgp-status-summary" class="stats-footer-text">暂无观测数据</span>
|
||||
<span id="bgp-status-summary" class="stats-footer-text" data-earth-stat="bgp-status-summary">暂无观测数据</span>
|
||||
</div>
|
||||
|
||||
<!-- hidden elements kept for JS compatibility -->
|
||||
<span id="terrain-status" hidden></span>
|
||||
<span id="texture-quality" hidden></span>
|
||||
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
|
||||
<span id="texture-quality" data-earth-stat="texture-quality" hidden></span>
|
||||
<span id="camera-distance" hidden></span>
|
||||
</div>
|
||||
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<div class="tv-panel-header-copy">
|
||||
<h3 class="hud-panel-title">新闻直播</h3>
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-panel" data-drag-self="true">
|
||||
<div class="hud-panel__header hud-panel-drag-handle">
|
||||
<div class="hud-panel__title-group">
|
||||
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
|
||||
</div>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tv-panel-controls">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-actions">
|
||||
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-panel-meta">
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
<button
|
||||
id="tv-resize-handle"
|
||||
class="tv-panel-resize-handle"
|
||||
type="button"
|
||||
aria-label="调整电视直播窗口大小"
|
||||
title="调整大小"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="earth-loading">
|
||||
<div id="loading-spinner" class="earth-loading-spinner"></div>
|
||||
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
|
||||
<div id="loading-subtitle" class="earth-loading-subtitle">同步卫星、海底光缆、登陆点与BGP态势数据</div>
|
||||
</div>
|
||||
<div id="status-message" class="earth-status-message"></div>
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet liquid-glass-surface" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="earth-settings-header">
|
||||
<div>
|
||||
<div class="earth-settings-kicker">设置</div>
|
||||
<h3 id="settings-title" class="earth-settings-title hud-panel-title">显示与视图</h3>
|
||||
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
<button id="tv-meta-toggle" class="hud-panel__action hud-panel__action--collapse tv-panel-meta-toggle" type="button" title="折叠新闻直播内容" aria-label="折叠新闻直播内容">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
</div>
|
||||
<button id="settings-close" class="earth-settings-close hud-panel-close" type="button" aria-label="关闭设置">
|
||||
</div>
|
||||
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
|
||||
<div class="news-panel-title-row">
|
||||
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
|
||||
</div>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭媒体情报面板">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-content">
|
||||
</div>
|
||||
|
||||
<div class="tv-panel-content">
|
||||
<section id="tv-panel" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
|
||||
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
|
||||
<div class="tv-panel-meta" id="tv-panel-meta">
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="news-panel" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
|
||||
<div id="news-panel-body" class="news-panel-body">
|
||||
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
|
||||
|
||||
<div class="news-panel-focus">
|
||||
<div>
|
||||
<div class="news-focus-kicker">当前关注区域</div>
|
||||
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
|
||||
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
|
||||
</div>
|
||||
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
|
||||
</div>
|
||||
|
||||
<div class="news-board">
|
||||
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
|
||||
<div id="news-board-list" class="news-board-list"></div>
|
||||
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
|
||||
<button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
|
||||
<button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
|
||||
</div>
|
||||
<div class="tv-panel-edge" data-edge="r"></div>
|
||||
<div class="tv-panel-edge" data-edge="b"></div>
|
||||
<div class="tv-panel-edge" data-edge="l"></div>
|
||||
<div class="tv-panel-edge" data-edge="br"></div>
|
||||
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||
</div>
|
||||
|
||||
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
|
||||
<span id="earth-mobile-popup-dock" class="earth-mobile-popup-dock" aria-hidden="true"></span>
|
||||
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
|
||||
<div class="earth-mobile-popup-body">
|
||||
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>
|
||||
<div class="earth-mobile-popup-sub" id="earth-mobile-popup-sub"></div>
|
||||
</div>
|
||||
<span class="material-symbols-rounded earth-mobile-popup-chevron">chevron_right</span>
|
||||
</div>
|
||||
<div id="mobile-drawer-overlay" class="earth-mobile-drawer-overlay" hidden></div>
|
||||
<div id="mobile-drawer-shell" class="earth-mobile-drawer-shell" aria-hidden="true">
|
||||
<div class="earth-mobile-drawer-sheet">
|
||||
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
|
||||
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-content">
|
||||
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
|
||||
<div class="earth-mobile-page earth-mobile-page--layers">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Layer Control</span>
|
||||
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
|
||||
</div>
|
||||
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
|
||||
<div class="earth-mobile-page earth-mobile-page--search">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Object Search</span>
|
||||
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星、算力中心和 BGP 事件</span>
|
||||
</div>
|
||||
<div class="earth-mobile-search-shell">
|
||||
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
|
||||
<input
|
||||
id="mobile-earth-search-input"
|
||||
class="earth-mobile-search-input"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="输入名称、地点、NORAD、ASN..."
|
||||
>
|
||||
<button id="mobile-earth-search-clear" class="earth-mobile-search-clear" type="button" aria-label="清除搜索" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
|
||||
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
|
||||
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
|
||||
<div class="earth-mobile-page earth-mobile-page--situation">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Situation</span>
|
||||
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stats-grid">
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-cable-count" class="earth-mobile-stat-num" data-earth-stat="cable-count">—</span>
|
||||
<span class="earth-mobile-stat-label">海缆系统</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-landing-point-count" class="earth-mobile-stat-num" data-earth-stat="landing-point-count">—</span>
|
||||
<span class="earth-mobile-stat-label">登陆点</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count">—</span>
|
||||
<span class="earth-mobile-stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count">—</span>
|
||||
<span class="earth-mobile-stat-label">算力中心</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num" data-earth-stat="bgp-anomaly-count">—</span>
|
||||
<span class="earth-mobile-stat-label">BGP 事件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-situation-card">
|
||||
<div class="earth-mobile-situation-card-title">图例</div>
|
||||
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
|
||||
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-situation-card">
|
||||
<div class="earth-mobile-situation-card-title">BGP 状态</div>
|
||||
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
|
||||
<div class="earth-mobile-page earth-mobile-page--news">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">News</span>
|
||||
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
|
||||
</div>
|
||||
<div class="earth-mobile-news-focus">
|
||||
<div>
|
||||
<div class="earth-mobile-news-focus-kicker">当前关注区域</div>
|
||||
<div id="mobile-news-focus-label" class="earth-mobile-news-focus-label">全球焦点</div>
|
||||
<div id="mobile-news-focus-coords" class="earth-mobile-news-focus-coords">跟随当前视角自动聚焦</div>
|
||||
</div>
|
||||
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
|
||||
</div>
|
||||
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
|
||||
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
|
||||
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||
<div class="earth-mobile-news-actions">
|
||||
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
|
||||
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
|
||||
</div>
|
||||
<a id="mobile-news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="tv">
|
||||
<div class="earth-mobile-page earth-mobile-page--tv">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">TV</span>
|
||||
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
|
||||
</div>
|
||||
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
|
||||
<div class="earth-mobile-tv-meta">
|
||||
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-player">
|
||||
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="mobile-tv-iframe"
|
||||
class="earth-mobile-tv-iframe"
|
||||
hidden
|
||||
title="移动端新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-actions">
|
||||
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
|
||||
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
|
||||
<div class="earth-mobile-page earth-mobile-page--settings">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Settings</span>
|
||||
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">旋转</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">旋转模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">视图</div>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">日夜模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-daynight-toggle checked>
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地球默认大小</span>
|
||||
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
data-default-earth-size-slider
|
||||
aria-label="移动端调整地球默认大小"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">地形</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地形透明度</span>
|
||||
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
data-terrain-opacity-slider
|
||||
aria-label="移动端调整地形透明度"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">系统</div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
|
||||
<div class="earth-mobile-page earth-mobile-page--details">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Details</span>
|
||||
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
|
||||
</div>
|
||||
<div class="earth-mobile-detail-card">
|
||||
<div class="earth-mobile-detail-header">
|
||||
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
|
||||
<div class="earth-mobile-detail-heading">
|
||||
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
|
||||
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
|
||||
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
|
||||
<div id="search-backdrop" class="earth-search-backdrop"></div>
|
||||
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
|
||||
<div class="earth-search-header hud-panel__header">
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-search-kicker">搜索</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-search-content hud-panel__body">
|
||||
<div class="earth-search-input-shell">
|
||||
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
|
||||
<input
|
||||
id="earth-search-input"
|
||||
class="earth-search-input"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="搜索海缆、登陆点、卫星、算力中心、BGP 事件..."
|
||||
>
|
||||
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
|
||||
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
|
||||
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||
<div class="earth-settings-header hud-panel__header">
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-settings-kicker">设置</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="关闭设置">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-content hud-panel__body">
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">旋转</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">旋转模式</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn is-active"
|
||||
data-rotation-mode="rotate"
|
||||
aria-pressed="true"
|
||||
>
|
||||
旋转模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn"
|
||||
data-rotation-mode="cruise"
|
||||
aria-pressed="false"
|
||||
>
|
||||
巡航模式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
<label class="earth-settings-item" for="toggle-daynight">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">日夜模式</span>
|
||||
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-daynight" type="checkbox" checked>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-layers">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">图层控制</span>
|
||||
@@ -402,16 +844,84 @@
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-tv">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">电视直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
<span class="earth-settings-item-title">新闻直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制电视直播 / 态势聚合显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地球默认大小</span>
|
||||
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="default-earth-size-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
aria-label="调整地球默认大小"
|
||||
>
|
||||
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
<a
|
||||
class="earth-settings-item earth-settings-link"
|
||||
href="/admin"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">Admin</span>
|
||||
<span class="earth-settings-item-subtitle">打开管理后台仪表盘</span>
|
||||
</div>
|
||||
<span class="earth-settings-link-meta">
|
||||
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||
<span class="material-symbols-rounded">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
589
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
589
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
@@ -0,0 +1,589 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||
import {
|
||||
computeNearestPerimeterAnchor,
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
} from "./callout-connector.js";
|
||||
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
||||
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
||||
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
|
||||
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
||||
const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
|
||||
const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
|
||||
const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17;
|
||||
const CRUISE_MOBILE_POPUP_MARGIN_PX = 14;
|
||||
const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52;
|
||||
const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16;
|
||||
const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12;
|
||||
const CRUISE_MARKER_SCREEN_PADDING_PX = 4;
|
||||
|
||||
function getDockAxisOffsets(dockSide, gapPx) {
|
||||
return {
|
||||
offsetX:
|
||||
dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0,
|
||||
offsetY:
|
||||
dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getObstaclePaddingBySide(side, paddingPx) {
|
||||
if (side === "left") {
|
||||
return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx };
|
||||
}
|
||||
if (side === "right") {
|
||||
return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx };
|
||||
}
|
||||
if (side === "top") {
|
||||
return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx };
|
||||
}
|
||||
return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 };
|
||||
}
|
||||
|
||||
const scratchMarkerWorldScale = new THREE.Vector3();
|
||||
const scratchCameraQuaternion = new THREE.Quaternion();
|
||||
const scratchCameraRight = new THREE.Vector3();
|
||||
const scratchCameraUp = new THREE.Vector3();
|
||||
const scratchMarkerRightPoint = new THREE.Vector3();
|
||||
const scratchMarkerLeftPoint = new THREE.Vector3();
|
||||
const scratchMarkerTopPoint = new THREE.Vector3();
|
||||
const scratchMarkerBottomPoint = new THREE.Vector3();
|
||||
|
||||
function projectWorldToScreen(point, camera) {
|
||||
if (!point || !camera) return null;
|
||||
const projected = point.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getMarkerTimestamp(marker) {
|
||||
const rawValue = marker?.userData?.created_at_raw;
|
||||
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);
|
||||
return projectWorldToScreen(scratchBGPWorldPosition, camera);
|
||||
}
|
||||
|
||||
function getVisibleMobilePopup() {
|
||||
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||
? mobilePopup
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVisibleInfoPanel() {
|
||||
const infoPanel = document.getElementById("info-panel");
|
||||
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||
? infoPanel
|
||||
: null;
|
||||
}
|
||||
|
||||
function getMarkerScreenRect(marker) {
|
||||
const center = getMarkerScreenCoords(marker);
|
||||
if (!center || !camera || !marker) return null;
|
||||
|
||||
marker.getWorldScale(scratchMarkerWorldScale);
|
||||
const worldWidth = Math.max(
|
||||
0.0001,
|
||||
Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x,
|
||||
);
|
||||
const worldHeight = Math.max(
|
||||
0.0001,
|
||||
Number(scratchMarkerWorldScale.y || worldWidth),
|
||||
);
|
||||
|
||||
camera.getWorldQuaternion(scratchCameraQuaternion);
|
||||
scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||
scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||
|
||||
scratchMarkerRightPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraRight, worldWidth * 0.5);
|
||||
scratchMarkerLeftPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraRight, -worldWidth * 0.5);
|
||||
scratchMarkerTopPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraUp, worldHeight * 0.5);
|
||||
scratchMarkerBottomPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraUp, -worldHeight * 0.5);
|
||||
|
||||
const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera);
|
||||
const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera);
|
||||
const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera);
|
||||
const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera);
|
||||
if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const halfWidth = Math.max(
|
||||
Math.abs(rightPoint.x - center.x),
|
||||
Math.abs(leftPoint.x - center.x),
|
||||
1,
|
||||
);
|
||||
const halfHeight = Math.max(
|
||||
Math.abs(topPoint.y - center.y),
|
||||
Math.abs(bottomPoint.y - center.y),
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||
y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||
width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||
height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardScreenCoords(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const safeBottom =
|
||||
parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||
) || 0;
|
||||
const estimatedCardWidth = Math.min(
|
||||
CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
|
||||
window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2,
|
||||
);
|
||||
const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX;
|
||||
const topBound = Math.max(
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO,
|
||||
window.innerHeight -
|
||||
CRUISE_MOBILE_DRAWER_CLEARANCE_PX -
|
||||
safeBottom -
|
||||
estimatedCardHeight -
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
),
|
||||
);
|
||||
const rightSlotLeft = Math.max(
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
);
|
||||
const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX;
|
||||
const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5;
|
||||
const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5;
|
||||
const rightClearance = rightSlotLeft - markerCoords.x;
|
||||
const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth);
|
||||
const rightCost =
|
||||
Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||
Math.abs(rightSlotCenterX - markerCoords.x);
|
||||
const leftCost =
|
||||
Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||
Math.abs(markerCoords.x - leftSlotCenterX);
|
||||
const placeOnRight = rightCost <= leftCost;
|
||||
const left = placeOnRight ? rightSlotLeft : leftSlotLeft;
|
||||
return {
|
||||
x: left,
|
||||
y: topBound,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
dockSide: placeOnRight ? "left" : "right",
|
||||
};
|
||||
}
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
) || 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),
|
||||
);
|
||||
return {
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardAnchorTarget() {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (
|
||||
document.body.classList.contains("layout-mode-mobile") &&
|
||||
mobilePopup
|
||||
) {
|
||||
const dockSide = mobilePopup.dataset.dockSide || "left";
|
||||
const { offsetX, offsetY } = getDockAxisOffsets(
|
||||
dockSide,
|
||||
CONNECTOR_CONFIG.panelGapPx,
|
||||
);
|
||||
return {
|
||||
element: mobilePopup,
|
||||
side: dockSide,
|
||||
alignRatio: 0.5,
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (
|
||||
document.body.classList.contains("layout-mode-mobile") &&
|
||||
mobilePopup
|
||||
) {
|
||||
const side = mobilePopup.dataset.dockSide || "left";
|
||||
return {
|
||||
element: mobilePopup,
|
||||
padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX),
|
||||
};
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
if (infoPanel) {
|
||||
return {
|
||||
element: infoPanel,
|
||||
padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX),
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackPlacement) {
|
||||
return {
|
||||
x: fallbackPlacement.x,
|
||||
y: fallbackPlacement.y,
|
||||
width: fallbackPlacement.width ?? 0,
|
||||
height: fallbackPlacement.height ?? 0,
|
||||
padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
const popupRect =
|
||||
mobilePopup
|
||||
? mobilePopup.getBoundingClientRect()
|
||||
: fallbackPlacement
|
||||
? {
|
||||
left: fallbackPlacement.x,
|
||||
top: fallbackPlacement.y,
|
||||
right: fallbackPlacement.x + (fallbackPlacement.width ?? 0),
|
||||
bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0),
|
||||
}
|
||||
: null;
|
||||
if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left";
|
||||
|
||||
return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left";
|
||||
}
|
||||
|
||||
function syncMobileDockSide(markerCoords, fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement);
|
||||
if (mobilePopup) {
|
||||
mobilePopup.dataset.dockSide = dockSide;
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectorPath(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
const markerRect = getMarkerScreenRect(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
syncMobileDockSide(markerCoords, targetCardCoords);
|
||||
const cardAnchorTarget = getCardAnchorTarget();
|
||||
const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget);
|
||||
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||
if (!cardAnchorCoords) return null;
|
||||
|
||||
return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, {
|
||||
routingMode: "adaptive",
|
||||
sourceRect: markerRect,
|
||||
targetAnchor: cardAnchorTarget ?? cardAnchorCoords,
|
||||
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||
startFrom: "source",
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||
if (!targetCardCoords) return null;
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
const desktopTarget =
|
||||
infoPanel
|
||||
? infoPanel
|
||||
: {
|
||||
x: targetCardCoords.x,
|
||||
y: targetCardCoords.y,
|
||||
width: targetCardCoords.width,
|
||||
height: targetCardCoords.height,
|
||||
};
|
||||
|
||||
return createConnectorPath(
|
||||
markerCoords,
|
||||
desktopTarget,
|
||||
{
|
||||
routingMode: "adaptive",
|
||||
sourceRect: markerRect,
|
||||
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
startFrom: "source",
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
});
|
||||
|
||||
cardPlacement = getCardScreenCoords(marker);
|
||||
},
|
||||
async presentMarker(marker, { context }) {
|
||||
if (!marker) return false;
|
||||
|
||||
const showCruiseMarkerInfo = ({ reveal = true } = {}) =>
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
reveal,
|
||||
anchorStable: true,
|
||||
dockSide: cardPlacement?.dockSide,
|
||||
});
|
||||
|
||||
showCruiseMarkerInfo({ reveal: false });
|
||||
await context.nextFrame();
|
||||
if (!context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
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;
|
||||
}
|
||||
|
||||
showCruiseMarkerInfo();
|
||||
await context.nextFrame();
|
||||
if (!isInfoVisible()) {
|
||||
showCruiseMarkerInfo();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const bgpGroup = new THREE.Group();
|
||||
const bgpOverlayGroup = new THREE.Group();
|
||||
@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
|
||||
}
|
||||
|
||||
function drawWaveSymbol(context) {
|
||||
context.lineWidth = 12;
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(18, 76);
|
||||
context.bezierCurveTo(34, 46, 46, 46, 64, 76);
|
||||
context.bezierCurveTo(80, 106, 94, 106, 110, 76);
|
||||
context.stroke();
|
||||
context.moveTo(14, 100);
|
||||
context.lineTo(38, 26);
|
||||
context.lineTo(64, 100);
|
||||
context.lineTo(90, 26);
|
||||
context.lineTo(114, 100);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function drawBurstSymbol(context) {
|
||||
@@ -279,37 +280,23 @@ function blendHexColors(fromHex, toHex, ratio) {
|
||||
function getCollectorDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
marker.getWorldPosition(collectorWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset;
|
||||
const referenceFovRad = (75 * Math.PI) / 180;
|
||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
||||
const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6);
|
||||
const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9);
|
||||
const worldPerPixel =
|
||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
|
||||
});
|
||||
}
|
||||
|
||||
function getEventDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
marker.getWorldPosition(collectorWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset;
|
||||
const referenceFovRad = (75 * Math.PI) / 180;
|
||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
||||
const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7);
|
||||
const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9);
|
||||
const worldPerPixel =
|
||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
|
||||
});
|
||||
}
|
||||
|
||||
function orientCollectorMarkerToSurface(marker, position) {
|
||||
@@ -1286,8 +1273,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
|
||||
}
|
||||
|
||||
export async function loadBGPAnomalies(scene, earth) {
|
||||
clearBGPData(earth);
|
||||
|
||||
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
|
||||
if (!collectorsResponse.ok) {
|
||||
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
||||
@@ -1312,6 +1297,9 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
? collectorsPayload.features
|
||||
: [];
|
||||
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
|
||||
|
||||
clearBGPData(earth);
|
||||
|
||||
totalAnomalyCount = selectedEventData.totalAnomalyCount;
|
||||
totalIncidentCount = selectedEventData.totalIncidentCount;
|
||||
activeEventCountByCollector.clear();
|
||||
@@ -1351,7 +1339,7 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
|
||||
const now = performance.now();
|
||||
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
||||
const hasLockedLayer = Boolean(
|
||||
@@ -1386,21 +1374,17 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
|
||||
if (isLocked) {
|
||||
scale *= 1.1 + 0.14 * pulse;
|
||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||
haloOpacity = 0.022;
|
||||
pulseOpacity = 0.012;
|
||||
coverageOpacity = 0.02;
|
||||
markerColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.lockedNeutralColor,
|
||||
marker.userData.baseColor || BGP_CONFIG.collectorColor,
|
||||
BGP_CONFIG.collectorIcon.lockedBlend,
|
||||
);
|
||||
opacity = 0.96;
|
||||
haloOpacity = 0.05;
|
||||
pulseOpacity = 0.024;
|
||||
coverageOpacity = 0.036;
|
||||
markerColor = 0xcff2ff;
|
||||
} else if (isHovered) {
|
||||
scale *= 1.08;
|
||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||
haloOpacity = 0.016;
|
||||
pulseOpacity = 0.008;
|
||||
coverageOpacity = 0.014;
|
||||
opacity = 0.88;
|
||||
haloOpacity = 0.03;
|
||||
pulseOpacity = 0.014;
|
||||
coverageOpacity = 0.02;
|
||||
markerColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.hoverNeutralColor,
|
||||
marker.userData.baseColor || BGP_CONFIG.collectorColor,
|
||||
@@ -1463,7 +1447,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const isLinkedCollectorLocked =
|
||||
lockedObjectType === "bgp_collector" &&
|
||||
lockedObject?.userData?.collector === marker.userData.collector;
|
||||
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
|
||||
const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
|
||||
const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
|
||||
const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
|
||||
const isActive = isLocked || isLinkedCollectorLocked || isCruise;
|
||||
const isHovered = marker.userData.state === "hover";
|
||||
const pulse =
|
||||
0.5 +
|
||||
@@ -1481,17 +1468,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
|
||||
if (isLocked || isLinkedCollectorLocked) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity =
|
||||
BGP_CONFIG.opacity.lockedMin +
|
||||
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
markerColor = 0xfff1a8;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isCruise) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isHovered) {
|
||||
scale *= BGP_CONFIG.marker.hoverScale;
|
||||
opacity = BGP_CONFIG.opacity.hover;
|
||||
opacity = 0.9;
|
||||
ringBaseOpacity *= 1.05;
|
||||
} else if (isOtherLocked) {
|
||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||
opacity = 0.1;
|
||||
opacity = 0.22;
|
||||
markerColor = 0x7d8ca3;
|
||||
ringBaseOpacity = 0.02;
|
||||
} else {
|
||||
@@ -1503,6 +1493,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
marker.material.color.setHex(markerColor);
|
||||
marker.material.opacity = opacity;
|
||||
marker.visible = showBGP;
|
||||
marker.renderOrder = isActive ? 7 : 3;
|
||||
|
||||
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
||||
const applyRingState = (ring, phase, maxScale) => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
CABLE_STATE,
|
||||
CABLE_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { showInfoCard } from "./info-card.js";
|
||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||
|
||||
@@ -20,7 +20,7 @@ export let lockedCable = null;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
const landingPointWorldPosition = new THREE.Vector3();
|
||||
let landingPointGeometry = null;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -32,24 +32,13 @@ function getLandingPointDistanceScale(point, camera) {
|
||||
!camera ||
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
|
||||
) return 1;
|
||||
point.getWorldPosition(landingPointWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition);
|
||||
const referenceDistance =
|
||||
CONFIG.defaultCameraZ -
|
||||
CONFIG.earthRadius +
|
||||
CABLE_CONFIG.landingPoint.altitudeOffset;
|
||||
const referenceFovDeg =
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75;
|
||||
const referenceFovRad = (referenceFovDeg * Math.PI) / 180;
|
||||
const cameraFovRad =
|
||||
(((camera.fov || referenceFovDeg)) * Math.PI) / 180;
|
||||
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
return clamp(
|
||||
worldPerPixel / referenceWorldPerPixel,
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
);
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
|
||||
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
@@ -72,7 +61,7 @@ function disposeObject(object, parent) {
|
||||
if (owner) {
|
||||
owner.remove(object);
|
||||
}
|
||||
if (object.geometry) {
|
||||
if (object.geometry && !object.userData?.sharedGeometry) {
|
||||
object.geometry.dispose();
|
||||
}
|
||||
if (object.material) {
|
||||
@@ -245,9 +234,12 @@ export function clearCableData(earthObj = null) {
|
||||
clearLandingPoints(earthObj);
|
||||
}
|
||||
|
||||
export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载电缆数据...");
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
if (!response.ok) {
|
||||
@@ -332,9 +324,8 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
feature.properties.status === "In Service"),
|
||||
).length;
|
||||
|
||||
const cableCountEl = document.getElementById("cable-count");
|
||||
const statusEl = document.getElementById("cable-status-summary");
|
||||
if (cableCountEl) cableCountEl.textContent = cableCount + "个";
|
||||
setEarthStatValue("cable-count", `${cableCount}个`);
|
||||
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
||||
|
||||
updateEarthStats({
|
||||
@@ -344,11 +335,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
textureQuality: "8K 卫星图",
|
||||
});
|
||||
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
}
|
||||
return cableLines.length;
|
||||
}
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj) {
|
||||
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载登陆点数据...");
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
@@ -363,78 +357,76 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
|
||||
const sphereGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
if (!landingPointGeometry) {
|
||||
landingPointGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
}
|
||||
let validCount = 0;
|
||||
|
||||
try {
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const properties = feature.properties || {};
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const properties = feature.properties || {};
|
||||
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = latLonToVector3(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
sphereGeometry.clone(),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
};
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
validCount++;
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
sphereGeometry.dispose();
|
||||
|
||||
const position = latLonToVector3(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
landingPointGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
sharedGeometry: true,
|
||||
};
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
validCount++;
|
||||
}
|
||||
|
||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
||||
if (landingPointCountEl) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
}
|
||||
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
}
|
||||
return validCount;
|
||||
}
|
||||
|
||||
@@ -558,14 +550,18 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
|
||||
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||
|
||||
if (isRelated) {
|
||||
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
|
||||
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
|
||||
lp.material.color.setHex(0xffd27a);
|
||||
lp.material.emissive.setHex(0x7a4a00);
|
||||
lp.material.emissiveIntensity =
|
||||
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse;
|
||||
0.2 +
|
||||
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
|
||||
lp.material.opacity =
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse;
|
||||
Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
|
||||
);
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
lp.scale.setScalar(
|
||||
|
||||
645
frontend/public/earth/js/callout-connector.js
Normal file
645
frontend/public/earth/js/callout-connector.js
Normal file
@@ -0,0 +1,645 @@
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DEFAULT_CLASS_NAME = "callout-connector";
|
||||
const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
|
||||
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
|
||||
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
|
||||
|
||||
function createSvgElement(tagName) {
|
||||
return document.createElementNS(SVG_NS, tagName);
|
||||
}
|
||||
|
||||
function resolveElementAnchorSide(side) {
|
||||
switch (side) {
|
||||
case "right":
|
||||
case "top":
|
||||
case "bottom":
|
||||
case "left":
|
||||
return side;
|
||||
default:
|
||||
return "left";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveElementAnchorAlignRatio(ratio) {
|
||||
if (!Number.isFinite(ratio)) return 0.5;
|
||||
return Math.min(Math.max(ratio, 0), 1);
|
||||
}
|
||||
|
||||
function resolveAnchorElement(target) {
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (target?.element instanceof HTMLElement) return target.element;
|
||||
if (typeof target?.selector === "string") {
|
||||
const matched = document.querySelector(target.selector);
|
||||
return matched instanceof HTMLElement ? matched : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRectElement(target) {
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (target?.element instanceof HTMLElement) return target.element;
|
||||
if (typeof target?.selector === "string") {
|
||||
const matched = document.querySelector(target.selector);
|
||||
return matched instanceof HTMLElement ? matched : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFiniteRect(rect) {
|
||||
return (
|
||||
rect &&
|
||||
Number.isFinite(rect.left) &&
|
||||
Number.isFinite(rect.top) &&
|
||||
Number.isFinite(rect.right) &&
|
||||
Number.isFinite(rect.bottom)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRect(rect) {
|
||||
if (!rect) return null;
|
||||
const left = Number(rect.left);
|
||||
const top = Number(rect.top);
|
||||
const right = Number(rect.right);
|
||||
const bottom = Number(rect.bottom);
|
||||
if (
|
||||
!Number.isFinite(left) ||
|
||||
!Number.isFinite(top) ||
|
||||
!Number.isFinite(right) ||
|
||||
!Number.isFinite(bottom)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: Math.min(left, right),
|
||||
top: Math.min(top, bottom),
|
||||
right: Math.max(left, right),
|
||||
bottom: Math.max(top, bottom),
|
||||
};
|
||||
}
|
||||
|
||||
function expandRect(rect, padding = 0) {
|
||||
const normalized = normalizeRect(rect);
|
||||
if (!normalized) return null;
|
||||
if (typeof padding === "object" && padding !== null) {
|
||||
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
|
||||
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
|
||||
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
|
||||
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
|
||||
return {
|
||||
left: normalized.left - leftPadding,
|
||||
top: normalized.top - topPadding,
|
||||
right: normalized.right + rightPadding,
|
||||
bottom: normalized.bottom + bottomPadding,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: normalized.left - padding,
|
||||
top: normalized.top - padding,
|
||||
right: normalized.right + padding,
|
||||
bottom: normalized.bottom + padding,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeSequentialPoints(points) {
|
||||
const nextPoints = [];
|
||||
for (const point of points) {
|
||||
const previous = nextPoints[nextPoints.length - 1];
|
||||
if (
|
||||
previous &&
|
||||
Math.abs(previous.x - point.x) < 0.5 &&
|
||||
Math.abs(previous.y - point.y) < 0.5
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
nextPoints.push(point);
|
||||
}
|
||||
return nextPoints;
|
||||
}
|
||||
|
||||
// Compute the anchor point on the nearest perimeter edge of rect to source.
|
||||
// gap is applied outward from the edge, so the anchor is outside the rect.
|
||||
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
|
||||
const { left, top, right, bottom } = rect;
|
||||
const midX = (left + right) * 0.5;
|
||||
const midY = (top + bottom) * 0.5;
|
||||
|
||||
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
|
||||
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
|
||||
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
|
||||
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
|
||||
|
||||
// Source inside rect: snap to nearest edge midpoint
|
||||
const dLeft = source.x - left;
|
||||
const dRight = right - source.x;
|
||||
const dTop = source.y - top;
|
||||
const dBottom = bottom - source.y;
|
||||
const minD = Math.min(dLeft, dRight, dTop, dBottom);
|
||||
|
||||
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
|
||||
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
|
||||
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
|
||||
return { x: midX, y: bottom + gap, side: "bottom" };
|
||||
}
|
||||
|
||||
export function resolveConnectorObstacleRect(target, options = {}) {
|
||||
if (!target) return null;
|
||||
|
||||
if (typeof target === "function") {
|
||||
return resolveConnectorObstacleRect(target(), options);
|
||||
}
|
||||
|
||||
const resolvedPadding =
|
||||
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
|
||||
? target.padding
|
||||
: options.padding;
|
||||
const padding =
|
||||
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
|
||||
? resolvedPadding
|
||||
: 0;
|
||||
|
||||
if (isFiniteRect(target)) {
|
||||
return expandRect(target, padding);
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isFinite(target.x) &&
|
||||
Number.isFinite(target.y) &&
|
||||
Number.isFinite(target.width) &&
|
||||
Number.isFinite(target.height)
|
||||
) {
|
||||
return expandRect(
|
||||
{
|
||||
left: Number(target.x),
|
||||
top: Number(target.y),
|
||||
right: Number(target.x) + Number(target.width),
|
||||
bottom: Number(target.y) + Number(target.height),
|
||||
},
|
||||
padding,
|
||||
);
|
||||
}
|
||||
|
||||
const element = resolveRectElement(target);
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
return expandRect(element.getBoundingClientRect(), padding);
|
||||
}
|
||||
|
||||
export function resolveConnectorAnchor(target) {
|
||||
if (!target) return null;
|
||||
|
||||
if (typeof target === "function") {
|
||||
return resolveConnectorAnchor(target());
|
||||
}
|
||||
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
|
||||
return { x: Number(target.x), y: Number(target.y) };
|
||||
}
|
||||
|
||||
const element = resolveAnchorElement(target);
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const side = resolveElementAnchorSide(target.side);
|
||||
const alignRatio = resolveElementAnchorAlignRatio(
|
||||
target.alignRatio ?? target.anchorRatio ?? target.ratio,
|
||||
);
|
||||
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
|
||||
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
|
||||
|
||||
let x = rect.left + rect.width * 0.5;
|
||||
let y = rect.top + rect.height * 0.5;
|
||||
|
||||
if (side === "left") {
|
||||
x = rect.left;
|
||||
y = rect.top + rect.height * alignRatio;
|
||||
} else if (side === "right") {
|
||||
x = rect.right;
|
||||
y = rect.top + rect.height * alignRatio;
|
||||
} else if (side === "top") {
|
||||
x = rect.left + rect.width * alignRatio;
|
||||
y = rect.top;
|
||||
} else if (side === "bottom") {
|
||||
x = rect.left + rect.width * alignRatio;
|
||||
y = rect.bottom;
|
||||
}
|
||||
|
||||
return {
|
||||
x: x + offsetX,
|
||||
y: y + offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConnectorRect(target) {
|
||||
return resolveConnectorObstacleRect(target, { padding: 0 });
|
||||
}
|
||||
|
||||
function createRectSideMidpoint(rect, side, gap = 0) {
|
||||
const normalizedRect = normalizeRect(rect);
|
||||
if (!normalizedRect) return null;
|
||||
|
||||
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
|
||||
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
|
||||
|
||||
if (side === "left") {
|
||||
return { x: normalizedRect.left - gap, y: midpointY, side };
|
||||
}
|
||||
if (side === "right") {
|
||||
return { x: normalizedRect.right + gap, y: midpointY, side };
|
||||
}
|
||||
if (side === "top") {
|
||||
return { x: midpointX, y: normalizedRect.top - gap, side };
|
||||
}
|
||||
if (side === "bottom") {
|
||||
return { x: midpointX, y: normalizedRect.bottom + gap, side };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function createRectEdgeAnchor(rect, side, position, gap = 0) {
|
||||
const normalizedRect = normalizeRect(rect);
|
||||
if (!normalizedRect) return null;
|
||||
|
||||
const x =
|
||||
side === "left"
|
||||
? normalizedRect.left - gap
|
||||
: side === "right"
|
||||
? normalizedRect.right + gap
|
||||
: clamp(
|
||||
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
|
||||
normalizedRect.left,
|
||||
normalizedRect.right,
|
||||
);
|
||||
const y =
|
||||
side === "top"
|
||||
? normalizedRect.top - gap
|
||||
: side === "bottom"
|
||||
? normalizedRect.bottom + gap
|
||||
: clamp(
|
||||
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
|
||||
normalizedRect.top,
|
||||
normalizedRect.bottom,
|
||||
);
|
||||
|
||||
return { x, y, side };
|
||||
}
|
||||
|
||||
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
|
||||
if (!startPoint || !endPoint) return null;
|
||||
const normalizedDirections = directions.filter(Boolean);
|
||||
if (!normalizedDirections.length) {
|
||||
return dedupeSequentialPoints([startPoint, endPoint]);
|
||||
}
|
||||
|
||||
const firstDirection = normalizedDirections[0];
|
||||
const corner =
|
||||
firstDirection === "left" || firstDirection === "right"
|
||||
? { x: endPoint.x, y: startPoint.y }
|
||||
: { x: startPoint.x, y: endPoint.y };
|
||||
|
||||
return dedupeSequentialPoints([startPoint, corner, endPoint]);
|
||||
}
|
||||
|
||||
function resolveSourceAnchorGapPx(sourceGapPx) {
|
||||
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
|
||||
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
|
||||
}
|
||||
|
||||
export function createElbowConnectorPoints(source, target, options = {}) {
|
||||
const resolvedSource = resolveConnectorAnchor(source);
|
||||
const resolvedTarget = resolveConnectorAnchor(target);
|
||||
if (!resolvedSource || !resolvedTarget) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
sourceGapPx = 12,
|
||||
targetGapPx = 8,
|
||||
elbowOffsetPx = 18,
|
||||
elbowDropPx = 14,
|
||||
} = options;
|
||||
|
||||
const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||
const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x) ||
|
||||
!Number.isFinite(sourcePoint.y) ||
|
||||
!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],
|
||||
};
|
||||
}
|
||||
|
||||
function createAdaptiveConnectorPoints(source, target, options = {}) {
|
||||
const resolvedSource = resolveConnectorAnchor(source);
|
||||
if (!resolvedSource) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
sourceGapPx = 12,
|
||||
targetGapPx = 8,
|
||||
obstacleClearancePx = 8,
|
||||
obstacles = [],
|
||||
targetAnchor = null,
|
||||
sourceRect = null,
|
||||
} = options;
|
||||
|
||||
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
|
||||
|
||||
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
|
||||
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
|
||||
.filter(Boolean);
|
||||
|
||||
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
|
||||
const resolvedTarget =
|
||||
resolveConnectorAnchor(targetAnchor ?? target) ||
|
||||
(fallbackTargetRect
|
||||
? computeNearestPerimeterAnchor(
|
||||
sp,
|
||||
fallbackTargetRect,
|
||||
Math.max(targetGapPx, obstacleClearancePx + 1),
|
||||
)
|
||||
: null);
|
||||
if (!resolvedTarget) return null;
|
||||
|
||||
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
|
||||
|
||||
const primaryObstacle = normalizedObstacles[0] || null;
|
||||
const relationRect = fallbackTargetRect || primaryObstacle;
|
||||
if (!relationRect) return null;
|
||||
|
||||
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
|
||||
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
|
||||
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
|
||||
|
||||
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
|
||||
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
|
||||
const isTargetAbove = relationRect.bottom < sp.y;
|
||||
const isTargetBelow = relationRect.top > sp.y;
|
||||
const isSourceWithinAnchorHorizontalRange =
|
||||
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
|
||||
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
|
||||
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
|
||||
|
||||
let directions = [];
|
||||
let targetSide = null;
|
||||
const isTargetCenterWithinSourceVerticalRange =
|
||||
sourceRelationRect &&
|
||||
targetCenterY >= sourceRelationRect.top &&
|
||||
targetCenterY <= sourceRelationRect.bottom;
|
||||
const isTargetCenterWithinSourceHorizontalRange =
|
||||
sourceRelationRect &&
|
||||
targetCenterX >= sourceRelationRect.left &&
|
||||
targetCenterX <= sourceRelationRect.right;
|
||||
|
||||
if (isRightMidpointLeftOfSource) {
|
||||
targetSide = "right";
|
||||
if (isTargetCenterWithinSourceVerticalRange) {
|
||||
directions = ["left"];
|
||||
} else if (rightMidpoint.y < sp.y) {
|
||||
directions = ["top", "left"];
|
||||
} else if (rightMidpoint.y > sp.y) {
|
||||
directions = ["bottom", "left"];
|
||||
} else {
|
||||
directions = ["left"];
|
||||
}
|
||||
} else if (isLeftMidpointRightOfSource) {
|
||||
targetSide = "left";
|
||||
if (isTargetCenterWithinSourceVerticalRange) {
|
||||
directions = ["right"];
|
||||
} else if (leftMidpoint.y < sp.y) {
|
||||
directions = ["top", "right"];
|
||||
} else if (leftMidpoint.y > sp.y) {
|
||||
directions = ["bottom", "right"];
|
||||
} else {
|
||||
directions = ["right"];
|
||||
}
|
||||
} else if (isSourceWithinAnchorHorizontalRange) {
|
||||
if (isTargetAbove) {
|
||||
targetSide = "bottom";
|
||||
directions = isTargetCenterWithinSourceHorizontalRange
|
||||
? ["top"]
|
||||
: targetCenterX >= sp.x
|
||||
? ["right", "top"]
|
||||
: ["left", "top"];
|
||||
} else if (isTargetBelow) {
|
||||
targetSide = "top";
|
||||
directions = isTargetCenterWithinSourceHorizontalRange
|
||||
? ["bottom"]
|
||||
: targetCenterX >= sp.x
|
||||
? ["right", "bottom"]
|
||||
: ["left", "bottom"];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetSide || !directions.length) {
|
||||
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||
}
|
||||
|
||||
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
|
||||
const targetPoint =
|
||||
resolvedTarget && targetAnchor
|
||||
? end
|
||||
: derivedTargetAnchor || end;
|
||||
|
||||
const sourceSide = directions[0] || null;
|
||||
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
|
||||
const shouldSlideSourceAnchorAlongEdge =
|
||||
(sourceSide === "left" || sourceSide === "right") &&
|
||||
isTargetCenterWithinSourceVerticalRange ||
|
||||
(sourceSide === "top" || sourceSide === "bottom") &&
|
||||
isTargetCenterWithinSourceHorizontalRange;
|
||||
const derivedSourceAnchor =
|
||||
sourceRelationRect && sourceSide
|
||||
? shouldSlideSourceAnchorAlongEdge
|
||||
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
|
||||
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
|
||||
: null;
|
||||
const startPoint = derivedSourceAnchor || sp;
|
||||
|
||||
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
|
||||
if (!pts) {
|
||||
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||
}
|
||||
pts = dedupeSequentialPoints(pts);
|
||||
return {
|
||||
points: startFrom === "target" ? pts.slice().reverse() : pts,
|
||||
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
|
||||
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
export function createConnectorPath(source, target, options = {}) {
|
||||
const {
|
||||
routingMode = "simple",
|
||||
} = options;
|
||||
|
||||
if (routingMode === "adaptive") {
|
||||
return createAdaptiveConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
if (routingMode === "simple") {
|
||||
return createElbowConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
return createElbowConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
export class CalloutConnector {
|
||||
constructor({
|
||||
container = null,
|
||||
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;
|
||||
}
|
||||
}
|
||||
645
frontend/public/earth/js/celestial.js
Normal file
645
frontend/public/earth/js/celestial.js
Normal file
@@ -0,0 +1,645 @@
|
||||
import * as THREE from "three";
|
||||
import * as Astronomy from "astronomy-engine";
|
||||
|
||||
import {
|
||||
CELESTIAL_CONFIG,
|
||||
EARTH_CONFIG,
|
||||
SCENE_LIGHT_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const defaultSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
|
||||
const defaultMoonDirection = new THREE.Vector3(-0.6, 0.45, -0.2).normalize();
|
||||
|
||||
let celestialRoot = null;
|
||||
let skySphere = null;
|
||||
let brightStarsGroup = null;
|
||||
let sunSprite = null;
|
||||
let moonSprite = null;
|
||||
let sunHaloSprite = null;
|
||||
let moonHaloSprite = null;
|
||||
let brightStarTexture = null;
|
||||
let sunDirection = defaultSunDirection.clone();
|
||||
let moonDirection = defaultMoonDirection.clone();
|
||||
let lastUpdatedAt = 0;
|
||||
let linkedSunLight = null;
|
||||
let linkedBackLight = null;
|
||||
let linkedAmbientLight = null;
|
||||
let linkedPointLight = null;
|
||||
let linkedCamera = null;
|
||||
let linkedEarth = null;
|
||||
let dayNightLightingEnabled = true;
|
||||
let brightStarSprites = [];
|
||||
let celestialRotationQuaternion = new THREE.Quaternion();
|
||||
let celestialViewQuaternion = new THREE.Quaternion();
|
||||
let runtimeOrientationEuler = {
|
||||
...CELESTIAL_CONFIG.orientationEulerRad,
|
||||
};
|
||||
let runtimeFollowConfig = {
|
||||
...CELESTIAL_CONFIG.followEarthRotation,
|
||||
};
|
||||
const scratchEuler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||
|
||||
function normalizeDegrees180(value) {
|
||||
let normalized = value;
|
||||
while (normalized <= -180) normalized += 360;
|
||||
while (normalized > 180) normalized -= 360;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function computeGreenwichMeanSiderealDegrees(date) {
|
||||
const jd = date.getTime() / 86400000 + 2440587.5;
|
||||
const t = (jd - 2451545.0) / 36525.0;
|
||||
const gmst =
|
||||
280.46061837 +
|
||||
360.98564736629 * (jd - 2451545.0) +
|
||||
0.000387933 * t * t -
|
||||
(t * t * t) / 38710000;
|
||||
return THREE.MathUtils.euclideanModulo(gmst, 360);
|
||||
}
|
||||
|
||||
function computeSubsolarLocalDirection(date) {
|
||||
const vector = Astronomy.GeoVector(Astronomy.Body.Sun, date, false);
|
||||
const radius = Math.sqrt(
|
||||
vector.x * vector.x +
|
||||
vector.y * vector.y +
|
||||
vector.z * vector.z,
|
||||
);
|
||||
if (!Number.isFinite(radius) || radius === 0) {
|
||||
return defaultSunDirection.clone();
|
||||
}
|
||||
|
||||
const rightAscensionDeg = THREE.MathUtils.radToDeg(
|
||||
Math.atan2(vector.y, vector.x),
|
||||
);
|
||||
const declinationDeg = THREE.MathUtils.radToDeg(
|
||||
Math.asin(THREE.MathUtils.clamp(vector.z / radius, -1, 1)),
|
||||
);
|
||||
const gmstDeg = computeGreenwichMeanSiderealDegrees(date);
|
||||
const subsolarLonDeg = normalizeDegrees180(rightAscensionDeg - gmstDeg);
|
||||
|
||||
return latLonToVector3(declinationDeg, subsolarLonDeg, 1).normalize();
|
||||
}
|
||||
|
||||
function getPhysicalSunDirection(date = new Date()) {
|
||||
const localSunDirection = computeSubsolarLocalDirection(date);
|
||||
if (!linkedEarth) {
|
||||
return localSunDirection;
|
||||
}
|
||||
|
||||
return localSunDirection
|
||||
.clone()
|
||||
.applyQuaternion(linkedEarth.quaternion)
|
||||
.normalize();
|
||||
}
|
||||
|
||||
function getCelestialEuler() {
|
||||
const { x, y, z } = runtimeOrientationEuler;
|
||||
return new THREE.Euler(x, y, z, "YXZ");
|
||||
}
|
||||
|
||||
function refreshCelestialOrientation() {
|
||||
celestialRotationQuaternion.setFromEuler(getCelestialEuler());
|
||||
refreshCelestialView();
|
||||
}
|
||||
|
||||
function refreshCelestialView() {
|
||||
if (linkedEarth && runtimeFollowConfig.enabled) {
|
||||
const followX = runtimeFollowConfig.x
|
||||
? (linkedEarth.rotation.x - EARTH_CONFIG.tiltRad) * (runtimeFollowConfig.invertX ? -1 : 1)
|
||||
: 0;
|
||||
const followY = runtimeFollowConfig.y
|
||||
? linkedEarth.rotation.y * (runtimeFollowConfig.invertY ? -1 : 1)
|
||||
: 0;
|
||||
const followZ = runtimeFollowConfig.z
|
||||
? linkedEarth.rotation.z * (runtimeFollowConfig.invertZ ? -1 : 1)
|
||||
: 0;
|
||||
|
||||
scratchEuler.set(
|
||||
followX,
|
||||
followY,
|
||||
followZ,
|
||||
"YXZ",
|
||||
);
|
||||
celestialViewQuaternion.setFromEuler(scratchEuler);
|
||||
} else {
|
||||
celestialViewQuaternion.identity();
|
||||
}
|
||||
|
||||
if (celestialRoot) {
|
||||
celestialRoot.quaternion
|
||||
.copy(celestialRotationQuaternion)
|
||||
.multiply(celestialViewQuaternion);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCelestialOrientation(direction) {
|
||||
return direction
|
||||
.clone()
|
||||
.applyQuaternion(celestialRotationQuaternion)
|
||||
.applyQuaternion(celestialViewQuaternion)
|
||||
.normalize();
|
||||
}
|
||||
|
||||
function configureTextureEncoding(texture) {
|
||||
if (!texture) return;
|
||||
if ("colorSpace" in texture && THREE.SRGBColorSpace) {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
} else if ("encoding" in texture && THREE.sRGBEncoding) {
|
||||
texture.encoding = THREE.sRGBEncoding;
|
||||
}
|
||||
}
|
||||
|
||||
function createDiscTexture(stops, size = 256) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const gradient = ctx.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size / 2,
|
||||
);
|
||||
|
||||
stops.forEach(([offset, color]) => gradient.addColorStop(offset, color));
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
configureTextureEncoding(texture);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createSprite({ texture, scale, opacity = 1, name, color = 0xffffff }) {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
toneMapped: false,
|
||||
color,
|
||||
});
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.name = name;
|
||||
sprite.scale.setScalar(scale);
|
||||
sprite.renderOrder = 100;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
function createSkySphere() {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CELESTIAL_CONFIG.skyRadius,
|
||||
64,
|
||||
64,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
side: THREE.BackSide,
|
||||
transparent: CELESTIAL_CONFIG.skyOpacity < 1,
|
||||
opacity: CELESTIAL_CONFIG.skyOpacity,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = "celestial-sky-sphere";
|
||||
mesh.renderOrder = -1000;
|
||||
mesh.raycast = () => {};
|
||||
|
||||
textureLoader.load(
|
||||
CELESTIAL_CONFIG.starMapUrl,
|
||||
(texture) => {
|
||||
configureTextureEncoding(texture);
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
console.warn("Failed to load celestial star map texture");
|
||||
},
|
||||
);
|
||||
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function geoVectorToWorldDirection(vector) {
|
||||
return new THREE.Vector3(vector.x, vector.z, vector.y).normalize();
|
||||
}
|
||||
|
||||
function raDecToWorldDirection(raDeg, decDeg) {
|
||||
const raRad = THREE.MathUtils.degToRad(raDeg);
|
||||
const decRad = THREE.MathUtils.degToRad(decDeg);
|
||||
|
||||
const x = Math.cos(decRad) * Math.cos(raRad);
|
||||
const y = Math.sin(decRad);
|
||||
const z = Math.cos(decRad) * Math.sin(raRad);
|
||||
|
||||
return new THREE.Vector3(x, z, y).normalize();
|
||||
}
|
||||
|
||||
function colorFromBvIndex(colorIndex) {
|
||||
if (colorIndex <= -0.2) return 0xa8c9ff;
|
||||
if (colorIndex <= 0.0) return 0xc8dcff;
|
||||
if (colorIndex <= 0.3) return 0xf4f7ff;
|
||||
if (colorIndex <= 0.7) return 0xfff4dc;
|
||||
if (colorIndex <= 1.1) return 0xffdfb0;
|
||||
if (colorIndex <= 1.5) return 0xffc47f;
|
||||
return 0xffa35e;
|
||||
}
|
||||
|
||||
function scaleFromMagnitude(mag) {
|
||||
const brightness = THREE.MathUtils.clamp(
|
||||
1 - (mag - (-1.5)) / (CELESTIAL_CONFIG.brightStarMinMag - (-1.5)),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
return THREE.MathUtils.lerp(
|
||||
CELESTIAL_CONFIG.brightStarMinScale,
|
||||
CELESTIAL_CONFIG.brightStarMaxScale,
|
||||
Math.pow(brightness, 0.72),
|
||||
);
|
||||
}
|
||||
|
||||
function createBrightStarTexture() {
|
||||
return createDiscTexture([
|
||||
[0, "rgba(255,255,255,1)"],
|
||||
[0.18, "rgba(255,255,255,0.98)"],
|
||||
[0.46, "rgba(215,230,255,0.42)"],
|
||||
[1, "rgba(128,168,255,0)"],
|
||||
], 128);
|
||||
}
|
||||
|
||||
function createBrightStarsGroup() {
|
||||
const group = new THREE.Group();
|
||||
group.name = "bright-stars-group";
|
||||
group.visible = true;
|
||||
return group;
|
||||
}
|
||||
|
||||
async function loadBrightStars() {
|
||||
try {
|
||||
const response = await fetch(CELESTIAL_CONFIG.brightStarsUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const stars = await response.json();
|
||||
const filteredStars = stars
|
||||
.filter((star) => Number.isFinite(star?.raDeg) && Number.isFinite(star?.decDeg))
|
||||
.filter((star) => star.mag <= CELESTIAL_CONFIG.brightStarMinMag)
|
||||
.sort((a, b) => a.mag - b.mag)
|
||||
.slice(0, CELESTIAL_CONFIG.brightStarMaxCount);
|
||||
|
||||
brightStarTexture = createBrightStarTexture();
|
||||
brightStarSprites = filteredStars.map((star) => {
|
||||
const sprite = createSprite({
|
||||
texture: brightStarTexture,
|
||||
scale: scaleFromMagnitude(star.mag),
|
||||
opacity: CELESTIAL_CONFIG.brightStarOpacity,
|
||||
name: `bright-star-${star.name}`,
|
||||
color: colorFromBvIndex(star.colorIndex ?? 0.4),
|
||||
});
|
||||
sprite.position
|
||||
.copy(raDecToWorldDirection(star.raDeg, star.decDeg))
|
||||
.multiplyScalar(CELESTIAL_CONFIG.brightStarDistance);
|
||||
sprite.userData.star = star;
|
||||
return sprite;
|
||||
});
|
||||
|
||||
brightStarSprites.forEach((sprite) => brightStarsGroup?.add(sprite));
|
||||
} catch (error) {
|
||||
console.warn("Failed to load bright star layer", error);
|
||||
}
|
||||
}
|
||||
|
||||
function computeBodyDirection(body, date) {
|
||||
const vector = Astronomy.GeoVector(body, date, false);
|
||||
return geoVectorToWorldDirection(vector);
|
||||
}
|
||||
|
||||
function updateSpritePositions() {
|
||||
if (sunSprite) {
|
||||
sunSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
|
||||
}
|
||||
if (sunHaloSprite) {
|
||||
sunHaloSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
|
||||
}
|
||||
|
||||
if (moonSprite) {
|
||||
moonSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
|
||||
}
|
||||
if (moonHaloSprite) {
|
||||
moonHaloSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLighting() {
|
||||
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
|
||||
const inspection = CELESTIAL_CONFIG.inspectionLighting;
|
||||
const cameraDirection = linkedCamera
|
||||
? linkedCamera.position.clone().normalize()
|
||||
: defaultSunDirection.clone();
|
||||
const worldUp = new THREE.Vector3(0, 1, 0);
|
||||
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
|
||||
if (right.lengthSq() < 1e-6) {
|
||||
right.set(1, 0, 0);
|
||||
} else {
|
||||
right.normalize();
|
||||
}
|
||||
const adjustedUp = new THREE.Vector3()
|
||||
.crossVectors(cameraDirection, right)
|
||||
.normalize();
|
||||
|
||||
const resolveInspectionDirection = (offset) =>
|
||||
cameraDirection
|
||||
.clone()
|
||||
.multiplyScalar(offset.z)
|
||||
.add(right.clone().multiplyScalar(offset.x))
|
||||
.add(adjustedUp.clone().multiplyScalar(offset.y))
|
||||
.normalize();
|
||||
|
||||
if (linkedAmbientLight) {
|
||||
linkedAmbientLight.color.setHex(inspection.ambientColor);
|
||||
linkedAmbientLight.intensity = inspection.ambientIntensity;
|
||||
}
|
||||
|
||||
if (linkedSunLight) {
|
||||
linkedSunLight.color.setHex(inspection.keyLightColor);
|
||||
linkedSunLight.intensity = inspection.keyLightIntensity;
|
||||
linkedSunLight.position
|
||||
.copy(resolveInspectionDirection(inspection.keyLightOffset))
|
||||
.multiplyScalar(inspection.keyLightDistance);
|
||||
}
|
||||
|
||||
if (linkedBackLight) {
|
||||
linkedBackLight.color.setHex(inspection.backLightColor);
|
||||
linkedBackLight.intensity = inspection.backLightIntensity;
|
||||
linkedBackLight.position
|
||||
.copy(resolveInspectionDirection(inspection.backLightOffset))
|
||||
.multiplyScalar(inspection.backLightDistance);
|
||||
}
|
||||
|
||||
if (linkedPointLight) {
|
||||
linkedPointLight.color.setHex(inspection.pointLightColor);
|
||||
linkedPointLight.intensity = inspection.pointLightIntensity;
|
||||
linkedPointLight.position
|
||||
.copy(resolveInspectionDirection(inspection.pointLightOffset))
|
||||
.multiplyScalar(inspection.pointLightDistance);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const physicalSunDirection = getPhysicalSunDirection(
|
||||
new Date(lastUpdatedAt || Date.now()),
|
||||
);
|
||||
|
||||
if (linkedAmbientLight) {
|
||||
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
|
||||
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
|
||||
}
|
||||
|
||||
if (linkedSunLight) {
|
||||
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
|
||||
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
|
||||
linkedSunLight.position
|
||||
.copy(physicalSunDirection)
|
||||
.multiplyScalar(CELESTIAL_CONFIG.sunLightDistance);
|
||||
}
|
||||
|
||||
if (linkedBackLight) {
|
||||
linkedBackLight.color.setHex(CELESTIAL_CONFIG.backLightColor);
|
||||
linkedBackLight.intensity = CELESTIAL_CONFIG.backLightIntensity;
|
||||
linkedBackLight.position
|
||||
.copy(physicalSunDirection)
|
||||
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
|
||||
}
|
||||
|
||||
if (linkedPointLight) {
|
||||
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
|
||||
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
|
||||
linkedPointLight.position.set(
|
||||
SCENE_LIGHT_CONFIG.point.position.x,
|
||||
SCENE_LIGHT_CONFIG.point.position.y,
|
||||
SCENE_LIGHT_CONFIG.point.position.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function computeCelestialState(date = new Date()) {
|
||||
sunDirection = computeBodyDirection(Astronomy.Body.Sun, date);
|
||||
moonDirection = computeBodyDirection(Astronomy.Body.Moon, date);
|
||||
updateSpritePositions();
|
||||
updateLighting();
|
||||
lastUpdatedAt = date.getTime();
|
||||
}
|
||||
|
||||
export function initCelestialLayer(
|
||||
scene,
|
||||
{
|
||||
camera = null,
|
||||
sunLight = null,
|
||||
backLight = null,
|
||||
ambientLight = null,
|
||||
pointLight = null,
|
||||
earth = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
|
||||
|
||||
disposeCelestialLayer();
|
||||
|
||||
linkedCamera = camera;
|
||||
linkedSunLight = sunLight;
|
||||
linkedBackLight = backLight;
|
||||
linkedAmbientLight = ambientLight;
|
||||
linkedPointLight = pointLight;
|
||||
linkedEarth = earth;
|
||||
|
||||
celestialRoot = new THREE.Group();
|
||||
celestialRoot.name = "celestial-root";
|
||||
celestialRoot.position.set(0, 0, 0);
|
||||
|
||||
refreshCelestialOrientation();
|
||||
|
||||
skySphere = createSkySphere();
|
||||
brightStarsGroup = createBrightStarsGroup();
|
||||
|
||||
const sunTexture = createDiscTexture([
|
||||
[0, "rgba(255,255,255,1)"],
|
||||
[0.08, "rgba(255,251,242,1)"],
|
||||
[0.22, "rgba(255,242,205,0.98)"],
|
||||
[0.52, "rgba(255,211,117,0.9)"],
|
||||
[0.82, "rgba(255,162,54,0.18)"],
|
||||
[1, "rgba(255,120,32,0)"],
|
||||
]);
|
||||
const sunHaloTexture = createDiscTexture([
|
||||
[0, "rgba(255,245,214,0.9)"],
|
||||
[0.2, "rgba(255,220,154,0.54)"],
|
||||
[0.52, "rgba(255,160,72,0.16)"],
|
||||
[1, "rgba(255,120,32,0)"],
|
||||
], 512);
|
||||
const moonTexture = createDiscTexture([
|
||||
[0, "rgba(255,255,255,0.98)"],
|
||||
[0.42, "rgba(229,236,248,0.92)"],
|
||||
[0.76, "rgba(164,178,202,0.34)"],
|
||||
[1, "rgba(80,92,118,0)"],
|
||||
]);
|
||||
const moonHaloTexture = createDiscTexture([
|
||||
[0, "rgba(226,235,250,0.42)"],
|
||||
[0.38, "rgba(188,203,230,0.16)"],
|
||||
[1, "rgba(120,136,170,0)"],
|
||||
], 384);
|
||||
|
||||
sunSprite = createSprite({
|
||||
texture: sunTexture,
|
||||
scale: CELESTIAL_CONFIG.sunScale,
|
||||
name: "sun-sprite",
|
||||
});
|
||||
sunHaloSprite = createSprite({
|
||||
texture: sunHaloTexture,
|
||||
scale: CELESTIAL_CONFIG.sunHaloScale,
|
||||
opacity: 0.78,
|
||||
name: "sun-halo-sprite",
|
||||
});
|
||||
moonSprite = createSprite({
|
||||
texture: moonTexture,
|
||||
scale: CELESTIAL_CONFIG.moonScale,
|
||||
opacity: 0.98,
|
||||
name: "moon-sprite",
|
||||
});
|
||||
moonHaloSprite = createSprite({
|
||||
texture: moonHaloTexture,
|
||||
scale: CELESTIAL_CONFIG.moonHaloScale,
|
||||
opacity: 0.52,
|
||||
name: "moon-halo-sprite",
|
||||
});
|
||||
|
||||
sunHaloSprite.renderOrder = 98;
|
||||
moonHaloSprite.renderOrder = 98;
|
||||
|
||||
celestialRoot.add(skySphere);
|
||||
celestialRoot.add(brightStarsGroup);
|
||||
celestialRoot.add(sunHaloSprite);
|
||||
celestialRoot.add(moonHaloSprite);
|
||||
celestialRoot.add(sunSprite);
|
||||
celestialRoot.add(moonSprite);
|
||||
scene.add(celestialRoot);
|
||||
|
||||
computeCelestialState(new Date());
|
||||
loadBrightStars();
|
||||
|
||||
return {
|
||||
root: celestialRoot,
|
||||
getSunDirection: () => sunDirection.clone(),
|
||||
getMoonDirection: () => moonDirection.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCelestialLayer(date = new Date(), camera = null) {
|
||||
if (!celestialRoot) return;
|
||||
|
||||
if (camera) {
|
||||
linkedCamera = camera;
|
||||
}
|
||||
|
||||
refreshCelestialView();
|
||||
|
||||
const now = date.getTime();
|
||||
if (!lastUpdatedAt || now - lastUpdatedAt >= CELESTIAL_CONFIG.updateIntervalMs) {
|
||||
computeCelestialState(date);
|
||||
} else {
|
||||
updateLighting();
|
||||
updateSpritePositions();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSunDirection() {
|
||||
return getPhysicalSunDirection(new Date(lastUpdatedAt || Date.now()));
|
||||
}
|
||||
|
||||
export function getMoonDirection() {
|
||||
return applyCelestialOrientation(moonDirection);
|
||||
}
|
||||
|
||||
export function getCelestialDebugState() {
|
||||
return {
|
||||
orientationEulerRad: { ...runtimeOrientationEuler },
|
||||
followEarthRotation: { ...runtimeFollowConfig },
|
||||
};
|
||||
}
|
||||
|
||||
export function setCelestialOrientation(nextEuler = {}) {
|
||||
runtimeOrientationEuler = {
|
||||
...runtimeOrientationEuler,
|
||||
...nextEuler,
|
||||
};
|
||||
refreshCelestialOrientation();
|
||||
updateLighting();
|
||||
return getCelestialDebugState();
|
||||
}
|
||||
|
||||
export function setCelestialFollow(nextFollow = {}) {
|
||||
runtimeFollowConfig = {
|
||||
...runtimeFollowConfig,
|
||||
...nextFollow,
|
||||
};
|
||||
refreshCelestialView();
|
||||
updateLighting();
|
||||
return getCelestialDebugState();
|
||||
}
|
||||
|
||||
export function setCelestialDayNightEnabled(enabled) {
|
||||
dayNightLightingEnabled = enabled;
|
||||
updateLighting();
|
||||
}
|
||||
|
||||
export function disposeCelestialLayer() {
|
||||
if (celestialRoot?.parent) {
|
||||
celestialRoot.parent.remove(celestialRoot);
|
||||
}
|
||||
|
||||
[skySphere, sunSprite, moonSprite, sunHaloSprite, moonHaloSprite, ...brightStarSprites].forEach((object) => {
|
||||
if (!object) return;
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
if (object.material) {
|
||||
if (object.material.map) object.material.map.dispose?.();
|
||||
object.material.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
skySphere = null;
|
||||
brightStarsGroup = null;
|
||||
sunSprite = null;
|
||||
moonSprite = null;
|
||||
sunHaloSprite = null;
|
||||
moonHaloSprite = null;
|
||||
celestialRoot = null;
|
||||
brightStarSprites = [];
|
||||
brightStarTexture?.dispose?.();
|
||||
brightStarTexture = null;
|
||||
|
||||
lastUpdatedAt = 0;
|
||||
linkedSunLight = null;
|
||||
linkedBackLight = null;
|
||||
linkedAmbientLight = null;
|
||||
linkedPointLight = null;
|
||||
linkedCamera = null;
|
||||
linkedEarth = null;
|
||||
dayNightLightingEnabled = true;
|
||||
sunDirection.copy(defaultSunDirection);
|
||||
moonDirection.copy(defaultMoonDirection);
|
||||
runtimeOrientationEuler = {
|
||||
...CELESTIAL_CONFIG.orientationEulerRad,
|
||||
};
|
||||
runtimeFollowConfig = {
|
||||
...CELESTIAL_CONFIG.followEarthRotation,
|
||||
};
|
||||
}
|
||||
375
frontend/public/earth/js/compute-centers.js
Normal file
375
frontend/public/earth/js/compute-centers.js
Normal file
@@ -0,0 +1,375 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const computeCenterGroup = new THREE.Group();
|
||||
const computeCenterMarkers = [];
|
||||
const textureCache = new Map();
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
let gpuClusterCount = 0;
|
||||
|
||||
function buildComputeCenterMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
const longitude = Number(coordinates[0]);
|
||||
const latitude = Number(coordinates[1]);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...props,
|
||||
latitude,
|
||||
longitude,
|
||||
displayLatitude: latitude,
|
||||
displayLongitude: longitude,
|
||||
site_type: normalizeSiteType(props.site_type),
|
||||
};
|
||||
}
|
||||
|
||||
function spreadComputeCenterPositions(markers) {
|
||||
const groups = new Map();
|
||||
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key).push(marker);
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
if (group.length <= 1) return;
|
||||
|
||||
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
|
||||
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
|
||||
group.forEach((marker, index) => {
|
||||
const angle = (Math.PI * 2 * index) / group.length;
|
||||
marker.displayLatitude =
|
||||
marker.latitude + Math.sin(angle) * radius * offsetStep;
|
||||
marker.displayLongitude =
|
||||
marker.longitude + Math.cos(angle) * radius * offsetStep;
|
||||
marker.isSpread = true;
|
||||
marker.groupSize = group.length;
|
||||
});
|
||||
});
|
||||
|
||||
markers.forEach((marker) => {
|
||||
if (marker.isSpread) return;
|
||||
marker.displayLatitude = marker.latitude;
|
||||
marker.displayLongitude = marker.longitude;
|
||||
marker.isSpread = false;
|
||||
marker.groupSize = 1;
|
||||
});
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
function createMarkerTexture(siteType, isEstimated = false) {
|
||||
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
|
||||
if (textureCache.has(textureKey)) {
|
||||
return textureCache.get(textureKey);
|
||||
}
|
||||
|
||||
const color =
|
||||
COMPUTE_CENTER_CONFIG.colors[siteType] ||
|
||||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
const centerX = 64;
|
||||
const centerY = 64;
|
||||
const baseFill = color;
|
||||
|
||||
function fillPath(draw, options = {}) {
|
||||
const { fillStyle = color } = options;
|
||||
context.save();
|
||||
context.fillStyle = fillStyle;
|
||||
context.beginPath();
|
||||
draw();
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 128, 128);
|
||||
|
||||
if (siteType === "supercomputer") {
|
||||
fillPath(() => {
|
||||
context.roundRect(40, 42, 48, 30, 7);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.roundRect(58, 74, 12, 8, 3);
|
||||
context.roundRect(50, 84, 28, 5, 2.5);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
} else {
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
|
||||
context.rect(46, 46, 36, 28);
|
||||
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
|
||||
context.rect(52, 58, 24, 6);
|
||||
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEstimated) {
|
||||
fillPath(() => {
|
||||
context.arc(94, 36, 12, 0, Math.PI * 2);
|
||||
}, {
|
||||
fillStyle: "rgba(15,23,42,0.92)",
|
||||
});
|
||||
context.save();
|
||||
context.fillStyle = "rgba(255,255,255,0.98)";
|
||||
context.font = "bold 18px sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("?", 94, 36);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function normalizeSiteType(siteType) {
|
||||
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
|
||||
}
|
||||
|
||||
function getBaseScale(siteType) {
|
||||
return siteType === "supercomputer"
|
||||
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
|
||||
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
|
||||
}
|
||||
|
||||
function getDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
|
||||
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
|
||||
});
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function createComputeCenterMarker(markerData) {
|
||||
const siteType = markerData.site_type;
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
|
||||
});
|
||||
const marker = new THREE.Sprite(material);
|
||||
const baseScale = getBaseScale(siteType);
|
||||
marker.position.copy(
|
||||
latLonToVector3(
|
||||
markerData.displayLatitude,
|
||||
markerData.displayLongitude,
|
||||
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(baseScale);
|
||||
marker.renderOrder = 8;
|
||||
marker.visible = showComputeCenters;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
site_type: siteType,
|
||||
type: "compute_center",
|
||||
baseScale,
|
||||
state: "normal",
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
};
|
||||
computeCenterGroup.add(marker);
|
||||
computeCenterMarkers.push(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function formatComputeCenterTypeLabel(siteType) {
|
||||
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
|
||||
}
|
||||
|
||||
export function formatComputeCenterCapacity(markerData) {
|
||||
const value = markerData?.capacity_value;
|
||||
const unit = markerData?.capacity_unit;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return `${value}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatComputeCenterUpdatedAt(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatComputeCenterLocationPrecision(markerData) {
|
||||
const precision = markerData?.location_precision;
|
||||
if (precision === "precise") return "精确坐标";
|
||||
if (precision === "estimated_site") return "估算位置(站点级)";
|
||||
if (precision === "estimated_country") return "估算位置(国家级)";
|
||||
return "位置未知";
|
||||
}
|
||||
|
||||
export function getComputeCenterLegendItems() {
|
||||
return [
|
||||
{
|
||||
label: "超算中心",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
|
||||
},
|
||||
{
|
||||
label: "GPU 集群",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getComputeCenterMarkers() {
|
||||
return computeCenterMarkers;
|
||||
}
|
||||
|
||||
export function getComputeCenterCount() {
|
||||
return computeCenterMarkers.length;
|
||||
}
|
||||
|
||||
export function getComputeCenterSupercomputerCount() {
|
||||
return supercomputerCount;
|
||||
}
|
||||
|
||||
export function getComputeCenterGPUClusterCount() {
|
||||
return gpuClusterCount;
|
||||
}
|
||||
|
||||
export function getComputeCenterStatusSummary() {
|
||||
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
|
||||
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
|
||||
}
|
||||
|
||||
export function setComputeCenterMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== "compute_center") return;
|
||||
marker.userData.state = state;
|
||||
}
|
||||
|
||||
export function clearComputeCenterSelection() {
|
||||
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
|
||||
}
|
||||
|
||||
export function clearComputeCenterData(earth) {
|
||||
computeCenterMarkers.length = 0;
|
||||
supercomputerCount = 0;
|
||||
gpuClusterCount = 0;
|
||||
clearGroup(computeCenterGroup);
|
||||
if (earth && computeCenterGroup.parent === earth) {
|
||||
earth.remove(computeCenterGroup);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleComputeCenters(show) {
|
||||
showComputeCenters = Boolean(show);
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowComputeCenters() {
|
||||
return showComputeCenters;
|
||||
}
|
||||
|
||||
export async function loadComputeCenters(_scene, earth) {
|
||||
const response = await fetch(PATHS.computeCentersApi);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Compute centers HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearComputeCenterData(earth);
|
||||
|
||||
spreadComputeCenterPositions(
|
||||
features
|
||||
.map((feature) => buildComputeCenterMarkerData(feature))
|
||||
.filter(Boolean),
|
||||
)
|
||||
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
|
||||
.forEach((markerData) => {
|
||||
const marker = createComputeCenterMarker(markerData);
|
||||
if (!marker) return;
|
||||
if (marker.userData.site_type === "supercomputer") {
|
||||
supercomputerCount += 1;
|
||||
} else {
|
||||
gpuClusterCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (earth && !computeCenterGroup.parent) {
|
||||
earth.add(computeCenterGroup);
|
||||
}
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
|
||||
return {
|
||||
totalCount: computeCenterMarkers.length,
|
||||
supercomputerCount,
|
||||
gpuClusterCount,
|
||||
summary: getComputeCenterStatusSummary(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
|
||||
const now = Date.now();
|
||||
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
|
||||
const state = marker.userData?.state || "normal";
|
||||
const pulse =
|
||||
1 +
|
||||
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
|
||||
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
|
||||
|
||||
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
|
||||
let scaleMultiplier = 1;
|
||||
|
||||
if (isLocked) {
|
||||
opacity = 1;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
|
||||
} else if (state === "hover") {
|
||||
opacity = 0.98;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
|
||||
} else if (hasFocus) {
|
||||
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
|
||||
}
|
||||
|
||||
const distanceScale = getDistanceScale(marker, camera);
|
||||
marker.material.opacity = showComputeCenters ? opacity : 0;
|
||||
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scene configuration
|
||||
export const CONFIG = {
|
||||
defaultCameraZ: 300,
|
||||
defaultViewZoom: 1.0,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 5.0,
|
||||
earthRadius: 100,
|
||||
@@ -12,6 +13,26 @@ export const CONFIG = {
|
||||
dragRotationScaleMax: 2.0,
|
||||
};
|
||||
|
||||
export const ROTATION_MODE = {
|
||||
ROTATE: "rotate",
|
||||
CRUISE: "cruise",
|
||||
};
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
pollIntervalMs: 15_000,
|
||||
maxPolledEvents: 200,
|
||||
cardAnchorXRatio: 0.68,
|
||||
cardAnchorYRatio: 0.24,
|
||||
};
|
||||
|
||||
export const CONNECTOR_CONFIG = {
|
||||
markerGapPx: 18,
|
||||
panelGapPx: 12,
|
||||
obstacleClearancePx: 8,
|
||||
};
|
||||
|
||||
export const HUD_CONFIG = {
|
||||
scaleReferenceWidth: 1920,
|
||||
scaleReferenceHeight: 1080,
|
||||
@@ -34,14 +55,144 @@ export const EARTH_CONFIG = {
|
||||
latCoefficient: 0.5
|
||||
};
|
||||
|
||||
export const CELESTIAL_CONFIG = {
|
||||
enabled: true,
|
||||
updateIntervalMs: 10_000,
|
||||
skyRadius: 2600,
|
||||
skyOpacity: 1,
|
||||
starMapUrl: "./assets/celestial/starmap_equatorial_4k.jpg",
|
||||
brightStarsUrl: "./assets/celestial/bright-stars.json",
|
||||
orientationEulerRad: {
|
||||
x: 0.46,
|
||||
y: 1.18,
|
||||
z: -0.08,
|
||||
},
|
||||
followEarthRotation: {
|
||||
enabled: true,
|
||||
x: true,
|
||||
y: true,
|
||||
z: false,
|
||||
invertX: true,
|
||||
invertY: false,
|
||||
invertZ: false,
|
||||
},
|
||||
sunDistance: 2150,
|
||||
moonDistance: 2050,
|
||||
sunScale: 78,
|
||||
moonScale: 38,
|
||||
sunHaloScale: 136,
|
||||
moonHaloScale: 62,
|
||||
brightStarDistance: 2350,
|
||||
brightStarMinMag: 2.1,
|
||||
brightStarMaxCount: 36,
|
||||
brightStarMinScale: 2.4,
|
||||
brightStarMaxScale: 6.8,
|
||||
brightStarOpacity: 0.88,
|
||||
sunLightDistance: 460,
|
||||
sunLightIntensity: 1.02,
|
||||
sunLightColor: 0xfff4df,
|
||||
backLightIntensity: 0.3,
|
||||
backLightColor: 0x2b4c78,
|
||||
inspectionLighting: {
|
||||
enabled: true,
|
||||
ambientIntensity: 0.64,
|
||||
ambientColor: 0x707070,
|
||||
keyLightIntensity: 0.92,
|
||||
keyLightColor: 0xfcfcfb,
|
||||
keyLightDistance: 380,
|
||||
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
|
||||
backLightIntensity: 0.26,
|
||||
backLightColor: 0x8f96a0,
|
||||
backLightDistance: 260,
|
||||
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
|
||||
pointLightIntensity: 0.36,
|
||||
pointLightColor: 0xfafcff,
|
||||
pointLightDistance: 320,
|
||||
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
|
||||
},
|
||||
};
|
||||
|
||||
export const SCENE_LIGHT_CONFIG = {
|
||||
ambient: {
|
||||
color: 0x404060,
|
||||
intensity: 1,
|
||||
},
|
||||
sun: {
|
||||
color: 0xffffff,
|
||||
intensity: 1.2,
|
||||
position: { x: 5, y: 3, z: 5 },
|
||||
},
|
||||
back: {
|
||||
color: 0x446688,
|
||||
intensity: 0.3,
|
||||
position: { x: -5, y: 0, z: -5 },
|
||||
},
|
||||
point: {
|
||||
color: 0xffffff,
|
||||
intensity: 0.4,
|
||||
position: { x: 10, y: 10, z: 10 },
|
||||
},
|
||||
};
|
||||
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
opacity: 0.62,
|
||||
color: 0x7f9d7f,
|
||||
emissive: 0x061008,
|
||||
specular: 0x233126,
|
||||
shininess: 10,
|
||||
urlTemplate:
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||
};
|
||||
|
||||
export const COMPUTE_CENTER_CONFIG = {
|
||||
altitudeOffset: 0.48,
|
||||
maxRenderedMarkers: 300,
|
||||
overlapSpread: {
|
||||
groupPrecision: 4,
|
||||
radius: 1.4,
|
||||
offsetStep: 0.28,
|
||||
},
|
||||
marker: {
|
||||
baseOpacity: 0.88,
|
||||
supercomputerScale: 12,
|
||||
gpuClusterScale: 12,
|
||||
hoverScale: 1.16,
|
||||
lockedScale: 1.22,
|
||||
dimmedScale: 0.82,
|
||||
dimmedOpacity: 0.34,
|
||||
pulseSpeed: 0.0038,
|
||||
pulseAmplitude: 0.03,
|
||||
},
|
||||
colors: {
|
||||
supercomputer: "#38bdf8",
|
||||
gpu_cluster: "#2dd4bf",
|
||||
linked: "#f8fafc",
|
||||
},
|
||||
sizeStabilization: {
|
||||
enabled: true,
|
||||
min: 0.12,
|
||||
max: 3.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Cable colors mapping
|
||||
export const CABLE_COLORS = {
|
||||
'Americas II': 0xff4444,
|
||||
@@ -110,7 +261,12 @@ export const CABLE_STATE = {
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
initialLoadCount: 2400,
|
||||
hydrateFullAfterInitialLoad: true,
|
||||
trailLength: 10,
|
||||
displayAltitudeOffset: 8,
|
||||
frontFacingDotThreshold: 0.015,
|
||||
overlayRenderOrder: 12,
|
||||
dotSize: 4,
|
||||
ringSize: 0.07,
|
||||
apiPath: '/api/v1/visualization/geo/satellites',
|
||||
@@ -237,18 +393,18 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
occluderSegments: 48,
|
||||
|
||||
// Fresnel atmosphere glow — inner rim
|
||||
atmosInnerRadiusFactor: 1.018,
|
||||
atmosInnerRadiusFactor: 1.01,
|
||||
atmosInnerSegments: 64,
|
||||
atmosInnerColor: [0.25, 0.62, 1.0],
|
||||
atmosInnerRimPower: 3.2,
|
||||
atmosInnerIntensity: 0.72,
|
||||
atmosInnerIntensity: 0.18,
|
||||
|
||||
// Fresnel atmosphere glow — outer corona
|
||||
atmosOuterRadiusFactor: 1.07,
|
||||
atmosOuterRadiusFactor: 1.016,
|
||||
atmosOuterSegments: 48,
|
||||
atmosOuterColor: [0.18, 0.45, 0.9],
|
||||
atmosOuterRimPower: 5.0,
|
||||
atmosOuterIntensity: 0.28,
|
||||
atmosOuterIntensity: 0.02,
|
||||
|
||||
// Texture candidates — tried in order, first success wins
|
||||
textureUrls: [
|
||||
@@ -256,4 +412,16 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
],
|
||||
|
||||
dayNight: {
|
||||
enabled: true,
|
||||
sunDirection: { x: 1, y: 0.2, z: 0.4 },
|
||||
nightFloor: 0.32,
|
||||
dayBoost: 0.94,
|
||||
twilightWidth: 0.24,
|
||||
twilightIntensity: 0.14,
|
||||
twilightColor: 0x4ea0ff,
|
||||
nightTintColor: 0x0b1830,
|
||||
nightTintIntensity: 0.05,
|
||||
},
|
||||
};
|
||||
|
||||
2643
frontend/public/earth/js/controls.js
vendored
2643
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,97 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
export let clouds = null;
|
||||
export let terrain = null;
|
||||
let showGridLines = true;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
let _earthShader = null;
|
||||
let _dayNightEnabled = true;
|
||||
const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
|
||||
).normalize();
|
||||
|
||||
function applyEarthDayNightShader(material) {
|
||||
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
|
||||
|
||||
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
|
||||
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
|
||||
|
||||
material.onBeforeCompile = (shader) => {
|
||||
_earthShader = shader;
|
||||
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
|
||||
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
|
||||
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
|
||||
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
|
||||
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
|
||||
shader.uniforms.uTwilightColor = { value: twilightColor };
|
||||
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
||||
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
|
||||
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
|
||||
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec3 vWorldNormal;`,
|
||||
).replace(
|
||||
"#include <begin_vertex>",
|
||||
`#include <begin_vertex>
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);`,
|
||||
);
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec3 vWorldNormal;
|
||||
uniform vec3 uSunDirectionWorld;
|
||||
uniform float uNightFloor;
|
||||
uniform float uDayBoost;
|
||||
uniform float uTwilightWidth;
|
||||
uniform float uTwilightIntensity;
|
||||
uniform vec3 uTwilightColor;
|
||||
uniform vec3 uNightTintColor;
|
||||
uniform float uNightTintIntensity;
|
||||
uniform float uDayNightEnabled;`,
|
||||
).replace(
|
||||
"#include <output_fragment>",
|
||||
`
|
||||
vec3 worldNormal = normalize(vWorldNormal);
|
||||
vec3 sunDir = normalize(uSunDirectionWorld);
|
||||
float sunFacing = dot(worldNormal, sunDir);
|
||||
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
||||
|
||||
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
|
||||
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
|
||||
float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
|
||||
float cameraBoost = mix(0.62, 1.08, nDotV);
|
||||
|
||||
float dn = uDayNightEnabled;
|
||||
vec3 dnLight = outgoingLight;
|
||||
dnLight *= mix(uNightFloor, uDayBoost, daylight);
|
||||
dnLight += uTwilightColor * twilight * uTwilightIntensity;
|
||||
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
|
||||
|
||||
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
|
||||
// dn=1: full day/night solar lighting
|
||||
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
|
||||
|
||||
#include <output_fragment>
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
material.customProgramCacheKey = () => "earth-day-night-v5";
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function createEarth(scene) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
@@ -27,6 +109,7 @@ export function createEarth(scene) {
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
});
|
||||
applyEarthDayNightShader(material);
|
||||
_earthMaterial = material;
|
||||
|
||||
earth = new THREE.Mesh(geometry, material);
|
||||
@@ -144,34 +227,35 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function createTerrain(scene, earthObj, simplex) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
const positionAttribute = geometry.getAttribute('position');
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const x = positionAttribute.getX(i);
|
||||
const y = positionAttribute.getY(i);
|
||||
const z = positionAttribute.getZ(i);
|
||||
|
||||
const noise = simplex(x / 20, y / 20, z / 20);
|
||||
const height = 1 + noise * 0.02;
|
||||
|
||||
positionAttribute.setXYZ(i, x * height, y * height, z * height);
|
||||
}
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
TERRAIN_CONFIG.geometryWidthSegments,
|
||||
TERRAIN_CONFIG.geometryHeightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x00aa00,
|
||||
flatShading: true,
|
||||
color: TERRAIN_CONFIG.color,
|
||||
emissive: TERRAIN_CONFIG.emissive,
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
vertexAlphas: true,
|
||||
transparent: true,
|
||||
opacity: 0.7
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
});
|
||||
|
||||
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
earthObj.add(terrain);
|
||||
|
||||
|
||||
return terrain;
|
||||
}
|
||||
|
||||
@@ -238,6 +322,7 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'latitude', value: lat };
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
latitudeLines.push(line);
|
||||
}
|
||||
@@ -252,11 +337,26 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'longitude', value: lon };
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
longitudeLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleGridLines(visible) {
|
||||
showGridLines = visible;
|
||||
latitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
longitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowGridLines() {
|
||||
return showGridLines;
|
||||
}
|
||||
|
||||
export function getEarth() {
|
||||
return earth;
|
||||
}
|
||||
@@ -271,6 +371,36 @@ export function clearEarthTexture() {
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function setEarthSunDirection(direction) {
|
||||
if (!direction) return;
|
||||
_earthSunDirection.copy(direction).normalize();
|
||||
if (_earthShader?.uniforms?.uSunDirectionWorld) {
|
||||
_earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection);
|
||||
}
|
||||
}
|
||||
|
||||
export function setDayNightEnabled(enabled) {
|
||||
_dayNightEnabled = enabled;
|
||||
if (_earthShader?.uniforms?.uDayNightEnabled) {
|
||||
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
|
||||
}
|
||||
if (_earthMaterial) {
|
||||
if (enabled) {
|
||||
// Restore normal Phong lighting + custom day/night shader
|
||||
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
|
||||
_earthMaterial.emissiveMap = null;
|
||||
} else {
|
||||
// Full bright: zero diffuse so directional light has no effect;
|
||||
// use original color as emissive map to show texture uniformly.
|
||||
_earthMaterial.color.setRGB(0, 0, 0);
|
||||
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||
_earthMaterial.emissiveMap = _earthMaterial.map;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEarthTexture() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
@@ -291,6 +421,10 @@ export function loadEarthTexture() {
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
_earthMaterial.map = texture;
|
||||
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
|
||||
if (!_dayNightEnabled) {
|
||||
_earthMaterial.emissiveMap = texture;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
resolve();
|
||||
},
|
||||
|
||||
233
frontend/public/earth/js/hud-panels.js
Normal file
233
frontend/public/earth/js/hud-panels.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
|
||||
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
|
||||
|
||||
function getHudScale() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function getEdgeFlipThresholdPx() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
|
||||
if (Number.isFinite(hudOffset) && hudOffset > 0) {
|
||||
return hudOffset;
|
||||
}
|
||||
return 20 * getHudScale();
|
||||
}
|
||||
|
||||
function clampExpandDirection(direction) {
|
||||
return direction === "up" ? "up" : "down";
|
||||
}
|
||||
|
||||
function resolveElement(target, root = document) {
|
||||
if (!target) return null;
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (typeof target === "string") {
|
||||
return root.querySelector(target);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickExpandDirection({
|
||||
panelRect,
|
||||
preferredDirection,
|
||||
}) {
|
||||
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
|
||||
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
|
||||
const preferred = clampExpandDirection(preferredDirection);
|
||||
|
||||
// Pure edge-threshold contract:
|
||||
// - d < threshold => "up" family
|
||||
// - d >= threshold => "down" family
|
||||
// Do not pre-flip early based on expanded height.
|
||||
if (spaceBelow <= edgeFlipThresholdPx) return "up";
|
||||
if (spaceBelow > edgeFlipThresholdPx) return "down";
|
||||
return preferred;
|
||||
}
|
||||
|
||||
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
|
||||
// The arrow always describes the next action and must stay aligned with the
|
||||
// real expansion direction chosen by the controller. Panels should not add
|
||||
// their own extra CSS rotation on top of this mapping.
|
||||
if (collapsed) {
|
||||
return {
|
||||
title: expandLabel,
|
||||
icon: direction === "up" ? "expand_less" : "expand_more",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: collapseLabel,
|
||||
icon: direction === "up" ? "expand_more" : "expand_less",
|
||||
};
|
||||
}
|
||||
|
||||
export function createHUDPanel({
|
||||
panel,
|
||||
header,
|
||||
body,
|
||||
collapseBtn,
|
||||
bodyCollapsedClass = "",
|
||||
preferredDirection = "down",
|
||||
collapsedClass = DEFAULT_COLLAPSED_CLASS,
|
||||
hiddenClass = DEFAULT_HIDDEN_CLASS,
|
||||
expandLabel = "展开",
|
||||
collapseLabel = "折叠",
|
||||
}) {
|
||||
const panelEl = resolveElement(panel);
|
||||
const headerEl = resolveElement(header, panelEl ?? document);
|
||||
const bodyEl = resolveElement(body, panelEl ?? document);
|
||||
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
|
||||
|
||||
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed() {},
|
||||
setVisible() {},
|
||||
syncLayout() {},
|
||||
destroy() {},
|
||||
isCollapsed() {
|
||||
return false;
|
||||
},
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
getExpandDirection() {
|
||||
return clampExpandDirection(preferredDirection);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let currentDirection = clampExpandDirection(preferredDirection);
|
||||
|
||||
const shouldAnchorBottomDuringToggle = () =>
|
||||
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
|
||||
|
||||
const compensateTopForBottomAnchor = (beforeBottom) => {
|
||||
if (!shouldAnchorBottomDuringToggle()) return;
|
||||
const afterBottom = panelEl.getBoundingClientRect().bottom;
|
||||
const delta = afterBottom - beforeBottom;
|
||||
if (delta === 0) return;
|
||||
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
|
||||
};
|
||||
|
||||
const syncDirection = () => {
|
||||
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
|
||||
const nextDirection = pickExpandDirection({
|
||||
panelRect: panelEl.getBoundingClientRect(),
|
||||
preferredDirection,
|
||||
});
|
||||
|
||||
currentDirection = nextDirection;
|
||||
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
|
||||
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
|
||||
panelEl.dataset.expandDirection = nextDirection;
|
||||
};
|
||||
|
||||
const syncButton = () => {
|
||||
if (!(collapseBtnEl instanceof HTMLElement)) return;
|
||||
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
|
||||
const { title, icon } = getCollapseButtonState({
|
||||
collapsed: panelEl.classList.contains(collapsedClass),
|
||||
direction: currentDirection,
|
||||
expandLabel,
|
||||
collapseLabel,
|
||||
});
|
||||
|
||||
collapseBtnEl.title = title;
|
||||
collapseBtnEl.setAttribute("aria-label", title);
|
||||
collapseBtnEl.dataset.expandDirection = currentDirection;
|
||||
if (iconEl) {
|
||||
iconEl.textContent = icon;
|
||||
}
|
||||
};
|
||||
|
||||
const syncLayout = () => {
|
||||
syncDirection();
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
syncDirection();
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
|
||||
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
|
||||
|
||||
if (shouldCompensate) {
|
||||
bodyEl.style.transition = "none";
|
||||
}
|
||||
|
||||
panelEl.classList.toggle(collapsedClass, nextCollapsed);
|
||||
if (bodyCollapsedClass) {
|
||||
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
|
||||
}
|
||||
|
||||
if (shouldCompensate) {
|
||||
void panelEl.offsetHeight;
|
||||
compensateTopForBottomAnchor(bottomBefore);
|
||||
requestAnimationFrame(() => {
|
||||
bodyEl.style.transition = "";
|
||||
});
|
||||
}
|
||||
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setVisible = (visible) => {
|
||||
panelEl.classList.toggle(hiddenClass, !visible);
|
||||
if (visible) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewportChange = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass)) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
document.addEventListener("pointerup", handleViewportChange);
|
||||
document.addEventListener("pointermove", handlePointerMove);
|
||||
|
||||
syncLayout();
|
||||
requestAnimationFrame(syncLayout);
|
||||
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed,
|
||||
setVisible,
|
||||
syncLayout,
|
||||
destroy() {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
document.removeEventListener("pointerup", handleViewportChange);
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
},
|
||||
isCollapsed() {
|
||||
return panelEl.classList.contains(collapsedClass);
|
||||
},
|
||||
isVisible() {
|
||||
return !panelEl.classList.contains(hiddenClass);
|
||||
},
|
||||
getExpandDirection() {
|
||||
return currentDirection;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCollapsibleHudPanel(options) {
|
||||
return createHUDPanel(options);
|
||||
}
|
||||
@@ -2,6 +2,220 @@
|
||||
import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
|
||||
// ── Mobile popup ─────────────────────────────────────────────
|
||||
|
||||
function getMobilePopupTitle(type, data) {
|
||||
switch (type) {
|
||||
case 'cable': return data.name || '海缆';
|
||||
case 'landing_point': return data.name || '登陆点';
|
||||
case 'satellite': return data.name || '卫星';
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
default: return '详情';
|
||||
}
|
||||
}
|
||||
|
||||
function getMobilePopupSubtitle(type, data) {
|
||||
switch (type) {
|
||||
case 'cable': return data.owner || data.status || '海缆';
|
||||
case 'landing_point': return data.country || '登陆点';
|
||||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||||
case 'bgp': return data.severity || 'BGP路由异常';
|
||||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||
case 'supercomputer': return data.country || '超级计算机';
|
||||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function positionMobilePopup(popup, touchX, touchY, options = {}) {
|
||||
const margin = 14;
|
||||
const drawerClearance = 52;
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
const safeBottom = parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
|
||||
) || 0;
|
||||
const bottomBound = vpH - drawerClearance - safeBottom;
|
||||
|
||||
// Measure actual popup size (it's rendered but invisible via opacity)
|
||||
const popW = popup.offsetWidth || 200;
|
||||
const popH = popup.offsetHeight || 68;
|
||||
|
||||
if (options.absolute === true) {
|
||||
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
|
||||
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 22;
|
||||
const spaceRight = vpW - touchX;
|
||||
const spaceLeft = touchX;
|
||||
const spaceBottom = bottomBound - touchY;
|
||||
const spaceTop = touchY;
|
||||
|
||||
let left, top;
|
||||
|
||||
// Horizontal: side with more room
|
||||
if (spaceRight >= popW + gap + margin) {
|
||||
left = touchX + gap;
|
||||
} else if (spaceLeft >= popW + gap + margin) {
|
||||
left = touchX - gap - popW;
|
||||
} else {
|
||||
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
|
||||
}
|
||||
|
||||
// Vertical: prefer above touch, then below
|
||||
if (spaceTop >= popH + gap + margin) {
|
||||
top = touchY - gap - popH;
|
||||
} else if (spaceBottom >= popH + gap + margin) {
|
||||
top = touchY + gap;
|
||||
} else {
|
||||
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
|
||||
}
|
||||
|
||||
left = Math.max(margin, Math.min(left, vpW - popW - margin));
|
||||
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
|
||||
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
let popupShowToken = 0;
|
||||
|
||||
function showMobilePopup(type, data, x, y, options = {}) {
|
||||
// Require coordinates — skip if called without position (e.g. from handleCableClick)
|
||||
if (x == null || y == null) return;
|
||||
|
||||
const popup = document.getElementById('earth-mobile-popup');
|
||||
const iconEl = document.getElementById('earth-mobile-popup-icon');
|
||||
const titleEl = document.getElementById('earth-mobile-popup-title');
|
||||
const subEl = document.getElementById('earth-mobile-popup-sub');
|
||||
if (!popup || !iconEl || !titleEl || !subEl) return;
|
||||
|
||||
const config = CARD_CONFIG[type];
|
||||
if (!config) return;
|
||||
|
||||
iconEl.textContent = config.icon;
|
||||
titleEl.textContent = getMobilePopupTitle(type, data);
|
||||
subEl.textContent = getMobilePopupSubtitle(type, data);
|
||||
|
||||
// Invalidate any in-flight hide listener
|
||||
popupShowToken += 1;
|
||||
const token = popupShowToken;
|
||||
|
||||
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
|
||||
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
|
||||
popup.removeAttribute('hidden');
|
||||
popup.classList.remove('is-visible');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
positionMobilePopup(popup, x, y, options);
|
||||
if (options.reveal === false) {
|
||||
return;
|
||||
}
|
||||
if (token !== popupShowToken) return; // superseded
|
||||
void popup.getBoundingClientRect();
|
||||
popup.classList.add('is-visible');
|
||||
});
|
||||
}
|
||||
|
||||
function hideMobilePopup() {
|
||||
const popup = document.getElementById('earth-mobile-popup');
|
||||
if (!popup) return;
|
||||
popupShowToken += 1; // invalidate any pending show
|
||||
popup.classList.remove('is-visible');
|
||||
popup.classList.remove('earth-mobile-popup--anchor-stable');
|
||||
delete popup.dataset.dockSide;
|
||||
popup.addEventListener('transitionend', () => {
|
||||
if (!popup.classList.contains('is-visible')) {
|
||||
popup.setAttribute('hidden', '');
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
let popupClickBound = false;
|
||||
function ensurePopupClickHandler() {
|
||||
if (popupClickBound) return;
|
||||
popupClickBound = true;
|
||||
const popup = document.getElementById('earth-mobile-popup');
|
||||
if (!popup) return;
|
||||
|
||||
let dragPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
let startLeft = 0, startTop = 0;
|
||||
let dragged = false;
|
||||
const DRAG_THRESHOLD = 10;
|
||||
|
||||
const emitDragEvent = (dragging) => {
|
||||
const rect = popup.getBoundingClientRect();
|
||||
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
|
||||
detail: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
popup.addEventListener('pointerdown', (e) => {
|
||||
if (e.button > 0) return;
|
||||
e.stopPropagation();
|
||||
dragPointerId = e.pointerId;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
const rect = popup.getBoundingClientRect();
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
dragged = false;
|
||||
});
|
||||
|
||||
// Track drag at document level so pointer can leave popup bounds
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerId !== dragPointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
dragged = true;
|
||||
e.stopPropagation();
|
||||
const margin = 8;
|
||||
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
|
||||
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
emitDragEvent(true);
|
||||
});
|
||||
|
||||
document.addEventListener('pointerup', (e) => {
|
||||
if (e.pointerId !== dragPointerId) return;
|
||||
const wasDragged = dragged;
|
||||
dragPointerId = null;
|
||||
dragged = false;
|
||||
emitDragEvent(false);
|
||||
if (!wasDragged) {
|
||||
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('pointercancel', (e) => {
|
||||
if (e.pointerId === dragPointerId) {
|
||||
dragPointerId = null;
|
||||
dragged = false;
|
||||
emitDragEvent(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Block click from bubbling to document (which would close the drawer)
|
||||
popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
const CARD_CONFIG = {
|
||||
cable: {
|
||||
@@ -17,6 +231,18 @@ const CARD_CONFIG = {
|
||||
{ key: 'rfs', label: '投入使用' }
|
||||
]
|
||||
},
|
||||
landing_point: {
|
||||
icon: '📍',
|
||||
title: '登陆点详情',
|
||||
className: 'cable',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'cable_count', label: '关联海缆数' },
|
||||
{ key: 'cables', label: '关联海缆' }
|
||||
]
|
||||
},
|
||||
satellite: {
|
||||
icon: '🛰️',
|
||||
title: '卫星详情',
|
||||
@@ -78,15 +304,22 @@ const CARD_CONFIG = {
|
||||
},
|
||||
supercomputer: {
|
||||
icon: '🖥️',
|
||||
title: '超算详情',
|
||||
title: '超算中心详情',
|
||||
className: 'supercomputer',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'site_type_label', label: '类型' },
|
||||
{ key: 'rank', label: '排名' },
|
||||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
||||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
||||
{ key: 'capacity', label: '实测算力' },
|
||||
{ key: 'vendor', label: '厂商' },
|
||||
{ key: 'operator', label: '运营方' },
|
||||
{ key: 'cores', label: '核心数' },
|
||||
{ key: 'power', label: '功耗', unit: 'kW' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'location_precision_label', label: '位置精度' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
},
|
||||
gpu_cluster: {
|
||||
@@ -95,8 +328,17 @@ const CARD_CONFIG = {
|
||||
className: 'gpu_cluster',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'site_type_label', label: '类型' },
|
||||
{ key: 'capacity', label: '估算算力' },
|
||||
{ key: 'gpu_count', label: 'GPU 数量' },
|
||||
{ key: 'gpu_type', label: 'GPU 型号' },
|
||||
{ key: 'vendor', label: '芯片/平台' },
|
||||
{ key: 'operator', label: '运营方' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'location_precision_label', label: '位置精度' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -105,84 +347,147 @@ function getPanel() {
|
||||
return document.getElementById('info-panel');
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y) {
|
||||
if (!panel) return;
|
||||
const margin = 12;
|
||||
const offset = 14;
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
function setupInfoCardDrag(panel) {
|
||||
const app = document.getElementById('container');
|
||||
if (!app) return;
|
||||
|
||||
const scale = parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
|
||||
) || 1;
|
||||
const estW = Math.min(300 * scale, vpW - 32);
|
||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||
const handle = panel.querySelector('.hud-panel-drag-handle');
|
||||
if (!handle) return;
|
||||
|
||||
let left = x + offset;
|
||||
let top = y + offset;
|
||||
let isDragging = false;
|
||||
let activePointerId = null;
|
||||
let startPointerX = 0;
|
||||
let startPointerY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
if (left + estW > vpW - margin) left = x - estW - offset;
|
||||
if (top + estH > vpH - margin) top = Math.max(margin, vpH - estH - margin);
|
||||
const emitDragEvent = () => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-drag', {
|
||||
detail: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging: isDragging,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
panel.style.left = `${Math.max(margin, left)}px`;
|
||||
panel.style.top = `${Math.max(margin, top)}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function showPanel(x, y) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
if (x != null && y != null) positionPanel(panel, x, y);
|
||||
panel.classList.add('is-visible');
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = getPanel();
|
||||
if (panel) panel.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
export function initInfoCard() {
|
||||
const card = document.getElementById('info-card');
|
||||
const content = document.getElementById('info-card-content');
|
||||
if (!card || !content) return;
|
||||
|
||||
if (card.dataset.interactionBound !== 'true') {
|
||||
const stopEvent = (event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
[
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'mouseup',
|
||||
'click',
|
||||
'dblclick',
|
||||
'wheel',
|
||||
'pointerdown',
|
||||
'pointerup',
|
||||
'pointermove',
|
||||
'touchstart',
|
||||
'touchmove',
|
||||
'touchend',
|
||||
].forEach((eventName) => {
|
||||
card.addEventListener(eventName, stopEvent, { passive: false });
|
||||
});
|
||||
|
||||
// Close button wires the panel hide
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
const stopDragging = (event) => {
|
||||
if (
|
||||
event &&
|
||||
activePointerId !== null &&
|
||||
"pointerId" in event &&
|
||||
event.pointerId !== activePointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
isDragging = false;
|
||||
activePointerId = null;
|
||||
panel.classList.remove('is-dragging');
|
||||
document.body.style.userSelect = '';
|
||||
emitDragEvent();
|
||||
};
|
||||
|
||||
card.dataset.interactionBound = 'true';
|
||||
const onMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
if (activePointerId !== null && event.pointerId !== activePointerId) return;
|
||||
event.preventDefault();
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nextLeft = Math.min(
|
||||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||||
appRect.width - panelRect.width,
|
||||
);
|
||||
const nextTop = Math.min(
|
||||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||||
appRect.height - panelRect.height,
|
||||
);
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
emitDragEvent();
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
activePointerId = event.pointerId;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
startLeft = panelRect.left - appRect.left;
|
||||
startTop = panelRect.top - appRect.top;
|
||||
panel.style.left = `${startLeft}px`;
|
||||
panel.style.top = `${startTop}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.classList.add('is-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
emitDragEvent();
|
||||
});
|
||||
|
||||
// Listen in capture phase so card-level stopPropagation used to shield the
|
||||
// globe canvas does not swallow the drag stream before we can reposition.
|
||||
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
|
||||
window.addEventListener('pointerup', stopDragging, { capture: true });
|
||||
window.addEventListener('pointercancel', stopDragging, { capture: true });
|
||||
handle.addEventListener('lostpointercapture', stopDragging);
|
||||
}
|
||||
|
||||
function mountCard() {
|
||||
if (cardMounted) return;
|
||||
|
||||
const container = document.getElementById('container');
|
||||
if (!container) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'info-panel';
|
||||
panel.className = 'hud-panel hud-panel-info';
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
panel.setAttribute('hidden', '');
|
||||
panel.innerHTML = `
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(panel);
|
||||
|
||||
const card = panel.querySelector('#info-card');
|
||||
const content = panel.querySelector('#info-card-content');
|
||||
|
||||
// Prevent pointer events from reaching the earth canvas
|
||||
const stopEvent = (event) => { event.stopPropagation(); };
|
||||
[
|
||||
'mousemove', 'mousedown', 'mouseup', 'click', 'dblclick', 'wheel',
|
||||
'pointerdown', 'pointerup', 'pointermove',
|
||||
'touchstart', 'touchmove', 'touchend',
|
||||
].forEach((evt) => card.addEventListener(evt, stopEvent, { passive: false }));
|
||||
|
||||
// Close button
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
}
|
||||
|
||||
if (content.dataset.copyBound === 'true') return;
|
||||
|
||||
// Copy value on label click
|
||||
content.addEventListener('click', async (event) => {
|
||||
const label = event.target.closest('.info-card-label');
|
||||
if (!label) return;
|
||||
@@ -205,9 +510,96 @@ export function initInfoCard() {
|
||||
}
|
||||
});
|
||||
|
||||
content.dataset.copyBound = 'true';
|
||||
setupInfoCardDrag(panel);
|
||||
|
||||
cardMounted = true;
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y, options = {}) {
|
||||
if (!panel) return;
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
panel.style.left = '8px';
|
||||
panel.style.right = '8px';
|
||||
panel.style.top = 'auto';
|
||||
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
|
||||
return;
|
||||
}
|
||||
const margin = 12;
|
||||
const offset = 14;
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
|
||||
const scale = parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
|
||||
) || 1;
|
||||
const estW = Math.min(300 * scale, vpW - 32);
|
||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||
|
||||
if (options.absolute === true) {
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(margin, x),
|
||||
Math.max(margin, vpW - estW - margin),
|
||||
);
|
||||
const clampedTop = Math.min(
|
||||
Math.max(margin, y),
|
||||
Math.max(margin, vpH - estH - margin),
|
||||
);
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
let left = x + offset;
|
||||
let top = y + offset;
|
||||
|
||||
if (left + estW > vpW - margin) left = x - estW - offset;
|
||||
if (top + estH > vpH - margin) top = Math.max(margin, vpH - estH - margin);
|
||||
|
||||
panel.style.left = `${Math.max(margin, left)}px`;
|
||||
panel.style.top = `${Math.max(margin, top)}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function showPanel(x, y, options = {}) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
|
||||
panel.removeAttribute('hidden');
|
||||
panel.setAttribute('aria-hidden', 'false');
|
||||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||
if (options.reveal === false) {
|
||||
panel.classList.remove('is-visible');
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
panel.classList.add('is-visible');
|
||||
});
|
||||
document.body.classList.add('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||
);
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = getPanel();
|
||||
if (panel) {
|
||||
panel.classList.remove('is-visible');
|
||||
panel.classList.remove('hud-panel-info--anchor-stable');
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
panel.setAttribute('hidden', '');
|
||||
}
|
||||
document.body.classList.remove('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
);
|
||||
}
|
||||
|
||||
// No-op: event binding now happens lazily in mountCard()
|
||||
export function initInfoCard() {}
|
||||
|
||||
export function setInfoCardNoBorder(noBorder = true) {
|
||||
const card = document.getElementById('info-card');
|
||||
if (card) {
|
||||
@@ -222,6 +614,53 @@ export function showInfoCard(type, data, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
currentType = type;
|
||||
|
||||
// Fill drawer details slot (accessible when user taps popup → opens details tab)
|
||||
const icon = document.getElementById('mobile-info-card-icon');
|
||||
const title = document.getElementById('mobile-info-card-title');
|
||||
const typeLabel = document.getElementById('mobile-info-card-type');
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
|
||||
if (icon) icon.textContent = config.icon;
|
||||
if (title) title.textContent = config.title;
|
||||
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
|
||||
|
||||
if (content) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// Show the floating mini popup near the touch point (requires coordinates)
|
||||
if (options.x != null && options.y != null) {
|
||||
ensurePopupClickHandler();
|
||||
showMobilePopup(type, data, options.x, options.y, options);
|
||||
document.body.classList.add('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
mountCard();
|
||||
|
||||
currentType = type;
|
||||
const card = document.getElementById('info-card');
|
||||
const icon = document.getElementById('info-card-icon');
|
||||
@@ -255,10 +694,19 @@ export function showInfoCard(type, data, options = {}) {
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showPanel(options.x, options.y);
|
||||
showPanel(options.x, options.y, options);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
hideMobilePopup();
|
||||
document.body.classList.remove('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
);
|
||||
currentType = null;
|
||||
return;
|
||||
}
|
||||
hidePanel();
|
||||
currentType = null;
|
||||
}
|
||||
|
||||
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,45 +1,49 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
bgp: { title: "BGP" },
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
computeCenters: { title: "算力" },
|
||||
bgp: { title: "BGP" },
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
computeCenters: [],
|
||||
bgp: [],
|
||||
};
|
||||
|
||||
export function initLegend() {
|
||||
// Tab click → switch mode
|
||||
const tabsEl = document.getElementById("legend-tabs");
|
||||
if (tabsEl) {
|
||||
tabsEl.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".legend-tab");
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.legendMode;
|
||||
if (mode) setLegendMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse toggle
|
||||
const collapseBtn = document.getElementById("legend-collapse");
|
||||
const legend = document.getElementById("legend");
|
||||
if (collapseBtn && legend) {
|
||||
legendPanel = createHUDPanel({
|
||||
panel: legend,
|
||||
header: ".legend-bar",
|
||||
body: "#legend-body",
|
||||
collapseBtn,
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图例",
|
||||
collapseLabel: "折叠图例",
|
||||
});
|
||||
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
legend.classList.toggle("legend--collapsed");
|
||||
legendPanel?.setCollapsed(!(legendPanel?.isCollapsed() ?? false));
|
||||
});
|
||||
}
|
||||
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
|
||||
export function setLegendMode(mode) {
|
||||
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
|
||||
currentLegendMode = nextMode;
|
||||
syncTabs(nextMode);
|
||||
syncCurrentLabel(nextMode);
|
||||
renderLegend(nextMode);
|
||||
}
|
||||
|
||||
@@ -59,19 +63,19 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTabs(mode) {
|
||||
const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
|
||||
tabs.forEach((tab) => {
|
||||
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
|
||||
});
|
||||
function syncCurrentLabel(mode) {
|
||||
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
|
||||
.forEach((labelEl) => {
|
||||
if (labelEl) {
|
||||
labelEl.textContent = nextLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const listEl = document.querySelector("#legend .legend-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const items = legendItemsByMode[mode] || [];
|
||||
listEl.innerHTML = items
|
||||
const html = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="legend-item">
|
||||
@@ -80,4 +84,14 @@ function renderLegend(mode) {
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const desktopList = document.querySelector("#legend .legend-list");
|
||||
if (desktopList) {
|
||||
desktopList.innerHTML = html;
|
||||
}
|
||||
|
||||
const mobileList = document.getElementById("mobile-situation-legend-list");
|
||||
if (mobileList) {
|
||||
mobileList.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
322
frontend/public/earth/js/news.js
Normal file
322
frontend/public/earth/js/news.js
Normal file
@@ -0,0 +1,322 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { isTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
// - this module renders into inner pane: #news-panel
|
||||
|
||||
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
|
||||
const FOCUS_UPDATE_INTERVAL_MS = 4000;
|
||||
const DATA_REFRESH_INTERVAL_MS = 180000;
|
||||
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
|
||||
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
|
||||
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
|
||||
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
|
||||
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
|
||||
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
|
||||
regionChip: document.getElementById("news-region-chip"),
|
||||
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
|
||||
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
|
||||
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
|
||||
};
|
||||
}
|
||||
|
||||
function formatCoord(value, positiveLabel, negativeLabel) {
|
||||
const abs = Math.abs(value).toFixed(1);
|
||||
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(raw) {
|
||||
if (!raw) return "刚刚同步";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return "刚刚同步";
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.max(1, Math.round(diff / 60000));
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
void visible;
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
const { board, empty, status, openBtn } = getElements();
|
||||
if (board) board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = message;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "等待聚合新闻源";
|
||||
}
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
}
|
||||
|
||||
function renderPayload(nextPayload) {
|
||||
payload = nextPayload;
|
||||
const {
|
||||
board,
|
||||
empty,
|
||||
status,
|
||||
focusLabel,
|
||||
focusCoords,
|
||||
sourceCount,
|
||||
regionChip,
|
||||
openBtn,
|
||||
feedAnchor,
|
||||
} = getElements();
|
||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (regionChip) {
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
}
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
// Mobile page does not show the compact chip row.
|
||||
} else if (!regionChip) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusLabel.textContent = focus.label || "全球焦点";
|
||||
|
||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||
} else {
|
||||
focusCoords.textContent = "跟随当前视角自动聚焦";
|
||||
}
|
||||
|
||||
sourceCount.textContent = `${sources.length} 路聚合源`;
|
||||
if (nextPayload?.stale) {
|
||||
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length} 条`;
|
||||
} else {
|
||||
status.textContent = nextPayload?.errors?.length
|
||||
? `已聚合 ${items.length} 条,部分源不可用`
|
||||
: `已聚合 ${items.length} 条态势新闻`;
|
||||
}
|
||||
|
||||
if (feedAnchor) {
|
||||
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
|
||||
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
|
||||
}
|
||||
|
||||
if (openBtn) {
|
||||
openBtn.disabled = !feedAnchor?.href;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
board.innerHTML = items
|
||||
.map((item) => {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const summary = item.summary
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${item.title}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${item.region}</span>
|
||||
<span class="news-story-tag">${item.feed_name}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const url = new URL(EARTH_NEWS_API, window.location.origin);
|
||||
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
|
||||
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
window.clearTimeout(timeoutId);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`新闻源请求失败: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "正在同步全球态势新闻...";
|
||||
}
|
||||
|
||||
refreshPromise = fetchNews(lat, lon)
|
||||
.then((nextPayload) => {
|
||||
renderPayload(nextPayload);
|
||||
lastFetchAt = Date.now();
|
||||
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
|
||||
}
|
||||
}
|
||||
return nextPayload;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("加载 Earth RSS 新闻失败:", error);
|
||||
const message = error?.name === "AbortError"
|
||||
? "新闻聚合请求超时,请稍后重试"
|
||||
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
|
||||
if (!payload) {
|
||||
renderEmptyState(message);
|
||||
} else if (!silent) {
|
||||
showStatusMessage("态势新闻同步失败", "error");
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function shouldRefreshForFocus(lat, lon, region) {
|
||||
const now = Date.now();
|
||||
if (!lastFocus) return true;
|
||||
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
|
||||
lastRegionSwitchAt = now;
|
||||
return true;
|
||||
}
|
||||
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
|
||||
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
|
||||
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
|
||||
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
|
||||
return latDrift >= 18 || lonDrift >= 25;
|
||||
}
|
||||
|
||||
function inferRegion(lat, lon) {
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return "global";
|
||||
if (lon >= -170 && lon <= -30) return "americas";
|
||||
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
|
||||
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
|
||||
return "asia-pacific";
|
||||
}
|
||||
|
||||
function openCurrentSourceHomepage() {
|
||||
const { feedAnchor } = getElements();
|
||||
if (feedAnchor?.href) {
|
||||
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNewsViewFocus(coords) {
|
||||
if (!initialized) return;
|
||||
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
|
||||
|
||||
const region = inferRegion(coords.lat, coords.lon);
|
||||
const nextFocus = {
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
region,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
|
||||
lastFocus = nextFocus;
|
||||
if (shouldRefresh) {
|
||||
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureNewsPanelReady() {
|
||||
if (!initialized) {
|
||||
initNewsPanel();
|
||||
}
|
||||
if (!payload) {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
});
|
||||
|
||||
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||
const refreshBtn = document.getElementById(id);
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
["news-open-external", "mobile-news-open-external"].forEach((id) => {
|
||||
const openBtn = document.getElementById(id);
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
});
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
let satellitePoints = null;
|
||||
let satelliteBackdropPoints = null;
|
||||
let satelliteTrails = null;
|
||||
let satelliteData = [];
|
||||
let showSatellites = false;
|
||||
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
|
||||
let lockedDotSprite = null;
|
||||
let predictedOrbitLine = null;
|
||||
let relatedSatelliteSprites = [];
|
||||
let highlightedSatelliteIndices = null;
|
||||
let earthObjRef = null;
|
||||
let sceneRef = null;
|
||||
let cameraRef = null;
|
||||
@@ -24,11 +26,15 @@ let lockedSatelliteIndex = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let positionUpdateAccumulator = 0;
|
||||
let satelliteCapacity = 0;
|
||||
let selectedSatelliteLegendKey = null;
|
||||
let satelliteSatrecCache = new Map();
|
||||
|
||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
const POSITION_UPDATE_INTERVAL_MS = 250;
|
||||
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
|
||||
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
|
||||
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
|
||||
const DIMMED_SATELLITE_BACKDROP_OPACITY = 0.1;
|
||||
|
||||
const scratchWorldSatellitePosition = new THREE.Vector3();
|
||||
const scratchToCamera = new THREE.Vector3();
|
||||
@@ -38,47 +44,90 @@ export let breathingPhase = 0;
|
||||
|
||||
const SATELLITE_LEGEND_RULES = [
|
||||
{
|
||||
key: "starlink",
|
||||
label: "Starlink",
|
||||
color: "#00e6ff",
|
||||
match: (props) => (props?.name || "").includes("STARLINK"),
|
||||
},
|
||||
{
|
||||
key: "geo",
|
||||
label: "GEO / 倾角 20-30",
|
||||
color: "#ffcc00",
|
||||
key: "equatorial",
|
||||
label: "赤道轨道(0-30°)",
|
||||
color: "#ff3333",
|
||||
match: (props) => {
|
||||
const inclination = props?.inclination || 53;
|
||||
return inclination > 20 && inclination < 30;
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 0 && inclination < 30;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "iridium",
|
||||
label: "Iridium",
|
||||
color: "#ff8000",
|
||||
match: (props) => (props?.name || "").includes("IRIDIUM"),
|
||||
key: "low",
|
||||
label: "低倾角轨道(30-60°)",
|
||||
color: "#ff9933",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 30 && inclination < 60;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "mid-inclination",
|
||||
label: "倾角 50-70",
|
||||
color: "#00ff4d",
|
||||
key: "medium",
|
||||
label: "中倾角轨道(60-90°)",
|
||||
color: "#ffff33",
|
||||
match: (props) => {
|
||||
const inclination = props?.inclination || 53;
|
||||
return inclination > 50 && inclination < 70;
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 60 && inclination < 90;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "high",
|
||||
label: "高倾角轨道(90-120°)",
|
||||
color: "#33ff33",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 90 && inclination < 120;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "retrograde",
|
||||
label: "逆行轨道(120-180°)",
|
||||
color: "#3333ff",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 120 && inclination <= 180;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
label: "其他卫星",
|
||||
color: "#ffffff",
|
||||
label: "其他",
|
||||
color: "#d7e2f4",
|
||||
match: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
const SATELLITE_RULE_COLOR_CACHE = new Map();
|
||||
|
||||
function getSatelliteLegendRule(props = {}) {
|
||||
return (
|
||||
SATELLITE_LEGEND_RULES.find((rule) => rule.match(props)) ||
|
||||
SATELLITE_LEGEND_RULES[SATELLITE_LEGEND_RULES.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
function getSatelliteRuleColor(rule) {
|
||||
if (!rule) {
|
||||
return { r: 1, g: 1, b: 1 };
|
||||
}
|
||||
|
||||
if (SATELLITE_RULE_COLOR_CACHE.has(rule.key)) {
|
||||
return SATELLITE_RULE_COLOR_CACHE.get(rule.key);
|
||||
}
|
||||
|
||||
const color = new THREE.Color(rule.color);
|
||||
const rgb = { r: color.r, g: color.g, b: color.b };
|
||||
SATELLITE_RULE_COLOR_CACHE.set(rule.key, rgb);
|
||||
return rgb;
|
||||
}
|
||||
|
||||
export function updateBreathingPhase(deltaTime = 16) {
|
||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||
}
|
||||
|
||||
function getBreathingPulse(phase) {
|
||||
return 0.5 + 0.5 * Math.sin(phase);
|
||||
}
|
||||
|
||||
export function getSatelliteLegendItems() {
|
||||
const presentKeys = new Set();
|
||||
|
||||
@@ -98,31 +147,15 @@ export function getSatelliteLegendItems() {
|
||||
.filter((item) => presentKeys.has(item.key))
|
||||
.map(({ key, label, color }) => ({ key, label, color }));
|
||||
|
||||
if (!selectedSatelliteLegendKey) {
|
||||
return items.map(({ label, color }) => ({ label, color }));
|
||||
}
|
||||
|
||||
const selectedIndex = items.findIndex(
|
||||
(item) => item.key === selectedSatelliteLegendKey,
|
||||
);
|
||||
|
||||
if (selectedIndex > 0) {
|
||||
const [selectedItem] = items.splice(selectedIndex, 1);
|
||||
items.unshift(selectedItem);
|
||||
}
|
||||
|
||||
return items.map(({ label, color }) => ({ label, color }));
|
||||
}
|
||||
|
||||
export function setSelectedSatelliteLegend(props) {
|
||||
const rule = SATELLITE_LEGEND_RULES.find((item) =>
|
||||
item.match(props || {}),
|
||||
);
|
||||
selectedSatelliteLegendKey = rule?.key || null;
|
||||
return getSatelliteLegendRule(props || {});
|
||||
}
|
||||
|
||||
export function clearSelectedSatelliteLegend() {
|
||||
selectedSatelliteLegendKey = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
@@ -182,6 +215,37 @@ function createDotTexture() {
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createBackdropDotTexture() {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = DOT_TEXTURE_SIZE;
|
||||
canvas.height = DOT_TEXTURE_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const center = DOT_TEXTURE_SIZE / 2;
|
||||
const radius = center - 1;
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
center,
|
||||
center,
|
||||
0,
|
||||
center,
|
||||
center,
|
||||
radius,
|
||||
);
|
||||
gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
|
||||
gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
|
||||
gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
|
||||
gradient.addColorStop(1, "rgba(7, 14, 27, 0)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
const size = DOT_TEXTURE_SIZE * 2;
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -204,8 +268,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
export function createSatellites(scene, earthObj) {
|
||||
initSatelliteScene(scene, earthObj);
|
||||
const dotTexture = createDotTexture();
|
||||
const backdropTexture = createBackdropDotTexture();
|
||||
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
const backdropGeometry = new THREE.BufferGeometry();
|
||||
|
||||
const backdropMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize * 1.28,
|
||||
map: backdropTexture,
|
||||
color: 0x0b1626,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.04,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const pointsMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize,
|
||||
@@ -215,29 +292,45 @@ export function createSatellites(scene, earthObj) {
|
||||
opacity: 0.9,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.1,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
|
||||
satelliteBackdropPoints.visible = false;
|
||||
satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
|
||||
satelliteBackdropPoints.renderOrder = 5;
|
||||
|
||||
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
|
||||
satellitePoints.visible = false;
|
||||
satellitePoints.userData = { type: "satellitePoints" };
|
||||
satellitePoints.renderOrder = 6;
|
||||
|
||||
const originalScale = { x: 1, y: 1, z: 1 };
|
||||
satellitePoints.onBeforeRender = () => {
|
||||
const syncPointScale = () => {
|
||||
if (earthObj && earthObj.scale.x !== 1) {
|
||||
satellitePoints.scale.set(
|
||||
originalScale.x / earthObj.scale.x,
|
||||
originalScale.y / earthObj.scale.y,
|
||||
originalScale.z / earthObj.scale.z,
|
||||
);
|
||||
const scaleX = originalScale.x / earthObj.scale.x;
|
||||
const scaleY = originalScale.y / earthObj.scale.y;
|
||||
const scaleZ = originalScale.z / earthObj.scale.z;
|
||||
satellitePoints.scale.set(scaleX, scaleY, scaleZ);
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
|
||||
}
|
||||
} else {
|
||||
satellitePoints.scale.set(
|
||||
originalScale.x,
|
||||
originalScale.y,
|
||||
originalScale.z,
|
||||
);
|
||||
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.scale.set(
|
||||
originalScale.x,
|
||||
originalScale.y,
|
||||
originalScale.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
satelliteBackdropPoints.onBeforeRender = syncPointScale;
|
||||
satellitePoints.onBeforeRender = syncPointScale;
|
||||
|
||||
earthObj.add(satelliteBackdropPoints);
|
||||
earthObj.add(satellitePoints);
|
||||
|
||||
const trailGeometry = new THREE.BufferGeometry();
|
||||
@@ -259,7 +352,12 @@ export function createSatellites(scene, earthObj) {
|
||||
return satellitePoints;
|
||||
}
|
||||
|
||||
function getRequestedSatelliteLimit() {
|
||||
function getRequestedSatelliteLimit(limitOverride) {
|
||||
if (limitOverride === null) return null;
|
||||
if (Number.isFinite(limitOverride) && limitOverride > 0) {
|
||||
return Math.floor(limitOverride);
|
||||
}
|
||||
|
||||
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
||||
}
|
||||
|
||||
@@ -273,13 +371,50 @@ function createSatellitePositionState() {
|
||||
}
|
||||
|
||||
function ensureSatelliteCapacity(count) {
|
||||
if (!satellitePoints || !satelliteTrails) return;
|
||||
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
|
||||
|
||||
const nextCapacity = Math.max(count, 0);
|
||||
if (nextCapacity === satelliteCapacity) return;
|
||||
|
||||
const previousPointPositions =
|
||||
satellitePoints.geometry.attributes.position?.array || null;
|
||||
const previousBackdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position?.array || null;
|
||||
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
|
||||
const previousTrailPositions =
|
||||
satelliteTrails.geometry.attributes.position?.array || null;
|
||||
const previousTrailColors =
|
||||
satelliteTrails.geometry.attributes.color?.array || null;
|
||||
const previousSatellitePositions = satellitePositions;
|
||||
const previousCapacity = satelliteCapacity;
|
||||
|
||||
const positions = new Float32Array(nextCapacity * 3);
|
||||
const backdropPositions = new Float32Array(nextCapacity * 3);
|
||||
const colors = new Float32Array(nextCapacity * 3);
|
||||
if (previousPointPositions) {
|
||||
positions.set(
|
||||
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
|
||||
);
|
||||
}
|
||||
if (previousBackdropPositions) {
|
||||
backdropPositions.set(
|
||||
previousBackdropPositions.subarray(
|
||||
0,
|
||||
Math.min(previousBackdropPositions.length, backdropPositions.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (previousColors) {
|
||||
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(backdropPositions, 3),
|
||||
);
|
||||
satelliteBackdropPoints.geometry.setDrawRange(
|
||||
0,
|
||||
Math.min(previousCapacity, nextCapacity),
|
||||
);
|
||||
satellitePoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions, 3),
|
||||
@@ -288,10 +423,26 @@ function ensureSatelliteCapacity(count) {
|
||||
"color",
|
||||
new THREE.BufferAttribute(colors, 3),
|
||||
);
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
|
||||
|
||||
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
if (previousTrailPositions) {
|
||||
trailPositions.set(
|
||||
previousTrailPositions.subarray(
|
||||
0,
|
||||
Math.min(previousTrailPositions.length, trailPositions.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (previousTrailColors) {
|
||||
trailColors.set(
|
||||
previousTrailColors.subarray(
|
||||
0,
|
||||
Math.min(previousTrailColors.length, trailColors.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(trailPositions, 3),
|
||||
@@ -301,10 +452,19 @@ function ensureSatelliteCapacity(count) {
|
||||
new THREE.BufferAttribute(trailColors, 3),
|
||||
);
|
||||
|
||||
satellitePositions = Array.from(
|
||||
{ length: nextCapacity },
|
||||
createSatellitePositionState,
|
||||
);
|
||||
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
||||
const previousState = previousSatellitePositions[index];
|
||||
if (!previousState) {
|
||||
return createSatellitePositionState();
|
||||
}
|
||||
|
||||
return {
|
||||
current: previousState.current.clone(),
|
||||
trail: previousState.trail.slice(),
|
||||
trailIndex: previousState.trailIndex,
|
||||
trailCount: previousState.trailCount,
|
||||
};
|
||||
});
|
||||
satelliteCapacity = nextCapacity;
|
||||
}
|
||||
|
||||
@@ -315,7 +475,7 @@ function computeSatellitePosition(satellite, time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, time);
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
@@ -334,7 +494,8 @@ function computeSatellitePosition(satellite, time) {
|
||||
}
|
||||
|
||||
const r = Math.sqrt(x * x + y * y + z * z);
|
||||
const displayRadius = CONFIG.earthRadius * 1.05;
|
||||
const displayRadius =
|
||||
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||
const scale = displayRadius / r;
|
||||
|
||||
return new THREE.Vector3(x * scale, y * scale, z * scale);
|
||||
@@ -360,6 +521,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
|
||||
return twoline2satrec(tleLines.line1, tleLines.line2);
|
||||
}
|
||||
|
||||
function getSatelliteSatrecCacheKey(props) {
|
||||
if (!props?.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.tle_line1 && props.tle_line2) {
|
||||
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
|
||||
}
|
||||
|
||||
if (props.epoch) {
|
||||
return [
|
||||
"elements",
|
||||
props.norad_cat_id,
|
||||
props.epoch,
|
||||
props.inclination,
|
||||
props.raan,
|
||||
props.eccentricity,
|
||||
props.arg_of_perigee,
|
||||
props.mean_anomaly,
|
||||
props.mean_motion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrBuildSatrec(props, fallbackTime) {
|
||||
const cacheKey = getSatelliteSatrecCacheKey(props);
|
||||
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
|
||||
return satelliteSatrecCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, fallbackTime);
|
||||
if (cacheKey && satrec && !satrec.error) {
|
||||
satelliteSatrecCache.set(cacheKey, satrec);
|
||||
}
|
||||
return satrec;
|
||||
}
|
||||
|
||||
function computeTleChecksum(line) {
|
||||
let sum = 0;
|
||||
|
||||
@@ -442,7 +642,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
|
||||
}
|
||||
|
||||
function generateFallbackPosition(satellite, index, total) {
|
||||
const radius = CONFIG.earthRadius + 5;
|
||||
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||
|
||||
const noradId = satellite.properties?.norad_cat_id || index;
|
||||
const inclination = satellite.properties?.inclination || 53;
|
||||
@@ -469,8 +669,8 @@ function generateFallbackPosition(satellite, index, total) {
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
export async function loadSatellites() {
|
||||
const limit = getRequestedSatelliteLimit();
|
||||
export async function loadSatellites(options = {}) {
|
||||
const limit = getRequestedSatelliteLimit(options.limit);
|
||||
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
|
||||
if (limit !== null) {
|
||||
url.searchParams.set("limit", String(limit));
|
||||
@@ -483,13 +683,17 @@ export async function loadSatellites() {
|
||||
|
||||
const data = await response.json();
|
||||
satelliteData = data.features || [];
|
||||
satelliteSatrecCache = new Map();
|
||||
ensureSatelliteCapacity(satelliteData.length);
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
return satelliteData.length;
|
||||
return {
|
||||
count: satelliteData.length,
|
||||
requestedLimit: limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
if (!satellitePoints || satelliteData.length === 0) return;
|
||||
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
|
||||
|
||||
const shouldUpdateTrails =
|
||||
showSatellites || showTrails || lockedSatelliteIndex !== null;
|
||||
@@ -506,6 +710,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positionUpdateAccumulator = 0;
|
||||
|
||||
const positions = satellitePoints.geometry.attributes.position.array;
|
||||
const backdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position.array;
|
||||
const colors = satellitePoints.geometry.attributes.color.array;
|
||||
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
||||
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
||||
@@ -537,41 +743,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3] = pos.x;
|
||||
positions[i * 3 + 1] = pos.y;
|
||||
positions[i * 3 + 2] = pos.z;
|
||||
backdropPositions[i * 3] = pos.x;
|
||||
backdropPositions[i * 3 + 1] = pos.y;
|
||||
backdropPositions[i * 3 + 2] = pos.z;
|
||||
|
||||
const inclination = props?.inclination || 53;
|
||||
const name = props?.name || "";
|
||||
const isStarlink = name.includes("STARLINK");
|
||||
const isGeo = inclination > 20 && inclination < 30;
|
||||
const isIridium = name.includes("IRIDIUM");
|
||||
const rule = getSatelliteLegendRule(props);
|
||||
const { r, g, b } = getSatelliteRuleColor(rule);
|
||||
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
if (isStarlink) {
|
||||
r = 0.0;
|
||||
g = 0.9;
|
||||
b = 1.0;
|
||||
} else if (isGeo) {
|
||||
r = 1.0;
|
||||
g = 0.8;
|
||||
b = 0.0;
|
||||
} else if (isIridium) {
|
||||
r = 1.0;
|
||||
g = 0.5;
|
||||
b = 0.0;
|
||||
} else if (inclination > 50 && inclination < 70) {
|
||||
r = 0.0;
|
||||
g = 1.0;
|
||||
b = 0.3;
|
||||
} else {
|
||||
r = 1.0;
|
||||
g = 1.0;
|
||||
b = 1.0;
|
||||
}
|
||||
const isNonFocusDimmed =
|
||||
highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i);
|
||||
const pointBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_BRIGHTNESS : 1;
|
||||
const trailBrightness = isNonFocusDimmed
|
||||
? DIMMED_SATELLITE_TRAIL_BRIGHTNESS
|
||||
: 1;
|
||||
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
colors[i * 3 + 2] = b;
|
||||
colors[i * 3] = r * pointBrightness;
|
||||
colors[i * 3 + 1] = g * pointBrightness;
|
||||
colors[i * 3 + 2] = b * pointBrightness;
|
||||
|
||||
const satPosition = satellitePositions[i];
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
@@ -587,9 +775,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
trailPositions[trailIdx + 1] = trailPoint.y;
|
||||
trailPositions[trailIdx + 2] = trailPoint.z;
|
||||
const alpha = (j + 1) / satPosition.trailCount;
|
||||
trailColors[trailIdx] = r * alpha;
|
||||
trailColors[trailIdx + 1] = g * alpha;
|
||||
trailColors[trailIdx + 2] = b * alpha;
|
||||
trailColors[trailIdx] = r * alpha * trailBrightness;
|
||||
trailColors[trailIdx + 1] = g * alpha * trailBrightness;
|
||||
trailColors[trailIdx + 2] = b * alpha * trailBrightness;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -607,6 +795,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3] = 0;
|
||||
positions[i * 3 + 1] = 0;
|
||||
positions[i * 3 + 2] = 0;
|
||||
backdropPositions[i * 3] = 0;
|
||||
backdropPositions[i * 3 + 1] = 0;
|
||||
backdropPositions[i * 3 + 2] = 0;
|
||||
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||
@@ -619,6 +810,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
||||
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
||||
satellitePoints.geometry.setDrawRange(0, count);
|
||||
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, count);
|
||||
|
||||
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
||||
@@ -637,6 +830,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
|
||||
export function toggleSatellites(visible) {
|
||||
showSatellites = visible;
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.visible = visible;
|
||||
}
|
||||
if (satellitePoints) {
|
||||
satellitePoints.visible = visible;
|
||||
}
|
||||
@@ -652,6 +848,10 @@ export function toggleTrails(visible) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getShowTrails() {
|
||||
return showTrails;
|
||||
}
|
||||
|
||||
export function getShowSatellites() {
|
||||
return showSatellites;
|
||||
}
|
||||
@@ -711,7 +911,10 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
|
||||
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
|
||||
.normalize();
|
||||
|
||||
return scratchToCamera.dot(scratchToSatellite) > 0;
|
||||
return (
|
||||
scratchToCamera.dot(scratchToSatellite) >
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
);
|
||||
}
|
||||
|
||||
function createBrighterDotCanvas() {
|
||||
@@ -757,6 +960,7 @@ function createRingSprite(position, isLocked = false) {
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.position.copy(position);
|
||||
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
|
||||
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||
earthObjRef.add(sprite);
|
||||
return sprite;
|
||||
}
|
||||
@@ -776,6 +980,7 @@ function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.position.copy(position);
|
||||
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
|
||||
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||
earthObjRef.add(sprite);
|
||||
return sprite;
|
||||
}
|
||||
@@ -798,6 +1003,7 @@ export function showHoverRing(position, isLocked = false) {
|
||||
lockedDotSprite = new THREE.Sprite(dotMaterial);
|
||||
lockedDotSprite.position.copy(position);
|
||||
lockedDotSprite.scale.set(4, 4, 1);
|
||||
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
|
||||
earthObjRef.add(lockedDotSprite);
|
||||
return lockedRingSprite;
|
||||
}
|
||||
@@ -827,10 +1033,15 @@ export function hideLockedRing() {
|
||||
|
||||
export function updateLockedRingPosition(position) {
|
||||
if (!position) return;
|
||||
if (!lockedRingSprite || !lockedDotSprite) {
|
||||
showHoverRing(position, true);
|
||||
}
|
||||
if (lockedRingSprite) {
|
||||
lockedRingSprite.position.copy(position);
|
||||
const ringPulse = getBreathingPulse(breathingPhase);
|
||||
const breathScale =
|
||||
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
1 +
|
||||
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
lockedRingSprite.scale.set(
|
||||
SATELLITE_CONFIG.ringSize * breathScale,
|
||||
SATELLITE_CONFIG.ringSize * breathScale,
|
||||
@@ -838,20 +1049,21 @@ export function updateLockedRingPosition(position) {
|
||||
);
|
||||
lockedRingSprite.material.opacity =
|
||||
SATELLITE_CONFIG.breathingOpacityMin +
|
||||
Math.sin(breathingPhase) *
|
||||
ringPulse *
|
||||
(SATELLITE_CONFIG.breathingOpacityMax -
|
||||
SATELLITE_CONFIG.breathingOpacityMin);
|
||||
}
|
||||
|
||||
if (lockedDotSprite) {
|
||||
lockedDotSprite.position.copy(position);
|
||||
const dotPulse = getBreathingPulse(breathingPhase);
|
||||
const dotBreathScale =
|
||||
1 +
|
||||
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||
(dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
|
||||
lockedDotSprite.material.opacity =
|
||||
SATELLITE_CONFIG.dotOpacityMin +
|
||||
Math.sin(breathingPhase) *
|
||||
dotPulse *
|
||||
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
|
||||
}
|
||||
}
|
||||
@@ -887,6 +1099,15 @@ export function setSatelliteRingState(index, state, position) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyDimMaterialState(isDimmed) {
|
||||
if (satellitePoints) {
|
||||
satellitePoints.material.opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
|
||||
}
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.material.opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRelatedSatelliteHighlights() {
|
||||
relatedSatelliteSprites.forEach((item) => {
|
||||
if (item.sprite) {
|
||||
@@ -894,12 +1115,16 @@ export function clearRelatedSatelliteHighlights() {
|
||||
}
|
||||
});
|
||||
relatedSatelliteSprites = [];
|
||||
highlightedSatelliteIndices = null;
|
||||
applyDimMaterialState(false);
|
||||
}
|
||||
|
||||
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
|
||||
clearRelatedSatelliteHighlights();
|
||||
if (!Array.isArray(indices) || indices.length === 0) return;
|
||||
|
||||
highlightedSatelliteIndices = new Set(indices);
|
||||
applyDimMaterialState(true);
|
||||
indices.forEach((index) => {
|
||||
const pos = satellitePositions?.[index]?.current;
|
||||
if (!pos) return;
|
||||
@@ -989,7 +1214,8 @@ function calculatePredictedOrbit(
|
||||
|
||||
if (points.length < samples * 0.5) {
|
||||
points.length = 0;
|
||||
const radius = CONFIG.earthRadius + 5;
|
||||
const radius =
|
||||
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||
const inclination = satellite.properties?.inclination || 53;
|
||||
const raan = satellite.properties?.raan || 0;
|
||||
|
||||
@@ -1040,9 +1266,12 @@ export function showPredictedOrbit(satellite) {
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
predictedOrbitLine = new THREE.Line(geometry, material);
|
||||
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||
earthObjRef.add(predictedOrbitLine);
|
||||
}
|
||||
|
||||
@@ -1055,10 +1284,12 @@ export function hidePredictedOrbit() {
|
||||
|
||||
export function clearSatelliteData() {
|
||||
satelliteData = [];
|
||||
satelliteSatrecCache = new Map();
|
||||
selectedSatellite = null;
|
||||
lockedSatelliteIndex = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
positionUpdateAccumulator = 0;
|
||||
breathingPhase = 0;
|
||||
|
||||
satellitePositions.forEach((position) => {
|
||||
position.current.set(0, 0, 0);
|
||||
@@ -1081,6 +1312,16 @@ export function clearSatelliteData() {
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
const backdropPositionAttr =
|
||||
satelliteBackdropPoints.geometry.attributes.position;
|
||||
if (backdropPositionAttr?.array) {
|
||||
backdropPositionAttr.array.fill(0);
|
||||
backdropPositionAttr.needsUpdate = true;
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteTrails) {
|
||||
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
|
||||
const trailColorAttr = satelliteTrails.geometry.attributes.color;
|
||||
@@ -1103,6 +1344,11 @@ export function clearSatelliteData() {
|
||||
export function resetSatelliteState() {
|
||||
clearSatelliteData();
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
disposeObject3D(satelliteBackdropPoints);
|
||||
satelliteBackdropPoints = null;
|
||||
}
|
||||
|
||||
if (satellitePoints) {
|
||||
disposeObject3D(satellitePoints);
|
||||
satellitePoints = null;
|
||||
@@ -1115,6 +1361,7 @@ export function resetSatelliteState() {
|
||||
|
||||
satellitePositions = [];
|
||||
satelliteCapacity = 0;
|
||||
satelliteSatrecCache = new Map();
|
||||
showSatellites = false;
|
||||
showTrails = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user