Compare commits

..

17 Commits

Author SHA1 Message Date
linkong
2b0d4cfc49 release: bump version to 0.29.2 2026-04-21 10:43:48 +08:00
rayd1o
e6d0332fba release: bump version to 0.29.1 2026-04-20 22:12:19 +08:00
linkong
fe45a99cbd release: bump version to 0.29.0 2026-04-20 17:43:59 +08:00
linkong
ae77b06c3c docs: expand earth celestial background implementation plan 2026-04-20 16:29:03 +08:00
linkong
b5dd4f12f8 release: bump version to 0.28.2 2026-04-20 16:00:54 +08:00
linkong
75cb214f23 release: bump version to 0.28.1 2026-04-20 15:14:53 +08:00
linkong
4c21973197 release: bump version to 0.28.0 2026-04-20 14:10:27 +08:00
linkong
51ae5e6ec9 release: bump version to 0.27.8 2026-04-20 12:03:09 +08:00
linkong
1cf1f32ddd feat: refine earth hud panel behaviors and news board 2026-04-20 11:23:05 +08:00
linkong
8f3ab88743 release: bump version to 0.27.7 2026-04-16 10:04:14 +08:00
rayd1o
f8b43a995b release: bump version to 0.27.6 2026-04-15 07:37:41 +08:00
linkong
d9adaf4134 release: bump version to 0.27.5 2026-04-14 17:34:04 +08:00
linkong
40e51d5b20 release: bump version to 0.27.4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:11:39 +08:00
linkong
93c1c1e550 release: bump version to 0.27.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:52:05 +08:00
linkong
48eb13b993 release: bump version to 0.27.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:55:04 +08:00
linkong
11179e7e67 release: bump version to 0.27.1 2026-04-14 10:22:05 +08:00
rayd1o
07e26d6d5a chore: add cleanup and release skills for claude and codex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:46:19 +08:00
86 changed files with 8033 additions and 1747 deletions

120
.claude/commands/cleanup.md Normal file
View 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 = 144处引用
✓ controls.js — export updateLayerButtonState移除 main.js 中的重复实现
...
未修改的问题(需人工确认):
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
```
## 约束
- **禁止**改变函数签名、接口定义、导出 API除非问题正是私有函数应被 export
- **禁止**添加新功能、新抽象、新参数
- **禁止**修改注释内容(只删除注释掉的死代码)
- **禁止**修改测试文件逻辑
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"

146
.claude/commands/release.md Normal file
View 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 更新,提醒用户手动运行

View 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

View File

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

View 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

View File

@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
详细文档:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/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/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md)
## 前端页面布局规范
@@ -346,7 +346,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/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
## License

View File

@@ -1 +1 @@
0.27.0
0.29.2

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,210 @@ 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.29.1] — 2026-04-20
## [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 补充进拖拽排除列表)
- 调整图层搜索框图标尺寸为 20pxBR 缩放角标改为直角 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 +282,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/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
### Improved
@@ -166,7 +368,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/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 +406,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/frontend/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
## 0.24.6
@@ -221,7 +423,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/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
### Fixed
@@ -327,8 +529,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/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
### Improved
@@ -433,7 +635,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/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 +761,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/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 +776,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/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 +934,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/backend/system-service-control.md).
### Improved
@@ -935,7 +1137,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.

View File

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

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

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

View File

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

View File

@@ -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/earth/bgp-region-aggregation-plan.md).
So the immediate next milestone is:

View File

@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
## Why This Layer Exists
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md):
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/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/earth/bgp-earth-rendering-plan.md).
### Layer Relationship

View File

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

View File

@@ -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/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
### 2. 本地运行与配置打通
@@ -77,7 +77,7 @@
相关文件:
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
## 当前限制

View File

@@ -0,0 +1,981 @@
# 智能星球 UE5 客户端一期实施方案(融合版)
> 版本v2.0
> 日期2026-04-14
> 目标:把现有 Web Earth 项目,平滑推进到 **UE5 可用 MVP 客户端**
> 适用对象:**UE 零基础新手**
> 输出结果:一份 **能直接照着做** 的实施手册
> 策略:**保留原 MVP 方案里适合入门的部分,吸收更稳的工程做法,降低你第一次做 UE 时踩坑概率**
---
## 一、这份融合版方案解决什么问题
你原来的 MVP 方案是靠谱的,优点很明显:
- 范围克制
- 适合新手入门
- 目标明确
- 能较快做出“看得见、点得到”的成果
但它也有几个风险:
- 默认 `localhost` 一定通,这在 WSL2 + Windows + Docker 环境里不一定成立
- 默认 UE 蓝图里直接做 HTTP + JSON 解析会很顺,这一步其实很容易卡
- 默认“一上来就接真实后端”,新手会同时踩 UE、Cesium、网络、JSON、蓝图五个坑
- 时间估计略乐观
所以这份融合版方案的核心思路是:
## 核心原则
**先做“本地数据可交互地球”,再做“真实后端对接”。**
也就是把一期再拆成两个更稳的里程碑:
### 里程碑 A本地演示版
先不接后端,只做:
- UE5 项目能打开
- Cesium 地球能显示
- 本地 JSON 里的点能正确落到地球
- 点击点能弹信息卡
- HUD 能正常显示假状态
### 里程碑 B后端接入版
在 A 的基础上再做:
- HTTP 拉取真实后端数据
- 显示真实 TOP500 数据
- 显示后端在线状态
- 为后续扩展海缆/BGP/卫星打基础
这样做的好处是:
- 把问题拆开
- 更容易调试
- 更适合 UE 新手
- 不会因为后端联调没通就把整个 UE 开发节奏打断
---
# 二、一期目标:做什么,不做什么
## 这次一期一定要做的
做一个 **可用的 UE5 客户端 MVP**,达到以下 6 项:
1. 能打开 UE 项目并看到 3D 地球
2. 能在地球上显示超算数据点
3. 能点击数据点弹出信息卡
4. 能显示一个基础 HUD
5. 能通过 HTTP 接入后端数据
6. 能打包成 Windows 可执行程序
---
## 这次一期先不做的
这些全部放到后续阶段:
- 海缆路径渲染
- 卫星轨迹与卫星图层
- BGP 图层
- WebSocket 实时更新
- 粒子特效大升级
- 自动巡航
- 多屏/3D 偏振/大屏联动
一句话:
**一期不是“把 Web Earth 全搬到 UE”而是“证明 UE 客户端链路能跑通”。**
---
# 三、UE 专有名词字典(零基础版)
这部分你最好先读一遍。后面所有步骤都围绕这些词。
## 1. Actor
**Actor = 场景里的一个对象**
你可以把它理解成:
- 一个地球控制器
- 一个超算点
- 一台相机
- 一条海缆
这些在 UE 里都可以是 Actor。
---
## 2. Component
**Component = 挂在 Actor 身上的功能零件**
比如一个超算点 Actor可能有
- 一个球形外观
- 一个碰撞盒
- 一个标签
- 一个发光效果
这些零件就是 Component。
一句话:
**Actor 是整台机器Component 是机器上的零件。**
---
## 3. Blueprint蓝图
**Blueprint = UE 的可视化编程系统**
你不用先写代码,而是把很多“逻辑节点”拖出来,用线连接起来。
你可以把它理解成:
- 前端里的函数 + 事件监听
- 只不过不是写文本代码,而是连线
---
## 4. Level / Map关卡
**Level = 一个场景文件**
你可以把它理解成 Three.js 的一个 Scene。
本期只需要一个主场景:
- `Main`
---
## 5. Widget / UMG
**Widget = UI 组件**
**UMG = UE 的 UI 编辑系统**
比如:
- 信息卡
- 状态栏
- 右上角连接状态
- 图例
- HUD 面板
这些都用 Widget 做。
---
## 6. Material材质
**Material = 决定物体外观的系统**
比如:
- 球体是什么颜色
- 是否发光
- 是否透明
- 是否随性能大小变亮
这些都由材质控制。
---
## 7. Static Mesh
**Static Mesh = 不会变形的 3D 模型**
比如:
-
- 立方体
- 平面
- 某个固定模型
超算点一期里可以先直接用球体 Static Mesh。
---
## 8. Pawn
**Pawn = 玩家控制的对象**
一期里你可以把它理解成:
- 带相机的飞行控制器
---
## 9. PlayerController
**PlayerController = 处理输入的对象**
比如:
- 鼠标点击
- 拖拽
- 滚轮缩放
这些都由 PlayerController 或其相关逻辑来处理。
---
## 10. GameMode
**GameMode = 游戏/场景的主规则配置入口**
它决定:
- 默认用哪个 Pawn
- 默认用哪个 PlayerController
你可以把它理解成“主入口配置”。
---
## 11. Viewport
**Viewport = 你看 3D 场景的窗口**
就是 UE 编辑器中间那块 3D 视图。
---
## 12. Outliner
**Outliner = 当前场景对象列表**
你可以把它理解成:
- Scene 树
- DOM 树
- 资源树
---
## 13. Details Panel
**Details Panel = 选中对象后的属性面板**
相当于“右侧属性编辑器”。
---
## 14. Cesium for Unreal
**Cesium for Unreal = UE 里的地球插件**
它负责:
- 真实地球
- 卫星影像
- 地形
- 经纬度坐标和 UE 世界坐标的转换
如果没有它,你得自己处理地球和坐标系统,会非常难。
---
## 15. Struct结构体
**Struct = 数据结构定义**
你可以把它理解成 TypeScript 里的 `interface`
比如:
```ts
interface ComputePoint {
id: string
name: string
latitude: number
longitude: number
performance: number
}
```
在 UE 里这类东西叫 Struct。
---
## 16. Event Dispatcher
**Event Dispatcher = 事件分发器**
你可以把它理解成:
- EventEmitter
- 发布订阅
比如:
“数据加载完毕”这个事件,就可以分发给其他蓝图。
---
## 17. Spline
**Spline = 一条平滑曲线**
后面做海缆、轨迹时非常有用。
一期可以先知道这个词,不一定马上用。
---
## 18. Niagara
**Niagara = UE 粒子特效系统**
比如:
- 流光
- 光晕
- 拖尾
- 火花
一期先不重点碰它。
---
# 四、你的真实开发策略:两阶段起步
这是这份融合版和原方案最大的区别。
---
## 阶段 A本地演示版先脱离后端
### 目标
先把下面这些完全打通:
- UE 项目启动正常
- Cesium 地球正常
- 相机可操作
- 本地 JSON 文件能生成地球标记点
- 点击点能弹信息卡
- HUD 能显示假数据
### 为什么一定要先做这个
因为如果你一上来就接真实后端,你会同时碰到:
- WSL2 到 Windows 网络
- Docker 端口映射
- UE HTTP 请求
- 蓝图 JSON 解析
- Cesium 坐标转换
- 标记点生成
新手很容易直接乱掉。
---
## 阶段 B后端接入版再联调
### 目标
在 A 的基础上,加上:
- HTTP 拉真实后端数据
- 显示真实 TOP500 点
- 右上角显示后端在线状态
- 为后续做更多图层留下数据接入层
---
# 五、环境准备
## 1. 你要安装的软件
### Epic Games Launcher
用来下载和启动 UE。
### Unreal Engine 5.4
建议直接用 5.4 稳定版。
### Visual Studio 2022
虽然一期主要用 Blueprint但 UE 的很多项目依赖 VS 环境。
安装组件:
- Desktop development with C++
- Game development with C++
### Git
用来管理文档和后续工程。
### Cesium for Unreal
用来做地球。
---
## 2. 你的环境约束
你现在是:
- 后端可能跑在 WSL2 / Docker
- UE 必须跑在 Windows
所以你的真实运行方式通常会是:
- **Windows** 运行 UE5
- **WSL2** 运行后端
- 两者通过 HTTP 通信
这里最关键的一条是:
**不要默认 `localhost` 一定能通,必须先在 Windows 浏览器里验证。**
---
# 六、推荐的项目结构
## UE 项目目录内的 Content 结构
```text
Content/
Blueprints/
Data/
Widgets/
Materials/
Levels/
FX/
Textures/
```
建议说明:
- `Blueprints/` 放逻辑蓝图
- `Data/` 放本地 JSON、DataTable、Struct
- `Widgets/` 放 UI
- `Materials/` 放材质
- `Levels/` 放场景
- `FX/` 放特效
- `Textures/` 放贴图
---
# 七、一期最小蓝图清单
一期只需要这几个核心蓝图。
## 1. `BP_GlobeCamera`
作用:相机控制器
负责:
- 鼠标拖拽旋转
- 滚轮缩放
- 初始视角控制
---
## 2. `BP_PlanetGameMode`
作用:指定默认的 Pawn 等
---
## 3. `BP_DataLoader`
作用:负责读数据
一期建议支持两种来源:
- 本地 JSON
- HTTP 接口
这样调试更稳。
---
## 4. `BP_ComputePoint`
作用:一个超算点的显示对象
负责:
- 接收一条数据
- 放到正确经纬度位置
- 显示外观
- 处理点击
---
## 5. `WBP_InfoCard`
作用:点开后显示详情
显示:
- 名称
- 国家
- 算力
- 可选显示更多字段
---
## 6. `WBP_StatusBar`
作用:右上角状态栏
显示:
- 后端在线/离线
- 当前加载条数
- 当前模式(本地数据 / 真实后端)
---
# 八、数据层设计
一期不要一开始就完全照搬后端返回结构。
你要先定义一个 UE 友好的结构。
## `S_ComputePoint`
字段建议:
- `PointId`:字符串,唯一 ID
- `Name`:字符串
- `Latitude`:浮点
- `Longitude`:浮点
- `Performance`:浮点
- `CoreCount`:整数
- `Country`:字符串
- `Source`:字符串
这个结构同时适用于:
- 本地 JSON
- 后端 API 返回结果转换后的对象
---
# 九、最稳的执行路线
下面是整个实施计划最重要的部分。
---
# Phase 0安装和验证环境
## 目标
确保你能:
- 安装 UE5.4
- 启用 Cesium
- 能打开一个空项目
- 能在 Windows 浏览器访问你的后端
## 验收
满足以下 4 条:
- UE 能打开
- Cesium 能启用
- 项目能创建
- Windows 浏览器能访问后端 summary 接口
如果第 4 条做不到,不要继续推进真实接口联调。
---
# Phase 1创建项目并把地球显示出来
## 目标
打开项目后,能看到一个真实地球。
## 操作顺序
1. 新建 UE5 Blank Blueprint 项目
2. 创建 `Main` 场景
3. 启用 Cesium
4. 添加:
- `Cesium World Terrain`
- `Cesium Sun Sky`
- `CesiumGeoreference`
5. 调整视角,让你能看到整个地球
## 验收
能录一段短视频,里面能看到地球和镜头移动。
---
# Phase 2做相机控制
## 目标
让地球可以:
- 鼠标拖拽旋转
- 滚轮缩放
## 说明
这里可以沿用原 MVP 方案的思路:
- `BP_GlobeCamera` 作为 Pawn
- Spring Arm + Camera 组成相机结构
- 用输入控制旋转和缩放
## 注意
这一版相机只是“一期可用版”,不是最终镜头系统。
## 验收
按 Play 后:
- 地球可旋转
- 可缩放
- 不会直接飞走或抖动失控
---
# Phase 3先喂本地 JSON 数据
这是融合版方案里最关键的改动。
## 目标
不接后端,先验证:
- 数据结构正常
- JSON 能读
- 点能生成
- 点击交互正常
## 为什么先这么做
因为这样可以把问题收缩成 3 件事:
- Cesium 坐标转换
- 点渲染
- UI 弹窗
不牵涉后端联调。
## 本地 JSON 示例格式
建议放在 `Content/Data/compute_points.json`
```json
[
{
"PointId": "top500_1",
"Name": "Frontier",
"Latitude": 35.93,
"Longitude": -84.31,
"Performance": 1194.0,
"CoreCount": 8730624,
"Country": "US",
"Source": "top500"
},
{
"PointId": "top500_2",
"Name": "Fugaku",
"Latitude": 34.69,
"Longitude": 135.19,
"Performance": 442.0,
"CoreCount": 7630848,
"Country": "JP",
"Source": "top500"
}
]
```
## 推荐做法
先做一个“本地模式”开关。
`BP_DataLoader` 里支持:
- Mode = LocalJson
- Mode = HttpApi
先永远跑 `LocalJson`
## 验收
你应该能看到:
- 多个点出现在地球上
- 大致位置正确
- 点击能弹信息卡
---
# Phase 4做超算点蓝图
## 目标
完成 `BP_ComputePoint`
每个点要实现:
- 接收一条 `S_ComputePoint`
- 经度纬度转成 UE 世界坐标
- 在地球上显示为一个可见的发光球
- 支持被点击
## 显示建议
### 外观
先用最简单的球体 Static Mesh。
### 材质
做一个发光材质:
- 红橙色
- 自发光
- 不追求复杂效果
### 大小
球体要足够大,确保在地球尺度下看得见。
### 高度
不要贴地表太近,建议悬浮在地表上方一个固定高度。
## 验收
同一批数据点在地球上的位置大体合理。
---
# Phase 5做信息卡
## 目标
点击一个点后,弹出一个简单的信息卡。
## `WBP_InfoCard` 要显示的内容
建议只显示最关键的 3 个字段:
- 名称
- 国家
- 算力
一期先不要堆太多字段。
## 验收
点击点 → 卡片出现
点击关闭 → 卡片消失
---
# Phase 6做基础 HUD
## 目标
屏幕上始终有一个简单状态栏。
## `WBP_StatusBar` 显示内容建议
- 当前模式Local / HTTP
- 已加载数据点数量
- 后端状态Unknown / Online / Offline
在本地模式阶段,状态可以先写死或显示 `Local Demo`
## 验收
不点击任何点时,屏幕右上角也有“系统正在工作”的感觉。
---
# Phase 7再接真实后端
这是第二阶段开始。
## 目标
把数据源从本地 JSON 切到 HTTP。
## 正确做法
不要把 `BP_DataLoader` 重写。
而是让它支持:
- LocalJsonLoader
- HttpLoader
也就是:
**显示层不变,只替换数据来源。**
## 最重要的接口原则
如果后端已有接口字段非常杂,不一定要 UE 直接吃。
可以加一个“更适合 UE 的轻量接口”。
例如:
`/api/v1/ue/bootstrap/top500`
返回尽量扁平的数据:
```json
[
{
"PointId": "top500_1",
"Name": "Frontier",
"Latitude": 35.93,
"Longitude": -84.31,
"Performance": 1194.0,
"CoreCount": 8730624,
"Country": "US",
"Source": "top500"
}
]
```
## 为什么推荐 UE 轻量接口
因为 UE 不适合像前端 React 那样,层层解包一大堆复杂 JSON。
---
# Phase 8做连接状态检测
## 目标
让 HUD 能显示:
- 在线
- 离线
- 本地模式
## 正确实现思路
建议用一个很小的状态请求,比如:
- summary 接口
- health 接口
- 或 UE 专用 ping 接口
不要让状态检测去依赖一个超大的数据接口。
## 验收
后端关掉时,状态栏能明显变成 Offline。
---
# Phase 9打包发布
## 目标
把项目打包成 Windows 可执行程序。
## 注意
打包是一期必须尝试的,但不要让它阻塞前面所有开发。
也就是说:
- 编辑器里没稳定跑通前,不要反复纠结打包
- 等 LocalJson 版和 HTTP 版都能在编辑器 Play 模式稳定运行后,再打包
## 验收
双击 exe 可以运行,进入地球场景并正常展示数据。
---
# 十、建议的 14 天执行计划
这版比原 MVP 的时间估计更保守,也更适合新手。
## 第 1 天
- 安装 UE5.4
- 安装 Cesium
- 创建空项目
- 创建 Main 场景
## 第 2 天
- 启用 Cesium
- 把地球跑起来
- 保存项目结构
## 第 3 天
-`BP_GlobeCamera`
- 跑通旋转和缩放
## 第 4 天
-`S_ComputePoint`
- 准备本地 JSON 文件
-`BP_DataLoader` 的本地模式
## 第 5 天
-`BP_ComputePoint`
- 本地 JSON 批量生成点
## 第 6 天
- 调整点大小、颜色、高度
- 检查经纬度位置是否大致正确
## 第 7 天
-`WBP_InfoCard`
- 跑通点击点弹卡片
## 第 8 天
-`WBP_StatusBar`
- 显示本地模式状态和点数量
## 第 9 天
- Windows 浏览器验证后端接口
- 准备 HTTP 版加载逻辑
## 第 10 天
- 实现 HTTP 拉真实数据
- 先在日志里确认数据到了
## 第 11 天
- 把 HTTP 数据接到点渲染
- 切换 Local / HTTP 两种模式
## 第 12 天
- 做连接状态 Online / Offline
- 补错误提示
## 第 13 天
- 测试完整链路
- 修点选、缩放、HUD 细节
## 第 14 天
- 进行第一次打包
- 在 Windows 下运行 exe 验证
---
# 十一、这份方案和原 MVP 方案怎么融合
下面是合并关系。
## 保留原 MVP 方案的部分
这些内容很好,建议继续用:
- 术语表
- Phase 结构化写法
- `BP_GlobeCamera`
- `BP_ComputePoint`
- `WBP_InfoCard`
- `WBP_StatusBar`
- 相机、点、信息卡、状态栏这 4 个核心对象
- “先别做海缆、卫星、BGP”的范围控制
## 用融合版修正的部分
这些是这份新文档加进去的:
- 两阶段起步:先本地 JSON再真实后端
- 不默认 `localhost` 一定通
- 推荐做 UE 轻量接口,而不是死扛原始接口
- 把打包放到后段,而不是过早纠结
- 时间预估更保守
- 明确“一期只是证明链路跑通”
---
# 十二、验收清单
## 环境
- [ ] UE5.4 安装成功
- [ ] Cesium 插件启用成功
- [ ] Windows 能访问后端接口
## 本地演示版
- [ ] 地球渲染正常
- [ ] 鼠标可旋转和缩放
- [ ] 本地 JSON 数据能生成点
- [ ] 点的位置大体正确
- [ ] 点击点能弹信息卡
- [ ] HUD 可显示本地模式和点数量
## 后端接入版
- [ ] HTTP 能拉取真实数据
- [ ] HTTP 数据能生成点
- [ ] HUD 能显示 Online/Offline
- [ ] 切换 Local / HTTP 模式不崩
- [ ] exe 能打包并运行
---
# 十三、后续路线MVP 之后)
当这一期做完后,下一步顺序建议是:
1. 海缆路径
2. 卫星点或轨迹
3. 更稳的相机与巡航
4. WebSocket 增量更新
5. BGP 区域态势
6. BGP 事件点
7. 更强的粒子和视觉风格
也就是说:
**先补“静态层和镜头层”,再补“高频实时层”。**
---
# 十四、一句话总结
这份融合版方案的核心就是:
**保留原 MVP 的入门友好度,但改成“先本地 JSON、再真实后端”的两阶段实施路线让你第一次做 UE 时更稳、更容易成功。**
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**

View File

@@ -16,12 +16,26 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.27.0`
- `dev` 当前开发分支历史推导到:`0.29.2`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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 |

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

View File

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

View File

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

View File

@@ -1,11 +1,16 @@
/* hud.css - HUD surfaces and shared overlays */
.hud-panel {
--panel-glow-x: 18%;
--panel-glow-y: 0%;
--panel-glow-opacity: 0.1;
--panel-tilt-x: 0deg;
--panel-tilt-y: 0deg;
position: absolute;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.08), transparent 30%),
radial-gradient(circle at var(--panel-glow-x) var(--panel-glow-y), rgba(255, 255, 255, calc(0.08 + var(--panel-glow-opacity))), transparent 30%),
radial-gradient(circle at 86% 115%, rgba(145, 186, 255, 0.08), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 26%),
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom));
@@ -14,9 +19,14 @@
inset 0 1px 0 var(--hud-highlight),
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
var(--hud-shadow),
0 0 0 1px rgba(255, 255, 255, 0.02);
0 0 0 1px rgba(255, 255, 255, 0.02),
0 0 20px rgba(123, 176, 236, calc(0.04 + var(--panel-glow-opacity) * 0.32));
backdrop-filter: blur(18px) saturate(125%);
-webkit-backdrop-filter: blur(18px) saturate(125%);
transition:
background 0.22s ease,
border-color 0.22s ease,
box-shadow 0.22s ease;
}
.hud-panel::before {
@@ -28,6 +38,11 @@
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%);
opacity: 0.52;
pointer-events: none;
transform:
perspective(240px)
rotateX(calc(var(--panel-tilt-x) * 0.36))
rotateY(calc(var(--panel-tilt-y) * 0.36));
transition: opacity 0.18s ease, transform 0.18s ease;
}
.hud-panel::after {
@@ -40,7 +55,11 @@
linear-gradient(135deg, rgba(244, 249, 255, 0.22), rgba(164, 194, 226, 0.08) 36%, rgba(90, 123, 161, 0.04) 70%, rgba(255, 255, 255, 0.16));
opacity: 0.72;
pointer-events: none;
filter: blur(0.2px);
filter: url(#liquid-glass-distortion) blur(0.22px);
transform:
perspective(240px)
rotateX(calc(var(--panel-tilt-x) * 0.24))
rotateY(calc(var(--panel-tilt-y) * 0.24));
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
@@ -49,6 +68,36 @@
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.hud-panel:hover:not(.is-dragging) {
--panel-glow-opacity: 0.16;
border-color: var(--hud-border-hover);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
0 20px 48px rgba(1, 7, 16, 0.34),
0 0 0 1px rgba(255, 255, 255, 0.03),
0 0 28px rgba(123, 176, 236, 0.1);
}
.hud-panel:hover:not(.is-dragging)::before {
opacity: 0.6;
}
.hud-panel:hover:not(.is-dragging)::after {
opacity: 0.84;
}
.hud-panel.is-pressed:not(.is-dragging) {
--panel-glow-opacity: 0.13;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
0 14px 34px rgba(1, 7, 16, 0.28),
0 0 0 1px rgba(255, 255, 255, 0.02),
0 0 22px rgba(123, 176, 236, 0.08);
}
.hud-panel > * {
@@ -65,20 +114,6 @@
line-height: 1.2;
}
.hud-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--hud-gap-sm);
margin-bottom: var(--hud-gap-sm);
padding-bottom: var(--hud-gap-sm);
border-bottom: 1px solid var(--hud-line);
}
.hud-panel-header .hud-panel-title {
margin-bottom: 0;
}
.hud-panel-drag-handle {
cursor: grab;
user-select: none;
@@ -88,19 +123,72 @@
cursor: grabbing;
}
.hud-panel__header,
.hud-panel-header {
--hud-header-padding: 0 0 var(--hud-gap-sm);
--hud-header-gap: var(--hud-gap-sm);
--hud-header-border-color: var(--hud-line);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--hud-header-gap);
margin-bottom: var(--hud-gap-sm);
padding: var(--hud-header-padding);
border-bottom: 1px solid var(--hud-header-border-color);
}
.hud-panel__title-group {
display: flex;
align-items: center;
gap: var(--hud-gap-xs);
min-width: 0;
flex: 1 1 auto;
}
.hud-panel__title,
.hud-panel__header .hud-panel-title,
.hud-panel-header .hud-panel-title {
margin: 0;
color: var(--hud-text-soft);
font-size: var(--hud-panel-header-title-size);
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.2;
}
.hud-panel__subtitle {
color: var(--hud-text-soft);
font-size: calc(0.7rem * var(--hud-scale));
line-height: 1.4;
}
.hud-panel__chip {
flex: 0 0 auto;
}
.hud-panel__actions {
display: inline-flex;
align-items: center;
gap: var(--hud-gap-xs);
flex-shrink: 0;
}
.hud-panel__action,
.hud-panel-close {
align-self: flex-start;
width: calc(var(--hud-title-size) * 1.24);
height: calc(var(--hud-title-size) * 1.24);
min-width: calc(var(--hud-title-size) * 1.24);
padding: 0;
--hud-action-padding: calc(7px * var(--hud-scale));
--hud-action-icon-size: calc(16px * var(--hud-scale));
border: 1px solid transparent;
border-radius: calc(4px * var(--hud-scale));
background: transparent;
color: var(--hud-text-muted);
padding: var(--hud-action-padding);
width: auto;
height: auto;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: auto;
cursor: pointer;
transition:
background 0.18s ease,
@@ -110,17 +198,51 @@
opacity 0.18s ease;
}
.hud-panel__action .material-symbols-rounded,
.hud-panel-close .material-symbols-rounded {
font-size: calc(var(--hud-title-size) * 0.8);
font-size: var(--hud-action-icon-size);
line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none;
}
.hud-panel-close:hover {
.hud-panel__action:hover:not(:disabled),
.hud-panel-close:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong);
}
.hud-panel__action:disabled,
.hud-panel-close:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.hud-panel__body {
position: relative;
z-index: 1;
}
.hud-panel__body--collapsible {
--hud-body-collapse-gap: var(--hud-gap-sm);
--hud-body-max-height: 1000px;
overflow: hidden;
opacity: 1;
max-height: var(--hud-body-max-height);
transition:
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease,
margin 0.22s ease;
}
.hud-panel--collapsed .hud-panel__body--collapsible {
max-height: 0;
opacity: 0;
pointer-events: none;
margin-top: calc(-1 * var(--hud-body-collapse-gap));
}
.hud-panel.is-dragging {
transition: none !important;
box-shadow:
@@ -170,21 +292,32 @@
.earth-status-message {
position: absolute;
top: 20px;
top: calc(20px * var(--hud-scale));
left: 50%;
transform: translate(-50%, -18px);
background:
linear-gradient(180deg, rgba(18, 31, 52, 0.92), rgba(8, 18, 32, 0.9));
border-radius: 14px;
padding: 11px 15px;
z-index: 210;
box-shadow: var(--hud-shadow-soft);
border: 1px solid var(--hud-border);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(10px);
text-align: center;
min-width: 180px;
align-items: center;
gap: calc(10px * var(--hud-scale));
background:
linear-gradient(90deg, rgba(145, 186, 255, 0.07) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-radius: 999px;
border: 1px solid var(--hud-border);
border-left-color: rgba(145, 186, 255, 0.32);
padding: calc(8px * var(--hud-scale)) calc(20px * var(--hud-scale)) calc(8px * var(--hud-scale)) calc(16px * var(--hud-scale));
z-index: 210;
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(145, 186, 255, 0.06);
font-size: calc(0.84rem * var(--hud-scale));
font-weight: 500;
letter-spacing: 0.01em;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
text-align: left;
min-width: min(calc(160px * var(--hud-scale)), 58vw);
max-width: min(calc(440px * var(--hud-scale)), 74vw);
color: var(--hud-text);
opacity: 0;
transition:
transform 0.28s ease,
@@ -196,19 +329,110 @@
opacity: 1;
}
/* ── Indicator: single dot (transient) or three dots (loading) ── */
.earth-status-indicator {
display: inline-flex;
align-items: center;
gap: calc(5px * var(--hud-scale));
flex: 0 0 auto;
}
.earth-status-dot {
width: calc(7px * var(--hud-scale));
height: calc(7px * var(--hud-scale));
border-radius: 50%;
background: rgba(145, 186, 255, 0.95);
box-shadow:
0 0 8px rgba(145, 186, 255, 0.7),
0 0 20px rgba(145, 186, 255, 0.28);
animation: statusDotPulse 2.4s ease-in-out infinite;
}
.earth-status-text {
flex: 1 1 auto;
min-width: 0;
}
/* Loading: three-dot sequential pulse */
.earth-status-message.loading .earth-status-dot {
animation: earthLoadingPulse 1.2s ease-in-out infinite;
}
.earth-status-message.loading .earth-status-dot:nth-child(2) {
animation-delay: 0.16s;
}
.earth-status-message.loading .earth-status-dot:nth-child(3) {
animation-delay: 0.32s;
}
/* ── Status color variants ───────────────────────────────────── */
.earth-status-message.success {
color: #d8f7df;
border-left: 3px solid #66d18f;
color: #dff8e7;
background:
linear-gradient(90deg, rgba(102, 209, 143, 0.08) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-left-color: rgba(102, 209, 143, 0.38);
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(102, 209, 143, 0.06);
}
.earth-status-message.success .earth-status-dot {
background: #66d18f;
box-shadow:
0 0 8px rgba(102, 209, 143, 0.8),
0 0 20px rgba(102, 209, 143, 0.3);
}
.earth-status-message.warning {
color: #fff2c3;
border-left: 3px solid #e4c464;
background:
linear-gradient(90deg, rgba(228, 196, 100, 0.08) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-left-color: rgba(228, 196, 100, 0.38);
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(228, 196, 100, 0.06);
}
.earth-status-message.warning .earth-status-dot {
background: #e4c464;
box-shadow:
0 0 8px rgba(228, 196, 100, 0.8),
0 0 20px rgba(228, 196, 100, 0.3);
}
.earth-status-message.error {
color: #ffd4d7;
border-left: 3px solid #ff7b86;
background:
linear-gradient(90deg, rgba(255, 123, 134, 0.08) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-left-color: rgba(255, 123, 134, 0.38);
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(255, 123, 134, 0.06);
}
.earth-status-message.error .earth-status-dot {
background: #ff7b86;
box-shadow:
0 0 8px rgba(255, 123, 134, 0.8),
0 0 20px rgba(255, 123, 134, 0.34);
}
@keyframes statusDotPulse {
0%, 100% {
opacity: 0.82;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.earth-tooltip {
@@ -231,11 +455,21 @@
position: fixed;
inset: 0;
z-index: 260;
display: none;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 0.24s ease, visibility 0.24s ease;
}
.earth-settings-modal.is-opening,
.earth-settings-modal.is-open,
.earth-settings-modal.is-closing {
visibility: visible;
}
.earth-settings-modal.is-open {
display: block;
opacity: 1;
pointer-events: auto;
}
.earth-settings-backdrop {
@@ -244,46 +478,47 @@
background: rgba(2, 8, 20, 0.46);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
opacity: 0;
transition: opacity 0.26s ease;
}
.earth-settings-modal.is-open .earth-settings-backdrop {
opacity: 1;
}
.earth-settings-sheet {
--settings-scale: clamp(0.72, calc(var(--hud-scale) * 0.96), 1);
position: fixed;
top: max(32px, 9vh);
right: 16px;
left: 16px;
width: min(560px, calc(100vw - 32px));
max-width: 560px;
max-height: calc(100vh - max(64px, 18vh));
top: max(calc(32px * var(--settings-scale)), 9vh);
right: calc(16px * var(--settings-scale));
left: calc(16px * var(--settings-scale));
width: min(calc(560px * var(--settings-scale)), calc(100vw - (32px * var(--settings-scale))));
max-width: calc(560px * var(--settings-scale));
max-height: calc(100vh - max(calc(64px * var(--settings-scale)), 18vh));
margin-inline: auto;
transform: none;
border-radius: calc(24px * var(--hud-scale));
padding: calc(20px * var(--hud-scale));
border-radius: 0;
padding: calc(var(--hud-panel-padding) * var(--settings-scale));
display: flex;
flex-direction: column;
gap: var(--hud-gap-md);
gap: calc(var(--hud-gap-md) * var(--settings-scale));
overflow: hidden;
transform: translateZ(0);
opacity: 1;
filter: none;
border-radius: 0;
will-change: transform, opacity, filter, border-radius;
}
.earth-settings-sheet.liquid-glass-surface {
animation: none;
background:
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.09), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%),
linear-gradient(180deg, rgba(19, 34, 56, 0.92), rgba(8, 18, 31, 0.9));
border-color: rgba(207, 224, 243, 0.12);
.earth-settings-sheet.hud-panel {
--panel-glow-x: 18%;
--panel-glow-y: 0%;
--panel-glow-opacity: 0.1;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 24px 56px rgba(2, 7, 15, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.025);
}
.earth-settings-sheet.liquid-glass-surface:hover,
.earth-settings-sheet.liquid-glass-surface:active,
.earth-settings-sheet.liquid-glass-surface.is-pressed {
--btn-scale: 1;
--press-offset: 0px;
--glow-opacity: 0.24;
transform: none;
inset 0 1px 0 var(--hud-highlight),
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
var(--hud-shadow),
0 0 0 1px rgba(255, 255, 255, 0.02);
}
.earth-settings-header,
@@ -293,12 +528,10 @@
}
.earth-settings-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
--hud-header-padding: 0 0 var(--hud-gap-sm);
--hud-header-gap: var(--hud-gap-md);
align-items: center;
gap: var(--hud-gap-md);
padding-bottom: var(--hud-gap-sm);
border-bottom: 1px solid var(--hud-line);
}
.earth-settings-kicker {
@@ -308,13 +541,9 @@
text-transform: uppercase;
}
.earth-settings-title {
margin: 4px 0 0;
color: var(--hud-title);
}
.earth-settings-close {
margin-top: 4px;
margin-top: 0;
flex-shrink: 0;
}
.earth-settings-content {
@@ -379,6 +608,10 @@
transform: translateY(-1px);
}
.earth-settings-link {
text-decoration: none;
}
.earth-settings-copy {
display: flex;
flex-direction: column;
@@ -397,6 +630,22 @@
line-height: 1.4;
}
.earth-settings-link-meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--hud-text-soft);
flex-shrink: 0;
}
.earth-settings-link-meta .material-symbols-rounded:first-child {
font-size: 1.1rem;
}
.earth-settings-link-meta .material-symbols-rounded:last-child {
font-size: 1rem;
}
.earth-settings-switch {
position: relative;
display: inline-flex;
@@ -456,4 +705,4 @@
/* .hud-panel-legend layout-expanded rule lives in legend.css */
/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */
/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */
/* .hud-panel-tv layout-expanded rule lives in tv-panel.css */
/* .hud-panel-media layout-expanded rule lives in tv-panel.css */

View File

@@ -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,
@@ -155,7 +184,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 +194,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 {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
/* toolbar.css - bottom dock and floating toolbar primitives */
/* toolbar.css - orbital hub toolbar */
.earth-toolbar-group {
position: absolute;
@@ -6,7 +6,6 @@
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 200;
@@ -24,119 +23,153 @@
}
.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 {
.earth-toolbar-cluster {
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;
width: 100%;
height: 100%;
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::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 > .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;
color: var(--hud-text-soft);
font-size: 14px;
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 +187,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 +201,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 +218,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 +230,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 +250,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 +276,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 +318,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,137 +350,50 @@
.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);
}
.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%, 10px);
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,
@@ -495,8 +401,8 @@
width: 42px;
min-width: 42px;
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 {
@@ -514,37 +420,8 @@
padding: 0;
font-size: 0.68rem;
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%);
@@ -555,8 +432,9 @@
bottom: 56px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
background:
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
color: var(--hud-text);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
@@ -564,9 +442,10 @@
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,
@@ -578,11 +457,11 @@
}
.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-top-color: rgba(18, 31, 52, 0.96);
}

View File

@@ -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: calc(-1 * var(--hud-gap-sm));
margin-bottom: calc(-1 * var(--hud-gap-sm));
}
.tv-panel-meta {
@@ -129,7 +169,7 @@
.tv-panel-player {
position: relative;
flex: 1 1 auto;
flex: 1 0 auto;
min-height: calc(220px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale));
overflow: hidden;
@@ -163,45 +203,131 @@
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;
}
/* 右下角视觉标记 */
.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. */

View File

@@ -10,7 +10,8 @@
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
"hls.js": "https://esm.sh/hls.js@1.6.15"
"hls.js": "https://esm.sh/hls.js@1.6.15",
"astronomy-engine": "https://esm.sh/astronomy-engine@2.1.19"
}
}
</script>
@@ -35,6 +36,7 @@
<link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css">
<link rel="stylesheet" href="css/tv-panel.css">
<link rel="stylesheet" href="css/news-panel.css">
<link rel="stylesheet" href="css/layer-panel.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
@@ -50,7 +52,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 +63,10 @@
<span class="material-symbols-rounded layer-panel-icon">layers</span>
<span class="layer-panel-title">图层</span>
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
<span class="material-symbols-rounded">expand_more</span>
<span class="material-symbols-rounded">expand_less</span>
</button>
<button class="layer-panel-btn hud-panel-close" type="button" data-close-panel="layer-toggles" aria-label="关闭图层面板" title="关闭">
<span class="material-symbols-rounded">close</span>
</button>
</div>
@@ -69,18 +74,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 -->
@@ -143,52 +150,47 @@
</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="hud-error-message"></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-expanded">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<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="1" style="--orb-delay: 0.18s;">
<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="2" 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="3" 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="4" 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 +203,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="5" 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="6" 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="7" 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>
@@ -299,74 +311,119 @@
<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="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-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">
@@ -402,16 +459,36 @@
</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">
<a
class="earth-settings-item earth-settings-link"
href="/admin"
target="_blank"
rel="noreferrer noopener"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">Admin</span>
<span class="earth-settings-item-subtitle">打开管理后台仪表盘</span>
</div>
<span class="earth-settings-link-meta">
<span class="material-symbols-rounded">admin_panel_settings</span>
<span class="material-symbols-rounded">arrow_forward</span>
</span>
</a>
</div>
</section>
</div>
</div>
</div>

View File

@@ -1386,21 +1386,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,
@@ -1482,12 +1478,13 @@ 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;
0.9 +
0.1 * pulse;
markerColor = 0xfff1a8;
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;

View File

@@ -558,14 +558,18 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) {
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
lp.material.color.setHex(0xffd27a);
lp.material.emissive.setHex(0x7a4a00);
lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse;
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
lp.material.opacity =
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse;
Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
);
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(

View File

@@ -0,0 +1,543 @@
import * as THREE from "three";
import * as Astronomy from "astronomy-engine";
import { CELESTIAL_CONFIG, EARTH_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 linkedEarth = null;
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() {
const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()),
);
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);
}
}
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, earth = null } = {},
) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer();
linkedSunLight = sunLight;
linkedBackLight = backLight;
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;
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 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;
linkedEarth = null;
sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
}

View File

@@ -34,6 +34,46 @@ 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,
};
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
@@ -237,18 +277,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 +296,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,
},
};

View File

@@ -1,5 +1,6 @@
// controls.js - Zoom, rotate and toggle controls
import * as THREE from "three";
import { CONFIG, EARTH_CONFIG } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain } from "./earth.js";
@@ -18,6 +19,11 @@ import {
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { ensureTVPanelReady } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -30,11 +36,137 @@ let cleanupFns = [];
const HUD_PANEL_IDS = [
"legend",
"earth-stats",
"tv-panel",
"media-panel",
"layer-toggles",
];
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
const PANEL_LAYOUT_ANIMATION_MS = 420;
const TOOLBAR_BASE_WIDTH_PX = 620;
const TOOLBAR_MIN_SCALE = 0.68;
const TOOLBAR_ORB_SIZE_PX = 46;
const TOOLBAR_HUB_SIZE_PX = 58;
const TOOLBAR_ORB_GAP_PX = 12;
const TOOLBAR_ARCH_SPAN_PX = 232;
const TOOLBAR_ARCH_RISE_PX = 40;
const TOOLBAR_SIDE_PADDING_PX = 12;
const TOOLBAR_BOTTOM_CLEARANCE_PX = 34;
const TOOLBAR_EXTRA_HEIGHT_PX = 34;
const SETTINGS_MODAL_OPEN_ANIMATION_MS = 420;
const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
const SETTINGS_SHEET_MIN_SCALE = 0.06;
const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
function cancelSettingsSheetAnimation() {
if (settingsSheetAnimation) {
settingsSheetAnimation.cancel();
settingsSheetAnimation = null;
}
}
function getSettingsSheetAnimationState(trigger, sheet) {
if (!(trigger instanceof HTMLElement) || !(sheet instanceof HTMLElement)) {
return null;
}
const triggerRect = trigger.getBoundingClientRect();
const sheetRect = sheet.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
const sheetCenterX = sheetRect.left + sheetRect.width / 2;
const sheetCenterY = sheetRect.top + sheetRect.height / 2;
return {
translateX: triggerCenterX - sheetCenterX,
translateY: triggerCenterY - sheetCenterY,
scaleX: Math.max(
SETTINGS_SHEET_MIN_SCALE,
Math.min(
SETTINGS_SHEET_MAX_SCALE_X,
triggerRect.width / Math.max(sheetRect.width, 1),
),
),
scaleY: Math.max(
SETTINGS_SHEET_MIN_SCALE,
Math.min(
SETTINGS_SHEET_MAX_SCALE_Y,
triggerRect.height / Math.max(sheetRect.height, 1),
),
),
radius: `${Math.max(triggerRect.width, triggerRect.height).toFixed(2)}px`,
};
}
function animateSettingsSheet(sheet, trigger, opening) {
const animationState = getSettingsSheetAnimationState(trigger, sheet);
if (!animationState || typeof sheet.animate !== "function") {
return;
}
cancelSettingsSheetAnimation();
const fromTransform = `translate(${animationState.translateX.toFixed(2)}px, ${animationState.translateY.toFixed(2)}px) scale(${animationState.scaleX.toFixed(4)}, ${animationState.scaleY.toFixed(4)})`;
const toTransform = "translate(0px, 0px) scale(1, 1)";
const keyframes = opening
? [
{
transform: fromTransform,
opacity: 0.22,
filter: "blur(10px)",
borderRadius: animationState.radius,
},
{
transform: "translate(0px, 0px) scale(1.015, 1.015)",
opacity: 1,
filter: "blur(0px)",
borderRadius: "0px",
offset: 0.76,
},
{
transform: toTransform,
opacity: 1,
filter: "blur(0px)",
borderRadius: "0px",
},
]
: [
{
transform: toTransform,
opacity: 1,
filter: "blur(0px)",
borderRadius: "0px",
},
{
transform: fromTransform,
opacity: 0.08,
filter: "blur(10px)",
borderRadius: animationState.radius,
},
];
settingsSheetAnimation = sheet.animate(keyframes, {
duration: opening
? SETTINGS_MODAL_OPEN_ANIMATION_MS
: SETTINGS_MODAL_CLOSE_ANIMATION_MS,
easing: opening
? "cubic-bezier(0.16, 1, 0.3, 1)"
: "cubic-bezier(0.4, 0, 0.2, 1)",
fill: "both",
});
settingsSheetAnimation.onfinish = () => {
sheet.style.transform = "";
sheet.style.opacity = "";
sheet.style.filter = "";
sheet.style.borderRadius = "";
settingsSheetAnimation = null;
};
settingsSheetAnimation.oncancel = () => {
settingsSheetAnimation = null;
};
}
function getFloatingGroups() {
return [
@@ -71,17 +203,49 @@ function closeFloatingMenus() {
function openSettingsModal() {
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
if (settingsModalTimer) {
clearTimeout(settingsModalTimer);
settingsModalTimer = null;
}
closeFloatingMenus();
cancelSettingsSheetAnimation();
modal.classList.remove("is-closing");
modal.classList.add("is-opening");
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => {
if (sheet instanceof HTMLElement) {
animateSettingsSheet(sheet, trigger, true);
}
window.setTimeout(() => {
modal.classList.remove("is-opening");
}, SETTINGS_MODAL_OPEN_ANIMATION_MS);
});
}
function closeSettingsModal() {
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
cancelSettingsSheetAnimation();
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
modal.classList.add("is-closing");
if (sheet instanceof HTMLElement) {
animateSettingsSheet(sheet, trigger, false);
}
if (settingsModalTimer) {
clearTimeout(settingsModalTimer);
}
settingsModalTimer = window.setTimeout(() => {
modal.classList.remove("is-closing", "is-opening");
modal.setAttribute("aria-hidden", "true");
settingsModalTimer = null;
}, SETTINGS_MODAL_CLOSE_ANIMATION_MS);
}
function setHudPanelVisibility(panelId, visible) {
@@ -89,8 +253,9 @@ function setHudPanelVisibility(panelId, visible) {
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
syncSettingsToggle(panelId, visible);
if (panelId === "tv-panel") {
if (panelId === "media-panel") {
updateTVToggleUI(visible);
updateNewsToggleUI(visible);
if (visible) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
@@ -174,7 +339,9 @@ function setupDraggableHudPanels() {
if (!app || draggablePanels.length === 0) return;
draggablePanels.forEach((panel) => {
const handle = panel.querySelector(".hud-panel-drag-handle");
const handle = panel.dataset.dragSelf === "true"
? panel
: panel.querySelector(".hud-panel-drag-handle");
if (!handle) return;
let isDragging = false;
@@ -193,14 +360,31 @@ function setupDraggableHudPanels() {
if (!isDragging) return;
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextLeft = Math.min(
const brandPanel = document.getElementById("brand-panel");
const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
const brandRight = brandRect ? brandRect.right - appRect.left : 0;
let nextLeft = Math.min(
Math.max(startLeft + (event.clientX - startPointerX), 0),
appRect.width - panelRect.width,
);
const nextTop = Math.min(
let nextTop = Math.min(
Math.max(startTop + (event.clientY - startPointerY), 0),
appRect.height - panelRect.height,
);
// Brand 面板形成 L 形禁区panel 不能进入 brand 左上角矩形区域。
// 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
// 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
const leftAdjust = brandRight - nextLeft;
const topAdjust = brandBottom - nextTop;
if (leftAdjust <= topAdjust) {
nextLeft = brandRight;
} else {
nextTop = brandBottom;
}
}
panel.style.left = `${nextLeft}px`;
panel.style.top = `${nextTop}px`;
@@ -211,7 +395,7 @@ function setupDraggableHudPanels() {
};
bindListener(handle, "pointerdown", (event) => {
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
isDragging = true;
startPointerX = event.clientX;
startPointerY = event.clientY;
@@ -221,9 +405,12 @@ function setupDraggableHudPanels() {
// If panel is inside a flow container (not a direct child of app), reparent
// it so absolute positioning is relative to the app container.
if (panel.parentElement !== app) {
panel.dataset.originalParentId = panel.parentElement?.id || "";
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
const capturedWidth = panelRect.width;
panel.style.position = "absolute";
panel.style.width = `${capturedWidth}px`;
panel.style.margin = "0";
app.appendChild(panel);
}
@@ -333,6 +520,7 @@ export function setupControls(camera, renderer, scene, earth) {
setupRotateControls(camera, earth);
setupTerrainControls();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
}
@@ -596,13 +784,20 @@ function setupLayerPanel() {
const emptyState = document.getElementById("layer-panel-empty");
if (!panel) return;
const layerPanel = createHUDPanel({
panel,
header: ".layer-panel-header",
body: "#layer-panel-body",
collapseBtn,
collapsedClass: "layer-panel--collapsed",
preferredDirection: "down",
expandLabel: "展开图层列表",
collapseLabel: "折叠图层列表",
});
bindListener(collapseBtn, "click", (e) => {
e.stopPropagation();
const isCollapsed = panel.classList.toggle("layer-panel--collapsed");
collapseBtn.title = isCollapsed ? "展开" : "折叠";
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
const icon = collapseBtn.querySelector(".material-symbols-rounded");
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
layerPanel.setCollapsed(!layerPanel.isCollapsed());
});
if (searchInput) {
@@ -773,13 +968,17 @@ function setupTerrainControls() {
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
});
const tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden");
updateTVToggleUI(tvVisible);
if (tvVisible) {
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
updateTVToggleUI(mediaVisible);
if (mediaVisible) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
});
}
updateNewsToggleUI(mediaVisible);
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
updateLayoutUI(container);
}
@@ -802,42 +1001,68 @@ function setupKeyboardControls() {
}
function setupLiquidGlassInteractions() {
const surfaces = document.querySelectorAll(".liquid-glass-surface");
const surfaces = document.querySelectorAll(".liquid-glass-surface, .hud-panel");
const resetSurface = (surface) => {
surface.style.setProperty("--elastic-x", "0px");
surface.style.setProperty("--elastic-y", "0px");
surface.style.setProperty("--tilt-x", "0deg");
surface.style.setProperty("--tilt-y", "0deg");
surface.style.setProperty("--panel-tilt-x", "0deg");
surface.style.setProperty("--panel-tilt-y", "0deg");
surface.style.setProperty("--dock-scale", "1");
surface.style.setProperty("--dock-lift", "0px");
surface.style.setProperty("--dock-shift-x", "0px");
surface.style.setProperty("--glow-x", "50%");
surface.style.setProperty("--glow-y", "22%");
surface.style.setProperty("--glow-opacity", "0.24");
surface.style.setProperty("--panel-glow-x", "18%");
surface.style.setProperty("--panel-glow-y", "0%");
surface.style.setProperty("--panel-glow-opacity", "0.1");
surface.classList.remove("dock-focused");
surface.classList.remove("dock-active");
surface.classList.remove("is-pressed");
};
surfaces.forEach((surface) => {
resetSurface(surface);
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar-items"));
const isPanelSurface = surface.classList.contains("hud-panel");
bindListener(surface, "pointermove", (event) => {
const rect = surface.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width;
const py = (event.clientY - rect.top) / rect.height;
const offsetX = (px - 0.5) * 6;
const offsetY = (py - 0.5) * 6;
const tiltX = (0.5 - py) * 8;
const tiltY = (px - 0.5) * 10;
if (isPanelSurface) {
const panelTiltX = (0.5 - py) * 5;
const panelTiltY = (px - 0.5) * 6;
surface.style.setProperty("--panel-glow-x", `${(px * 100).toFixed(1)}%`);
surface.style.setProperty("--panel-glow-y", `${(py * 100).toFixed(1)}%`);
surface.style.setProperty("--panel-glow-opacity", "0.16");
surface.style.setProperty("--panel-tilt-x", `${panelTiltX.toFixed(2)}deg`);
surface.style.setProperty("--panel-tilt-y", `${panelTiltY.toFixed(2)}deg`);
} else if (!isToolbarSurface) {
const offsetX = (px - 0.5) * 6;
const offsetY = (py - 0.5) * 6;
const tiltX = (0.5 - py) * 8;
const tiltY = (px - 0.5) * 10;
surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`);
surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`);
surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`);
surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`);
surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`);
surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`);
surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`);
surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`);
}
surface.style.setProperty("--glow-x", `${(px * 100).toFixed(1)}%`);
surface.style.setProperty("--glow-y", `${(py * 100).toFixed(1)}%`);
surface.style.setProperty("--glow-opacity", "0.34");
});
bindListener(surface, "pointerenter", () => {
surface.style.setProperty("--glow-opacity", "0.28");
if (isPanelSurface) {
surface.style.setProperty("--panel-glow-opacity", "0.14");
} else {
surface.style.setProperty("--glow-opacity", "0.28");
}
});
bindListener(surface, "pointerleave", () => {
@@ -858,6 +1083,123 @@ function setupLiquidGlassInteractions() {
});
}
function setupToolbarHubCluster() {
const cluster = document.getElementById("toolbar-cluster");
const hub = document.getElementById("toolbar-hub");
const toolbar = document.getElementById("control-toolbar");
if (!(cluster instanceof HTMLElement) || !(hub instanceof HTMLButtonElement)) {
return;
}
let collapseTimer = null;
const layoutToolbarOrbs = () => {
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
const orbCount = orbs.length;
let toolbarScale = THREE.MathUtils.clamp(
toolbarWidth / TOOLBAR_BASE_WIDTH_PX,
TOOLBAR_MIN_SCALE,
1,
);
let orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale;
let desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale;
let span = TOOLBAR_ARCH_SPAN_PX * toolbarScale;
let rise = TOOLBAR_ARCH_RISE_PX * toolbarScale;
const minSpanForSpacing =
orbCount > 1 ? (orbCount - 1) * (orbSize + desiredGap) : orbSize;
const maxSpanByWidth =
toolbarWidth - orbSize - TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale;
if (minSpanForSpacing > maxSpanByWidth) {
toolbarScale = THREE.MathUtils.clamp(
maxSpanByWidth / minSpanForSpacing,
TOOLBAR_MIN_SCALE,
toolbarScale,
);
orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale;
desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale;
span = TOOLBAR_ARCH_SPAN_PX * toolbarScale;
rise = TOOLBAR_ARCH_RISE_PX * toolbarScale;
}
span = THREE.MathUtils.clamp(
span,
minSpanForSpacing,
maxSpanByWidth,
);
rise = Math.min(rise, span * 0.32);
const hubSize = TOOLBAR_HUB_SIZE_PX * toolbarScale;
const maxVerticalReach = rise + orbSize * 0.5;
const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale;
toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3));
toolbar.style.height = `${Math.ceil(toolbarHeight)}px`;
toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`);
toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`);
toolbar.style.setProperty("--toolbar-inner-arc-width", `${Math.ceil(span * 0.72)}px`);
toolbar.style.setProperty("--toolbar-inner-arc-height", `${Math.ceil((hubSize * 0.8) + (desiredGap * 0.5))}px`);
orbs.forEach((orb, index) => {
const t = orbCount === 1 ? 0.5 : index / (orbCount - 1);
const x = (t - 0.5) * span;
const normalized = (x / (span / 2 || 1));
const y = -(1 - normalized * normalized) * rise;
orb.style.setProperty("--orb-x", `${x.toFixed(2)}px`);
orb.style.setProperty("--orb-y", `${y.toFixed(2)}px`);
});
};
const setExpanded = (expanded) => {
cluster.classList.toggle("is-expanded", expanded);
cluster.classList.toggle("is-collapsed", !expanded);
};
const scheduleCollapse = () => {
if (collapseTimer) clearTimeout(collapseTimer);
collapseTimer = window.setTimeout(() => {
setExpanded(false);
collapseTimer = null;
}, 200);
};
const cancelCollapse = () => {
if (collapseTimer) {
clearTimeout(collapseTimer);
collapseTimer = null;
}
};
cleanupFns.push(() => {
if (collapseTimer) clearTimeout(collapseTimer);
});
// Start collapsed — hub acts as the hover target to reveal the arc
layoutToolbarOrbs();
setExpanded(false);
bindListener(window, "resize", layoutToolbarOrbs);
bindListener(hub, "mouseenter", () => {
cancelCollapse();
setExpanded(true);
});
// Keep expanded while cursor stays anywhere within the toolbar area
bindListener(toolbar, "mouseenter", () => {
cancelCollapse();
});
bindListener(toolbar, "mouseleave", () => {
scheduleCollapse();
});
}
export function teardownControls() {
resetCleanup();
}
@@ -912,11 +1254,29 @@ function updateLayoutUI(container) {
}
function resetPanelInlineLayout(panel) {
const originalParentId = panel.dataset.originalParentId;
if (originalParentId) {
const originalParent = document.getElementById(originalParentId);
if (originalParent) {
const nextId = panel.dataset.originalNextSiblingId;
const nextSibling = nextId ? document.getElementById(nextId) : null;
if (nextSibling) {
originalParent.insertBefore(panel, nextSibling);
} else {
originalParent.appendChild(panel);
}
}
delete panel.dataset.originalParentId;
delete panel.dataset.originalNextSiblingId;
}
panel.style.left = "";
panel.style.top = "";
panel.style.right = "";
panel.style.bottom = "";
panel.style.transform = "";
panel.style.position = "";
panel.style.width = "";
panel.style.margin = "";
delete panel.dataset.dragged;
}

View File

@@ -10,6 +10,73 @@ export let terrain = null;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
let _earthShader = null;
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.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;`,
).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));
outgoingLight *= mix(uNightFloor, uDayBoost, daylight);
outgoingLight += uTwilightColor * twilight * uTwilightIntensity;
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
#include <output_fragment>
`,
);
};
material.customProgramCacheKey = () => "earth-day-night-v1";
material.needsUpdate = true;
}
export function createEarth(scene) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
@@ -27,6 +94,7 @@ export function createEarth(scene) {
depthWrite: true,
depthTest: true,
});
applyEarthDayNightShader(material);
_earthMaterial = material;
earth = new THREE.Mesh(geometry, material);
@@ -271,6 +339,14 @@ 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 loadEarthTexture() {
return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; }

View File

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

View File

@@ -2,6 +2,7 @@
import { showStatusMessage } from './ui.js';
let currentType = null;
let cardMounted = false;
const CARD_CONFIG = {
cable: {
@@ -105,6 +106,138 @@ function getPanel() {
return document.getElementById('info-panel');
}
function setupInfoCardDrag(panel) {
const app = document.getElementById('container');
if (!app) return;
const handle = panel.querySelector('.hud-panel-drag-handle');
if (!handle) return;
let isDragging = false;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = () => {
isDragging = false;
panel.classList.remove('is-dragging');
document.body.style.userSelect = '';
};
const onMove = (event) => {
if (!isDragging) return;
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`;
};
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
isDragging = true;
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);
});
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', stopDragging);
handle.addEventListener('pointercancel', stopDragging);
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.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();
});
}
// Copy value on label click
content.addEventListener('click', async (event) => {
const label = event.target.closest('.info-card-label');
if (!label) return;
const property = label.closest('.info-card-property');
const valueEl = property?.querySelector('.info-card-value');
const value = valueEl?.textContent?.trim();
if (!value || value === '-') {
showStatusMessage('无可复制内容', 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(`已复制${label.textContent}${value}`, 'success');
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage('复制失败', 'error');
}
});
setupInfoCardDrag(panel);
cardMounted = true;
}
function positionPanel(panel, x, y) {
if (!panel) return;
const margin = 12;
@@ -142,71 +275,8 @@ function hidePanel() {
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();
});
}
card.dataset.interactionBound = 'true';
}
if (content.dataset.copyBound === 'true') return;
content.addEventListener('click', async (event) => {
const label = event.target.closest('.info-card-label');
if (!label) return;
const property = label.closest('.info-card-property');
const valueEl = property?.querySelector('.info-card-value');
const value = valueEl?.textContent?.trim();
if (!value || value === '-') {
showStatusMessage('无可复制内容', 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(`已复制${label.textContent}${value}`, 'success');
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage('复制失败', 'error');
}
});
content.dataset.copyBound = 'true';
}
// No-op: event binding now happens lazily in mountCard()
export function initInfoCard() {}
export function setInfoCardNoBorder(noBorder = true) {
const card = document.getElementById('info-card');
@@ -222,6 +292,8 @@ export function showInfoCard(type, data, options = {}) {
return;
}
mountCard();
currentType = type;
const card = document.getElementById('info-card');
const icon = document.getElementById('info-card-icon');

View File

@@ -1,3 +1,5 @@
import { createHUDPanel } from "./hud-panels.js";
const LEGEND_MODES = {
cables: { title: "海缆" },
satellites: { title: "卫星" },
@@ -5,6 +7,7 @@ const LEGEND_MODES = {
};
let currentLegendMode = "cables";
let legendPanel = null;
let legendItemsByMode = {
cables: [],
satellites: [],
@@ -12,34 +15,33 @@ let legendItemsByMode = {
};
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,11 +61,10 @@ 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 labelEl = document.getElementById("legend-current-label");
if (!labelEl) return;
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
}
function renderLegend(mode) {

View File

@@ -20,12 +20,21 @@ import {
createEarth,
createClouds,
createTerrain,
createStars,
createGridLines,
getEarth,
loadEarthTexture,
clearEarthTexture,
setEarthSunDirection,
} from "./earth.js";
import {
initCelestialLayer,
updateCelestialLayer,
disposeCelestialLayer,
getCelestialDebugState,
getSunDirection,
setCelestialOrientation,
setCelestialFollow,
} from "./celestial.js";
import {
loadGeoJSONFromPath,
loadLandingPoints,
@@ -127,6 +136,7 @@ import {
} from "./legend.js";
import { mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
export let scene;
export let camera;
@@ -164,6 +174,7 @@ let cablesEnabled = true;
let satellitesEnabled = true;
let cableToggleToken = 0;
let satelliteToggleToken = 0;
let sceneLights = null;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
@@ -173,6 +184,7 @@ const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = [];
const DRAG_SMOOTHING_FACTOR = 0.18;
@@ -192,8 +204,8 @@ const HUD_INTERACTIVE_SELECTORS = [
"#legend *",
"#earth-stats",
"#earth-stats *",
"#tv-panel",
"#tv-panel *",
"#media-panel",
"#media-panel *",
];
function bindListener(target, eventName, handler, options) {
@@ -688,14 +700,17 @@ function applyCableVisualState() {
switch (state) {
case CABLE_STATE.LOCKED:
cable.material.opacity =
CABLE_CONFIG.lockedOpacityMin +
pulse *
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
cable.material.color.setRGB(1, 1, 1);
Math.max(
0.92,
CABLE_CONFIG.lockedOpacityMin +
pulse *
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin),
);
cable.material.color.setRGB(0.86, 0.96, 1.0);
break;
case CABLE_STATE.HOVERED:
cable.material.opacity = 1;
cable.material.color.setRGB(1, 1, 1);
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.NORMAL:
default:
@@ -871,6 +886,20 @@ function updateStatsSummary() {
});
}
function getCurrentViewCenterCoords() {
const earth = getEarth();
if (!earth || !camera) return null;
scratchViewCenterWorld
.copy(camera.position)
.sub(earth.position)
.normalize()
.multiplyScalar(CONFIG.earthRadius);
earth.worldToLocal(scratchViewCenterWorld);
return vector3ToLatLon(scratchViewCenterWorld);
}
window.addEventListener("error", (event) => {
console.error("全局错误:", event.error);
});
@@ -889,13 +918,14 @@ export function init() {
const brandRoot = document.getElementById("brand-root");
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
initTVPanel();
initNewsPanel();
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
75,
getViewportAspect(),
0.1,
1000,
5000,
);
camera.position.z = CONFIG.defaultCameraZ;
setSatelliteCamera(camera);
@@ -906,7 +936,7 @@ export function init() {
powerPreference: "high-performance",
});
syncRendererViewport();
renderer.setClearColor(0x0a0a1a, 1);
renderer.setClearColor(0x02040a, 1);
renderer.setPixelRatio(window.devicePixelRatio);
const container = document.getElementById("container");
@@ -915,7 +945,7 @@ export function init() {
container.appendChild(renderer.domElement);
}
addLights();
sceneLights = addLights();
initInfoCard();
initLegend();
setLegendItems("cables", getCableLegendItems());
@@ -929,7 +959,12 @@ export function init() {
inertialVelocity = { x: 0, y: 0 };
createClouds(scene, earthObj);
createTerrain(scene, earthObj, simplex);
createStars(scene);
initCelestialLayer(scene, {
camera,
sunLight: sceneLights?.sunLight ?? null,
backLight: sceneLights?.backLight ?? null,
earth: earthObj,
});
createGridLines(scene, earthObj);
createSatellites(scene, earthObj);
@@ -950,17 +985,25 @@ function registerGlobalApi() {
hideInfoCard();
clearLockedObject();
},
celestial: {
getState: () => getCelestialDebugState(),
setOrientation: (nextEuler) => setCelestialOrientation(nextEuler),
setFollow: (nextFollow) => setCelestialFollow(nextFollow),
},
destroy,
init,
};
}
function addLights() {
scene.add(new THREE.AmbientLight(0x404060));
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(5, 3, 5);
scene.add(directionalLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
sunLight.position.set(5, 3, 5);
sunLight.target.position.set(0, 0, 0);
scene.add(sunLight);
scene.add(sunLight.target);
const backLight = new THREE.DirectionalLight(0x446688, 0.3);
backLight.position.set(-5, 0, -5);
@@ -969,6 +1012,13 @@ function addLights() {
const pointLight = new THREE.PointLight(0xffffff, 0.4);
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
return {
ambientLight,
sunLight,
backLight,
pointLight,
};
}
// Yield control to the browser so the renderer can paint a frame before the next step
@@ -992,7 +1042,7 @@ async function loadData() {
clearCableData(earth);
clearSatelliteData();
setLoadingMessage("正在初始化...", "清除旧数据");
setLoadingMessage("正在初始化...");
setLoading(true);
await yieldFrame();
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
@@ -1001,7 +1051,7 @@ async function loadData() {
// Step 1 — Landing points
if (cablesEnabled) {
setLoadingMessage("正在加载登陆点...", "同步海底光缆登陆站数据");
setLoadingMessage("正在加载登陆点...");
await yieldFrame(30);
try {
await loadLandingPoints(scene, earth);
@@ -1014,7 +1064,7 @@ async function loadData() {
// Step 2 — Cables
if (cablesEnabled) {
setLoadingMessage("正在加载海缆...", "同步海底光缆网络数据");
setLoadingMessage("正在加载海缆...");
await yieldFrame(30);
try {
const cableCount = await loadGeoJSONFromPath(scene, earth);
@@ -1033,7 +1083,7 @@ async function loadData() {
// Step 3 — Satellites
if (satellitesEnabled) {
setLoadingMessage("正在加载卫星...", "同步在轨卫星轨道数据");
setLoadingMessage("正在加载卫星...");
await yieldFrame(30);
try {
clearSatelliteData();
@@ -1053,7 +1103,7 @@ async function loadData() {
}
// Step 4 — BGP
setLoadingMessage("正在加载BGP态势...", "同步全球路由观测数据");
setLoadingMessage("正在加载BGP态势...");
await yieldFrame(30);
try {
const bgpResult = await loadBGPAnomalies(scene, earth);
@@ -1068,7 +1118,7 @@ async function loadData() {
await yieldFrame();
// Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
setLoadingMessage("正在加载地球纹理...", "加载8K卫星地图");
setLoadingMessage("正在加载地球纹理...");
await yieldFrame(30);
try {
await loadEarthTexture();
@@ -1080,19 +1130,10 @@ async function loadData() {
// Step 6 — Terrain (if enabled)
if (getShowTerrain()) {
setLoadingMessage("正在渲染地形...", "生成地表高程数据");
setLoadingMessage("正在渲染地形...");
await yieldFrame(50);
}
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
showError(errorMessage);
showStatusMessage(errorMessage, "error");
} else {
hideError();
showStatusMessage("数据已加载", "success");
}
updateStatsSummary();
updateCableToggleUi(cablesEnabled);
updateSatelliteToggleUi(satellitesEnabled);
@@ -1102,6 +1143,15 @@ async function loadData() {
refreshLegend();
setLoading(false);
isDataLoading = false;
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
showError(errorMessage);
showStatusMessage(errorMessage, "error");
} else {
hideError();
showStatusMessage("数据已加载", "success");
}
}
const POSITION_UPDATE_FORCE_DELTA = 250;
@@ -1123,7 +1173,7 @@ export async function setCablesEnabled(enabled) {
return 0;
}
setLoadingMessage("正在加载线缆数据...", "重建海缆与登陆点对象");
setLoadingMessage("正在加载线缆数据...");
setLoading(true);
hideError();
@@ -1156,7 +1206,7 @@ export async function setSatellitesEnabled(enabled) {
return 0;
}
setLoadingMessage("正在加载卫星数据...", "重建卫星点位与轨迹缓存");
setLoadingMessage("正在加载卫星数据...");
setLoading(true);
hideError();
@@ -1693,6 +1743,9 @@ function animate() {
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
setEarthSunDirection(getSunDirection());
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (
@@ -1733,6 +1786,7 @@ export function destroy() {
clearBGPData(getEarth());
resetSatelliteState();
clearUiState();
disposeCelestialLayer();
if (scene) {
disposeSceneObject(scene);
@@ -1749,6 +1803,7 @@ export function destroy() {
scene = null;
camera = null;
renderer = null;
sceneLights = null;
initialized = false;
delete window.__planetEarth;

View File

@@ -0,0 +1,302 @@
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() {
return {
refreshBtn: document.getElementById("news-refresh"),
openBtn: document.getElementById("news-open-external"),
status: document.getElementById("news-board-status"),
focusLabel: document.getElementById("news-focus-label"),
focusCoords: document.getElementById("news-focus-coords"),
sourceCount: document.getElementById("news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById("news-board-list"),
empty: document.getElementById("news-board-empty"),
feedAnchor: document.getElementById("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();
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
return;
}
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
focusLabel.textContent = focus.label || "全球焦点";
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
} 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;
const { refreshBtn, openBtn } = getElements();
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
openBtn?.addEventListener("click", openCurrentSourceHomepage);
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}

View File

@@ -24,7 +24,6 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let selectedSatelliteLegendKey = null;
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
@@ -38,43 +37,82 @@ 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);
}
@@ -98,31 +136,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) {
@@ -538,36 +560,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3 + 1] = pos.y;
positions[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");
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 rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule);
colors[i * 3] = r;
colors[i * 3 + 1] = g;

View File

@@ -1,5 +1,11 @@
import Hls from "hls.js";
import { showStatusMessage } from "./ui.js";
import { createHUDPanel } from "./hud-panels.js";
// Naming convention:
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
// - #tv-panel is the inner live tab pane
// - #news-panel is the inner aggregation-news tab pane
const TV_STREAMS_API = "/api/v1/tv/streams";
const TV_PROXY_API = "/api/v1/tv/proxy";
@@ -20,6 +26,26 @@ let initialized = false;
let refreshPromise = null;
let hlsPlayer = null;
let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null;
let mediaPanel = null;
let activeTab = "live";
const failedSourceIds = new Set();
let probeTimer = null;
let reformCleanupTimer = null;
const tabPanelState = {
live: null,
news: null,
};
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const DEFAULT_HUD_OFFSET_PX = 20;
const MIN_NEWS_TAB_HEIGHT_PX = 280;
const PANEL_RESIZE_MARGIN_PX = 12;
const TV_PANEL_MIN_WIDTH_PX = 360;
const TV_PANEL_MIN_HEIGHT_PX = 340;
const REFORM_CLEANUP_MS = 280;
const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor";
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
@@ -31,9 +57,9 @@ const HLS_RETRY_CONFIG = {
function getElements() {
return {
panel: document.getElementById("tv-panel"),
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
resizeHandle: document.getElementById("tv-resize-handle"),
select: document.getElementById("tv-source-select"),
title: document.getElementById("tv-source-title"),
meta: document.getElementById("tv-source-meta"),
@@ -45,9 +71,59 @@ function getElements() {
empty: document.getElementById("tv-empty-state"),
refreshBtn: document.getElementById("tv-refresh"),
openBtn: document.getElementById("tv-open-external"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"),
newsTabBtn: document.getElementById("tv-tab-news"),
// Inner tab panes.
livePane: document.getElementById("tv-panel"),
newsPane: document.getElementById("news-panel"),
};
}
function setMetaCollapsed(collapsed) {
mediaPanel?.setCollapsed(collapsed);
}
function syncPanelActiveTab(tab = activeTab) {
const { panel } = getElements();
if (panel instanceof HTMLElement) {
panel.dataset.activeTab = tab;
}
}
function syncNewsDefaultMaxHeight() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
const earthStats = document.getElementById("earth-stats");
const hudOffset = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"),
);
const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX;
if (!(earthStats instanceof HTMLElement)) {
panel.style.removeProperty("--tv-news-default-max-height");
return;
}
const statsRect = earthStats.getBoundingClientRect();
const availableHeight = Math.max(
Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()),
Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom),
);
panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`);
}
function autoExpandMeta() {
clearTimeout(metaAutoCollapseTimer);
setMetaCollapsed(false);
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
}
function clearPanelPositioningForResize(panel) {
panel.style.left = `${panel.offsetLeft}px`;
panel.style.top = `${panel.offsetTop}px`;
@@ -57,6 +133,82 @@ function clearPanelPositioningForResize(panel) {
panel.dataset.dragged = "true";
}
function readPanelLayoutState(panel) {
return {
width: panel.style.width || "",
height: panel.style.height || "",
resized: panel.dataset.resized === "true",
};
}
function resetPanelLayoutState(panel) {
panel.style.width = "";
panel.style.height = "";
delete panel.dataset.resized;
}
function captureTabState(tab = activeTab) {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
};
}
function restoreTabState(tab, panel, container, anchor = null) {
if (!(panel instanceof HTMLElement)) return;
const snapshot = tabPanelState[tab];
if (!snapshot?.layout) {
resetPanelLayoutState(panel);
return;
}
const { layout } = snapshot;
panel.style.width = layout.width;
panel.style.height = layout.height;
if (layout.resized) {
panel.dataset.resized = "true";
} else {
delete panel.dataset.resized;
}
if (tab === "live" && snapshot.metaCollapsed !== null) {
setMetaCollapsed(snapshot.metaCollapsed);
}
requestAnimationFrame(() => {
if (anchor) {
const panelRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const targetLeft = anchor.right - containerRect.left - panelRect.width;
const targetTop = anchor.bottom - containerRect.top - panelRect.height;
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
} else {
panel.style.right = "";
panel.style.bottom = "";
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
delete panel.dataset.dragged;
}
});
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -64,86 +216,132 @@ function getHudScale() {
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function clampPanelToContainer(panel, container) {
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
if (panel.dataset.dragged !== "true") return;
const containerRect = container.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const currentLeft = panelRect.left - containerRect.left;
const currentTop = panelRect.top - containerRect.top;
const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft));
const clampedTop = Math.min(maxTop, Math.max(0, currentTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
}
function setupResizeHandle() {
const { panel, resizeHandle } = getElements();
const { panel } = getElements();
const container = document.getElementById("container");
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) {
return;
}
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
let resizing = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
let activeEdge = "";
const resizeStart = {
pointerX: 0,
pointerY: 0,
width: 0,
height: 0,
left: 0,
top: 0,
};
const stopResize = () => {
resizing = false;
activeEdge = "";
panel.classList.remove("is-resizing");
document.body.style.userSelect = "";
};
resizeHandle.addEventListener("pointerdown", (event) => {
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
return;
}
event.preventDefault();
event.stopPropagation();
resizing = true;
startX = event.clientX;
startY = event.clientY;
clearPanelPositioningForResize(panel);
const rect = panel.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
panel.classList.add("is-resizing");
document.body.style.userSelect = "none";
resizeHandle.setPointerCapture?.(event.pointerId);
});
resizeHandle.addEventListener("pointermove", (event) => {
const onMove = (event) => {
if (!resizing) return;
const containerRect = container.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const currentLeft = panelRect.left - containerRect.left;
const currentTop = panelRect.top - containerRect.top;
const hudScale = getHudScale();
const minWidth = Math.max(320, Math.round(360 * hudScale));
const minHeight = Math.max(260, Math.round(340 * hudScale));
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12);
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12);
const nextWidth = Math.min(
maxWidth,
Math.max(minWidth, startWidth + (event.clientX - startX)),
);
const nextHeight = Math.min(
maxHeight,
Math.max(minHeight, startHeight + (event.clientY - startY)),
);
const minWidth = Math.max(320, Math.round(TV_PANEL_MIN_WIDTH_PX * hudScale));
const minHeight = Math.max(260, Math.round(TV_PANEL_MIN_HEIGHT_PX * hudScale));
const dx = event.clientX - resizeStart.pointerX;
const dy = event.clientY - resizeStart.pointerY;
panel.style.width = `${nextWidth}px`;
panel.style.minHeight = `${nextHeight}px`;
if (activeEdge.includes("r")) {
const maxW = containerRect.width - resizeStart.left - PANEL_RESIZE_MARGIN_PX;
panel.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
}
if (activeEdge.includes("l")) {
const newW = Math.max(minWidth, resizeStart.width - dx);
panel.style.width = `${newW}px`;
panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
}
if (activeEdge.includes("b")) {
const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX;
panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
}
};
panel.querySelectorAll(".tv-panel-edge[data-edge]").forEach((edgeEl) => {
edgeEl.addEventListener("pointerdown", (event) => {
if (container.classList.contains("layout-expanded")) return;
event.preventDefault();
event.stopPropagation();
activeEdge = edgeEl.dataset.edge ?? "";
resizing = true;
resizeStart.pointerX = event.clientX;
resizeStart.pointerY = event.clientY;
clearPanelPositioningForResize(panel);
panel.dataset.resized = "true";
const rect = panel.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
resizeStart.width = rect.width;
resizeStart.height = rect.height;
resizeStart.left = rect.left - cRect.left;
resizeStart.top = rect.top - cRect.top;
panel.style.width = `${resizeStart.width}px`;
panel.style.height = `${resizeStart.height}px`;
panel.style.minHeight = "";
panel.classList.add("is-resizing");
document.body.style.userSelect = "none";
edgeEl.setPointerCapture?.(event.pointerId);
});
edgeEl.addEventListener("pointermove", onMove);
edgeEl.addEventListener("pointerup", stopResize);
edgeEl.addEventListener("pointercancel", stopResize);
edgeEl.addEventListener("lostpointercapture", stopResize);
});
resizeHandle.addEventListener("pointerup", stopResize);
resizeHandle.addEventListener("pointercancel", stopResize);
resizeHandle.addEventListener("lostpointercapture", stopResize);
}
function updateToggleButton(visible) {
const { toggleBtn } = getElements();
if (!toggleBtn) return;
const icon = toggleBtn.querySelector(".material-symbols-rounded");
const isLiveTab = activeTab === "live";
toggleBtn.classList.toggle("active", visible);
if (icon) {
icon.textContent = isLiveTab ? "live_tv" : "newspaper";
}
const title = visible
? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播")
: (isLiveTab ? "打开新闻直播" : "打开态势新闻");
toggleBtn.title = title;
toggleBtn.setAttribute("aria-label", title);
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
tooltip.textContent = title;
}
}
function syncSettingsToggle(visible) {
const input = document.querySelector('[data-settings-panel="tv-panel"]');
const input = document.querySelector('[data-settings-panel="media-panel"]');
if (input instanceof HTMLInputElement) {
input.checked = visible;
}
@@ -152,9 +350,202 @@ function syncSettingsToggle(visible) {
function setPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
mediaPanel?.setVisible(visible);
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
detail: { visible },
}));
}
export function setTVPanelVisible(visible) {
setPanelVisible(visible);
}
function clearReformState() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
panel.classList.remove("is-reforming");
panel.style.height = "";
if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") {
panel.style.top = "";
panel.style.bottom = "";
delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY];
}
if (reformCleanupTimer) {
clearTimeout(reformCleanupTimer);
reformCleanupTimer = null;
}
}
function animateTabReform(applyChange) {
const { panel } = getElements();
const container = document.getElementById("container");
if (!(panel instanceof HTMLElement)) {
applyChange();
return;
}
if (!(container instanceof HTMLElement)) {
applyChange();
return;
}
if (panel.dataset.resized === "true") {
applyChange();
requestAnimationFrame(() => {
clampPanelToContainer(panel, container);
});
return;
}
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
applyChange();
return;
}
clearReformState();
const reformStartRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const reformSnapshot = {
height: reformStartRect.height,
anchoredBottom: reformStartRect.bottom - containerRect.top,
shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true",
};
if (reformSnapshot.shouldRestoreDefaultAnchoring) {
panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true";
panel.style.bottom = "auto";
}
panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`;
panel.style.height = `${reformSnapshot.height}px`;
panel.classList.add("is-reforming");
void panel.offsetHeight;
applyChange();
panel.style.height = "auto";
const targetHeight = panel.getBoundingClientRect().height;
panel.style.height = `${reformSnapshot.height}px`;
void panel.offsetHeight;
const targetTop = reformSnapshot.anchoredBottom - targetHeight;
const finalizeReform = () => {
panel.removeEventListener("transitionend", handleReformTransitionEnd);
clearReformState();
};
const handleReformTransitionEnd = (event) => {
if (event.target === panel && event.propertyName === "height") {
finalizeReform();
}
};
panel.addEventListener("transitionend", handleReformTransitionEnd);
reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS);
requestAnimationFrame(() => {
panel.style.top = `${targetTop}px`;
panel.style.height = `${targetHeight}px`;
});
}
function updateTabState(target, isActive, activeClassName = "") {
if (!(target instanceof HTMLElement)) return;
target.hidden = !isActive;
if (activeClassName) {
target.classList.toggle(activeClassName, isActive);
}
}
function updateTabButtonState(button, isActive) {
if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle("media-panel-tab--active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
}
function setActiveTab(tab) {
const nextTab = tab === "news" ? "news" : "live";
if (activeTab === nextTab) return;
captureTabState(activeTab);
const { panel } = getElements();
const targetSnapshot = tabPanelState[nextTab];
const currentIsCustom =
panel instanceof HTMLElement && panel.dataset.resized === "true";
const targetIsCustom = Boolean(targetSnapshot?.layout?.resized);
const container = document.getElementById("container");
const currentAnchor =
panel instanceof HTMLElement && container instanceof HTMLElement
? (() => {
const panelRect = panel.getBoundingClientRect();
return {
right: panelRect.right,
bottom: panelRect.bottom,
};
})()
: null;
const applyTabSwitch = (restoreLayoutState = false) => {
activeTab = nextTab;
syncPanelActiveTab(nextTab);
syncNewsDefaultMaxHeight();
const {
liveTabBtn,
newsTabBtn,
liveHeaderControls,
newsHeaderControls,
livePane,
newsPane,
} = getElements();
updateTabButtonState(liveTabBtn, nextTab === "live");
updateTabButtonState(newsTabBtn, nextTab === "news");
updateTabState(liveHeaderControls, nextTab === "live");
updateTabState(newsHeaderControls, nextTab === "news");
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
if (
restoreLayoutState &&
panel instanceof HTMLElement &&
container instanceof HTMLElement
) {
restoreTabState(nextTab, panel, container, currentAnchor);
}
requestAnimationFrame(() => {
captureTabState(nextTab);
});
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: nextTab },
}));
};
if (currentIsCustom || targetIsCustom) {
applyTabSwitch(true);
return;
}
animateTabReform(() => applyTabSwitch(false));
}
export function openTVPanelTab(tab = "live") {
setPanelVisible(true);
if (activeTab === tab) return;
setActiveTab(tab);
}
export function isTVPanelVisible() {
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
}
export function getActiveTVTab() {
return activeTab;
}
function getEmbeddedUrl(source) {
@@ -338,7 +729,7 @@ function attachVideoSource(video, source) {
}
}
if (!showEmbeddedFallback(source)) {
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
@@ -371,6 +762,59 @@ function findSourceById(sourceId) {
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
}
function markSourceFailed(sourceId) {
if (!sourceId) return;
failedSourceIds.add(sourceId);
renderSourceOptions();
if (!probeTimer) {
probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
}
}
function clearSourceFailed(sourceId) {
if (!failedSourceIds.has(sourceId)) return;
failedSourceIds.delete(sourceId);
renderSourceOptions();
if (failedSourceIds.size === 0 && probeTimer) {
clearInterval(probeTimer);
probeTimer = null;
}
}
async function probeFailedSources() {
if (failedSourceIds.size === 0) {
clearInterval(probeTimer);
probeTimer = null;
return;
}
for (const sourceId of [...failedSourceIds]) {
const source = findSourceById(sourceId);
if (!source) { failedSourceIds.delete(sourceId); continue; }
const probeUrl = source.stream_url || source.embed_url;
if (!probeUrl) continue;
try {
const resp = await fetch(probeUrl, {
method: "HEAD",
signal: AbortSignal.timeout(5000),
});
if (resp.ok) clearSourceFailed(sourceId);
} catch {
// 仍然失效,保持标记
}
}
}
function tryFallbackSource() {
const fallback = tvPayload?.fallback_source;
if (!fallback || fallback.id === currentSourceId) return false;
markSourceFailed(currentSourceId);
currentSourceId = fallback.id;
const { select } = getElements();
if (select) select.value = currentSourceId;
renderSource(fallback);
return true;
}
function getCurrentSource() {
return findSourceById(currentSourceId);
}
@@ -433,10 +877,11 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment();
sources.forEach((source) => {
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option");
option.value = source.id;
option.textContent = `${source.name}${marker}`;
option.textContent = `${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option);
});
@@ -502,6 +947,7 @@ function renderSource(source) {
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
);
updateOpenButton(source);
autoExpandMeta();
}
function resolveInitialSourceId() {
@@ -567,22 +1013,57 @@ export function initTVPanel() {
if (initialized) return;
initialized = true;
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
const {
select,
refreshBtn,
iframe,
video,
toggleBtn,
panel,
metaToggle,
liveTabBtn,
newsTabBtn,
} = getElements();
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
if (panel && metaToggle) {
mediaPanel = createHUDPanel({
panel,
header: ".hud-panel__header",
body: "#tv-meta-wrap",
collapseBtn: metaToggle,
bodyCollapsedClass: "is-collapsed",
preferredDirection: "up",
expandLabel: "展开新闻直播信息",
collapseLabel: "折叠新闻直播信息",
});
}
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
toggleBtn?.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
setPanelVisible(nextVisible);
if (nextVisible) {
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("新闻直播窗口已关闭", "info");
const currentlyVisible = mediaPanel?.isVisible() ?? false;
if (!currentlyVisible) {
setPanelVisible(true);
if (activeTab === "live") {
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("态势新闻窗口已打开", "info");
}
return;
}
const nextTab = activeTab === "live" ? "news" : "live";
setActiveTab(nextTab);
if (nextTab === "live") {
await ensureTVPanelReady();
showStatusMessage("已切换到新闻直播", "info");
return;
}
showStatusMessage("已切换到态势新闻", "info");
});
select?.addEventListener("change", (event) => {
@@ -592,26 +1073,48 @@ export function initTVPanel() {
renderSource(findSourceById(currentSourceId));
});
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
setMetaCollapsed(isNowCollapsed);
});
refreshBtn?.addEventListener("click", () => {
refreshTVPanel();
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
});
newsTabBtn?.addEventListener("click", () => {
setActiveTab("news");
});
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
video?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource)) {
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
setupResizeHandle();
syncPanelActiveTab("live");
syncNewsDefaultMaxHeight();
updateTabButtonState(liveTabBtn, true);
updateTabButtonState(newsTabBtn, false);
captureTabState("live");
window.addEventListener("resize", syncNewsDefaultMaxHeight);
}

View File

@@ -2,8 +2,13 @@
let statusTimeoutId = null;
let statusHideTimeoutId = null;
let statusReplayTimeoutId = null;
const STATUS_BASE_CLASS = "earth-status-message";
const STATUS_DISPLAY_MS = 3000;
const STATUS_FADE_MS = 280;
let statusQueue = [];
let statusBusy = false;
let loadingActive = false;
let loadingLockedWidth = 0;
function getElement(id) {
return document.getElementById(id);
@@ -19,53 +24,97 @@ function clearStatusTimers() {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
if (statusHideTimeoutId) {
clearTimeout(statusHideTimeoutId);
statusHideTimeoutId = null;
}
}
if (statusReplayTimeoutId) {
clearTimeout(statusReplayTimeoutId);
statusReplayTimeoutId = null;
function clearLoadingWidthLock(statusEl) {
loadingLockedWidth = 0;
if (statusEl) {
statusEl.style.minWidth = "";
}
}
// Show status message
export function showStatusMessage(message, type = "info") {
function updateLoadingWidthLock(statusEl) {
if (!statusEl || !loadingActive) return;
const nextWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
if (nextWidth <= 0) return;
loadingLockedWidth = Math.max(loadingLockedWidth, nextWidth);
statusEl.style.minWidth = `${loadingLockedWidth}px`;
}
function buildStatusContent(statusEl, message, type) {
statusEl.innerHTML = "";
const indicator = document.createElement("span");
indicator.className = "earth-status-indicator";
indicator.setAttribute("aria-hidden", "true");
const dotCount = type === "loading" ? 3 : 1;
for (let i = 0; i < dotCount; i++) {
const dot = document.createElement("span");
dot.className = "earth-status-dot";
indicator.appendChild(dot);
}
const text = document.createElement("span");
text.className = "earth-status-text";
text.textContent = message;
statusEl.appendChild(indicator);
statusEl.appendChild(text);
}
function hideStatusElement(statusEl, onHidden) {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
if (!statusEl.classList.contains("visible")) {
setElementDisplay(statusEl, false);
statusEl.className = STATUS_BASE_CLASS;
statusEl.innerHTML = "";
}
statusHideTimeoutId = null;
if (typeof onHidden === "function") {
onHidden();
}
}, STATUS_FADE_MS);
}
function processStatusQueue() {
if (loadingActive || statusBusy || statusQueue.length === 0) return;
const next = statusQueue.shift();
if (!next) return;
startTransientStatus(next.message, next.type);
}
function startTransientStatus(message, type = "info") {
const statusEl = getElement("status-message");
if (!statusEl) return;
clearStatusTimers();
statusBusy = true;
const startShow = () => {
statusEl.textContent = message;
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
setElementDisplay(statusEl, true);
statusEl.offsetHeight;
statusEl.classList.add("visible");
buildStatusContent(statusEl, message, type);
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
setElementDisplay(statusEl, true, "inline-flex");
statusEl.offsetHeight;
statusEl.classList.add("visible");
statusTimeoutId = setTimeout(() => {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
setElementDisplay(statusEl, false);
statusEl.textContent = "";
statusHideTimeoutId = null;
}, 280);
statusTimeoutId = null;
}, 3000);
};
statusTimeoutId = setTimeout(() => {
hideStatusElement(statusEl, () => {
statusBusy = false;
processStatusQueue();
});
statusTimeoutId = null;
}, STATUS_DISPLAY_MS);
}
if (statusEl.classList.contains("visible")) {
statusEl.classList.remove("visible");
statusReplayTimeoutId = setTimeout(() => {
startShow();
statusReplayTimeoutId = null;
}, 180);
return;
}
startShow();
// Show status message
export function showStatusMessage(message, type = "info") {
statusQueue.push({ message, type });
processStatusQueue();
}
// Update coordinates display
@@ -120,23 +169,49 @@ export function updateEarthStats(stats) {
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
}
// Show/hide loading
// Show/hide loading via status message
export function setLoading(loading) {
const loadingEl = getElement("loading");
if (!loadingEl) return;
setElementDisplay(loadingEl, loading);
const statusEl = getElement("status-message");
if (!statusEl) return;
if (loading) {
clearStatusTimers();
loadingActive = true;
statusBusy = false;
clearLoadingWidthLock(statusEl);
buildStatusContent(statusEl, "正在加载...", "loading");
statusEl.className = `${STATUS_BASE_CLASS} loading`;
setElementDisplay(statusEl, true, "inline-flex");
statusEl.offsetHeight;
statusEl.classList.add("visible");
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
});
} else {
if (!statusEl.classList.contains("loading")) {
loadingActive = false;
clearLoadingWidthLock(statusEl);
processStatusQueue();
return;
}
loadingActive = false;
hideStatusElement(statusEl, () => {
clearLoadingWidthLock(statusEl);
statusBusy = false;
processStatusQueue();
});
}
}
export function setLoadingMessage(title, subtitle = "") {
const titleEl = getElement("loading-title");
const subtitleEl = getElement("loading-subtitle");
if (titleEl) {
titleEl.textContent = title;
}
if (subtitleEl) {
subtitleEl.textContent = subtitle;
export function setLoadingMessage(title) {
const statusEl = getElement("status-message");
if (!statusEl || !statusEl.classList.contains("loading")) return;
const textEl = statusEl.querySelector(".earth-status-text");
if (textEl) {
textEl.textContent = title;
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
});
}
}
@@ -177,12 +252,16 @@ export function hideError() {
export function clearUiState() {
clearStatusTimers();
statusQueue = [];
statusBusy = false;
loadingActive = false;
const statusEl = getElement("status-message");
if (statusEl) {
statusEl.className = STATUS_BASE_CLASS;
setElementDisplay(statusEl, false);
statusEl.textContent = "";
statusEl.innerHTML = "";
clearLoadingWidthLock(statusEl);
}
hideTooltip();

View File

@@ -265,6 +265,7 @@ function Scrollbar({
{scrollbar.y.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--y"
tabIndex={0}
style={{
height: `${scrollbar.y.thumbSize}px`,
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
@@ -284,6 +285,7 @@ function Scrollbar({
{scrollbar.x.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--x"
tabIndex={0}
style={{
width: `${scrollbar.x.thumbSize}px`,
transform: `translateX(${scrollbar.x.thumbOffset}px)`,

View File

@@ -0,0 +1,37 @@
import { forwardRef, useImperativeHandle, useRef, type CSSProperties, type ReactNode } from 'react'
import ScrollbarOverlay from './ScrollbarOverlay'
interface TableScrollRegionProps {
children: ReactNode
className?: string
style?: CSSProperties
targetSelector?: string
}
const TableScrollRegion = forwardRef<HTMLDivElement, TableScrollRegionProps>(function TableScrollRegion(
{
children,
className = '',
style,
targetSelector = '.ant-table-body',
},
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null)
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement, [])
return (
<div
ref={containerRef}
className={['table-scroll-region', className].filter(Boolean).join(' ')}
style={style}
>
{children}
<ScrollbarOverlay containerRef={containerRef} targetSelector={targetSelector} />
</div>
)
})
export default TableScrollRegion

View File

@@ -0,0 +1,26 @@
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
import { Button, Dropdown } from 'antd'
import { MoreOutlined } from '@ant-design/icons'
interface Props {
collapsed: boolean
items: MenuProps['items']
children: ReactNode
}
/** onCell style for action columns — prevents overflow ellipsis and text wrapping */
export const actionCellProps = {
style: { whiteSpace: 'nowrap' as const, textOverflow: 'clip' as const },
}
export function TableActions({ collapsed, items, children }: Props) {
if (collapsed) {
return (
<Dropdown trigger={['click']} menu={{ items }}>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
)
}
return <div style={{ display: 'inline-flex', gap: 4 }}>{children}</div>
}

View File

@@ -1 +1,2 @@
export { useCollapsedActions } from './useCollapsedActions'
export { useWebSocket } from './useWebSocket'

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* 监听容器宽度,宽时展开操作按钮,窄时收入 Dropdown。
* @param threshold 折叠阈值px默认 700
* @returns [collapsed, callbackRef]
*/
export function useCollapsedActions(threshold = 700) {
const [collapsed, setCollapsed] = useState(false)
const observerRef = useRef<ResizeObserver | null>(null)
const elementRef = useRef<HTMLElement | null>(null)
const ref = useCallback(
(el: HTMLElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
elementRef.current = el
if (!el || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(([entry]) => {
setCollapsed(entry.contentRect.width < threshold)
})
observer.observe(el)
observerRef.current = observer
},
[threshold],
)
useEffect(() => {
return () => {
observerRef.current?.disconnect()
observerRef.current = null
elementRef.current = null
}
}, [])
return [collapsed, ref] as const
}

View File

@@ -140,7 +140,8 @@ body {
opacity: 0;
cursor: pointer;
pointer-events: auto;
transition: opacity 0.18s ease, background 0.18s ease;
outline: none;
transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.scrollbar__thumb::after {
@@ -173,6 +174,7 @@ body {
}
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
.scrollbar:focus-within .scrollbar__track--visible .scrollbar__thumb,
.scrollbar__track--visible .scrollbar__thumb,
.scrollbar__track--dragging .scrollbar__thumb {
opacity: 1;
@@ -182,12 +184,15 @@ body {
pointer-events: auto;
}
.scrollbar__thumb:hover {
background: rgba(216, 226, 240, 0.68);
.scrollbar__thumb:hover,
.scrollbar__thumb:focus-visible {
background: rgba(125, 146, 174, 0.82);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.scrollbar__track--dragging .scrollbar__thumb {
background: rgba(226, 235, 246, 0.82);
background: rgba(92, 115, 146, 0.9);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}
.dashboard-brand {
@@ -385,6 +390,10 @@ body {
color: #64748b;
}
.playground-chat__service-btn {
display: none;
}
.playground-card__icon-button:hover {
color: #1677ff !important;
background: rgba(22, 119, 255, 0.08) !important;
@@ -398,9 +407,11 @@ body {
.playground-card__scroll {
height: 100%;
}
.playground-card__scroll .scrollbar__viewport {
height: 100%;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-card__scroll::-webkit-scrollbar,
@@ -527,6 +538,21 @@ body {
padding: 12px;
}
.playground-chat__input-row {
display: flex;
align-items: center;
gap: 8px;
}
.playground-chat__input-row .playground-chat__input.ant-input {
flex: 1 1 auto;
min-width: 0;
}
.playground-chat__input-wrap--expanded .playground-chat__input-row .playground-chat__send-button.ant-btn {
flex: 0 0 auto;
}
.playground-chat__input.ant-input {
border: 0;
box-shadow: none;
@@ -536,6 +562,15 @@ body {
margin-top: 10px;
}
.playground-chat__input-wrap--expanded .playground-chat__input.ant-input {
margin-top: 10px;
}
.playground-chat__input-wrap:not(.playground-chat__input-wrap--expanded) .playground-chat__input.ant-input {
margin-top: 0;
padding: 4px 2px;
}
.playground-chat__input.ant-input:focus,
.playground-chat__input.ant-input-focused {
box-shadow: none;
@@ -1251,25 +1286,21 @@ body {
.playground-result-modal__content {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-right: 6px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.playground-result-modal__content::-webkit-scrollbar {
width: 8px;
.playground-result-modal__content .scrollbar__viewport {
height: 100%;
overflow: auto;
}
.playground-result-modal__content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
.playground-result__blocks-scroll .scrollbar__viewport {
height: 100%;
overflow: auto;
}
.playground-result-modal__content::-webkit-scrollbar-track {
background: transparent;
.playground-result__blocks-scroll.scrollbar {
min-height: 0;
}
@media (max-width: 1200px) {
@@ -1285,6 +1316,10 @@ body {
flex-direction: column;
}
.playground-chat__service-btn {
display: inline-flex;
}
.playground-shell__sidebar {
display: none;
}
@@ -1490,10 +1525,26 @@ body {
min-height: 0;
}
.users-table-region .ant-table-body {
height: auto !important;
/* users table: flex-fill approach so overlay x-track aligns with table bottom */
.users-table-region .ant-table-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.users-table-region .ant-table-header {
flex: 0 0 auto;
}
.users-table-region .ant-table-body {
flex: 1 1 auto;
min-height: 0;
height: 0 !important;
max-height: none !important;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container {
@@ -1583,14 +1634,14 @@ body {
padding: 10px 12px !important;
}
.data-source-table-region .ant-table-body,
.data-source-table-region .ant-table-content {
.table-scroll-region .ant-table-body,
.table-scroll-region .ant-table-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.data-source-table-region .ant-table-body::-webkit-scrollbar,
.data-source-table-region .ant-table-content::-webkit-scrollbar {
.table-scroll-region .ant-table-body::-webkit-scrollbar,
.table-scroll-region .ant-table-content::-webkit-scrollbar {
width: 0;
height: 0;
}
@@ -1757,7 +1808,10 @@ body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
@@ -1836,10 +1890,11 @@ body {
display: grid;
gap: 14px;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 6px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.bgp-page__brief-modal-body .scrollbar__viewport {
overflow: auto;
}
.bgp-page__brief-evidence {
@@ -1857,6 +1912,10 @@ body {
gap: 12px;
}
.alerts-brief-drawer .scrollbar__viewport {
overflow: auto;
}
.alerts-brief-drawer__loading {
min-height: 160px;
display: flex;
@@ -1887,8 +1946,25 @@ body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.alerts-tab-panel > .ant-space-item,
.system-alerts-page__stack > .ant-space-item,
.bgp-alerts-page__stack > .ant-space-item,
.situational-alerts-page__stack > .ant-space-item {
min-width: 0;
}
.alerts-tab-panel > .ant-space-item:last-child,
.system-alerts-page__stack > .ant-space-item:last-child,
.bgp-alerts-page__stack > .ant-space-item:last-child,
.situational-alerts-page__stack > .ant-space-item:last-child {
flex: 1 1 auto;
min-height: 0;
}
.system-alerts-page__table-card,
@@ -1915,8 +1991,22 @@ body {
min-height: 0;
}
.bgp-alerts-page__tabs,
.bgp-alerts-page__tabs .ant-tabs-content-holder,
.bgp-alerts-page__tabs {
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.bgp-alerts-page__tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.bgp-alerts-page__tabs .ant-tabs-content,
.bgp-alerts-page__tabs .ant-tabs-tabpane {
min-width: 0;
@@ -2128,29 +2218,71 @@ body {
line-height: 1;
}
.bgp-page__summary-grid--compact {
flex-wrap: nowrap !important;
.bgp-page__summary-scroll.scrollbar,
.alerts-summary-scroll.scrollbar {
overflow: hidden;
}
.bgp-page__summary-scroll .scrollbar__viewport,
.alerts-summary-scroll .scrollbar__viewport {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
}
.bgp-page__summary-grid--compact::-webkit-scrollbar {
width: 8px;
height: 8px;
.bgp-page__summary-grid,
.alerts-summary-grid {
display: flex;
flex-wrap: nowrap !important;
min-width: max-content;
}
.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
.bgp-page__summary-cell,
.alerts-summary-cell {
flex: 0 0 auto;
}
.bgp-page__summary-grid--compact::-webkit-scrollbar-track {
background: transparent;
.situational-alerts-page__summary-grid {
display: flex;
flex-wrap: nowrap;
min-width: max-content;
}
.situational-alerts-page__panels-grid {
display: flex;
min-width: max-content;
}
.situational-alerts-page__panels-scroll .scrollbar__viewport {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
}
@media (max-width: 1200px) {
.situational-alerts-page__summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
min-width: 0;
width: 100%;
}
.situational-alerts-page__summary-grid .alerts-summary-cell {
width: auto !important;
}
}
@media (max-width: 560px) {
.situational-alerts-page__summary-grid {
display: flex;
flex-wrap: nowrap;
width: auto;
min-width: max-content;
}
.situational-alerts-page__summary-grid .alerts-summary-cell {
width: 220px !important;
}
}
.bgp-page__table-card,
@@ -2408,25 +2540,21 @@ body {
.settings-panel-scroll {
flex: 1 1 auto;
min-height: 0;
padding-right: 6px;
}
.settings-panel-scroll.scrollbar {
overflow: hidden;
}
.settings-panel-scroll .scrollbar__viewport {
overflow-y: auto;
overflow-x: hidden;
padding-right: 6px;
scrollbar-gutter: stable;
}
.settings-panel-scroll::-webkit-scrollbar {
width: 10px;
}
.settings-panel-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.8);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.settings-panel-scroll::-webkit-scrollbar-track {
background: transparent;
.settings-panel-scroll .scrollbar__viewport > * {
min-width: 0;
}
.settings-pane .data-source-table-region .ant-table-container {
@@ -2447,34 +2575,25 @@ body {
max-height: none !important;
}
.settings-tv-toolbar {
.settings-tv-edit-modal .ant-modal-content {
overflow: hidden;
}
.settings-tv-edit-modal__body {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
flex-direction: column;
height: min(80vh, 640px);
min-height: 0;
padding: 16px 0 0 24px;
}
.settings-tv-toolbar__controls {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
.settings-tv-edit-modal__scroll {
flex: 1 1 auto;
min-height: 0;
padding-right: 20px;
}
.settings-tv-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.settings-tv-field {
display: grid;
gap: 8px;
}
.data-list-workspace {
min-height: 0;
@@ -3273,7 +3392,6 @@ body {
.dashboard-restart-log {
max-height: 180px;
overflow-y: auto;
padding: 10px 12px;
border-radius: 12px;
background: #0f172a;
@@ -3281,16 +3399,11 @@ body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
scrollbar-width: thin;
}
.dashboard-restart-log::-webkit-scrollbar {
width: 8px;
}
.dashboard-restart-log::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(148, 163, 184, 0.55);
.dashboard-restart-log .scrollbar__viewport {
overflow-y: auto;
max-height: 180px;
}
@media (max-width: 768px) {

View File

@@ -5,10 +5,8 @@ import {
Alert,
Button,
Card,
Col,
Descriptions,
Modal,
Row,
Space,
Spin,
Statistic,
@@ -21,6 +19,8 @@ import {
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -171,20 +171,22 @@ export function BGPAlertsPanel() {
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
<Row gutter={[12, 12]}>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
</Row>
<Scrollbar className="alerts-summary-scroll bgp-alerts-page__summary-scroll">
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
</div>
</div>
</Scrollbar>
<Card className="bgp-alerts-page__table-card">
<Tabs
@@ -194,7 +196,7 @@ export function BGPAlertsPanel() {
key: 'incidents',
label: 'BGP 事件',
children: (
<div className="table-scroll-region bgp-alerts-page__table-region">
<TableScrollRegion className="bgp-alerts-page__table-region">
<Table<BGPIncident>
columns={incidentColumns}
dataSource={incidents}
@@ -204,14 +206,14 @@ export function BGPAlertsPanel() {
scroll={{ x: 1200, y: 480 }}
tableLayout="fixed"
/>
</div>
</TableScrollRegion>
),
},
{
key: 'anomalies',
label: 'BGP 异常',
children: (
<div className="table-scroll-region bgp-alerts-page__table-region">
<TableScrollRegion className="bgp-alerts-page__table-region">
<Table<BGPAnomaly>
columns={anomalyColumns}
dataSource={anomalies}
@@ -221,7 +223,7 @@ export function BGPAlertsPanel() {
scroll={{ x: 1100, y: 480 }}
tableLayout="fixed"
/>
</div>
</TableScrollRegion>
),
},
]}
@@ -244,14 +246,14 @@ export function BGPAlertsPanel() {
<Spin tip="正在生成 BGP AI 简报..." />
</div>
) : brief ? (
<div className="bgp-page__brief-modal-body">
<Scrollbar className="bgp-page__brief-modal-body">
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
</Descriptions>
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
</div>
</Scrollbar>
) : (
<Text type="secondary"> BGP </Text>
)}

View File

@@ -5,10 +5,8 @@ import {
Alert,
Button,
Card,
Col,
Descriptions,
Drawer,
Row,
Space,
Spin,
Statistic,
@@ -18,6 +16,7 @@ import {
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
import {
getSituationalAwarenessGateway,
@@ -119,42 +118,46 @@ export function SituationalAlertsPanel() {
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
/>
<Row gutter={[12, 12]}>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
</Col>
</Row>
<Scrollbar className="alerts-summary-scroll situational-alerts-page__summary-scroll">
<div className="alerts-summary-grid situational-alerts-page__summary-grid" style={{ gap: '12px' }}>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
</div>
</div>
</Scrollbar>
<Row gutter={[12, 12]}>
<Col xs={24} lg={12}>
<Card title="系统告警侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="BGP 风险侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
<Scrollbar className="alerts-summary-scroll situational-alerts-page__panels-scroll">
<div className="alerts-summary-grid situational-alerts-page__panels-grid" style={{ gap: '12px' }}>
<div className="alerts-summary-cell" style={{ width: '360px' }}>
<Card title="系统告警侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</div>
<div className="alerts-summary-cell" style={{ width: '360px' }}>
<Card title="BGP 风险侧">
<Descriptions size="small" column={1}>
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
</Descriptions>
</Card>
</div>
</div>
</Scrollbar>
</Space>
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>

View File

@@ -5,11 +5,9 @@ import {
Alert,
Button,
Card,
Col,
Descriptions,
Drawer,
Modal,
Row,
Space,
Spin,
Statistic,
@@ -22,6 +20,8 @@ import {
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -216,20 +216,22 @@ export function SystemAlertsPanel() {
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
<Row gutter={[12, 12]}>
<Col xs={24} sm={8}>
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
</Col>
</Row>
<Scrollbar className="alerts-summary-scroll system-alerts-page__summary-scroll">
<div className="alerts-summary-grid" style={{ gap: '12px' }}>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
</div>
<div className="alerts-summary-cell" style={{ width: '220px' }}>
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
</div>
</div>
</Scrollbar>
<Card className="system-alerts-page__table-card" title="系统告警列表">
<div className="table-scroll-region system-alerts-page__table-region">
<TableScrollRegion className="system-alerts-page__table-region">
<Table<AlertRecord>
columns={columns}
dataSource={alerts}
@@ -239,7 +241,7 @@ export function SystemAlertsPanel() {
scroll={{ x: 1100, y: 480 }}
tableLayout="fixed"
/>
</div>
</TableScrollRegion>
</Card>
</Space>
@@ -260,7 +262,7 @@ export function SystemAlertsPanel() {
</Modal>
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
<div className="alerts-brief-drawer">
<Scrollbar className="alerts-brief-drawer">
{briefLoading ? (
<div className="alerts-brief-drawer__loading">
<Spin tip="正在汇总系统告警事实并生成简报..." />
@@ -293,7 +295,7 @@ export function SystemAlertsPanel() {
</Card>
</Space>
) : null}
</div>
</Scrollbar>
</Drawer>
</div>
</AppLayout>

View File

@@ -4,10 +4,8 @@ import {
Alert,
Button,
Card,
Col,
Descriptions,
Modal,
Row,
Select,
Space,
Spin,
@@ -22,6 +20,8 @@ import {
} from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
import {
type BGPAnomaly,
@@ -708,37 +708,36 @@ function BGP() {
/>
<Card className="bgp-page__summary-card">
<Row
gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]}
className={`bgp-page__summary-grid${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`}
wrap={!compactViewport}
>
{summaryItems.map((item) => (
<Col
key={item.label}
xs={24}
sm={12}
md={8}
flex={compactViewport ? '180px' : undefined}
>
<div className="bgp-page__summary-item">
<div className="bgp-page__summary-label">{item.label}</div>
<Statistic className="bgp-page__summary-stat" value={item.value} />
<Scrollbar className="bgp-page__summary-scroll">
<div
className="bgp-page__summary-grid"
style={{ gap: `${compactViewport ? 8 : 12}px` }}
>
{summaryItems.map((item) => (
<div
key={item.label}
className="bgp-page__summary-cell"
style={{ width: compactViewport ? '180px' : '220px' }}
>
<div className="bgp-page__summary-item">
<div className="bgp-page__summary-label">{item.label}</div>
<Statistic className="bgp-page__summary-stat" value={item.value} />
</div>
</div>
</Col>
))}
</Row>
))}
</div>
</Scrollbar>
</Card>
<Card className="bgp-page__table-card">
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region">
<TableScrollRegion ref={tableRegionRef} className="bgp-page__table-region">
<Tabs
className="bgp-page__tabs"
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</div>
</TableScrollRegion>
</Card>
</Space>
</div>
@@ -755,7 +754,7 @@ function BGP() {
destroyOnHidden
>
{brief ? (
<div className="bgp-page__brief-modal-body">
<Scrollbar className="bgp-page__brief-modal-body">
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
<div className="bgp-page__brief-evidence">
{brief.facts.length > 0 ? (
@@ -785,7 +784,7 @@ function BGP() {
</div>
) : null}
<MarkdownRenderer markdown={brief.content_markdown} />
</div>
</Scrollbar>
) : (
<Text type="secondary"></Text>
)}

View File

@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
import axios from 'axios'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useWebSocket } from '../../hooks/useWebSocket'
import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -532,11 +533,11 @@ function Dashboard() {
<div className="dashboard-restart-section">
<Text className="dashboard-restart-section__label"></Text>
<div className="dashboard-restart-log">
<Scrollbar className="dashboard-restart-log">
{restartLogs.length > 0 ? restartLogs.map((line, index) => (
<div key={`${line}-${index}`}>{line}</div>
)) : <div></div>}
</div>
</Scrollbar>
</div>
</div>
</Modal>

View File

@@ -12,6 +12,7 @@ import {
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
const { Title, Text } = Typography
@@ -939,7 +940,7 @@ function DataList() {
/>
</div>
</div>
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
<TableScrollRegion className="data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
<Table
columns={columns}
dataSource={data}
@@ -960,7 +961,7 @@ function DataList() {
showTotal: (count) => `${count}`,
}}
/>
</div>
</TableScrollRegion>
</Card>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import {
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
@@ -116,18 +118,39 @@ function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgres
if (!batch || batch.sourceIds.length === 0) {
return null
}
return batch
}
const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running)
if (hasRunningItem) {
return batch
function resolveTerminalBatchItem(
sourceId: number,
batch: BulkProgressBatch,
builtInSources: BuiltInDataSource[],
taskProgress: Record<number, TaskTrackerState>,
): BulkProgressItem | null {
const currentItem = batch.items[sourceId]
const source = builtInSources.find((item) => item.id === sourceId)
const trackedTask = taskProgress[sourceId]
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
if (isRunning) {
return null
}
const allFinished = batch.sourceIds.every((sourceId) => {
const status = batch.items[sourceId]?.status
return Boolean(status && status !== 'running')
})
const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
if (!status || status === 'running') {
return null
}
return allFinished ? null : batch
return {
task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null,
progress:
status === 'success'
? 100
: trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0,
is_running: false,
phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null,
status,
}
}
interface WebSocketTaskMessage {
@@ -192,6 +215,8 @@ function DataSources() {
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
const [customTableHeight, setCustomTableHeight] = useState(360)
const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions()
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
const [form] = Form.useForm()
const fetchData = useCallback(async () => {
@@ -436,6 +461,41 @@ function DataSources() {
return () => clearInterval(interval)
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
useEffect(() => {
if (!bulkProgressBatch) return
let changed = false
const nextItems = { ...bulkProgressBatch.items }
for (const sourceId of bulkProgressBatch.sourceIds) {
const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress)
if (!nextItem) continue
const previousItem = bulkProgressBatch.items[sourceId]
if (
previousItem?.status === nextItem.status &&
previousItem?.is_running === nextItem.is_running &&
previousItem?.progress === nextItem.progress &&
previousItem?.phase === nextItem.phase
) {
continue
}
nextItems[sourceId] = nextItem
changed = true
}
if (!changed) return
setBulkProgressBatch((prev) => {
if (!prev) return prev
return finalizeBulkProgressBatch({
...prev,
items: nextItems,
})
})
}, [bulkProgressBatch, builtInSources, taskProgress])
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
const force = options?.force ?? false
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
@@ -884,10 +944,29 @@ function DataSources() {
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right' as const,
width: builtinActionsCollapsed ? 40 : 164,
onCell: () => actionCellProps,
render: (_: unknown, record: BuiltInDataSource) => (
<Space size="small">
<TableActions
collapsed={builtinActionsCollapsed}
items={[
{
key: 'trigger',
label: '触发',
icon: <SyncOutlined />,
disabled: !record.is_active,
onClick: () => handleTrigger(record.id),
},
{
key: 'toggle',
label: record.is_active ? '禁用' : '启用',
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
danger: record.is_active,
onClick: () => handleToggle(record.id, record.is_active),
},
]}
>
<Button
type="link"
size="small"
@@ -907,7 +986,7 @@ function DataSources() {
>
{record.is_active ? '禁用' : '启用'}
</Button>
</Space>
</TableActions>
),
},
]
@@ -945,30 +1024,56 @@ function DataSources() {
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right' as const,
width: customActionsCollapsed ? 40 : 228,
onCell: () => actionCellProps,
render: (_: unknown, record: CustomDataSource) => (
<Space size="small">
<Tooltip title="编辑">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} />
</Tooltip>
<Tooltip title={record.is_active ? '禁用' : '启用'}>
<Button
type="link"
size="small"
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => handleToggleCustom(record.id, record.is_active)}
/>
</Tooltip>
<Popconfirm
title="确定删除此配置?"
onConfirm={() => handleDelete(record.id)}
<TableActions
collapsed={customActionsCollapsed}
items={[
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => openDrawer(record),
},
{
key: 'toggle',
label: record.is_active ? '禁用' : '启用',
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
danger: record.is_active,
onClick: () => handleToggleCustom(record.id, record.is_active),
},
{ type: 'divider' },
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确定删除此配置?',
onOk: () => handleDelete(record.id),
})
},
},
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}></Button>
<Button
type="link"
size="small"
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
danger={record.is_active}
style={record.is_active ? undefined : { color: '#52c41a' }}
onClick={() => handleToggleCustom(record.id, record.is_active)}
>
<Tooltip title="删除">
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Tooltip>
{record.is_active ? '禁用' : '启用'}
</Button>
<Popconfirm title="确定删除此配置?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</TableActions>
),
},
]
@@ -978,7 +1083,7 @@ function DataSources() {
key: 'builtin',
label: '内置数据源',
children: (
<div className="page-shell__body data-source-builtin-tab">
<div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
<div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div>
@@ -1064,7 +1169,7 @@ function DataSources() {
</span>
),
children: (
<div className="page-shell__body data-source-custom-tab">
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
<div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>

View File

@@ -22,6 +22,8 @@ import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { useAuthStore } from '../../stores/auth'
const { Title, Text, Paragraph } = Typography
@@ -237,8 +239,10 @@ function Playground() {
const [editingContent, setEditingContent] = useState('')
const [editSaving, setEditSaving] = useState(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [composerFocused, setComposerFocused] = useState(false)
const pollTimerRef = useRef<number | null>(null)
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
const messagesShellRef = useRef<HTMLDivElement | null>(null)
const forceScrollToBottomRef = useRef(true)
const selectedPreset = useMemo(
@@ -575,7 +579,7 @@ function Playground() {
/>
)}
>
<div className="playground-card__scroll">
<Scrollbar className="playground-card__scroll">
<Spin spinning={statusLoading}>
{providerStatus ? (
<div className="playground-provider-panel">
@@ -617,7 +621,7 @@ function Playground() {
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
)}
</Spin>
</div>
</Scrollbar>
</Card>
)
@@ -678,7 +682,7 @@ function Playground() {
title="AI Chatbox"
extra={(
<Space size={4}>
<Tooltip title="服务状态">
<Tooltip title="服务状态" className="playground-chat__service-btn">
<Button
type="text"
shape="circle"
@@ -699,7 +703,7 @@ function Playground() {
</Space>
)}
>
<div className="playground-chat__messages-shell">
<div ref={messagesShellRef} className="playground-chat__messages-shell">
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
{messages.map((entry) => (
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
@@ -760,8 +764,9 @@ function Playground() {
<Button size="small" type="primary" loading={editSaving} onClick={() => void handleSaveEditMessage(entry)}>
</Button>
</div>
</div>
</div>
<ScrollbarOverlay containerRef={messagesShellRef} targetSelector=".playground-chat__messages" />
</div>
</div>
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
<div className="playground-message__markdown">
@@ -845,31 +850,50 @@ function Playground() {
</div>
<div className="playground-chat__composer">
<div className="playground-chat__input-wrap">
<div className="playground-preset-strip__actions">
{PLAYGROUND_PRESETS.map((preset) => (
<Tag.CheckableTag
key={preset.key}
checked={preset.key === selectedPreset.key}
onChange={() => handleApplyPreset(preset)}
>
{preset.label}
</Tag.CheckableTag>
))}
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
{(composerFocused || !!inputValue) && (
<div className="playground-preset-strip__actions">
{PLAYGROUND_PRESETS.map((preset) => (
<Tag.CheckableTag
key={preset.key}
checked={preset.key === selectedPreset.key}
onChange={() => handleApplyPreset(preset)}
>
{preset.label}
</Tag.CheckableTag>
))}
</div>
)}
<div className="playground-chat__input-row">
<Input.TextArea
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
placeholder="在这里输入本次分析请求..."
className="playground-chat__input"
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
void handleSend()
}
}}
/>
{!(composerFocused || !!inputValue) && (
<Tooltip title={sendButtonTooltip}>
<Button
type="text"
shape="circle"
className={`playground-chat__send-button${requestPending || streaming ? ' playground-chat__send-button--stop' : ''}`}
icon={requestPending || streaming ? <BorderOutlined /> : <ArrowUpOutlined />}
onClick={requestPending || streaming ? handleStop : () => void handleSend()}
aria-label={sendButtonTooltip}
/>
</Tooltip>
)}
</div>
<Input.TextArea
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
autoSize={{ minRows: 4, maxRows: 10 }}
placeholder="在这里输入本次分析请求。你可以写观察、问题、目标,或者直接贴一段待分析事实。"
className="playground-chat__input"
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
void handleSend()
}
}}
/>
{(composerFocused || !!inputValue) && (
<div className="playground-chat__actions">
<div className="playground-chat__hints">
<Tag>{title}</Tag>
@@ -886,8 +910,9 @@ function Playground() {
/>
</Tooltip>
</div>
</div>
)}
</div>
</div>
</Card>
</div>
</div>
@@ -1000,42 +1025,42 @@ function Playground() {
/>
</div>
<div className="playground-result-modal__content">
<Scrollbar className="playground-result-modal__content">
<Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}>
{analysis.text_blocks.length ? (
<div className="playground-result__blocks">
<Text strong></Text>
<div className="playground-result__blocks-scroll">
<Scrollbar className="playground-result__blocks-scroll">
{analysis.text_blocks.map((block, index) => (
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
<pre>{block}</pre>
</Card>
))}
</div>
</Scrollbar>
</div>
) : null}
{analysis.thinking_blocks.length ? (
<div className="playground-result__blocks">
<Text strong>Thinking Blocks</Text>
<div className="playground-result__blocks-scroll">
<Scrollbar className="playground-result__blocks-scroll">
{analysis.thinking_blocks.map((block, index) => (
<Card key={`${index}-${block.slice(0, 12)}`} size="small">
<pre>{block}</pre>
</Card>
))}
</div>
</Scrollbar>
</div>
) : null}
<div className="playground-result__blocks">
<Text strong>Raw Response</Text>
<div className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
<Scrollbar className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
<Card size="small">
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
</Card>
</div>
</Scrollbar>
</div>
</Space>
</div>
</Scrollbar>
</div>
) : null}
</Modal>

View File

@@ -1,4 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import {
Button,
Card,
@@ -6,15 +9,19 @@ import {
Input,
InputNumber,
message,
Modal,
Select,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
@@ -91,7 +98,7 @@ function SettingsPanel({
return (
<div className="settings-pane">
<Card className="settings-panel-card" loading={loading}>
<div className="settings-panel-scroll">{children}</div>
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
</Card>
</div>
)
@@ -106,11 +113,14 @@ function Settings() {
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [savingTvSettings, setSavingTvSettings] = useState(false)
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const fetchSettings = async () => {
try {
@@ -204,90 +214,95 @@ function Settings() {
}
}
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => {
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev))
}
const updateTvSourceField = <K extends keyof TVStreamSource>(
sourceId: string,
field: K,
value: TVStreamSource[K]
) => {
setTvSettings((prev) => {
if (!prev) return prev
const nextSources = prev.sources.map((source) => {
if (field === 'is_fallback' && value === true) {
return { ...source, is_fallback: source.id === sourceId }
}
if (source.id === sourceId) {
return { ...source, [field]: value }
}
return source
})
const nextDefaultSourceId =
field === 'is_enabled' && value === false && prev.default_source_id === sourceId
? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || ''
: prev.default_source_id
return {
...prev,
default_source_id: nextDefaultSourceId,
sources: nextSources,
}
})
const setDefaultSource = (sourceId: string) => {
if (!tvSettings) return
const next = { ...tvSettings, default_source_id: sourceId }
setTvSettings(next)
saveTvSettings(next)
}
const addTvSource = () => {
setTvSettings((prev) => {
if (!prev) return prev
const nextIndex = prev.sources.length + 1
const newSource: TVStreamSource = {
id: `manual-tv-${Date.now()}`,
name: `新闻直播源 ${nextIndex}`,
provider: 'Manual',
region: 'Global',
language: 'und',
source_type: 'iframe',
embed_url: '',
stream_url: '',
homepage_url: '',
poster_url: '',
youtube_video_id: '',
youtube_channel: '',
is_enabled: true,
is_fallback: false,
sort_order: nextIndex * 10,
collector_source: null,
notes: '',
}
return {
...prev,
sources: [...prev.sources, newSource],
}
})
const nextIndex = (tvSettings?.sources.length || 0) + 1
const newSource: TVStreamSource = {
id: `manual-tv-${Date.now()}`,
name: `新闻直播源 ${nextIndex}`,
provider: 'Manual',
region: 'Global',
language: 'und',
source_type: 'iframe',
embed_url: '',
stream_url: '',
homepage_url: '',
poster_url: '',
youtube_video_id: '',
youtube_channel: '',
is_enabled: true,
is_fallback: false,
sort_order: nextIndex * 10,
collector_source: null,
notes: '',
}
setEditingSource(newSource)
tvEditForm.setFieldsValue(newSource)
}
const removeTvSource = (sourceId: string) => {
setTvSettings((prev) => {
if (!prev) return prev
const nextSources = prev.sources.filter((source) => source.id !== sourceId)
const nextDefaultSourceId =
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id
return {
...prev,
default_source_id: nextDefaultSourceId,
sources: nextSources,
const confirmEditSource = async () => {
if (!editingSource || !tvSettings) return
const values = tvEditForm.getFieldsValue()
const nextSources = tvSettings.sources
.map((source) => {
if (source.id === editingSource.id) return { ...source, ...values }
if (values.is_fallback) return { ...source, is_fallback: false }
return source
})
if (!tvSettings.sources.some((source) => source.id === editingSource.id)) {
nextSources.push({
...editingSource,
...values,
})
if (values.is_fallback) {
for (let index = 0; index < nextSources.length - 1; index += 1) {
nextSources[index] = { ...nextSources[index], is_fallback: false }
}
}
})
}
const nextDefaultSourceId =
values.is_enabled === false && tvSettings.default_source_id === editingSource.id
? nextSources.find((s) => s.id !== editingSource.id && s.is_enabled)?.id || ''
: tvSettings.default_source_id
const next = { ...tvSettings, default_source_id: nextDefaultSourceId, sources: nextSources }
setTvSettings(next)
setEditingSource(null)
await saveTvSettings(next)
}
const saveTvSettings = async () => {
const removeTvSource = async (sourceId: string) => {
if (!tvSettings) return
const nextSources = tvSettings.sources.filter((source) => source.id !== sourceId)
const nextDefaultSourceId =
tvSettings.default_source_id === sourceId ? nextSources[0]?.id || '' : tvSettings.default_source_id
const next = {
...tvSettings,
default_source_id: nextDefaultSourceId,
sources: nextSources,
}
setTvSettings(next)
if (editingSource?.id === sourceId) {
setEditingSource(null)
}
await saveTvSettings(next)
}
const saveTvSettings = async (next?: TVSettings) => {
const toSave = next ?? tvSettings
if (!toSave) return
try {
setSavingTvSettings(true)
await axios.put('/api/v1/settings/tv', tvSettings)
await axios.put('/api/v1/settings/tv', toSave)
message.success('电视直播配置已保存')
await fetchSettings()
} catch (error) {
@@ -400,105 +415,39 @@ function Settings() {
const tvSourceColumns = [
{
title: '频道',
dataIndex: 'name',
key: 'name',
width: 220,
render: (_: string, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}>
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} />
<Input
value={record.provider}
placeholder="提供方"
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
/>
width: 180,
render: (_: unknown, record: TVStreamSource) => (
<div>
<div style={{ fontWeight: 500 }}>{record.name}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
</div>
),
},
{
title: '区域 / 语言',
key: 'locale',
width: 160,
width: 130,
render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}>
<Input
value={record.region}
placeholder="区域"
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
/>
<Input
value={record.language}
placeholder="语言"
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
/>
</div>
<Text type="secondary">{record.region} · {record.language}</Text>
),
},
{
title: '类型',
dataIndex: 'source_type',
key: 'source_type',
width: 120,
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => (
<Select
value={value}
style={{ width: '100%' }}
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
options={[
{ value: 'iframe', label: 'iframe' },
{ value: 'hls', label: 'hls' },
{ value: 'video', label: 'video' },
{ value: 'youtube', label: 'youtube' },
{ value: 'external', label: 'external' },
]}
/>
),
},
{
title: '播放地址',
key: 'urls',
width: 320,
render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}>
<Input
value={record.embed_url}
placeholder="嵌入地址 / iframe 地址"
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
/>
<Input
value={record.stream_url}
placeholder="流地址 / HLS 地址"
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
/>
<Input
value={record.youtube_video_id}
placeholder="YouTube 视频 ID可选"
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
/>
<Input
value={record.youtube_channel}
placeholder="YouTube 频道 Handle / URL可选"
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
/>
</div>
),
},
{
title: '官网',
dataIndex: 'homepage_url',
key: 'homepage_url',
width: 220,
render: (value: string, record: TVStreamSource) => (
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
),
width: 90,
render: (value: string) => <Tag>{value}</Tag>,
},
{
title: '状态',
key: 'status',
width: 110,
width: 130,
render: (_: unknown, record: TVStreamSource) => (
<div style={{ display: 'grid', gap: 8 }}>
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} />
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} />
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
<Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
{record.id === tvSettings?.default_source_id && <Tag color="gold"></Tag>}
{record.is_fallback && <Tag color="blue"></Tag>}
</div>
),
},
@@ -506,20 +455,82 @@ function Settings() {
title: '备注',
dataIndex: 'notes',
key: 'notes',
width: 220,
render: (value: string, record: TVStreamSource) => (
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
),
width: 200,
ellipsis: true,
render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right' as const,
width: tvActionsCollapsed ? 40 : 258,
onCell: () => actionCellProps,
render: (_: unknown, record: TVStreamSource) => (
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
</Button>
<TableActions
collapsed={tvActionsCollapsed}
items={[
{
key: 'default',
label: '设为默认',
icon: <CheckCircleOutlined />,
disabled: record.id === tvSettings?.default_source_id,
onClick: () => setDefaultSource(record.id),
},
{
key: 'edit',
label: '编辑',
icon: <EditOutlined />,
onClick: () => {
setEditingSource(record)
tvEditForm.setFieldsValue(record)
},
},
{ type: 'divider' },
{
key: 'delete',
label: '删除',
icon: <DeleteOutlined />,
danger: true,
disabled: record.id === tvSettings?.default_source_id,
onClick: () => {
void removeTvSource(record.id)
},
},
]}
>
<Button
type="link"
size="small"
icon={<CheckCircleOutlined />}
disabled={record.id === tvSettings?.default_source_id}
onClick={() => setDefaultSource(record.id)}
>
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingSource(record)
tvEditForm.setFieldsValue(record)
}}
>
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={record.id === tvSettings?.default_source_id}
onClick={() => {
void removeTvSource(record.id)
}}
>
</Button>
</TableActions>
),
},
]
@@ -610,51 +621,111 @@ function Settings() {
key: 'tv',
label: '电视直播',
children: (
<div className="settings-pane">
<Card className="settings-panel-card settings-panel-card--table" loading={loading}>
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}>
<div className="settings-tv-toolbar">
<div className="settings-tv-toolbar__controls">
<div className="settings-tv-field">
<Text type="secondary"></Text>
<Select
value={tvSettings?.default_source_id}
style={{ minWidth: 260 }}
options={(tvSettings?.sources || []).map((source) => ({
value: source.id,
label: source.name,
}))}
onChange={(value) => updateTvSetting('default_source_id', value)}
/>
</div>
<div className="settings-tv-field">
<Text type="secondary">退</Text>
<Switch
checked={tvSettings?.auto_fallback || false}
onChange={(checked) => updateTvSetting('auto_fallback', checked)}
/>
</div>
</div>
<div className="settings-tv-toolbar__actions">
<Button onClick={addTvSource}></Button>
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}>
</Button>
</div>
</div>
<div className="table-scroll-region data-source-table-region">
<Table
rowKey="id"
columns={tvSourceColumns}
dataSource={tvSettings?.sources || []}
pagination={false}
scroll={{ x: 1500, y: 420 }}
tableLayout="fixed"
size="small"
/>
</div>
</div>
<div className="settings-pane" ref={tvTableRef}>
<Card
className="settings-panel-card settings-panel-card--table"
loading={loading}
styles={{ body: { padding: 0 } }}
>
<TableScrollRegion
className="data-source-table-region"
style={{ flex: '1 1 auto', minHeight: 0 }}
>
<Table
rowKey="id"
columns={tvSourceColumns}
dataSource={tvSettings?.sources || []}
pagination={false}
scroll={{ x: 'max-content', y: 420 }}
tableLayout="fixed"
size="small"
/>
</TableScrollRegion>
<Tooltip title="新增直播源">
<Button
type="text"
icon={<PlusOutlined />}
onClick={addTvSource}
style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
/>
</Tooltip>
</Card>
<Modal
title={editingSource?.id.startsWith('manual-tv-') ? '新增直播源' : '编辑直播源'}
open={editingSource !== null}
onOk={confirmEditSource}
onCancel={() => {
setEditingSource(null)
tvEditForm.resetFields()
}}
okText="保存"
okButtonProps={{ loading: savingTvSettings }}
cancelText="取消"
width={560}
centered
destroyOnHidden
className="settings-tv-edit-modal"
styles={{ body: { padding: 0 } }}
>
<div className="settings-tv-edit-modal__body">
<Scrollbar className="settings-tv-edit-modal__scroll">
<Form form={tvEditForm} layout="vertical" style={{ paddingBottom: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="name" label="频道名称" rules={[{ required: true, message: '请输入频道名称' }]}>
<Input />
</Form.Item>
<Form.Item name="provider" label="提供方">
<Input />
</Form.Item>
<Form.Item name="region" label="区域">
<Input />
</Form.Item>
<Form.Item name="language" label="语言">
<Input />
</Form.Item>
<Form.Item name="source_type" label="类型">
<Select options={[
{ value: 'iframe', label: 'iframe' },
{ value: 'hls', label: 'HLS' },
{ value: 'video', label: 'video' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'external', label: 'external仅外部打开' },
]} />
</Form.Item>
<Form.Item name="sort_order" label="排序">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</div>
<Form.Item name="embed_url" label="嵌入地址 / iframe 地址">
<Input />
</Form.Item>
<Form.Item name="stream_url" label="流地址 / HLS 地址">
<Input />
</Form.Item>
<Form.Item name="youtube_video_id" label="YouTube 视频 ID">
<Input />
</Form.Item>
<Form.Item name="youtube_channel" label="YouTube 频道 Handle / URL">
<Input />
</Form.Item>
<Form.Item name="homepage_url" label="官网地址">
<Input />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="is_fallback" label="设为备用源" valuePropName="checked">
<Switch />
</Form.Item>
</div>
</Form>
</Scrollbar>
</div>
</Modal>
</div>
),
},
@@ -668,7 +739,7 @@ function Settings() {
loading={loading}
styles={{ body: { padding: 0 } }}
>
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
<TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
<Table
rowKey="id"
columns={collectorColumns}
@@ -678,7 +749,7 @@ function Settings() {
tableLayout="fixed"
size="small"
/>
</div>
</TableScrollRegion>
</Card>
</div>
),

View File

@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
interface Task {
@@ -146,9 +147,9 @@ function Tasks() {
</Button>
}
>
<div className="table-scroll-region">
<TableScrollRegion>
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</div>
</TableScrollRegion>
</Card>
</AppLayout>
)

View File

@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
import { useEffect, useState } from 'react'
import { Table, Button, Tag, message, Modal, Form, Input, Select } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
interface User {
id: number
@@ -18,9 +21,8 @@ function Users() {
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const [tableHeight, setTableHeight] = useState(360)
const [form] = Form.useForm()
const [actionsCollapsed, containerRef] = useCollapsedActions()
const fetchUsers = async () => {
setLoading(true)
@@ -36,24 +38,6 @@ function Users() {
fetchUsers()
}, [])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = tableRegionRef.current?.offsetHeight || 0
setTableHeight(Math.max(220, regionHeight - 56))
}
updateTableHeight()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateTableHeight)
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
return () => observer.disconnect()
}, [users.length])
const handleAdd = () => {
setEditingUser(null)
form.resetFields()
@@ -126,12 +110,21 @@ function Users() {
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right' as const,
width: actionsCollapsed ? 56 : 172,
onCell: () => actionCellProps,
render: (_: unknown, record: User) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</Space>
<TableActions
collapsed={actionsCollapsed}
items={[
{ key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: () => handleEdit(record) },
{ type: 'divider' },
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: () => handleDelete(record.id) },
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
</TableActions>
),
},
]
@@ -143,17 +136,19 @@ function Users() {
<h2 style={{ margin: 0 }}></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<div className="page-shell__body">
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
<div className="page-shell__body" ref={containerRef}>
<TableScrollRegion className="data-source-table-region users-table-region">
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content', y: tableHeight }}
scroll={{ x: 'max-content' }}
pagination={false}
size="small"
tableLayout="fixed"
/>
</div>
</TableScrollRegion>
</div>
</div>
<Modal

View File

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

2
uv.lock generated
View File

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