diff --git a/.claude/commands/cleanup.md b/.claude/commands/cleanup.md new file mode 100644 index 00000000..68c17323 --- /dev/null +++ b/.claude/commands/cleanup.md @@ -0,0 +1,120 @@ +--- +description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理 +argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改) +allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"] +--- + +# /cleanup — 垃圾代码审查与清理 + +分析当前工作区(git diff)中的未提交代码,找出并修复常见垃圾代码,**不得改变任何运行逻辑**。 + +## 检查范围 + +若 `$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。 + +## 审查清单 + +按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题): + +### 1. 重复逻辑 (Duplicate Logic) +- 完全相同或高度相似的代码块在多处出现 +- 同一函数/方法被多个地方各自实现,已有公共版本未被复用 +- 相同的 DOM 查询、正则、模板字符串在同一文件重复 + +### 2. Magic Numbers / Magic Strings +- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量 +- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中 +- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算 + +### 3. 命名问题 +- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`) +- 命名与实际用途不符 +- 同一概念在不同地方用不同名字表达 + +### 4. 死代码 / 无效代码 +- 注释掉的旧代码块(3行以上) +- 声明后从未使用的变量/参数/导入 +- 永远不会执行的条件分支 + +### 5. 代码风格问题 +- 尾部空白字符(trailing whitespace) +- 同一文件内风格不一致(如混用单双引号、缩进不统一) +- 空行使用不一致(连续多个空行等) + +### 6. 其他常见问题 +- 私有辅助函数应被 export 但没有,导致调用方重复实现 +- 类型/接口重复定义 +- 过于冗长的条件表达式可以简化(不改逻辑) + +## 执行步骤 + +### Step 1 — 获取待检查文件列表 + +```bash +# 无参数时:获取所有未提交修改 +git diff HEAD --name-only + +# 有参数时:用 $ARGUMENTS 过滤 +``` + +### Step 2 — 逐文件阅读并分析 + +- 用 Read 工具读取完整文件(不只读 diff) +- 对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式 + +### Step 3 — 报告问题清单 + +在修改前,先以列表形式输出所有发现的问题: + +``` +发现 N 个问题: + +[文件] js/foo.js + · L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel() + · L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET + +[文件] js/bar.js + · L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB + ... +``` + +如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。 + +### Step 4 — 执行修复 + +对每个问题,使用 Edit 工具进行**最小化修改**: + +- **重复逻辑**:提取为共享常量/函数,更新所有调用点 +- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用 +- **命名问题**:重命名变量,更新所有使用处 +- **死代码**:直接删除 +- **尾部空白/风格**:修正 +- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现) + +**修复原则:** +- 只改在审查清单中发现的问题,不做额外优化 +- 每次 Edit 只修改确实有问题的行,保持 diff 最小 +- 改完后用 `grep` 验证旧的坏代码已消失 + +### Step 5 — 输出总结 + +``` +清理完成: + +修复了 N 个问题: + ✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量 + ✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 14(4处引用) + ✓ controls.js — export updateLayerButtonState,移除 main.js 中的重复实现 + ... + +未修改的问题(需人工确认): + ! foo.js L45 — 注释代码块较长,建议手动确认是否可删除 +``` + +## 约束 + +- **禁止**改变函数签名、接口定义、导出 API(除非问题正是私有函数应被 export) +- **禁止**添加新功能、新抽象、新参数 +- **禁止**修改注释内容(只删除注释掉的死代码) +- **禁止**修改测试文件逻辑 +- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认" diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 00000000..3708ccd2 --- /dev/null +++ b/.claude/commands/release.md @@ -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 ` +- 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 + +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 更新,提醒用户手动运行 diff --git a/.codex/skills/cleanup/SKILL.md b/.codex/skills/cleanup/SKILL.md new file mode 100644 index 00000000..07359c6c --- /dev/null +++ b/.codex/skills/cleanup/SKILL.md @@ -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 diff --git a/.codex/skills/release-workflow/SKILL.md b/.codex/skills/release-workflow/SKILL.md deleted file mode 100644 index 512f6169..00000000 --- a/.codex/skills/release-workflow/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: release-workflow -description: Use when the user asks to release, bump version, update changelog/version files, or commit/push a repository release for the Planet repo. Applies the repo's versioning rules, updates all required version-bearing files, updates changelog/version-history, runs minimal relevant validation, and then commits/pushes when requested. ---- - -# Release Workflow - -Use this skill for release-oriented work in this repository. - -## When To Use - -- The user asks to `发版` -- The user asks to bump a version -- The user asks to update `CHANGELOG`, `version-history`, or version files as part of a release -- The user asks to commit/push a release or a publishable bugfix/feature bundle - -Do not use this skill for ordinary commits that are not being released. - -## Versioning Rules - -- `feature` -> bump `+0.1.0` -- `bugfix` -> bump `+0.0.1` -- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release - -When intent is mixed, prefer the user’s stated release intent. If they ask to release a bugfix bundle, use a patch bump. - -## Required Files - -Every release bump must update these files together: - -- `/home/ray/dev/linkong/planet/VERSION` -- `/home/ray/dev/linkong/planet/frontend/package.json` -- `/home/ray/dev/linkong/planet/pyproject.toml` -- `/home/ray/dev/linkong/planet/uv.lock` -- `/home/ray/dev/linkong/planet/docs/CHANGELOG.md` -- `/home/ray/dev/linkong/planet/docs/version-history.md` - -## Workflow - -1. Inspect the current worktree and current version. -2. Decide the release type from the user request: - - feature - - bugfix - - release without code changes -3. Compute the next version. -4. Update all required version-bearing files. -5. Add a concise but specific changelog entry: - - highlights - - important added/improved/fixed items - - mention the highest-signal files only -6. Update `docs/version-history.md`: - - current dev version - - new timeline row with summary -7. Run the smallest relevant validation available. -8. Before commit, verify the target version is present in all required files. -9. If the user asked for commit/push: - - stage the release files and code changes - - commit with a conventional message - - push to the requested branch, usually `dev` - -## Validation Guidance - -- Prefer scope-matched validation over broad expensive checks -- Typical examples: - - Python backend edits: `python3 -m py_compile ...` - - Frontend edits: use the project-standard frontend build/check if available -- If the environment prevents a check, say that explicitly in the final summary - -## Release Checklist - -Before closing the task, confirm: - -- version bump applied consistently -- changelog updated -- version history updated -- generated/runtime artifacts are not accidentally staged -- validation status recorded -- commit and push completed if requested diff --git a/.codex/skills/release/SKILL.md b/.codex/skills/release/SKILL.md new file mode 100644 index 00000000..cd02b3fd --- /dev/null +++ b/.codex/skills/release/SKILL.md @@ -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 ` +- 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 + +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 diff --git a/README.md b/README.md index 6ce6ab59..a91acf37 100644 --- a/README.md +++ b/README.md @@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me 详细文档: -- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) +- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md) - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) -- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) -- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) -- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) +- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) +- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) +- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) ## 前端页面布局规范 @@ -346,7 +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/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) ## License diff --git a/TODO.md b/TODO.md index 6da363a7..92a3a977 100644 --- a/TODO.md +++ b/TODO.md @@ -20,3 +20,7 @@ - [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector - [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度 - [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性 +- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置 +- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略 +- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题 +- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector diff --git a/VERSION b/VERSION index 1b58cc10..be386c9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.27.0 +0.33.0 diff --git a/aiprovider/README.md b/aiprovider/README.md index c65a0ba9..cb8651fe 100644 --- a/aiprovider/README.md +++ b/aiprovider/README.md @@ -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) 当前支持: diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 6860a914..54f4f4b6 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -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"]) diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 08b8b154..fedcad0c 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -420,6 +420,7 @@ async def list_datasources( collector_list.append( { "id": datasource.id, + "source": datasource.source, "name": datasource.name, "module": datasource.module, "priority": datasource.priority, diff --git a/backend/app/api/v1/news.py b/backend/app/api/v1/news.py new file mode 100644 index 00000000..03946404 --- /dev/null +++ b/backend/app/api/v1/news.py @@ -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) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index be360ad1..23487b65 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium. from datetime import UTC, datetime import math -from fastapi import APIRouter, HTTPException, Depends, Query +import httpx +from fastapi import APIRouter, HTTPException, Depends, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from typing import List, Dict, Any, Optional @@ -23,6 +24,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS router = APIRouter() +TERRAIN_TILE_URL_TEMPLATE = ( + "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" +) # ============== Converter Functions ============== @@ -782,9 +786,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/landing-points") async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): try: - records = await _load_current_collected_data(db, "arcgis_landing_points") - relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation") - cable_records = await _load_current_collected_data(db, "arcgis_cables") + records_by_source = await _load_current_collected_data_by_sources( + db, + [ + "arcgis_landing_points", + "arcgis_cable_landing_relation", + "arcgis_cables", + ], + ) + records = records_by_source.get("arcgis_landing_points", []) + relation_records = records_by_source.get( + "arcgis_cable_landing_relation", + [], + ) + cable_records = records_by_source.get("arcgis_cables", []) city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps( relation_records, @@ -804,6 +819,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") +@router.get("/terrain/terrarium/{z}/{x}/{y}.png") +async def get_terrarium_tile(z: int, x: int, y: int): + """Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues.""" + if z < 0 or x < 0 or y < 0: + raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates") + + url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y) + + try: + async with httpx.AsyncClient( + timeout=20.0, + follow_redirects=True, + ) as client: + upstream = await client.get(url) + upstream.raise_for_status() + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=exc.response.status_code, + detail=f"Terrain tile upstream error: {exc.response.status_code}", + ) from exc + except httpx.HTTPError as exc: + raise HTTPException( + status_code=502, + detail=f"Terrain tile fetch failed: {exc}", + ) from exc + + cache_control = upstream.headers.get("cache-control") or "public, max-age=86400" + etag = upstream.headers.get("etag") + last_modified = upstream.headers.get("last-modified") + headers = { + "Cache-Control": cache_control, + } + if etag: + headers["ETag"] = etag + if last_modified: + headers["Last-Modified"] = last_modified + + return Response( + content=upstream.content, + media_type=upstream.headers.get("content-type", "image/png"), + headers=headers, + ) + + @router.get("/geo/all") async def get_all_geojson(db: AsyncSession = Depends(get_db)): records_by_source = await _load_current_collected_data_by_sources( diff --git a/backend/app/core/data_sources.py b/backend/app/core/data_sources.py index 4246052e..8a2a7669 100644 --- a/backend/app/core/data_sources.py +++ b/backend/app/core/data_sources.py @@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = { "iptoasn_prefix_geo": "iptoasn.combined_url", "opengeofeed_prefix_geo": "opengeofeed.public_csv_url", "nro_delegated_prefix_geo": "nro.delegated_stats_url", + "news_live_streams": "news_live_streams.channels_url", } diff --git a/backend/app/core/data_sources.yaml b/backend/app/core/data_sources.yaml index c4223f74..17e5658e 100644 --- a/backend/app/core/data_sources.yaml +++ b/backend/app/core/data_sources.yaml @@ -86,3 +86,11 @@ opengeofeed: nro: # NRO delegated stats 下载地址 delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats" + +news_live_streams: + # IPTV-org 频道元数据 JSON + channels_url: "https://iptv-org.github.io/api/channels.json" + # IPTV-org 频道播放流 JSON + streams_url: "https://iptv-org.github.io/api/streams.json" + # IPTV-org 台标 JSON + logos_url: "https://iptv-org.github.io/api/logos.json" diff --git a/backend/app/services/collectors/news_live_streams.py b/backend/app/services/collectors/news_live_streams.py index 84644605..f3b89e99 100644 --- a/backend/app/services/collectors/news_live_streams.py +++ b/backend/app/services/collectors/news_live_streams.py @@ -1,10 +1,16 @@ from __future__ import annotations +import asyncio +import base64 from datetime import UTC, datetime from typing import Any +from urllib.parse import urlparse import httpx +from sqlalchemy import select +from app.core.data_sources import get_data_sources_config +from app.models.datasource_config import DataSourceConfig from app.services.collectors.base import BaseCollector @@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector): data_type = "news_live_stream" fail_on_empty = False + DEFAULT_TIMEOUT = 45.0 + DEFAULT_HEADERS = { + "User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", + "Accept": "application/json", + } + RESPONSE_CANDIDATE_KEYS = ("sources", "streams", "channels", "items", "results", "data") + DEFAULT_ADAPTER = "iptv_org" + DEFAULT_IPTV_ORG_STREAMS_URL = "https://iptv-org.github.io/api/streams.json" + DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json" + DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather") + DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment") + DEFAULT_IPTV_ORG_MAX_SOURCES = 120 + async def fetch(self) -> list[dict[str, Any]]: request_url = (self._resolved_url or "").strip() if not request_url: return [] - async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client: - response = await client.get( + datasource_config = await self._load_datasource_config() + effective_config = self._get_effective_config(datasource_config) + adapter = str(effective_config.get("adapter") or "").strip().lower() + if adapter == "iptv_org": + return await self._fetch_iptv_org(request_url, effective_config) + + request_headers = self._build_request_headers(datasource_config) + request_config = self._get_request_config(datasource_config) + request_params = self._build_request_params(datasource_config) + request_json = self._build_request_json_body(datasource_config) + request_data = self._build_request_form_body(datasource_config) + timeout = self._get_timeout(datasource_config) + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + response = await client.request( + request_config["method"], request_url, - headers={ - "User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)", - "Accept": "application/json", - }, + headers=request_headers, + params=request_params or None, + json=request_json, + data=request_data, ) response.raise_for_status() - return self.parse_response(response.json()) + return self.parse_response( + response.json(), + response_path=request_config["response_path"], + ) - def parse_response(self, response: Any) -> list[dict[str, Any]]: - if isinstance(response, dict): - candidates = response.get("sources") or response.get("streams") or response.get("data") or [] - elif isinstance(response, list): - candidates = response + async def _load_datasource_config(self) -> DataSourceConfig | None: + if not self._db_session: + return None + + result = await self._db_session.execute( + select(DataSourceConfig) + .where(DataSourceConfig.name == self.name) + .where(DataSourceConfig.is_active.is_(True)) + .limit(1) + ) + return result.scalar_one_or_none() + + def _get_effective_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]: + payload = dict(datasource_config.config or {}) if datasource_config else {} + if payload: + return payload + + yaml_config = get_data_sources_config() + return { + "adapter": self.DEFAULT_ADAPTER, + "streams_url": yaml_config.get_yaml_value("news_live_streams.streams_url") + or self.DEFAULT_IPTV_ORG_STREAMS_URL, + "logos_url": yaml_config.get_yaml_value("news_live_streams.logos_url") + or self.DEFAULT_IPTV_ORG_LOGOS_URL, + "news_categories": list(self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES), + "exclude_categories": list(self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES), + "max_sources": self.DEFAULT_IPTV_ORG_MAX_SOURCES, + } + + def _get_request_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]: + payload = self._get_effective_config(datasource_config) + raw_method = payload.get("method") or payload.get("request_method") or "GET" + method = str(raw_method).strip().upper() or "GET" + if method not in {"GET", "POST"}: + method = "GET" + + response_path = payload.get("response_path") or payload.get("payload_path") or payload.get("items_path") + if isinstance(response_path, str): + response_path = response_path.strip() else: - candidates = [] + response_path = None + + return { + "method": method, + "response_path": response_path or None, + } + + def _get_timeout(self, datasource_config: DataSourceConfig | None) -> float: + payload = self._get_effective_config(datasource_config) + try: + return float(payload.get("timeout", self.DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + return self.DEFAULT_TIMEOUT + + def _build_request_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]: + headers = dict(self.DEFAULT_HEADERS) + if datasource_config: + headers.update(self._normalize_headers(datasource_config.headers)) + headers.update(self._build_auth_headers(datasource_config)) + return headers + + def _build_request_params(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]: + params: dict[str, Any] = {} + if not datasource_config: + return params + + payload = datasource_config.config or {} + candidate = payload.get("params") or payload.get("query_params") + if isinstance(candidate, dict): + params.update(candidate) + + if datasource_config.auth_type == "api_key": + auth_config = datasource_config.auth_config or {} + if str(auth_config.get("in") or auth_config.get("location") or "header").lower() == "query": + api_key = auth_config.get("api_key") + key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key" + if api_key and key_name: + params[str(key_name)] = api_key + + return params + + def _build_request_json_body(self, datasource_config: DataSourceConfig | None) -> Any: + if not datasource_config: + return None + + payload = datasource_config.config or {} + body = payload.get("json_body") + if body is None and str(payload.get("body_type") or "").lower() in {"json", ""}: + candidate = payload.get("body") + if isinstance(candidate, (dict, list)): + body = candidate + return body + + def _build_request_form_body(self, datasource_config: DataSourceConfig | None) -> Any: + if not datasource_config: + return None + + payload = datasource_config.config or {} + form_body = payload.get("form_body") + if form_body is not None: + return form_body + + if str(payload.get("body_type") or "").lower() == "form": + candidate = payload.get("body") + if isinstance(candidate, dict): + return candidate + return None + + def _normalize_headers(self, headers: Any) -> dict[str, str]: + if not isinstance(headers, dict): + return {} + normalized: dict[str, str] = {} + for key, value in headers.items(): + header_name = str(key).strip() + if not header_name or value is None: + continue + normalized[header_name] = str(value) + return normalized + + def _build_auth_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]: + if not datasource_config: + return {} + + auth_type = str(datasource_config.auth_type or "none").lower() + auth_config = datasource_config.auth_config or {} + if auth_type == "bearer" and auth_config.get("token"): + return {"Authorization": f"Bearer {auth_config['token']}"} + + if auth_type == "api_key" and auth_config.get("api_key"): + location = str(auth_config.get("in") or auth_config.get("location") or "header").lower() + if location == "query": + return {} + key_name = auth_config.get("key_name") or "X-API-Key" + return {str(key_name): str(auth_config["api_key"])} + + if auth_type == "basic": + username = str(auth_config.get("username") or "") + password = str(auth_config.get("password") or "") + encoded = base64.b64encode(f"{username}:{password}".encode()).decode() + return {"Authorization": f"Basic {encoded}"} + + return {} + + def _extract_candidates(self, response: Any, response_path: str | None) -> list[Any]: + if response_path: + extracted = self._extract_from_path(response, response_path) + if isinstance(extracted, list): + return extracted + if isinstance(extracted, dict): + for key in self.RESPONSE_CANDIDATE_KEYS: + nested = extracted.get(key) + if isinstance(nested, list): + return nested + return [extracted] + + if isinstance(response, dict): + for key in self.RESPONSE_CANDIDATE_KEYS: + nested = response.get(key) + if isinstance(nested, list): + return nested + return [] + + if isinstance(response, list): + return response + return [] + + def _extract_from_path(self, payload: Any, path: str) -> Any: + current = payload + for segment in (part.strip() for part in path.split(".") if part.strip()): + if isinstance(current, dict): + current = current.get(segment) + continue + if isinstance(current, list): + try: + current = current[int(segment)] + except (TypeError, ValueError, IndexError): + return None + continue + return None + return current + + def _infer_source_type(self, item: dict[str, Any]) -> str: + explicit = str(item.get("source_type") or item.get("type") or "").strip().lower() + if explicit in {"iframe", "hls", "video", "external", "youtube"}: + return explicit + + youtube_video_id = self._clean_text( + item.get("youtube_video_id") + or item.get("video_id") + or item.get("youtubeVideoId") + ) + youtube_channel = self._clean_text(item.get("youtube_channel") or item.get("channel_handle")) + embed_url = self._clean_url(item.get("embed_url") or item.get("embed") or item.get("page_url")) + stream_url = self._clean_url(item.get("stream_url") or item.get("stream") or item.get("playback_url") or item.get("hls_url")) + homepage_url = self._clean_url(item.get("homepage_url") or item.get("source_url") or item.get("website")) + + if youtube_video_id or youtube_channel: + return "youtube" + if stream_url.endswith(".m3u8"): + return "hls" + if stream_url: + return "video" + if embed_url: + parsed = urlparse(embed_url) + if "youtube.com" in (parsed.netloc or "") or "youtu.be" in (parsed.netloc or ""): + return "youtube" + return "iframe" + if homepage_url: + return "external" + return "iframe" + + def _parse_enabled(self, item: dict[str, Any]) -> bool: + if "is_enabled" in item: + return self._to_bool(item.get("is_enabled"), default=True) + if "enabled" in item: + return self._to_bool(item.get("enabled"), default=True) + if "active" in item: + return self._to_bool(item.get("active"), default=True) + if "status" in item: + status = str(item.get("status") or "").strip().lower() + if status in {"disabled", "inactive", "offline"}: + return False + if status in {"enabled", "active", "online", "live"}: + return True + return True + + def _to_bool(self, value: Any, *, default: bool) -> bool: + if isinstance(value, bool): + return value + if value in (None, ""): + return default + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on", "enabled", "active", "online", "live"}: + return True + if lowered in {"0", "false", "no", "off", "disabled", "inactive", "offline"}: + return False + return bool(value) + + def _clean_text(self, value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + def _clean_url(self, value: Any) -> str: + text = self._clean_text(value) + if not text: + return "" + parsed = urlparse(text) + if parsed.scheme and parsed.scheme not in {"http", "https"}: + return "" + if parsed.scheme and not parsed.netloc: + return "" + return text + + async def _fetch_iptv_org(self, channels_url: str, collector_config: dict[str, Any]) -> list[dict[str, Any]]: + streams_url = self._clean_url(collector_config.get("streams_url")) or self.DEFAULT_IPTV_ORG_STREAMS_URL + logos_url = self._clean_url(collector_config.get("logos_url")) or self.DEFAULT_IPTV_ORG_LOGOS_URL + news_categories = { + self._clean_text(value).lower() + for value in (collector_config.get("news_categories") or self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES) + if self._clean_text(value) + } + exclude_categories = { + self._clean_text(value).lower() + for value in (collector_config.get("exclude_categories") or self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES) + if self._clean_text(value) + } + try: + max_sources = int(collector_config.get("max_sources", self.DEFAULT_IPTV_ORG_MAX_SOURCES)) + except (TypeError, ValueError): + max_sources = self.DEFAULT_IPTV_ORG_MAX_SOURCES + + timeout = self.DEFAULT_TIMEOUT + try: + timeout = float(collector_config.get("timeout", self.DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + timeout = self.DEFAULT_TIMEOUT + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + channels_payload, streams_payload, logos_payload = await self._gather_iptv_org_payloads( + client, + channels_url, + streams_url, + logos_url, + ) + + channels = channels_payload if isinstance(channels_payload, list) else [] + streams = streams_payload if isinstance(streams_payload, list) else [] + logos = logos_payload if isinstance(logos_payload, list) else [] + + logo_by_channel = { + self._clean_text(item.get("channel")): self._clean_url(item.get("url")) + for item in logos + if isinstance(item, dict) and self._clean_text(item.get("channel")) and self._clean_url(item.get("url")) + } + + streams_by_channel: dict[str, list[dict[str, Any]]] = {} + for stream in streams: + if not isinstance(stream, dict): + continue + channel_id = self._clean_text(stream.get("channel")) + if not channel_id: + continue + streams_by_channel.setdefault(channel_id, []).append(stream) + + normalized: list[dict[str, Any]] = [] + for channel in channels: + if not isinstance(channel, dict): + continue + + categories = [ + self._clean_text(value).lower() + for value in (channel.get("categories") or []) + if self._clean_text(value) + ] + if news_categories and not any(category in news_categories for category in categories): + continue + if exclude_categories and any(category in exclude_categories for category in categories): + continue + if channel.get("is_nsfw") is True: + continue + if channel.get("closed"): + continue + + channel_id = self._clean_text(channel.get("id")) + if not channel_id: + continue + + stream = self._pick_iptv_org_stream(streams_by_channel.get(channel_id) or []) + if not stream: + continue + + stream_url = self._clean_url(stream.get("url")) + if not stream_url: + continue + + name = self._clean_text(channel.get("name")) or channel_id + notes_parts = [ + f"Imported from IPTV-org catalog ({channel_id})", + f"Categories: {', '.join(categories)}" if categories else "", + f"Quality: {self._clean_text(stream.get('quality'))}" if self._clean_text(stream.get("quality")) else "", + ] + metadata = { + "provider": self._clean_text(channel.get("network")) or "IPTV-org", + "region": self._clean_text(channel.get("country")) or "Global", + "language": "und", + "source_type": "hls" if stream_url.endswith(".m3u8") else "video", + "embed_url": "", + "stream_url": stream_url, + "homepage_url": self._clean_url(channel.get("website")), + "poster_url": logo_by_channel.get(channel_id, ""), + "youtube_video_id": "", + "youtube_channel": "", + "sort_order": 400 + len(normalized), + "notes": "; ".join(part for part in notes_parts if part), + "is_enabled": True, + "collector_adapter": "iptv_org", + "channel_id": channel_id, + "categories": categories, + "quality": self._clean_text(stream.get("quality")), + "stream_label": self._clean_text(stream.get("label") or stream.get("title")), + "stream_referrer": self._clean_text(stream.get("referrer")), + "stream_user_agent": self._clean_text(stream.get("user_agent")), + } + + normalized.append( + { + "source_id": channel_id, + "name": name, + "description": metadata["notes"], + "metadata": metadata, + "reference_date": datetime.now(UTC).isoformat(), + } + ) + if len(normalized) >= max_sources: + break + + return normalized + + async def _gather_iptv_org_payloads( + self, + client: httpx.AsyncClient, + channels_url: str, + streams_url: str, + logos_url: str, + ) -> tuple[Any, Any, Any]: + headers = dict(self.DEFAULT_HEADERS) + channels_payload, streams_payload, logos_payload = await asyncio.gather( + client.get(channels_url, headers=headers), + client.get(streams_url, headers=headers), + client.get(logos_url, headers=headers), + ) + channels_payload.raise_for_status() + streams_payload.raise_for_status() + logos_payload.raise_for_status() + return channels_payload.json(), streams_payload.json(), logos_payload.json() + + def _pick_iptv_org_stream(self, streams: list[dict[str, Any]]) -> dict[str, Any] | None: + if not streams: + return None + + def score(stream: dict[str, Any]) -> tuple[int, int]: + url = self._clean_url(stream.get("url")) + quality = self._clean_text(stream.get("quality")).lower() + quality_score = 0 + if quality.endswith("p"): + try: + quality_score = int(quality[:-1]) + except ValueError: + quality_score = 0 + stream_score = 1000 if url.endswith(".m3u8") else 0 + return stream_score, quality_score + + sorted_streams = sorted(streams, key=score, reverse=True) + return sorted_streams[0] + + def parse_response(self, response: Any, *, response_path: str | None = None) -> list[dict[str, Any]]: + candidates = self._extract_candidates(response, response_path) normalized: list[dict[str, Any]] = [] for index, item in enumerate(candidates): if not isinstance(item, dict): continue - stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}" - name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip() + stream_id = ( + item.get("id") + or item.get("source_id") + or item.get("slug") + or item.get("channel_id") + or item.get("code") + or f"news-live-{index + 1}" + ) + name = self._clean_text( + item.get("name") + or item.get("title") + or item.get("channel") + or item.get("display_name") + or f"News Live {index + 1}" + ) if not name: continue + source_type = self._infer_source_type(item) + stream_url = self._clean_url( + item.get("stream_url") + or item.get("stream") + or item.get("playback_url") + or item.get("hls_url") + or item.get("m3u8_url") + ) + embed_url = self._clean_url( + item.get("embed_url") + or item.get("embed") + or item.get("page_url") + or (item.get("url") if source_type == "iframe" else "") + ) + homepage_url = self._clean_url( + item.get("homepage_url") + or item.get("source_url") + or item.get("website") + or item.get("url") + ) metadata = { - "provider": item.get("provider") or item.get("publisher") or "Collector", - "region": item.get("region") or item.get("country") or "Global", - "language": item.get("language") or "und", - "source_type": item.get("source_type") or "iframe", - "embed_url": item.get("embed_url") or item.get("url") or "", - "stream_url": item.get("stream_url") or "", - "homepage_url": item.get("homepage_url") or item.get("source_url") or "", - "poster_url": item.get("poster_url") or "", + "provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector", + "region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global", + "language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und", + "source_type": source_type, + "embed_url": embed_url, + "stream_url": stream_url, + "homepage_url": homepage_url, + "poster_url": self._clean_url(item.get("poster_url") or item.get("thumbnail_url") or item.get("logo_url")), + "youtube_video_id": self._clean_text( + item.get("youtube_video_id") + or item.get("video_id") + or item.get("youtubeVideoId") + ), + "youtube_channel": self._clean_text( + item.get("youtube_channel") + or item.get("channel_handle") + or item.get("youtubeChannel") + ), "sort_order": item.get("sort_order", 200 + index), - "notes": item.get("notes") or item.get("description") or "", - "is_enabled": item.get("is_enabled", True), + "notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")), + "is_enabled": self._parse_enabled(item), } normalized.append( @@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector): "name": name, "description": metadata["notes"], "metadata": metadata, - "reference_date": item.get("reference_date", datetime.now(UTC).isoformat()), + "reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(), } ) diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py new file mode 100644 index 00000000..22f359a1 --- /dev/null +++ b/backend/app/services/earth_news.py @@ -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, + ) diff --git a/backend/app/services/tv_streams.py b/backend/app/services/tv_streams.py index 7e28b3e1..a6d83d91 100644 --- a/backend/app/services/tv_streams.py +++ b/backend/app/services/tv_streams.py @@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams" TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream" DEFAULT_TV_SETTINGS = { - "default_source_id": DEFAULT_TV_SOURCE_ID, + "default_source_id": DEFAULT_TV_SOURCE_ID, "auto_fallback": True, "sources": [ { @@ -362,7 +362,7 @@ def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, A "sort_order": metadata.get("sort_order", 200 + index), "collector_source": record.source, "notes": record.description or metadata.get("notes") or "", - "updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)), + "updated_at": to_iso8601_utc(record.collected_at or record.reference_date or datetime.now(UTC)), }, index=index, ) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 180543e6..cdea90d8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,8 +5,331 @@ 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.33.0] — 2026-04-22 + +### ✨ Highlights +- `news_live_streams` 采集器默认接入 `iptv-org` 频道目录,并将采集结果稳定并入 Earth TV 直播源列表 +- 数据源页支持直接编辑内置数据源 override,并为内置源提供一键恢复默认配置入口 + +### 🔧 Improvements +- `News Live Streams` 现在作为可直接触发的内置默认数据源提供,无需先手工补 override 才能采集 +- TV 播放源菜单会直接区分 `[内置]` 和 `[采集]` 来源,频道来源信息也会同步展示 +- 新增 [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md),正式规划 Earth 态势新闻源配置化与后续采集器化路线 + +### 🐛 Fixes +- 修复 `news_live_streams` 采集完成后 `/api/v1/tv/streams` 因读取不存在的 `updated_at` 字段而导致默认频道全部消失的问题 +- 修复内置数据源操作列按钮显示不全,以及编辑抽屉中多个 `Collapse` 紧贴的问题 + +--- + +## [0.32.0] — 2026-04-22 + +### ✨ Highlights +- Earth 设置新增“地球默认大小”持久化项,重置视角、缩放百分比重置和 BGP 巡航视图现在统一复用这一份默认 zoom +- 卫星焦点层次继续收口:巡航进入 presentation 前不再过早 dim,非焦点卫星改成“降亮度/尾迹/背板”而不是去饱和度 + +### 🔧 Improvements +- Earth 设置面板区块和左右留白进一步收紧,整体更贴近 HUD 面板的密度 +- toolbar 展开边界缓存改为按需刷新,减少 document 级 mousemove 期间的重复布局读取 +- Scrollbar 和 ScrollbarOverlay 收窄 observer 范围,减少大表格和动态菜单下的额外刷新成本 +- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md),补充默认视图大小已进入 Earth 设置持久化真源 + +### 🐛 Fixes +- 修复开启巡航后,尚未进入连线/presentation 时卫星已经整体变暗的问题 +- 修复默认大小重置链路分散在多个入口、实际 reset/cruise/缩放提示不一致的问题 +- 修复开启地形后卫星反馈层与地球背面可见性之间的一组表现问题,保留正面反馈同时恢复背面轨道遮挡 + +--- + +## [0.31.3] — 2026-04-22 + +### ✨ Highlights +- Earth 图层注册表和启动任务框架继续收口,启动顺序、启动模式、启动提示和任务注册现在都能从统一入口扩展 +- 修复 Earth 普通旋转模式与巡航模式切换时的一组交互回归,同时让卫星/地形/昼夜模式的表现更稳定 + +### 🔧 Improvements +- 新增 [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) 启动任务注册表,支持 `registerLayerStartupTask(id, taskFactory)`,并拆成海缆 / 卫星 / BGP 独立注册函数 +- Earth 图层控制改成注册表驱动,统一承载 `startupPriority`、`startupMode`、`startupLabel`、`startupMessage` 与图层持久化元信息 +- Earth 设置支持持久化图层开关、旋转模式、HUD 面板显示状态、地形透明度与日夜模式,并提供一键重置 +- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 记录图层注册表、启动任务、设置持久化与巡航适配边界 + +### 🐛 Fixes +- 修复普通旋转模式下点击海缆 / 卫星 / BGP 后卡片和选中表现会被异常清空的问题 +- 修复巡航模式切回旋转再切回巡航后无法继续自动巡航的问题 +- 修复开启地形后卫星选中反馈层被高海拔区域吞掉的问题,并恢复轨道只在地球前半侧可见 +- 修复关闭日夜模式后地球照明仍沿真实昼夜切换、亮部过曝和偏色的问题,改成更中性的 inspection lighting +- 修复 toolbar 收起态仍挡住地球交互,以及首帧短暂展开闪现的问题 + +--- + +## [0.31.0] — 2026-04-21 + +## [0.31.2] — 2026-04-21 + +### ✨ Highlights +- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机 +- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场 + +### 🔧 Improvements +- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复 +- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画 +- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配 +- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界 + +### 🐛 Fixes +- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题 +- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题 +- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点 + +--- + +## [0.31.1] — 2026-04-21 + +### ✨ Highlights +- Earth 图层开关状态统一成可复用的 `active / loading` 状态机,首次启用地形和卫星时不再像按钮失效 +- 文档目录重构为 `docs/technical`、`docs/plans`、`docs/deprecated`,并吸收 `.sisyphus/plans` 中有价值的 Earth / 卫星 / UE5 草案 + +### 🔧 Improvements +- 新增 [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js),统一按钮 tooltip、`aria-busy`、禁用态和状态文本同步 +- 地形图层支持 hover/focus 预热与空闲预热,首次点击等待前移,加载中状态持续可见 +- 卫星图层启用前会立即切换为 `loading` 中间态,请求完成后再切回正常开关表现 + +### 🐛 Fixes +- 修复地形首次加载时通知过早消失、开关仍像关闭状态导致用户误判按钮损坏的问题 +- 修复卫星接口较慢时按钮没有任何中间态反馈的问题 + +--- + +### ✨ Features +- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式) +- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆 +- BGP 事件图标新增填充 W 形波动符号(flap 类型),替换原有难以辨认的贝塞尔细线 +- 巡航/点击激活时其余卫星自动降饱和度 + 增加透明度以突出焦点;海缆未受影响时同步变暗 + +### 🔧 Improvements +- 修复巡航轮播期间 BGP 事件 polling 刷新导致标记闪烁消失的问题(clearBGPData 延迟到请求完成后执行) +- 点击与巡航锁定颜色统一为 hover 色(0.92, 0.98, 1.0 全透明),移除锁定态脉冲动画 +- 巡航连接折线转折点从尖角调整为钝角(linkElbowDropPx),提升连线可读性 + +--- + +## [0.29.1] — 2026-04-20 + +## [0.30.0] — 2026-04-21 + +### ✨ Features +- Earth 新增真实地形图层:后端代理 Terrarium DEM 瓦片(`/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png`),前端新增 `terrain.js` 负责瓦片拉取、顶点位移与按海拔着色 +- 设置弹窗新增"地形"分组,支持通过滑块实时调整地形图层透明度 + +### 🔧 Improvements +- 地形按钮改为异步加载,首次点击显示进度提示并在失败时自动回退 +- 启动阶段改用 `applyImmediateView` 直接应用初始视角,`showStatusMessage` / `queueStatusMessage` 区分即时与队列态状态消息,加载中不再被临时状态打断 +- 控制面板抽取 `applyTerrainUiState` / `getViewRotation` 收敛地形切换与视角旋转的重复 UI 同步逻辑 + +--- + +## [0.29.2] — 2026-04-21 + +### ✨ Highlights +- Earth 继续收口 HUD 交互与设置面板表现,设置弹窗改成更接近从按钮展开的窗口感,同时加入系统级 admin 入口 +- 修正天球太阳方向与地球受光解耦后的日照逻辑,地表昼夜判断改为按太阳直射点经纬度落到地球贴图坐标 + +### 🔧 Improvements +- toolbar 进一步收成更贴近 hub 的浅弓形排列,并统一成与 HUD panel 一致的液态玻璃配色与透明度 +- 设置弹窗与各 HUD panel 继续统一样式、等比缩放和头部基线,设置列表补充系统分组与 admin 跳转 +- 所有 HUD panel 增加更统一的液态玻璃高光与 hover / press 反馈 + +### 🐛 Fixes +- 修复设置弹窗仍像旧圆角矩形、标题文案重复和从底边直直飞出的动画问题 +- 修复天球与太阳方向混用显示校准导致中国白天仍落在夜面的日照错误 + +--- + +## [0.29.1] — 2026-04-20 + +### ✨ Highlights +- Earth 加载状态条改成单一队列式通知面板,加载阶段不再因为步骤文案变化而回缩,也不会被其他通知打断 +- 调整 brand panel 的呈现方式与昼夜/选中态可读性,让品牌区更自然、交互高亮在白天和黑夜里都更稳定 + +### 🔧 Improvements +- 移除旧的地球加载浮层结构,统一由 HUD 状态消息承载三点脉冲加载过程 +- brand panel 改为无边框品牌层,仅保留轻微氛围光,不再因为非常规尺寸显得像第五块功能面板 +- 温和收敛地球昼夜材质与主背光强度,保留昼夜辨识度的同时提升白天地表纹理和夜面交互可见性 + +### 🐛 Fixes +- 修复加载地球时通知条在步骤切换中反复缩短、其他状态消息抢占加载流程的问题 +- 修复海缆、登陆点和 BGP 选中高亮在黑夜中过暗、在高光中过亮导致难以辨识的问题 + +--- + +## [0.29.0] — 2026-04-20 + +### ✨ Highlights +- Earth 新增天球层第一版:引入真实全天星图、亮星层与太阳/月亮位置计算,地球场景首次具备可校准的天文背景 +- 地球昼夜分隔升级为更明显的日夜增强效果,夜面、晨昏带和太阳方向联动更容易直接读出来 + +### 🔧 Improvements +- 新增 `celestial.js` 模块和 `assets/celestial/` 资源目录,统一管理星图、亮星数据以及太阳/月亮与光照同步 +- 卫星图例改为按倾角分组,严格固定为“赤道轨道 → 低倾角轨道 → 中倾角轨道 → 高倾角轨道 → 逆行轨道”顺序,并全部中文化 +- 图层面板补齐关闭按钮,拖拽脱离左列后不再被流布局 margin 影响,能够真正贴到品牌面板下沿 + +### 🐛 Fixes +- 修复天球球壳放大后被相机 far plane 裁剪导致的外层黑环问题 +- 修复图层面板在左侧上移时始终与 brand panel 保持额外间距的问题 + +--- + +## [0.28.2] — 2026-04-20 + +### ✨ Highlights +- 修正媒体情报面板在 `电视直播 / 态势聚合` tab 间切换时的尺寸记忆逻辑,切回原 tab 后可恢复各自大小状态 +- 清理 `docs/` 根目录遗留的旧路径文档,只保留新的分组目录和归档目录,结束同一文档双路径并存状态 + +### 🔧 Improvements +- `media-panel` 切换逻辑改成按 tab 分别记忆尺寸状态,避免 `A -> B -> A` 时继续共用同一套外层尺寸 +- 目录整理真正完成收尾:旧的 `docs/*.md` 平铺计划文档删除,继续以 `docs/agents / earth / backend / frontend / ops / ue5 / deprecated` 为唯一入口 + +### 🐛 Fixes +- 修复拉伸 `media-panel` 后切换 tab 时,`news-panel` 高度回退到旧默认值的问题 +- 修复拉伸后切换 tab 导致面板视觉锚点异常的问题,切换时改为围绕当前卡片自身右下角进行尺寸恢复 + +--- + +## [0.28.1] — 2026-04-20 + +### ✨ Highlights +- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界 +- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated` + +### 🔧 Improvements +- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合” +- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义 +- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构 + +### 🐛 Fixes +- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位 +- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签 + +--- + +## [0.28.0] — 2026-04-20 + +### ✨ Features +- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区 +- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现 + +说明: +- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel` 和 `news-panel` + +### 🔧 Improvements +- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口 +- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀 +- `tv.js`、`news.js` 进一步清理共享 HUDPanel 迁移后的残留逻辑,收紧 tab / resize / reform 相关局部 helper + +### 🐛 Fixes +- 修复媒体情报面板右下角缩放时高度异常抬升、标题栏被顶出视口的问题 +- 修复直播/聚合 tab 切换时按钮高亮、内容切换和底边基准表现不一致的问题 + +--- + +## [0.27.7] — 2026-04-16 + +## [0.27.8] — 2026-04-20 + +### 🔧 Improvements +- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机 +- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响 + +### 🐛 Fixes +- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题 +- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题 + +--- + +## [0.27.7] — 2026-04-16 + +### 🔧 Improvements +- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压 +- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览 +- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源 + +### 🐛 Fixes +- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题 +- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化 +- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题 +- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题 + +--- + +## [0.27.6] — 2026-04-15 + +### 🔧 Improvements +- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链,tabs content-holder 正确撑满剩余高度 +- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部 +- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口 +- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer + +--- + +## [0.27.4] — 2026-04-14 + +### 🔧 Improvements +- info-card 改为懒加载动态挂载:页面初始 DOM 不再含隐藏的 `#info-panel` 节点,仅首次点击交互元素时创建 + +--- + +## [0.27.5] — 2026-04-14 + +### 🔧 Improvements +- 统一控制台多页面滚动体验:BGP、alerts、采集数据、用户管理、任务、设置、Playground 等区域接入自定义滚动条与表格滚动容器 +- 优化 BGP 与 alerts 页响应式布局:顶部概览卡在窄宽度下优先重排,必要时才启用横向滚动,避免卡片裁切和全局滚动条接管 +- 调整 `situational alerts` 布局策略:统计卡按宽度在单行、两列和横滚之间切换,下方详情卡保持单行高度优先 +- 实时采集进度优化:一键采集完成后在未刷新页面时保留 100% 完成态,不再错误归零 +- 补充 UE5 MVP 融合方案文档,完善后续集成规划沉淀 + +### 🐛 Fixes +- 修复 BGP summary 与 alerts 顶部卡片在无真实溢出时误出现横向滚动的问题 +- 修复 alerts 页面缩窄后外层全局竖向滚动被接管的问题,恢复“一屏内、内部滚动”的布局逻辑 +- 修复 `situational alerts` 在两排布局下详情卡竖向溢出的问题,改为更稳定的分区响应式排版 +- 修复自定义滚动条交互反馈,悬停、聚焦、拖拽时颜色加深但不再显示多余外圈 + +--- + +## [0.27.3] — 2026-04-14 + +### 🔧 Improvements +- TV panel meta 折叠展开方向稳定:底部锚定时向上生长,拖拽后(顶部锚定)通过 JS 补偿 top 保持播放器底部位置不变 +- 修复 TV panel 展开/折叠时视频区域跳动问题:移除面板 min-height,使播放器高度在两种状态下保持一致 +- 修正 TV panel meta toggle 箭头方向:展开朝下,折叠朝上 +- 修复图例面板折叠按钮失效(legend-bar-btn 补充进拖拽排除列表) +- 调整图层搜索框图标尺寸为 20px,BR 缩放角标改为直角 L 形 + +--- + +## [0.27.2] — 2026-04-14 + +### 🔧 Improvements +- 修复 brand copy 宽度不随内容收缩的问题,现在与 title 图片宽度保持一致 +- 提取 `--brand-copy-width` CSS 自定义属性,消除 160px / 172px 魔法数字重复 + +--- + +## [0.27.1] — 2026-04-14 + +### 🔧 Improvements +- 面板拖拽新增 L 形边界约束,其他面板无法覆盖 brand 面板区域,并从右侧/底部自然卡边 +- brand 组件引入 `--brand-scale` 整体缩放变量,padding 与内容尺寸独立控制 +- 图层控制面板宽度收窄(260px),与 brand 面板错落排列,间距调大 + +### 🐛 Fixes +- 修复搜索框 `type="search"` 导致清除按钮重复显示的问题 +- 修复 `[hidden]` 属性被组件 `display` 规则覆盖的问题 + +--- + ## 0.27.0 Released: 2026-04-14 @@ -80,7 +403,7 @@ Released: 2026-04-12 - Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion. - Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling. -- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion. +- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/technical/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/technical/earth-news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion. ### Improved @@ -166,7 +489,7 @@ Released: 2026-04-10 - Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling. - Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page. -- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation. +- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation. ### Fixed @@ -204,7 +527,7 @@ Released: 2026-04-10 ### Improved - Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks. -- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.” +- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.” ## 0.24.6 @@ -221,7 +544,7 @@ Released: 2026-04-10 - Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database. - Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads. - Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully. -- Improved [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up. +- Improved [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up. ### Fixed @@ -327,8 +650,8 @@ Released: 2026-04-09 ### Added - Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance. -- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling. -- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion. +- Added [docs/frontend/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling. +- Added [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion. ### Improved @@ -433,7 +756,7 @@ Released: 2026-04-07 - Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry. - Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service. - Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates. -- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns. +- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns. - Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py). ### Improved @@ -559,7 +882,7 @@ Released: 2026-04-02 - Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py). - Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels. -- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md). +- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-prefix-geography-plan.md). - Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py). - Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py). - Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures. @@ -574,7 +897,7 @@ Released: 2026-04-02 - Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently. - Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime. - Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging. -- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work. +- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work. ### Fixed @@ -732,7 +1055,7 @@ Released: 2026-03-31 - Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py). - Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py). - Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts. -- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md). +- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md). ### Improved @@ -935,7 +1258,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. diff --git a/docs/deprecated/README.md b/docs/deprecated/README.md new file mode 100644 index 00000000..30596739 --- /dev/null +++ b/docs/deprecated/README.md @@ -0,0 +1,22 @@ +# Deprecated Docs + +这个目录用于存放两类文档: + +1. 已经完成、主要保留为历史记录的实施计划 +2. 已被现有实现或新方案替代的旧计划 + +放到这里并不代表这些文档“错误”,而是表示: + +- 它们不再适合作为当前开发的主指导文档 +- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考 + +当前归档原则: + +- 明确写明“已完成”的计划,优先归档 +- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档 +- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/` + +补充说明: + +- 一部分归档文档来自外部或临时工作流草案,例如 sisyphus 生成的初稿 +- 这类文档如果有可用内容,应先吸收到 `docs/plans/` 或 `docs/technical/`,再归档保留来源记录 diff --git a/docs/collected-data-column-removal-plan.md b/docs/deprecated/collected-data-column-removal-plan.md similarity index 100% rename from docs/collected-data-column-removal-plan.md rename to docs/deprecated/collected-data-column-removal-plan.md diff --git a/docs/earth-module-plan.md b/docs/deprecated/earth-module-plan.md similarity index 100% rename from docs/earth-module-plan.md rename to docs/deprecated/earth-module-plan.md diff --git a/docs/earth-tv-live-module-plan.md b/docs/deprecated/earth-tv-live-module-plan.md similarity index 100% rename from docs/earth-tv-live-module-plan.md rename to docs/deprecated/earth-tv-live-module-plan.md diff --git a/docs/deprecated/hud-panel-component-plan.md b/docs/deprecated/hud-panel-component-plan.md new file mode 100644 index 00000000..4abb802f --- /dev/null +++ b/docs/deprecated/hud-panel-component-plan.md @@ -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. diff --git a/.sisyphus/plans/earth-architecture-refactor.md b/docs/deprecated/sisyphus-earth-architecture-refactor.md similarity index 95% rename from .sisyphus/plans/earth-architecture-refactor.md rename to docs/deprecated/sisyphus-earth-architecture-refactor.md index 2ce150a7..6f5c9042 100644 --- a/.sisyphus/plans/earth-architecture-refactor.md +++ b/docs/deprecated/sisyphus-earth-architecture-refactor.md @@ -1,3 +1,5 @@ +> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate. + # 地球3D可视化架构重构计划 ## 背景 diff --git a/.sisyphus/plans/predicted-orbit.md b/docs/deprecated/sisyphus-predicted-orbit.md similarity index 95% rename from .sisyphus/plans/predicted-orbit.md rename to docs/deprecated/sisyphus-predicted-orbit.md index bc407693..3adae115 100644 --- a/.sisyphus/plans/predicted-orbit.md +++ b/docs/deprecated/sisyphus-predicted-orbit.md @@ -1,3 +1,5 @@ +> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate. + # 卫星预测轨道显示功能 ## TL;DR diff --git a/.sisyphus/plans/ue5_client.md b/docs/deprecated/sisyphus-ue5-client.md similarity index 97% rename from .sisyphus/plans/ue5_client.md rename to docs/deprecated/sisyphus-ue5-client.md index 2c8c619a..b230bd7f 100644 --- a/.sisyphus/plans/ue5_client.md +++ b/docs/deprecated/sisyphus-ue5-client.md @@ -1,3 +1,5 @@ +> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate. + # UE5 3D 大屏客户端开发计划 ## 项目概述 diff --git a/.sisyphus/plans/webgl-instancing-satellites.md b/docs/deprecated/sisyphus-webgl-instancing-satellites.md similarity index 97% rename from .sisyphus/plans/webgl-instancing-satellites.md rename to docs/deprecated/sisyphus-webgl-instancing-satellites.md index c9a61e95..30306151 100644 --- a/.sisyphus/plans/webgl-instancing-satellites.md +++ b/docs/deprecated/sisyphus-webgl-instancing-satellites.md @@ -1,3 +1,5 @@ +> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate. + # WebGL Instancing 卫星渲染优化计划 ## 背景 diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 00000000..2062a1c2 --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,35 @@ +# Plans Docs + +这里放“未来实施方案和未完成计划”的文档,重点回答: + +- 我们准备做什么 +- 为什么要做 +- 分几期做 +- 当前差距和下一步是什么 + +适合放入这里的内容: + +- Earth / BGP / 地形 / 天球实施方案 +- AI Playground 发展计划 +- backend / datasource / agent roadmap +- UE5 MVP 方案 + +当前重点入口: + +- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md) +- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md) +- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md) +- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md) +- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md) +- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) +- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md) + +不适合放入这里的内容: + +- 当前代码结构说明 +- 组件现状和实现入口 +- 已经落地的技术上下文说明 + +这些应放入: + +- [docs/technical/README.md](/home/ray/dev/linkong/planet/docs/technical/README.md) diff --git a/docs/agent-architecture-plan.md b/docs/plans/agents-agent-architecture-plan.md similarity index 100% rename from docs/agent-architecture-plan.md rename to docs/plans/agents-agent-architecture-plan.md diff --git a/docs/agent-runtime-roadmap.md b/docs/plans/agents-agent-runtime-roadmap.md similarity index 95% rename from docs/agent-runtime-roadmap.md rename to docs/plans/agents-agent-runtime-roadmap.md index 70fff821..ced65307 100644 --- a/docs/agent-runtime-roadmap.md +++ b/docs/plans/agents-agent-runtime-roadmap.md @@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r Related documents: -- [aiprovider](/home/ray/dev/linkong/planet/docs/aiprovider.md) -- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/datasource-health-plan.md) -- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agent-architecture-plan.md) +- [aiprovider](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md) +- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/plans/agents-datasource-health-plan.md) +- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/plans/agents-agent-architecture-plan.md) ## Big Picture diff --git a/docs/datasource-health-plan.md b/docs/plans/agents-datasource-health-plan.md similarity index 100% rename from docs/datasource-health-plan.md rename to docs/plans/agents-datasource-health-plan.md diff --git a/docs/datasource-health-stage2-tasks.md b/docs/plans/agents-datasource-health-stage2-tasks.md similarity index 100% rename from docs/datasource-health-stage2-tasks.md rename to docs/plans/agents-datasource-health-stage2-tasks.md diff --git a/docs/situational-awareness-foundation-plan.md b/docs/plans/agents-situational-awareness-foundation-plan.md similarity index 100% rename from docs/situational-awareness-foundation-plan.md rename to docs/plans/agents-situational-awareness-foundation-plan.md diff --git a/docs/collected-data-history-plan.md b/docs/plans/backend-collected-data-history-plan.md similarity index 100% rename from docs/collected-data-history-plan.md rename to docs/plans/backend-collected-data-history-plan.md diff --git a/docs/system-settings-plan.md b/docs/plans/backend-system-settings-plan.md similarity index 100% rename from docs/system-settings-plan.md rename to docs/plans/backend-system-settings-plan.md diff --git a/docs/bgp-earth-rendering-plan.md b/docs/plans/earth-bgp-earth-rendering-plan.md similarity index 100% rename from docs/bgp-earth-rendering-plan.md rename to docs/plans/earth-bgp-earth-rendering-plan.md diff --git a/docs/bgp-observability-plan.md b/docs/plans/earth-bgp-observability-plan.md similarity index 100% rename from docs/bgp-observability-plan.md rename to docs/plans/earth-bgp-observability-plan.md diff --git a/docs/bgp-region-aggregation-plan.md b/docs/plans/earth-bgp-region-aggregation-plan.md similarity index 98% rename from docs/bgp-region-aggregation-plan.md rename to docs/plans/earth-bgp-region-aggregation-plan.md index f31a20f3..75b6f0dc 100644 --- a/docs/bgp-region-aggregation-plan.md +++ b/docs/plans/earth-bgp-region-aggregation-plan.md @@ -17,7 +17,7 @@ It is an aggregation/view-model layer: ## Why This Layer Exists -Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md): +Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md): - incident density is naturally low - anomaly density is higher, but still not enough to keep the globe expressive all the time @@ -290,7 +290,7 @@ Each feature should include: ## Earth Rendering Plan -Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/bgp-earth-rendering-plan.md). +Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-earth-rendering-plan.md). ### Layer Relationship diff --git a/docs/plans/earth-celestial-background-plan.md b/docs/plans/earth-celestial-background-plan.md new file mode 100644 index 00000000..a0ac4fc9 --- /dev/null +++ b/docs/plans/earth-celestial-background-plan.md @@ -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 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。 diff --git a/docs/plans/earth-news-source-configuration-and-collector-plan.md b/docs/plans/earth-news-source-configuration-and-collector-plan.md new file mode 100644 index 00000000..7c3ab44d --- /dev/null +++ b/docs/plans/earth-news-source-configuration-and-collector-plan.md @@ -0,0 +1,156 @@ +# Earth News Source Configuration And Collector Plan + +## Why + +当前 Earth 的“态势新闻”由 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 直接在请求时抓取 RSS / Google News feed,再按当前地球视角中心区域聚合返回。 + +这条链已经可用,但存在两个明显限制: + +- 新闻源写死在代码里,不能像 TV 直播源一样从后台维护 +- 新闻并未进入统一采集体系,没有采集状态、失败监控、历史数据和后续 AI 复用能力 + +因此这块更合理的路线不是一步到位重写,而是分阶段推进: + +1. 先做“新闻源配置化” +2. 再做“新闻采集器化” + +## Current State + +当前实现分布在: + +- 新闻接口 + - [news.py](/home/ray/dev/linkong/planet/backend/app/api/v1/news.py) +- 实时聚合逻辑 + - [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) +- 前端消费 + - [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js) + +当前新闻源包含: + +- `BBC World` RSS +- `DW Top Stories` RSS +- 按区域关键词拼出来的 `Google News RSS` + - `Global` + - `Americas` + - `Europe` + - `Middle East / Africa` + - `Asia Pacific` + +当前不是采集器,也不落库,只做内存缓存。 + +## Phase 1: Source Configuration + +### Goal + +把 `NEWS_FEED_SOURCES` 从硬编码列表升级成可配置新闻源目录,但继续保留当前“实时聚合”的工作方式。 + +### Scope + +- 为 Earth news 建立独立配置结构 +- 支持后台维护 feed 源 +- 支持启用/禁用、优先级、区域、源类型 +- 保持现有 `/api/v1/news/earth-feed` 输出协议不变 + +### Proposed Shape + +建议配置字段至少包括: + +- `id` +- `name` +- `region` +- `feed_url` +- `homepage_url` +- `source_type` +- `priority` +- `is_enabled` +- 可选 `query_profile` +- 可选 `language` +- 可选 `notes` + +### Suggested Storage + +优先走系统设置或单独的 news source settings payload,而不是先建复杂新表。 + +推荐原因: + +- 改动小 +- 易上线 +- 和当前 TV settings 维护体验更接近 +- 先解决“写死在代码里”的问题 + +### Non-goals + +这一阶段不做: + +- 新闻入库 +- 新闻历史回看 +- 新闻采集任务监控 +- 新闻去重流水线 + +## Phase 2: News Collectorization + +### Goal + +把“态势新闻”升级为真正的采集器链路,使其进入采集系统和数据层。 + +### Scope + +- 新增专用 news collector +- 按配置源定时采集 RSS / feed +- 做标题/链接级去重 +- 建立统一新闻记录模型 +- 为 Earth、控制台、AI 研判复用同一份新闻数据 + +### Benefits + +- 有采集状态 +- 有失败监控 +- 有历史缓存 +- 可以做时间轴 / 区域新闻基线 +- 可以作为 AI 引用证据 + +### Required Design Work + +需要提前明确: + +- 新闻数据模型 +- 去重策略 +- 过期清理策略 +- 区域映射策略 +- 聚合排序策略 +- 新闻与 Earth 当前视角/区域的关联方式 + +### Candidate Output Model + +至少应包含: + +- `source_id` +- `headline` +- `summary` +- `url` +- `publisher` +- `region` +- `published_at` +- `language` +- `tags` +- `raw_feed_source` +- `reference_date` + +## Recommended Order + +推荐执行顺序: + +1. 先完成 Phase 1 配置化 +2. 保持 Earth 继续实时聚合,但改为读取配置源 +3. 等新闻源稳定后,再设计 Phase 2 的 collector / storage / dedupe + +## Decision + +当前结论: + +- TV 直播源:优先采集器化 +- 态势新闻:优先配置化,再采集器化 + +## Source Note + +This plan is newly created for the Planet repo to separate the short-term "configurable source directory" work from the longer-term "collectorized news pipeline" work. diff --git a/docs/plans/earth-predicted-orbit-plan.md b/docs/plans/earth-predicted-orbit-plan.md new file mode 100644 index 00000000..df963f24 --- /dev/null +++ b/docs/plans/earth-predicted-orbit-plan.md @@ -0,0 +1,98 @@ +# Earth Predicted Orbit Plan + +> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/predicted-orbit.md`. + +## Goal + +在 Earth 中锁定卫星时,显示“预测轨道”而不是只有历史尾迹: + +- 从当前时刻开始 +- 绕地球一圈 +- 当前点最亮 +- 向后沿轨道逐步衰减 + +## Current State + +当前已经有: + +- 卫星历史轨迹 +- 锁定卫星 +- 轨道高亮与相关联动 + +但“预测轨道”仍然不是一套稳定、可验证的单独功能计划。 + +## Why It Is Valuable + +预测轨道可以明显提升: + +- 锁定卫星后的空间可读性 +- 轨道类型辨识 +- 演示解释力 + +相比短历史尾迹,预测轨道更符合用户对“这颗卫星接下来会怎么走”的预期。 + +## Scope + +### Phase 1 + +- 锁定卫星时显示一整圈预测轨道 +- 解锁时隐藏 +- 不替代现有普通轨迹系统 + +### Phase 2 + +- 根据轨道类型调整采样率 +- GEO / MEO / LEO 不同密度 +- 进一步减少 fallback 轨迹的比例 + +## Implementation Direction + +### 1. Orbit period + +基于 `meanMotion` 估算轨道周期。 + +### 2. Predicted samples + +以固定采样步长从 `now -> now + period` 推算轨迹点。 + +### 3. Render object lifecycle + +预测轨道应是一个独立渲染对象: + +- show +- update +- hide +- dispose + +### 4. Visual semantics + +预测轨道不应与普通尾迹混淆: + +- 更稳定 +- 更完整 +- 透明度沿轨道衰减 +- 当前点附近更亮 + +## Known Risks + +### 1. TLE propagation gaps + +部分卫星可能出现 SGP4 计算不足,需要 fallback。 + +### 2. Multiple orbit lines + +必须确保: + +- 锁定切换前先清旧轨道 +- 页面隐藏/销毁时清理 + +### 3. Performance + +GEO 轨道点数高,采样率需要按轨道类型分层。 + +## Acceptance + +1. 锁定单颗卫星时只显示一条预测轨道 +2. 解锁后轨道立即清除 +3. 不同轨道类型下点数可控 +4. 页面切换回来不会闪出旧轨道残留 diff --git a/docs/prefix-geography-plan.md b/docs/plans/earth-prefix-geography-plan.md similarity index 100% rename from docs/prefix-geography-plan.md rename to docs/plans/earth-prefix-geography-plan.md diff --git a/docs/plans/earth-real-terrain-plan.md b/docs/plans/earth-real-terrain-plan.md new file mode 100644 index 00000000..a21a0aaf --- /dev/null +++ b/docs/plans/earth-real-terrain-plan.md @@ -0,0 +1,472 @@ +# Earth Real Terrain Plan + +## Goal + +将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。 + +当前占位实现位于: + +- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) + +具体问题: + +- `createTerrain()` 直接对球体顶点应用 `simplex noise` +- 没有真实海拔数据来源 +- 没有分辨率分层 +- 没有和当前相机/视角配套的性能控制 + +## Constraints + +本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎: + +- 地球主体仍然是一个 Three.js sphere +- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上 +- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换 +- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形 + +## Recommended Data Source + +### Primary recommendation + +使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入: + +- Mapzen/AWS `Terrarium` elevation tiles + 参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/) + +原因: + +- 已经是全球瓦片化高程 +- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe +- 编码简单稳定: + - `heightMeters = (R * 256 + G + B / 256) - 32768` +- 不需要我们先离线拼整球 DEM + +### Data quality upgrade path + +如果后面第一阶段效果确认可用,再逐步升级到底层源: + +- Copernicus DEM GLO-30 + 参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html) +- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles + +这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。 + +## Why Not Replace the Engine + +不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因: + +- 现有 Earth 业务对象都依附当前球面坐标 +- 切引擎会同时波及: + - 海缆绘制 + - 卫星/轨迹 + - BGP 标记 + - HUD 与交互 +- 这是“重做一页”,不是“给地形层接真实数据” + +所以推荐路线是: + +- 保持当前 sphere globe +- 为 sphere 增加真实高度位移层 + +## Implementation Strategy + +分三期推进。 + +### Phase 1 — Global Heightmap Terrain Overlay + +目标: + +- 地形层切换后显示真实海拔起伏 +- 全球范围可用 +- 性能可控 + +做法: + +1. 新增 terrain 数据模块 + +建议文件: + +- `frontend/public/earth/js/terrain.js` + +职责: + +- 选择 DEM zoom level +- 请求 Terrarium tiles +- 解码 tile 高程 +- 将高程重采样到当前地形球体网格 + +2. 替换 `createTerrain()` + +当前: + +- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形 + +调整后: + +- `createTerrain()` 只负责创建 terrain mesh 骨架 +- 真正的顶点位移由 terrain 模块异步注入 + +3. 第一阶段采用“整球低分辨率位移” + +不要一上来做动态 patch stitching。第一阶段更稳的办法是: + +- 保留一张全球 terrain sphere +- 使用较低分辨率几何 + - 例如 `SphereGeometry(radius, 192, 192)` 或 `256/256` +- 运行时按一个固定地形 zoom(如 `z=4` 或 `z=5`)抓取覆盖全球的 Terrarium tiles +- 将 tile 解码后重投影到经纬度采样网格 +- 将每个球面顶点按真实高度抬升 + +这样第一阶段就能做到: + +- 有真实地形 +- 不需要复杂的局部 LOD +- 不会让现有球体对象体系爆炸 + +### Phase 2 — View-Aware Refinement + +目标: + +- 正面可见区域更精细 +- 背面与远处维持低成本 + +做法: + +- 引入“基础全球地形 + 当前视角高分局部补丁” +- 正面区域额外抓更高 zoom 的高程 tile +- 只替换局部顶点位移或局部 overlay mesh + +这一阶段适合在第一阶段稳定后做。 + +### Phase 3 — Normals / Shading / Terrain UX + +目标: + +- 地形不仅有起伏,还更好看、更可读 + +包括: + +- 根据高度生成更合理的 normals +- 调整 terrain material,使山脉/高原更易读 +- 可选加入: + - hillshade + - contour lines +- snowline / bathymetry tint + +## Calibration Overlay Before More Terrain Tuning + +在当前项目里,terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。 + +没有清晰的海岸线、国界线和地表分层时,人眼很难判断: + +- 山脉是不是在应该高的地方高 +- terrain 是否真的贴在正确的大陆位置上 +- 地球纹理、本初子午线、terrain 采样之间是否存在偏移 + +这里要明确区分两件事: + +- 国界线不会修好错误的 terrain +- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准 + +所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。 + +### Recommended order for the calibration layer + +1. 海岸线 +2. 国界线 +3. 再继续调 terrain + +原因: + +- 海岸线比国界线更基础,也更接近真实地表边界 +- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系 +- 国界线更多是政治边界,只能作为辅助参照 + +如果只加国界线,不加海岸线,效果仍然可能会怪,因为: + +- 很多国界线本来就是人为直线 +- 它们并不总是跟真实地形走 + +### Suggested layer order during debugging + +建议调试期临时把地球层次明确成: + +1. base earth texture +2. coastline / borders overlay +3. terrain relief +4. cables / landing points / bgp / satellites + +这样会比现在更容易判断: + +- 山脉是否位于正确区域 +- terrain 是否和地表对齐 +- 国界/海岸是否漂移 + +### Suggested data source for the calibration overlay + +优先用 `Natural Earth` 的轻量全球矢量数据: + +- 海岸线(coastline) +- Admin 0 国界线(country borders) + +优点: + +- 全球一致 +- 轻量 +- 很适合当前 Three.js globe 做 overlay + +### Recommended execution path + +#### Phase A — Add reference overlays + +先加两层可开关的参考线: + +- 海岸线 +- 国界线 + +这两层的目标不是最终美术表现,而是调试 / 校准。 + +#### Phase B — Recalibrate terrain against coastline + +有了海岸线以后,再重新看 terrain: + +- terrain 是否和大陆边缘错位 +- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移 + +#### Phase C — Decide whether to keep the current terrain path + +这时再决定后面的路线: + +- 如果发现真实高程整体是对的,只是缺少 shading / readability + 继续保留当前 DEM + terrain overlay 路线 +- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭 + 再考虑重做 terrain pipeline + +### Practical recommendation + +当前阶段不建议“从头开始重做 terrain”。 + +更稳的策略是: + +- 暂停继续盲调 terrain 参数 +- 先补海岸线 / 国界线作为校准参照层 +- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移” + +## Recommended Geometry Model + +### First usable model + +保留一层独立 terrain sphere: + +- base earth sphere:贴纹理、昼夜、海洋 +- terrain sphere:略高于地球半径,真实高程位移 + +建议: + +- `terrainBaseRadius = CONFIG.earthRadius + 0.2` +- 高度缩放使用真实米制换算,再乘一个可调 exaggeration + +示例关系: + +- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration` + +建议第一阶段 `exaggeration = 1.3 ~ 1.8` + +因为完全真实比例在全球球体上会太平,看不出来。 + +## Tile Decoding Plan + +### Terrarium decode + +对于每个高程 tile 像素: + +```text +heightMeters = (R * 256 + G + B / 256) - 32768 +``` + +### Sampling path + +对于 terrain mesh 上每个顶点: + +1. 将顶点方向转成经纬度 +2. 将经纬度映射到 Web Mercator tile 坐标 +3. 找到对应的 tile 和像素 +4. 解码高程 +5. 将顶点沿法线方向抬升 + +### Needed helpers + +建议新增: + +- `latLonToTileXY(lat, lon, z)` +- `tilePixelFromLatLon(lat, lon, z, tileSize)` +- `decodeTerrariumHeight(r, g, b)` + +## Caching Strategy + +为了不让地形开关每次重开都重新抓全量 tile: + +- terrain tile 按 `z/x/y` 存到内存缓存 +- terrain mesh 结果也缓存一份 +- 当用户关闭/开启 terrain: + - 直接复用已有位移结果 + +建议: + +- `Map` + +## Material Strategy + +第一阶段不要复杂化。 + +建议 terrain material: + +- 半透明低饱和地形色 +- 比 base earth 稍亮或稍偏冷 +- 保留当前 HUD 风格下的可读性 + +第一阶段不需要: + +- 真实土地覆被纹理 +- 独立卫星影像贴 terrain + +因为那会和现有地球纹理、云层、昼夜 shader 打架。 + +## Integration Points + +### Files to change + +- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) + - 重写 `createTerrain()` + - 删除 simplex noise 占位逻辑 +- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) + - 初始化 terrain 数据加载 + - 控制 terrain readiness / loading message +- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) + - `toggleTerrain` 逻辑保持,但应能区分: + - mesh 已就绪 + - 正在加载 +- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js) + - 新增 `TERRAIN_CONFIG` +- 新文件: + - `frontend/public/earth/js/terrain.js` + +### Suggested new config + +建议新增: + +```js +export const TERRAIN_CONFIG = { + enabled: true, + tileSize: 256, + baseZoom: 4, + baseRadiusOffset: 0.2, + exaggeration: 1.5, + opacity: 0.55, + color: 0x6c876f, + maxConcurrentRequests: 8, + cacheEnabled: true, +}; +``` + +## Loading UX + +地形第一次开启时,不能像现在一样瞬时切换。 + +建议: + +- 如果地形数据尚未准备: + - 顶部状态条显示:`正在加载真实地形数据...` +- 完成后: + - `真实地形已就绪` + +如果加载失败: + +- 保留 base earth +- 显示轻量错误提示 +- 不要让 terrain 开关卡死在“开”状态 + +## Risks + +### 1. Global tile count too high + +即使 `z=5` 全球 tile 数也不少。 + +缓解: + +- 第一阶段限定低 zoom +- 并发上限 +- 缓存 + +### 2. Mesh resolution too low + +如果球面分段太低,山脉会被抹平。 + +缓解: + +- 第一阶段先选一个中等分辨率 +- 用 exaggeration 保证可见性 + +### 3. Existing overlays may z-fight with terrain + +海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。 + +缓解: + +- terrain sphere 单独作为 overlay +- overlay 保持略低或略高的固定 offset +- 必要时局部调整 landing point / cable altitude offset + +### 4. Mercator sampling distortion near poles + +Web Mercator 在高纬会有失真。 + +缓解: + +- 第一阶段接受 +- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline + +## Acceptance Criteria + +第一阶段完成后,应满足: + +1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声 +2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认 +3. 关闭/重新开启 terrain 不重复全量请求 +4. 不破坏: + - 海缆 + - 卫星 + - BGP + - 地球昼夜 + - 天球层 + +## Suggested Execution Order + +1. 引入 `TERRAIN_CONFIG` +2. 新建 `terrain.js` +3. 实现 Terrarium tile 请求与 decode +4. 用低 zoom 全球 tile 构建真实 terrain sphere +5. 接管 `toggleTerrain()` +6. 调整 terrain material 和高度 exaggeration +7. 做缓存 +8. 再考虑第二阶段局部高分 refinement + +## Source References + +- Mapzen Terrarium / AWS terrain tiles + [Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/) +- Terrarium tile experiments / format background + [mapzen/terrarium](https://github.com/mapzen/terrarium) +- Copernicus DEM overview + [Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html) + +## Recommendation Summary + +如果现在就要开始做,我建议直接按这条路线开工: + +- 第一阶段接入 Terrarium 全球高程 tile +- 替换掉当前 simplex 假地形 +- 先做一层真实可见的全球 terrain overlay +- 等第一阶段稳定,再做视角高分 refinement + +这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。 diff --git a/docs/plans/earth-renderer-architecture-separation-plan.md b/docs/plans/earth-renderer-architecture-separation-plan.md new file mode 100644 index 00000000..649c20ac --- /dev/null +++ b/docs/plans/earth-renderer-architecture-separation-plan.md @@ -0,0 +1,111 @@ +# Earth Renderer / Logic Separation Plan + +> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/earth-architecture-refactor.md`. + +## Goal + +将 Earth 前端继续往“逻辑层 / 状态层 / 渲染层”分离推进,降低后续这几类工作的耦合成本: + +- Three.js 渲染重构 +- 部分图层替换实现 +- 未来 UE / Cesium 客户端迁移 +- Earth 行为逻辑复用 + +## Why This Matters + +当前 Earth 已经有一些良好分层,例如: + +- 图层显隐入口 +- Cable state 枚举与状态 map +- 交互逻辑与实际视觉效果的部分分离 + +但还没有形成一套更明确的统一规则。现在的风险是: + +- 同一类对象的 hover / locked / hidden / loading 语义不一致 +- 状态和渲染更新散落在多个模块 +- 后续再加新图层时容易复制旧逻辑 + +## Target Architecture + +Earth 对每类对象都尽量拆成三层: + +1. `state layer` + - 保存对象状态 + - 例如:`normal / hovered / locked / hidden / loading` + +2. `logic layer` + - 处理点击、悬停、锁定、过滤、显隐切换 + - 不直接关心 Three.js 具体材质怎么改 + +3. `renderer layer` + - 根据状态更新 Three.js / HUD 外观 + - 是最容易针对不同渲染引擎替换的一层 + +## Current Good Signals + +当前已经接近这条方向的地方: + +- cable 状态管理 +- 部分 landing point 状态同步 +- layer button 的统一状态入口 +- tooltip / legend / info-card 开始朝状态驱动靠拢 + +## Next Steps + +### 1. Standardize object state enums + +优先为这些对象建立更稳定的状态语义: + +- cables +- satellites +- landing points +- BGP markers +- media / news 面板入口按钮 + +### 2. Unify state-to-visual adapters + +为各模块建立更清晰的渲染适配函数,例如: + +- `applyCableVisualState()` +- `applySatelliteVisualState()` +- `applyBGPVisualState()` + +要求: + +- 逻辑层只改状态 +- 视觉层负责把状态映射到材质、透明度、发光、尺寸、文字 + +### 3. Separate Earth UI state from render state + +HUD / 面板 / 图层按钮状态也需要和渲染状态分离: + +- `loading` +- `active` +- `locked` +- `hidden` +- `error` + +不要再让 UI 通过“猜渲染结果”推导业务状态。 + +### 4. Prepare migration-safe boundaries + +后续如果做 UE / Cesium 客户端,尽量保留: + +- 状态枚举 +- 交互规则 +- 数据层接口 + +只替换: + +- Three.js 具体渲染实现 +- HUD 展示实现 + +## Practical Rule + +后续 Earth 新功能开发时,优先问三个问题: + +1. 这个状态由谁持有? +2. 这个交互逻辑在哪一层处理? +3. 这个视觉变化是否能在不改逻辑的情况下单独替换? + +如果答不上来,就说明还在把状态、逻辑、渲染揉在一起。 diff --git a/docs/plans/earth-webgl-instancing-satellites-plan.md b/docs/plans/earth-webgl-instancing-satellites-plan.md new file mode 100644 index 00000000..7ef05fab --- /dev/null +++ b/docs/plans/earth-webgl-instancing-satellites-plan.md @@ -0,0 +1,82 @@ +# Earth WebGL Instancing Satellites Plan + +> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/webgl-instancing-satellites.md`. + +## Goal + +把 Earth 卫星渲染从当前方案继续推进到更适合高数量卫星的 instancing 方向,目标是: + +- 支持更多卫星 +- 降低渲染压力 +- 仍然保留当前数据层和交互层 + +## Why It Matters + +当前卫星系统已经具备: + +- 数据加载 +- 轨迹 +- 选择/锁定 +- 图例 +- 相关区域联动 + +但当卫星数量持续增加时,渲染层会越来越接近瓶颈。 + +## Recommended Direction + +优先调研并原型验证: + +- `InstancedBufferGeometry + custom shader` + +而不是一开始就推倒重写成 raw WebGL。 + +原因: + +- 仍能保留 Three.js 主架构 +- 更容易渐进迁移 +- 比继续堆普通点渲染更有上限 + +## What Should Stay + +尽量保留这些层: + +- 卫星数据获取 +- 位置计算 +- 锁定/悬停逻辑 +- legend / info-card / 相关联动 + +主要替换的是: + +- 卫星点渲染实现 +- 颜色/大小等实例属性更新方式 + +## Phases + +### Phase 1: Prototype + +- 用 instancing 做最小原型 +- 先只渲染卫星点 +- 不碰轨迹系统 + +### Phase 2: Integrate + +- 接入当前 `satellites.js` 数据层 +- 保留当前选择和高亮语义 + +### Phase 3: Tune + +- 调整可视大小 +- 调整选中高亮方式 +- 评估是否需要分层 LOD + +## Risks + +1. 透明度排序更复杂 +2. Shader 调试成本更高 +3. 选中态和 hover 态不能简单复用旧材质逻辑 + +## Acceptance + +1. 在更高卫星数量下保持可接受帧率 +2. 不破坏现有锁定/高亮语义 +3. 图例、信息卡、相关卫星联动仍然成立 diff --git a/docs/ai-playground-development-plan.md b/docs/plans/frontend-ai-playground-development-plan.md similarity index 97% rename from docs/ai-playground-development-plan.md rename to docs/plans/frontend-ai-playground-development-plan.md index 8f741b88..fb318989 100644 --- a/docs/ai-playground-development-plan.md +++ b/docs/plans/frontend-ai-playground-development-plan.md @@ -30,7 +30,7 @@ - [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py) - [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py) - [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py) -- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) +- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md) ### 2. 本地运行与配置打通 @@ -77,7 +77,7 @@ 相关文件: -- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) +- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) - [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) ## 当前限制 diff --git a/docs/plans/ue5-mvp-fused-plan.md b/docs/plans/ue5-mvp-fused-plan.md new file mode 100644 index 00000000..2def1cfd --- /dev/null +++ b/docs/plans/ue5-mvp-fused-plan.md @@ -0,0 +1,1015 @@ +# 智能星球 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。** + +--- + +# 附录:来自 sisyphus 草案的补充 + +> 这部分吸收自一个 sisyphus-created draft,原始草案已归档,不再单独维护为主计划。 + +## 1. 项目骨架建议 + +原草案给过一个更偏“工程初始化”的目录示意,适合拿来做一期的命名参考: + +- `Levels/` +- `Blueprints/` +- `Materials/` +- `Widgets/` +- `Source/PlanetAPI/` +- `Source/CesiumIntegration/` +- `Source/Visualization/` + +这不是强制结构,但对 UE 初期整理目录很有帮助。 + +## 2. API 契约意识 + +原草案有一个很对的提醒: + +- 一期虽然可以先走 HTTP +- 但数据模型命名不应只服务于一次性演示 +- 后续 WebSocket 接入时,字段设计最好能沿用 + +所以当前主计划继续建议: + +- 先做 HTTP 拉取 +- 尽量把 UE 侧数据模型定义清楚 +- 不要在蓝图各处散写临时 JSON 字段解析 diff --git a/docs/technical/README.md b/docs/technical/README.md new file mode 100644 index 00000000..a8a585de --- /dev/null +++ b/docs/technical/README.md @@ -0,0 +1,26 @@ +# Technical Docs + +这里放“当前实现和当前结构”的文档,重点回答: + +- 现在代码是怎么组织的 +- 当前入口在哪 +- 状态和组件如何工作 +- 后续改动应该沿着哪条实现边界继续走 + +适合放入这里的内容: + +- 前端上下文 +- Earth 前端结构 +- 后端运行控制 +- collector 现状 +- 采集格式约定 + +不适合放入这里的内容: + +- 尚未完成的 roadmap +- 未来迭代方案 +- 大范围重构计划 + +这些应放入: + +- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md) diff --git a/docs/aiprovider.md b/docs/technical/agents-aiprovider.md similarity index 100% rename from docs/aiprovider.md rename to docs/technical/agents-aiprovider.md diff --git a/docs/collectors.md b/docs/technical/backend-collectors.md similarity index 100% rename from docs/collectors.md rename to docs/technical/backend-collectors.md diff --git a/docs/system-service-control.md b/docs/technical/backend-system-service-control.md similarity index 100% rename from docs/system-service-control.md rename to docs/technical/backend-system-service-control.md diff --git a/docs/bgp-context.md b/docs/technical/earth-bgp-context.md similarity index 99% rename from docs/bgp-context.md rename to docs/technical/earth-bgp-context.md index 4a21b907..bd0eb8ef 100644 --- a/docs/bgp-context.md +++ b/docs/technical/earth-bgp-context.md @@ -187,7 +187,7 @@ Current reality: - that is expected, because incidents are aggregated and de-noised - but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer -Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/bgp-region-aggregation-plan.md). +Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md). So the immediate next milestone is: diff --git a/docs/technical/earth-frontend-context.md b/docs/technical/earth-frontend-context.md new file mode 100644 index 00000000..b2d6c5e9 --- /dev/null +++ b/docs/technical/earth-frontend-context.md @@ -0,0 +1,381 @@ +# Earth Frontend Context + +本文件描述当前 Earth 大屏前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。 + +相关规则建议一起参考: + +- [rules.md](/home/ray/dev/linkong/planet/rules.md) +- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) + +## 当前目标 + +Earth 前端不是普通管理页,它是独立的大屏展示前端。当前产品目标是: + +- 维持地球视图的空间感和可读性 +- 让 HUD、图层、媒体面板、BGP、卫星、海缆等保持统一交互 +- 把加载中、已启用、已隐藏、锁定中这类状态做清楚 + +## 当前入口 + +React 路由入口: + +- [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx) + +当前做法很简单: + +- React 页面只负责提供一个全屏 `iframe` +- 真正的 Earth 应用运行在: + - [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) + +所以 Earth 前端本质上是 `public/earth` 下的一套独立静态应用。 + +## 当前文件分层 + +### 1. 页面入口与结构 + +- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html) + +职责: + +- HUD 基础 DOM +- 图层面板 +- 媒体面板 +- 工具栏 +- 设置弹窗 +- 兼容旧元素 id + +### 2. 主运行时 + +- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) + +职责: + +- 地球初始化 +- Three.js 场景组装 +- 数据加载与刷新 +- 各图层集成 +- Earth 级别状态同步 + +### 3. 地球控制层 + +- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) + +职责: + +- 工具栏交互 +- 图层面板交互 +- 旋转/缩放/布局 +- HUD 面板拖拽 +- 图层开关状态机 +- Earth 设置读取、持久化与重置 + +这份文件是 Earth 前端当前最核心的 UI 控制入口。 + +### 4. UI 与状态消息 + +- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js) + +职责: + +- loading 面板 +- status message +- tooltip / error / 清理逻辑 + +### 5. 地球与地形 + +- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) +- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js) + +职责: + +- 地球球体、云层、大气 +- 真实地形 mesh +- terrain tile 拉取、解码、位移、着色 + +### 6. 图层模块 + +- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js) +- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js) +- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) +- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) +- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js) +- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js) +- [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) +- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) +- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) + +职责: + +- 各自的数据层 +- 开关行为 +- 面板内容 +- hover/lock/selection 语义 + +其中 Earth 启动加载链现在也拆成了两层: + +- `controls.js` + - 提供图层注册表与启动元信息 +- `layer-startup-tasks.js` + - 提供图层启动任务注册表 + - 通过 `registerLayerStartupTask(id, taskFactory)` 扩展启动任务 +- `main.js` + - 只负责读取排序后的启动图层,再按映射执行队列 + +其中巡航模式现在已经拆成两层: + +- `cruise-sequencer.js` + - 负责目标队列顺序、停留时长、切换节奏、打断与恢复 +- `callout-connector.js` + - 负责卡片连线 SVG、路径计算与绘制动画 +- `bgp-cruise-adapter.js` + - 负责 BGP 巡航展示适配:目标排序、卡片落点、连线路径、focus/overlay/info-card 时序 + +当前 BGP 巡航只是这套能力的一个调用方,不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。 + +## 当前样式分层 + +Earth 的 CSS 不是一份大样式表,而是分层管理: + +- [base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css) +- [hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css) +- [toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css) +- [layer-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/layer-panel.css) +- [info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css) +- [legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css) +- [earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css) +- [coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css) +- [tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css) + +当前建议: + +- 通用 HUD 壳层写进 `hud.css` +- 单一面板特性写进各自子文件 +- 不要把业务状态样式再散回 `index.html` + +## 当前图层开关状态语义 + +Earth 图层按钮现在不应再只有“开/关”两态,而应支持: + +- `inactive` +- `active` +- `loading` + +当前入口在: + +- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) +- [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js) + +关键函数: + +- `updateLayerButtonState(button, isActive)` +- `setLayerButtonState(button, options)` + +`setLayerButtonState` 负责: + +- `loading` 样式 +- `aria-busy` +- 按钮禁用 +- tooltip 更新 +- 绑定状态文本更新 +- 可选同步 `active` + +因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。 + +另外,Earth 图层控制现在已经收成“注册表驱动”: + +- 图层元数据 + - `id` + - `icon` + - `label` + - `meta` + - `buttonId` + - `persist` + - `startupPriority` + - `startupMode` + - `startupLabel` + - `startupMessage` +- 图层行为 + - `getVisible()` + - `setVisible(next, options)` + +当前入口仍在 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)。 + +这意味着后续新增图层时,优先应补一条图层注册定义,而不是同时去改: + +- 图层面板 HTML +- 持久化快照 +- 初始化恢复 +- click 绑定 + +这四处现在都应该由注册表派生。 + +其中: + +- `startupPriority` + - 描述图层参与启动加载时的顺序 +- `startupMode` + - `visible` + - 仅当前图层处于启用/可见状态时,才加入启动加载队列 + - `preload` + - 即使当前图层未显示,也会参与启动预加载 + +当前 `main.js` 会通过注册表读取排序后的启动图层列表,再动态拼装启动加载队列,而不是手写一串固定步骤。像 BGP 这类需要尽早准备数据、但不一定默认显示的图层,应该优先走 `startupMode: "preload"`,而不是在启动流程里写隐式特判。 + +此外,启动阶段给用户看的提示文案也应尽量从注册表派生: + +- `startupLabel` + - 用于描述当前启动任务的业务名称 +- `startupMessage` + - 用于描述启动中的提示文案 + - 可以是字符串 + - 也可以是对象,用于像海缆这种“准备阶段 / 主加载阶段”两段式文案 + +这样后续新增会参与启动加载的图层时,顺序、模式和提示文案都在同一处定义,不需要再去 `main.js` 里补第二套常量。 + +### `data-status-target` + +图层按钮可以通过: + +- `data-status-target` + +指向一个状态文本节点。当前 terrain 已接入: + +- 按钮:`#toggle-terrain` +- 状态节点:`#terrain-status` + +以后别的异步图层也可以沿用这套约定。 + +## 当前设置持久化 + +Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一负责: + +- 捕获默认值 +- 从 `localStorage` 读取上次设置 +- 初始化应用当前设置 +- 用户变更后即时持久化 +- 一键重置回默认值 + +当前持久化的范围是: + +- 旋转模式 +- 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源) +- HUD 面板显示/隐藏 +- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP` +- 地形透明度 + +也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。 + +## 当前地形链路 + +真实地形首次启用会慢,原因不只是一个: + +1. 需要拉取 Terrarium 瓦片 +2. 需要解码图片 +3. 需要按顶点采样高程 +4. 需要重新写入 geometry 和 color +5. 需要重新计算法线与包围体 + +当前入口在: + +- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js) + +当前已经做了两层体验优化: + +1. 图层开关 loading 状态持续可见 +2. 页面空闲时会预热 `ensureTerrainReady()` + +也就是说,后续再继续优化 terrain 时,优先顺序应该是: + +1. 先保证用户感知正确 +2. 再压缩首次等待 +3. 最后才做更激进的几何/瓦片优化 + +## 当前高频风险点 + +### 1. 视觉状态和业务状态不同步 + +Earth 里最常见的 bug 不是“没渲染”,而是: + +- 图层关了,tooltip 还在 +- 锁定对象隐藏了,info card 还在 +- legend 没跟图层切换 +- loading 已结束,但按钮还像没开 + +后续改动必须优先检查状态同步。 + +### 2. HUD 布局问题先查结构,不要先打 CSS 补丁 + +Earth HUD 历史上反复出现: + +- 面板只剩一条缝 +- markdown 被裁掉 +- tabs/iframe 被 `overflow: hidden` 吃掉 + +优先检查: + +1. 谁负责高度 +2. 谁负责滚动 +3. 哪一层在裁剪 + +不要上来先加 `overflow: hidden` 或额外包装层。 + +### 3. Transitional path 必须收口 + +Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易积累: + +- 旧 helper +- 旧 class +- 旧 fallback 逻辑 +- 已废弃变体 + +每次大功能完成后,都要做一次 cleanup pass。 + +### 4. 巡航与业务事件不要再深度耦合 + +当前正确边界应该是: + +- 通用巡航层只知道: + - 当前目标 + - 队列顺序 + - 相机 focus + - 停留 / 隐藏 / 切换 +- 业务模块只负责: + - 提供目标队列 + - 提供 focus 坐标 + - 提供卡片内容 + - 提供高亮/图层副作用 + +如果以后再给海缆、卫星或新闻做巡航,不应复制一套新的 `main.js` 状态变量,而应复用: + +- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) +- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) +- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 这种业务适配层模式 + +## 当前推荐改动方式 + +如果后续继续改 Earth,建议按这个顺序: + +1. 先确认改的是: + - Three.js 渲染层 + - HUD 结构层 + - 图层状态层 + - 面板内容层 +2. 如果涉及图层按钮,优先接入统一状态机 +3. 如果涉及可见性切换,检查 tooltip / legend / info-card / lock 是否一起收口 +4. 如果涉及面板布局,先查结构再动 CSS + +## 当前与控制台前端的边界 + +Earth 前端和控制台前端不是同一套 UI 系统: + +- 控制台前端:React + Ant Design 工作台 +- Earth 前端:`public/earth` 原生 HUD + Three.js 展示面 + +因此: + +- Earth 不应该直接复用 Ant Table / AppLayout 语义 +- 控制台也不应该照搬 Earth HUD 动画和玻璃层语言 + +控制台相关结构见: + +- [admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/frontend-admin-frontend-context.md) diff --git a/docs/news-live-streams-collector-format.md b/docs/technical/earth-news-live-streams-collector-format.md similarity index 57% rename from docs/news-live-streams-collector-format.md rename to docs/technical/earth-news-live-streams-collector-format.md index 5233b97e..2883e4d7 100644 --- a/docs/news-live-streams-collector-format.md +++ b/docs/technical/earth-news-live-streams-collector-format.md @@ -95,3 +95,93 @@ - 手工配置源 - `news_live_streams` 采集器采集源 - 当前默认兜底源为 `CCTV-4 中文国际` +- `news_live_streams` 在未配置 override 时,默认使用 `iptv-org`: + - `channels.json` + - `streams.json` + - `logos.json` + 并自动筛出新闻类频道目录 + +## 采集器配置方式 + +`news_live_streams` 不需要单独新页面,直接复用现有数据源配置: + +- `endpoint` + - 频道目录 JSON API 地址 +- `auth_type` + - `none` / `bearer` / `api_key` / `basic` +- `headers` + - 额外请求头 +- `config` + - 采集器请求与解析行为 + +### 支持的 `config` 字段 + +```json +{ + "timeout": 30, + "method": "GET", + "params": { + "region": "global" + }, + "body_type": "json", + "body": { + "include_disabled": false + }, + "response_path": "payload.channels" +} +``` + +- `timeout` + - 请求超时秒数 +- `method` + - `GET` 或 `POST` +- `params` + - 查询参数对象 +- `body_type` + - `json` 或 `form` +- `body` + - 配合 `POST` 使用的请求体 +- `json_body` + - 显式 JSON 请求体,优先级高于 `body` +- `form_body` + - 显式表单请求体,优先级高于 `body` +- `response_path` + - 返回 JSON 中频道数组所在路径,支持点路径,例如: + - `payload.channels` + - `data.items` + - `result.streams` + +### 认证补充 + +- `bearer` + - 使用 `Authorization: Bearer ` +- `api_key` + - 默认作为请求头发送 + - 如果 `auth_config.in = "query"`,则作为 query param 发送 +- `basic` + - 使用 HTTP Basic Authorization + +## 兼容的响应结构 + +采集器会优先读取: + +- 顶层数组 +- 或这些常见字段下的数组: + - `sources` + - `streams` + - `channels` + - `items` + - `results` + - `data` + +同时会兼容这些字段别名: + +- `id` / `source_id` / `slug` / `channel_id` / `code` +- `name` / `title` / `channel` / `display_name` +- `provider` / `publisher` / `network` +- `stream_url` / `stream` / `playback_url` / `hls_url` / `m3u8_url` +- `embed_url` / `embed` / `page_url` +- `homepage_url` / `source_url` / `website` +- `language` / `lang` / `locale` +- `youtube_video_id` / `video_id` +- `youtube_channel` / `channel_handle` diff --git a/docs/technical/frontend-admin-frontend-context.md b/docs/technical/frontend-admin-frontend-context.md new file mode 100644 index 00000000..bbcab832 --- /dev/null +++ b/docs/technical/frontend-admin-frontend-context.md @@ -0,0 +1,236 @@ +# Admin Frontend Context + +本文件描述当前控制台前端的真实结构,目标是帮助后续页面开发、表格改造、布局治理和状态收口时快速找到正确入口。 + +相关规则建议一起参考: + +- [rules.md](/home/ray/dev/linkong/planet/rules.md) +- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) + +## 当前目标 + +控制台前端承担的是后台工作台,而不是展示型大屏。当前约束是: + +- 页面默认遵循单屏工作区 +- 主交互在内部模块滚动,而不是依赖整页无限变长 +- 列表、表格、分析页优先保证主工作区可见 +- 通用布局、滚动条、表格滚动行为尽量复用,不要每页各写一套 + +## 当前路由入口 + +主入口在: + +- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx) + +当前后台相关路由包括: + +- `/admin` +- `/users` +- `/datasources` +- `/data` +- `/alerts/system` +- `/alerts/bgp` +- `/alerts/situational` +- `/bgp` +- `/playground` +- `/settings` + +`/earth` 是独立展示页,不属于控制台骨架。 + +## 当前页面骨架 + +控制台公共壳层在: + +- [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx) + +职责: + +- 左侧导航 +- 折叠与展开 +- 当前账号/版本信息 +- 内容区高度闭合 +- 全站统一侧边栏滚动条 + +当前结构是: + +```tsx + + ... + + +
{children}
+
+
+
+``` + +后续控制台页面应优先适配这套壳层,而不是重新定义全页高度语义。 + +## 当前共享组件 + +### 1. `Scrollbar` + +文件: + +- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx) + +用途: + +- 控制台侧边栏这类普通内容容器 +- 组件内部管理可见性、thumb 尺寸、拖拽和双轴 overflow 判定 + +当前约束: + +- 滚动条必须是浮层,不参与布局 +- 无 overflow 时不应留下可见痕迹 +- 真实滚动仍交给原生容器,只替换可见层和交互层 + +### 2. `ScrollbarOverlay` + +文件: + +- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx) + +用途: + +- Ant Table 这类内部已有滚动容器的区域 +- 不接管滚动语义,只叠加新的滚动条可见层 + +当前使用场景: + +- 数据源 +- 采集数据 +- 用户管理 +- 设置页 +- 告警页 +- BGP 页面 + +### 3. `TableScrollRegion` + +文件: + +- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/TableScrollRegion.tsx) + +用途: + +- 为表格滚动区提供统一包裹层 +- 后续新表格页优先复用,不要重复写“表格区域 + overlay scrollbar”样板 + +### 4. 其他共享组件 + +- [MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) +- [TableActions.tsx](/home/ray/dev/linkong/planet/frontend/src/components/TableActions/TableActions.tsx) + +## 当前状态来源 + +### 1. 认证状态 + +文件: + +- [auth.ts](/home/ray/dev/linkong/planet/frontend/src/stores/auth.ts) + +职责: + +- token +- 当前用户 +- 登录/退出 + +`App.tsx` 用它判断是否进入登录页。 + +### 2. 业务数据网关 + +目前 AI / 态势感知相关服务集中在: + +- [http-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/http-gateway.ts) +- [port.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/port.ts) +- [types.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/types.ts) + +约束: + +- 页面不要直接散落拼 URL +- 先通过 port/types 定义边界 +- 再由 http/mock gateway 实现 + +## 当前页面分层建议 + +### 1. 仪表盘和摘要型页面 + +例如: + +- [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx) + +优先目标: + +- 页头稳定 +- 摘要卡片先紧凑化 +- 主工作区占据主要高度 + +### 2. 表格型页面 + +例如: + +- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) +- [DataList.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataList/DataList.tsx) +- [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx) +- [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) + +约束: + +- 优先内部滚动 +- 不要让表格撑爆整页 +- 新表格区域优先复用 `TableScrollRegion` / `ScrollbarOverlay` + +### 3. 复杂工作区页面 + +例如: + +- [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) +- [Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) + +约束: + +- Tabs 里的内容不能套同一套高度逻辑 +- 表格 tab、Markdown tab、配置 tab 要各自定义滚动责任 +- AI 结果区、长文本区优先保证最小可读高度 + +## 当前布局约束 + +这些原则已经在项目里反复验证过: + +1. 父容器高度链要闭合 +2. `min-height: 0` 不能漏 +3. overflow 责任必须明确 +4. 不要用 `overflow: hidden` 掩盖结构问题 +5. 不要为了摘要卡完整显示去压缩主工作区 +6. 自定义滚动条必须是浮层,不得挤压内容宽度 + +详细经验见: + +- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) + +## 当前推荐改动方式 + +如果后续继续改后台页面,建议按这个顺序: + +1. 先确认页面属于摘要页、表格页还是复杂工作区 +2. 先接入现有壳层和滚动语义 +3. 优先复用共享滚动组件 +4. 最后再改视觉和细节交互 + +不要先写局部 CSS 补丁,再回头补结构。 + +## 当前明显边界 + +控制台前端和 Earth 前端不是一套系统: + +- 控制台前端是 React + Ant Design 工作台 +- Earth 前端是 `public/earth` 下的独立原生 HUD 系统 + +因此: + +- 不要把 Earth 的 HUD/动画/状态机直接挪进控制台 +- 不要把控制台表格/滚动策略硬套到 Earth HUD + +Earth 相关结构见: + +- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) diff --git a/docs/frontend-layout-guidelines.md b/docs/technical/frontend-layout-guidelines.md similarity index 100% rename from docs/frontend-layout-guidelines.md rename to docs/technical/frontend-layout-guidelines.md diff --git a/docs/docker-compose-buildx-upgrade.md b/docs/technical/ops-docker-compose-buildx-upgrade.md similarity index 100% rename from docs/docker-compose-buildx-upgrade.md rename to docs/technical/ops-docker-compose-buildx-upgrade.md diff --git a/docs/version-history.md b/docs/version-history.md index e1cf4469..33b21828 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,33 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.27.0` +- `dev` 当前开发分支历史推导到:`0.33.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override,并修复 TV 合并采集源后默认频道消失的问题 | +| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源,并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 | +| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 | +| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 | +| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 | +| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 | +| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 | +| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 | +| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 | +| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 | +| `0.28.2` | bugfix | `dev` | `pending` | 修正媒体情报 tab 尺寸记忆与切换锚点逻辑,并清理 docs 根目录遗留旧路径文档 | +| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 | +| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab,并稳定 TV/news 的 reform、resize 与共享 HUD 行为 | +| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 | +| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 | +| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复,Playground 响应式按钮与输入框收起优化 | +| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 | +| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 | +| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 | +| `0.27.2` | improvement | `dev` | — | 修复 brand copy 宽度问题,提取 --brand-copy-width CSS 变量 | +| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 | | `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 | | `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit | | `0.1.0` | feature | `main` | `6cb4398f` | Modularize 3D Earth page with ES Modules | diff --git a/frontend/package.json b/frontend/package.json index 9b4570d2..0d2d4392 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.27.0", + "version": "0.33.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/assets/celestial/bright-stars.json b/frontend/public/earth/assets/celestial/bright-stars.json new file mode 100644 index 00000000..20928618 --- /dev/null +++ b/frontend/public/earth/assets/celestial/bright-stars.json @@ -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 } +] diff --git a/frontend/public/earth/assets/celestial/starmap_deep_8k.jpg b/frontend/public/earth/assets/celestial/starmap_deep_8k.jpg new file mode 100644 index 00000000..7a327d91 Binary files /dev/null and b/frontend/public/earth/assets/celestial/starmap_deep_8k.jpg differ diff --git a/frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg b/frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg new file mode 100644 index 00000000..b5334540 Binary files /dev/null and b/frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg differ diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css index c54831f9..1243c65e 100644 --- a/frontend/public/earth/css/base.css +++ b/frontend/public/earth/css/base.css @@ -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); } } diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index 6affa4c0..e09874a8 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -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 ────────────────────────────────────────── */ diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 38d8ad74..f75badf0 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -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: @@ -157,58 +279,162 @@ font-weight: 600; } -.hud-error-message { - color: #ff4444; - margin-top: 10px; - font-size: 0.9rem; - display: none; - padding: 10px; - background-color: rgba(255, 68, 68, 0.1); - border-radius: 5px; - border-left: 3px solid #ff4444; -} - -.earth-status-message { +.earth-status-message, +.earth-error-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; + line-height: 1.2; + 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, opacity 0.28s ease; } -.earth-status-message.visible { +.earth-status-message.visible, +.earth-error-message.visible { transform: translate(-50%, 0); opacity: 1; } +.earth-error-message { + top: calc(62px * var(--hud-scale)); + z-index: 211; + min-width: min(calc(220px * var(--hud-scale)), 58vw); +} + +/* ── Indicator: single dot (transient) or three dots (loading) ── */ + +.earth-status-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + gap: calc(5px * var(--hud-scale)); + flex: 0 0 auto; + align-self: center; +} + +.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 { + display: inline-flex; + align-items: center; + 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 +457,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 +480,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,33 +530,44 @@ } .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 { color: var(--hud-text-soft); - font-size: var(--hud-kicker-size); + font-size: calc(0.72rem * var(--hud-scale)); letter-spacing: 0.16em; text-transform: uppercase; } -.earth-settings-title { - margin: 4px 0 0; - color: var(--hud-title); +.earth-settings-close { + margin-top: 0; + flex-shrink: 0; } -.earth-settings-close { - margin-top: 4px; +.earth-settings-reset { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + padding-inline: calc(10px * var(--hud-scale)); + color: var(--hud-text-soft); + font-size: calc(0.68rem * var(--hud-scale)); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.earth-settings-reset .material-symbols-rounded { + font-size: calc(0.86rem * var(--hud-scale)); } .earth-settings-content { overflow-y: auto; - padding-right: 4px; + padding-inline: 8px; + padding-right: 10px; scrollbar-width: thin; scrollbar-color: rgba(160, 186, 216, 0.34) transparent; } @@ -338,13 +586,13 @@ } .earth-settings-section { - padding: 12px 0 20px; + padding: 6px 0 10px; } .earth-settings-section-title { - margin-bottom: 12px; + margin-bottom: 10px; color: var(--hud-text-soft); - font-size: var(--hud-kicker-size); + font-size: calc(0.62rem * var(--hud-scale)); letter-spacing: 0.16em; text-transform: uppercase; } @@ -352,16 +600,16 @@ .earth-settings-list { display: flex; flex-direction: column; - gap: 10px; + gap: 8px; } .earth-settings-item { display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 15px 16px; - border-radius: 16px; + gap: 12px; + padding: 10px 13px; + border-radius: 14px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), rgba(255, 255, 255, 0.025); @@ -379,24 +627,194 @@ transform: translateY(-1px); } +.earth-settings-item--stacked { + align-items: stretch; + flex-direction: column; + gap: 10px; + cursor: default; +} + +.earth-settings-item--stacked:hover { + transform: none; +} + +.earth-settings-link { + text-decoration: none; +} + +.earth-settings-slider-row { + display: flex; + align-items: center; + gap: 12px; +} + +.earth-settings-segmented { + display: inline-flex; + align-self: flex-start; + padding: 4px; + border-radius: 999px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent), + rgba(255, 255, 255, 0.03); + border: 1px solid rgba(212, 227, 244, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + gap: 4px; +} + +.earth-settings-segmented-btn { + border: 0; + background: transparent; + color: var(--hud-text-soft); + padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale)); + border-radius: 999px; + font: inherit; + font-size: calc(0.7rem * var(--hud-scale)); + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.earth-settings-segmented-btn:hover { + color: var(--hud-text); + transform: translateY(-1px); +} + +.earth-settings-segmented-btn.is-active { + color: var(--hud-title); + background: + radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%), + linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 18px rgba(0, 0, 0, 0.2); +} + +.earth-settings-slider { + flex: 1 1 auto; + width: 100%; + height: calc(4px * var(--hud-scale)); + appearance: none; + background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5)); + border-radius: 999px; + outline: none; + cursor: pointer; + transition: background 0.18s ease; +} + +.earth-settings-slider:hover { + background: linear-gradient(90deg, rgba(152, 184, 224, 0.44), rgba(114, 155, 202, 0.64)); +} + +.earth-settings-slider::-webkit-slider-thumb { + appearance: none; + width: calc(14px * var(--hud-scale)); + height: calc(14px * var(--hud-scale)); + border-radius: 50%; + background: + radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%), + linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92)); + border: 1px solid rgba(222, 236, 252, 0.4); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.06), + 0 4px 10px rgba(0, 0, 0, 0.24); + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.earth-settings-slider:hover::-webkit-slider-thumb { + transform: scale(1.22); + background: + radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.32) 55%, transparent 70%), + linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98)); + border-color: rgba(232, 244, 255, 0.72); + box-shadow: + 0 0 0 3px rgba(145, 186, 255, 0.22), + 0 6px 14px rgba(0, 0, 0, 0.3); +} + +.earth-settings-slider:active::-webkit-slider-thumb { + transform: scale(1.08); + box-shadow: + 0 0 0 4px rgba(145, 186, 255, 0.32), + 0 4px 10px rgba(0, 0, 0, 0.28); +} + +.earth-settings-slider::-moz-range-thumb { + width: calc(14px * var(--hud-scale)); + height: calc(14px * var(--hud-scale)); + border-radius: 50%; + background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92)); + border: 1px solid rgba(222, 236, 252, 0.4); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.06), + 0 4px 10px rgba(0, 0, 0, 0.24); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.earth-settings-slider:hover::-moz-range-thumb { + transform: scale(1.22); + background: linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98)); + border-color: rgba(232, 244, 255, 0.72); + box-shadow: + 0 0 0 3px rgba(145, 186, 255, 0.22), + 0 6px 14px rgba(0, 0, 0, 0.3); +} + +.earth-settings-slider:active::-moz-range-thumb { + transform: scale(1.08); + box-shadow: + 0 0 0 4px rgba(145, 186, 255, 0.32), + 0 4px 10px rgba(0, 0, 0, 0.28); +} + +.earth-settings-slider-value { + flex: 0 0 auto; + min-width: calc(34px * var(--hud-scale)); + text-align: right; + color: var(--hud-text-soft); + font-size: calc(0.66rem * var(--hud-scale)); + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; +} + .earth-settings-copy { display: flex; flex-direction: column; - gap: 4px; + gap: 3px; } .earth-settings-item-title { color: var(--hud-text); - font-size: calc(0.98rem * var(--hud-scale)); + font-size: calc(0.76rem * var(--hud-scale)); font-weight: 600; } .earth-settings-item-subtitle { color: var(--hud-text-muted); - font-size: 0.82rem; + font-size: calc(0.67rem * var(--hud-scale)); 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: calc(0.9rem * var(--hud-scale)); +} + +.earth-settings-link-meta .material-symbols-rounded:last-child { + font-size: calc(0.82rem * var(--hud-scale)); +} + .earth-settings-switch { position: relative; display: inline-flex; @@ -410,8 +828,8 @@ } .earth-settings-switch-track { - width: 48px; - height: 30px; + width: calc(38px * var(--hud-scale)); + height: calc(22px * var(--hud-scale)); border-radius: 999px; background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(215, 229, 242, 0.12); @@ -422,10 +840,10 @@ .earth-settings-switch-track::after { content: ""; position: absolute; - top: 3px; - left: 3px; - width: 22px; - height: 22px; + top: calc(3px * var(--hud-scale)); + left: calc(3px * var(--hud-scale)); + width: calc(16px * var(--hud-scale)); + height: calc(16px * var(--hud-scale)); border-radius: 50%; background: #edf4fc; box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26); @@ -438,7 +856,7 @@ } .earth-settings-switch input:checked + .earth-settings-switch-track::after { - transform: translateX(18px); + transform: translateX(calc(16px * var(--hud-scale))); } @media (max-width: 960px) { @@ -456,4 +874,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 */ diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index 05f82979..f415dc0d 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -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, @@ -132,6 +161,84 @@ pointer-events: auto; } +.info-card-cruise-link { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + opacity: 0; + pointer-events: none; + transition: opacity 0.22s ease; + z-index: 49; +} + +.info-card-cruise-link polyline { + fill: none; + stroke: rgba(255, 255, 255, 0.98); + stroke-width: 2.15; + stroke-linecap: round; + stroke-linejoin: round; + filter: + drop-shadow(0 0 1px rgba(6, 14, 28, 0.72)) + drop-shadow(0 0 2px rgba(6, 14, 28, 0.56)) + drop-shadow(0 0 6px rgba(8, 20, 36, 0.1)); +} + +.info-card-cruise-link circle { + fill: rgba(255, 255, 255, 0.98); + stroke: rgba(7, 16, 32, 0.72); + stroke-width: 1.0; + filter: + drop-shadow(0 0 1px rgba(6, 14, 28, 0.72)) + drop-shadow(0 0 2px rgba(6, 14, 28, 0.54)) + drop-shadow(0 0 6px rgba(8, 20, 36, 0.1)); + transform-box: fill-box; + transform-origin: center; +} + +.info-card-cruise-link.is-visible { + opacity: 1; +} + +.info-card-cruise-link.is-animating polyline { + animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.info-card-cruise-link.is-animating circle { + opacity: 0; +} + +.info-card-cruise-link.is-animating circle:first-of-type { + animation: cruiseConnectorNodeIn 0.14s ease forwards; + animation-delay: 0.02s; +} + +.info-card-cruise-link.is-animating circle:last-of-type { + animation: cruiseConnectorNodeIn 0.16s ease forwards; + animation-delay: 0.34s; +} + +@keyframes cruiseConnectorDraw { + from { + stroke-dashoffset: var(--connector-length, 0px); + } + to { + stroke-dashoffset: 0px; + } +} + +@keyframes cruiseConnectorNodeIn { + from { + opacity: 0; + transform: scale(0.72); + } + to { + opacity: 1; + transform: scale(1); + } +} + /* ── Info Card ────────────────────────────────────────────────── */ .info-card { @@ -155,7 +262,7 @@ .info-card-header h3 { flex: 1; margin: 0; - font-size: calc(0.92rem * var(--hud-scale)); + font-size: var(--hud-panel-header-title-size); color: var(--hud-title); font-weight: 600; white-space: nowrap; @@ -165,6 +272,15 @@ .info-card-close { flex-shrink: 0; + align-self: auto; + width: auto; + height: auto; + min-width: 0; + padding: calc(7px * var(--hud-scale)); +} + +.info-card-close .material-symbols-rounded { + font-size: calc(16px * var(--hud-scale)); } .info-card-content { diff --git a/frontend/public/earth/css/layer-panel.css b/frontend/public/earth/css/layer-panel.css index 1a88dc31..2c9511fe 100644 --- a/frontend/public/earth/css/layer-panel.css +++ b/frontend/public/earth/css/layer-panel.css @@ -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; @@ -219,6 +235,7 @@ border: none; background: transparent; cursor: pointer; + transition: opacity 0.18s ease; } .layer-row-toggle-track { @@ -231,6 +248,11 @@ transition: background 0.18s ease, border-color 0.18s ease; } +.layer-row-toggle:disabled { + cursor: progress; + opacity: 1; +} + /* Thumb */ .layer-row-toggle-track::after { content: ""; @@ -245,6 +267,24 @@ transition: transform 0.18s ease, background 0.18s ease; } +.layer-row-toggle.is-loading .layer-row-toggle-track { + background: linear-gradient( + 90deg, + rgba(104, 147, 221, 0.38), + rgba(143, 185, 255, 0.72), + rgba(104, 147, 221, 0.38) + ); + background-size: 180% 100%; + border-color: rgba(223, 236, 252, 0.28); + animation: layer-toggle-loading-track 1.2s linear infinite; +} + +.layer-row-toggle.is-loading .layer-row-toggle-track::after { + background: #f0f6ff; + transform: translateX(calc(7px * var(--hud-scale))); + animation: layer-toggle-loading-thumb 1s ease-in-out infinite; +} + /* Active (ON) state */ .layer-row-toggle.active .layer-row-toggle-track { background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78)); @@ -256,5 +296,24 @@ transform: translateX(calc(14px * var(--hud-scale))); } +@keyframes layer-toggle-loading-track { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 180% 50%; + } +} + +@keyframes layer-toggle-loading-thumb { + 0%, + 100% { + box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 0 rgba(174, 205, 255, 0.18); + } + 50% { + box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 calc(10px * var(--hud-scale)) rgba(174, 205, 255, 0.42); + } +} + /* Layout-expanded: layer panel slides off with .earth-left-column — no individual rule needed since the whole column translates together. */ diff --git a/frontend/public/earth/css/legend.css b/frontend/public/earth/css/legend.css index 3993eb43..8eca724e 100644 --- a/frontend/public/earth/css/legend.css +++ b/frontend/public/earth/css/legend.css @@ -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 ────────────────────────────────────────────────── */ diff --git a/frontend/public/earth/css/news-panel.css b/frontend/public/earth/css/news-panel.css new file mode 100644 index 00000000..23941ce3 --- /dev/null +++ b/frontend/public/earth/css/news-panel.css @@ -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)); +} diff --git a/frontend/public/earth/css/toolbar.css b/frontend/public/earth/css/toolbar.css index f6a83b82..353be6b4 100644 --- a/frontend/public/earth/css/toolbar.css +++ b/frontend/public/earth/css/toolbar.css @@ -1,4 +1,4 @@ -/* toolbar.css - bottom dock and floating toolbar primitives */ +/* toolbar.css - orbital hub toolbar */ .earth-toolbar-group { position: absolute; @@ -6,10 +6,10 @@ left: 50%; transform: translateX(-50%); display: flex; - flex-direction: row; align-items: center; justify-content: center; z-index: 200; + pointer-events: none; } .earth-toolbar-group, @@ -24,119 +24,162 @@ } .earth-toolbar { + --toolbar-scale: 1; + --toolbar-orb-size: calc(46px * var(--toolbar-scale)); + --toolbar-hub-size: calc(58px * var(--toolbar-scale)); + --toolbar-arc-width: calc(420px * var(--toolbar-scale)); + --toolbar-arc-height: calc(160px * var(--toolbar-scale)); + --toolbar-inner-arc-width: calc(260px * var(--toolbar-scale)); + --toolbar-inner-arc-height: calc(56px * var(--toolbar-scale)); position: relative; + width: min(620px, calc(100vw - 40px)); + height: calc(200px * var(--toolbar-scale)); display: flex; align-items: center; justify-content: center; - gap: 0; background: transparent; border: none; box-shadow: none; padding: 0; -} - -.earth-toolbar-items { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: nowrap; -} - -.earth-toolbar-popover { - position: relative; -} - -.earth-toolbar-popover::before { - content: ''; - position: absolute; - left: 50%; - bottom: 100%; - transform: translateX(-50%); - width: 56px; - height: 16px; - background: transparent; -} - -.earth-toolbar-popover > .earth-stack-toolbar { - position: absolute; - left: 50%; - top: auto; - right: auto; - bottom: calc(100% + 12px); - transform: translate(-50%, 10px); - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - opacity: 0; - visibility: hidden; pointer-events: none; - transition: - opacity 0.22s ease, - transform 0.22s ease, - visibility 0.22s ease; - z-index: 220; } -.earth-toolbar-btn { +.earth-toolbar-cluster { + position: relative; + width: 100%; + height: 100%; + pointer-events: none; +} + +.earth-toolbar-cluster::before { + content: ""; + position: absolute; + left: 50%; + bottom: calc(14px * var(--toolbar-scale)); + width: var(--toolbar-arc-width); + height: var(--toolbar-arc-height); + transform: translateX(-50%); + border-radius: 50%; + border: 1px solid rgba(145, 186, 255, 0.08); + border-bottom-color: transparent; + background: + radial-gradient(circle at 50% 100%, rgba(145, 186, 255, 0.05), transparent 58%); + opacity: 0.9; + mask: linear-gradient(180deg, rgba(0, 0, 0, 0.82), transparent 86%); + pointer-events: none; +} + +.earth-toolbar-orb { + position: absolute; + left: 50%; + bottom: calc(30px * var(--toolbar-scale)); + transform: translate(-50%, -50%); +} + +.earth-toolbar-hub { + position: absolute; + left: 50%; + bottom: calc(8px * var(--toolbar-scale)); + transform: translateX(-50%); +} + +.earth-toolbar-orb { + pointer-events: none; + opacity: 1; + transition: + transform 0.36s cubic-bezier(0.34, 1.15, 0.64, 1), + opacity 0.24s ease; +} + +.earth-toolbar-cluster.is-expanded .earth-toolbar-orb { + transform: translate(calc(-50% + var(--orb-x)), calc(-50% + var(--orb-y))); +} + +.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb { + transform: translate(-50%, -50%) scale(0.42); + opacity: 0; +} + +.earth-toolbar-orb > *, +.earth-toolbar-hub > * { + pointer-events: auto; +} + +.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * { + pointer-events: none; +} + +.earth-toolbar-hub > * { + pointer-events: auto; +} + +.earth-toolbar-orb > .liquid-glass-surface { + animation: floatDock 4.6s ease-in-out infinite; + animation-delay: var(--orb-delay, 0s); +} + +.earth-toolbar-cluster.is-dock-engaged .earth-toolbar-orb > .liquid-glass-surface { + animation-play-state: paused; +} + +.earth-toolbar-btn, +.earth-toolbar-hub-btn { position: relative; width: 28px; height: 28px; border: none; - border-radius: 0; background: transparent; - color: #4db8ff; - font-size: 14px; + color: var(--hud-text-soft); + font-size: calc(14px * var(--toolbar-scale)); cursor: pointer; - display: flex; + display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 0; margin: 0; - overflow: visible; appearance: none; -webkit-appearance: none; } .earth-toolbar-btn.floating-btn { - width: 42px; - height: 42px; - min-width: 42px; - min-height: 42px; + width: var(--toolbar-orb-size); + height: var(--toolbar-orb-size); + min-width: var(--toolbar-orb-size); + min-height: var(--toolbar-orb-size); border-radius: 50%; overflow: hidden; } -.earth-toolbar-btn:not(.liquid-glass-surface)::after { - content: none; +.earth-toolbar-hub-btn { + width: var(--toolbar-hub-size); + height: var(--toolbar-hub-size); + min-width: var(--toolbar-hub-size); + min-height: var(--toolbar-hub-size); + border-radius: 50%; + overflow: hidden; + color: var(--hud-title); +} + +.earth-toolbar-btn .icon, +.earth-toolbar-hub-btn .material-symbols-rounded { + position: relative; + z-index: 1; + line-height: 1; } .earth-toolbar-btn .icon { display: inline-flex; align-items: center; justify-content: center; - position: relative; - z-index: 1; - transform: translateZ(0); transition: transform 0.16s ease, opacity 0.16s ease; backface-visibility: hidden; -webkit-backface-visibility: hidden; - line-height: 1; } -.earth-toolbar-btn svg { - width: 20px; - height: 20px; - stroke: currentColor; - stroke-width: 2.1; - fill: none; - stroke-linecap: round; - stroke-linejoin: round; -} - -.earth-toolbar-btn .material-symbols-rounded { - font-size: 21px; +.earth-toolbar-btn .material-symbols-rounded, +.earth-toolbar-hub-btn .material-symbols-rounded { + font-size: calc(21px * var(--toolbar-scale)); line-height: 1; font-variation-settings: 'FILL' 0, @@ -154,28 +197,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 +211,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 +228,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 +240,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 +260,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 +286,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 +328,16 @@ .liquid-glass-surface:active, .liquid-glass-surface.is-pressed { - --btn-scale: 0.942; + --btn-scale: 0.95; --press-offset: 2px; --glow-opacity: 0.2; - background: - radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%), - radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%), - linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)), - rgba(10, 24, 44, 0.24); - border-color: rgba(240, 249, 255, 0.58); - box-shadow: - inset 0 2px 10px rgba(0, 0, 0, 0.2), - inset 0 1px 0 rgba(255, 255, 255, 0.16), - 0 4px 10px rgba(0, 0, 0, 0.18), - 0 0 14px rgba(176, 226, 255, 0.18); -} - -.liquid-glass-surface:active::before, -.liquid-glass-surface.is-pressed::before { - opacity: 0.46; - transform: translateY(2px) scale(0.985); -} - -.liquid-glass-surface:active::after, -.liquid-glass-surface.is-pressed::after { - opacity: 0.78; - transform: scale(0.985); -} - -.liquid-glass-surface:active .icon, -.liquid-glass-surface.is-pressed .icon { - transform: translateY(1.5px); -} - -.liquid-glass-surface:active img, -.liquid-glass-surface.is-pressed img, -.liquid-glass-surface:active .material-symbols-rounded, -.liquid-glass-surface.is-pressed .material-symbols-rounded { - transform: translateY(1.5px); - transition: transform 0.16s ease, opacity 0.16s ease; } .liquid-glass-surface.active { background: - radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%), - linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)), - rgba(11, 34, 58, 0.26); + radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)), + rgba(11, 34, 58, 0.18); border-color: var(--hud-border-active); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), @@ -357,151 +360,64 @@ .earth-zoom-group:hover > .earth-zoom-toolbar, .earth-zoom-group:focus-within > .earth-zoom-toolbar, -.earth-zoom-group.open > .earth-zoom-toolbar, -.earth-info-group:hover > .earth-info-toolbar, -.earth-info-group:focus-within > .earth-info-toolbar, -.earth-info-group.open > .earth-info-toolbar { +.earth-zoom-group.open > .earth-zoom-toolbar { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(-50%, 0); } -.earth-zoom-group.force-closed > .earth-zoom-toolbar, -.earth-info-group.force-closed > .earth-info-toolbar { +.earth-zoom-group.force-closed > .earth-zoom-toolbar { opacity: 0; visibility: hidden; pointer-events: none; - transform: translate(-50%, 8px); + transform: translate(-50%, calc(8px * var(--toolbar-scale))); } -.earth-zoom-group > .earth-zoom-toolbar, -.earth-info-group > .earth-info-toolbar { +.earth-toolbar-popover::before { + content: ""; + position: absolute; + left: 50%; + bottom: 100%; + transform: translateX(-50%); + width: calc(56px * var(--toolbar-scale)); + height: calc(16px * var(--toolbar-scale)); + background: transparent; +} + +.earth-toolbar-popover > .earth-stack-toolbar { + position: absolute; + left: 50%; top: auto; right: auto; - left: 50%; - bottom: calc(100% + 12px); + bottom: calc(100% + (12px * var(--toolbar-scale))); + transform: translate(-50%, calc(10px * var(--toolbar-scale))); display: flex; flex-direction: column; align-items: center; - justify-content: flex-start; - gap: 8px; -} - -.earth-info-toolbar { - width: min(280px, calc(100vw - 36px)); - padding: 12px; - border-radius: 22px; - background: - radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%), - linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94)); - border: 1px solid rgba(211, 228, 246, 0.14); - box-shadow: - 0 20px 40px rgba(0, 0, 0, 0.28), - inset 0 1px 0 rgba(255, 255, 255, 0.08); - backdrop-filter: blur(18px) saturate(135%); - -webkit-backdrop-filter: blur(18px) saturate(135%); -} - -.earth-layer-toolbar-header { - width: 100%; - padding: 2px 4px 8px; - border-bottom: 1px solid rgba(201, 225, 247, 0.08); - margin-bottom: 2px; -} - -.earth-layer-toolbar-title { - display: block; - color: var(--hud-accent-strong); - font-size: 0.86rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.earth-layer-toolbar-subtitle { - display: block; - margin-top: 4px; - color: var(--hud-text-soft); - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.earth-layer-btn { - width: 100%; - min-width: 0; - min-height: 52px; - height: auto; - border-radius: 16px; - padding: 12px 14px; - justify-content: space-between; - align-items: center; - gap: 12px; - overflow: hidden; - animation: none; -} - -.earth-layer-btn__copy { - min-width: 0; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 3px; -} - -.earth-layer-btn__label { - color: var(--hud-text); - font-size: 0.92rem; - font-weight: 600; - line-height: 1.15; -} - -.earth-layer-btn__meta { - color: var(--hud-text-soft); - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; - line-height: 1.2; -} - -.earth-layer-btn__state { - flex: 0 0 auto; - min-width: 42px; - padding: 5px 10px; - border-radius: 999px; - border: 1px solid rgba(201, 225, 247, 0.12); - color: var(--hud-text-soft); - font-size: 0.67rem; - font-weight: 700; - letter-spacing: 0.12em; - text-align: center; - text-transform: uppercase; - background: rgba(255, 255, 255, 0.04); -} - -.earth-layer-btn.active .earth-layer-btn__state { - color: #dff4ff; - border-color: rgba(220, 240, 255, 0.24); - background: rgba(131, 197, 255, 0.14); -} - -.earth-layer-btn .earth-toolbar-tooltip { - display: none; + gap: calc(8px * var(--toolbar-scale)); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity 0.22s ease, + transform 0.22s ease, + visibility 0.22s ease; + z-index: 220; } .earth-zoom-toolbar .earth-zoom-btn, .earth-zoom-toolbar .earth-zoom-value { - width: 42px; - min-width: 42px; + width: calc(42px * var(--toolbar-scale)); + min-width: calc(42px * var(--toolbar-scale)); border-radius: 50%; - color: #4db8ff; - animation: floatDock 3.8s ease-in-out infinite; + color: var(--hud-text-soft); + animation: none; } .earth-zoom-toolbar .earth-zoom-btn { - height: 42px; - font-size: 20px; + height: calc(42px * var(--toolbar-scale)); + font-size: calc(20px * var(--toolbar-scale)); font-weight: 500; line-height: 1; } @@ -510,41 +426,12 @@ display: inline-flex; align-items: center; justify-content: center; - height: 42px; + height: calc(42px * var(--toolbar-scale)); padding: 0; - font-size: 0.68rem; + font-size: calc(11px * var(--toolbar-scale)); letter-spacing: normal; - animation-delay: 0.18s; } -.earth-zoom-toolbar .earth-zoom-btn:active, -.earth-zoom-toolbar .earth-zoom-btn.is-pressed, -.earth-zoom-toolbar .earth-zoom-value:active, -.earth-zoom-toolbar .earth-zoom-value.is-pressed { - letter-spacing: -0.01em; -} - -.earth-zoom-toolbar .earth-zoom-btn:nth-child(1) { - animation-delay: 0s; -} - -.earth-zoom-toolbar .earth-zoom-btn:nth-child(3) { - animation-delay: 0.34s; -} - -.earth-zoom-toolbar .earth-toolbar-tooltip { - bottom: calc(100% + 10px); -} - -.earth-zoom-toolbar .earth-toolbar-tooltip::after { - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 6px solid transparent; - border-top-color: rgba(77, 184, 255, 0.4); -} - - .earth-app.layout-expanded .earth-toolbar-group { bottom: 18px; transform: translateX(-50%); @@ -552,21 +439,23 @@ .earth-toolbar-btn .earth-toolbar-tooltip { position: absolute; - bottom: 56px; + bottom: calc(56px * var(--toolbar-scale)); left: 50%; transform: translateX(-50%); - background: rgba(10, 10, 30, 0.95); - color: #fff; - padding: 6px 12px; - border-radius: 6px; - font-size: 12px; + background: + linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95)); + color: var(--hud-text); + padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale)); + border-radius: calc(6px * var(--toolbar-scale)); + font-size: calc(12px * var(--toolbar-scale)); white-space: nowrap; opacity: 0; visibility: hidden; transition: all 0.2s ease; - border: 1px solid rgba(77, 184, 255, 0.4); + border: 1px solid var(--hud-border); pointer-events: none; z-index: 100; + box-shadow: var(--hud-shadow-soft); } .earth-toolbar-btn:hover .earth-toolbar-tooltip, @@ -574,15 +463,15 @@ .earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip { opacity: 1; visibility: visible; - bottom: 58px; + bottom: calc(58px * var(--toolbar-scale)); } .earth-toolbar-btn .earth-toolbar-tooltip::after { - content: ''; + content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); - border: 6px solid transparent; - border-top-color: rgba(77, 184, 255, 0.4); + border: calc(6px * var(--toolbar-scale)) solid transparent; + border-top-color: rgba(18, 31, 52, 0.96); } diff --git a/frontend/public/earth/css/tv-panel.css b/frontend/public/earth/css/tv-panel.css index 73e6b1c7..23b5708e 100644 --- a/frontend/public/earth/css/tv-panel.css +++ b/frontend/public/earth/css/tv-panel.css @@ -1,22 +1,98 @@ -/* tv-panel */ +/* media-panel + * Outer HUD shell: #media-panel + * Inner live pane: #tv-panel + * Inner news pane: #news-panel + */ -.hud-panel-tv { +.hud-panel-media { bottom: var(--hud-offset); right: var(--hud-offset); width: calc(420px * var(--hud-scale)); max-width: calc(100vw - 32px); + max-height: calc(100vh - (2 * var(--hud-offset))); min-width: calc(300px * var(--hud-scale)); - min-height: calc(340px * var(--hud-scale)); - padding: calc(18px * var(--hud-scale)); + padding: calc(10px * var(--hud-scale)); display: flex; flex-direction: column; gap: var(--hud-gap-sm); z-index: 18; } -.tv-panel-header-copy { - display: grid; - gap: calc(3px * var(--hud-scale)); +.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) { + max-height: min( + var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))), + calc(100vh - (2 * var(--hud-offset))) + ); +} + +.hud-panel-media.is-reforming { + transition: + height 0.24s cubic-bezier(0.22, 1, 0.36, 1), + top 0.24s cubic-bezier(0.22, 1, 0.36, 1); + will-change: height, top; +} + +.hud-panel-media .hud-panel__header { + align-items: center; + gap: var(--hud-gap-xs); +} + +.hud-panel-media .hud-panel__title-group { + flex: 0 0 auto; + min-width: auto; +} + +.tv-panel-header-title { + flex: 0 0 auto; + white-space: nowrap; + margin: 0; +} + +.hud-panel-media .hud-panel__header .hud-panel__action, +.hud-panel-media .hud-panel__header .hud-panel-close { + cursor: pointer; + user-select: auto; +} + +.hud-panel-media .hud-panel__header .tv-panel-select, +.hud-panel-media .hud-panel__header .media-panel-tab { + cursor: pointer; + user-select: auto; +} + +.tv-panel-header-controls { + display: flex; + align-items: center; + gap: var(--hud-gap-xs); + flex: 1 1 auto; + min-width: 0; +} + +.tv-panel-header-controls--news { + justify-content: flex-end; +} + +.tv-panel-content { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.tv-tab-pane { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: var(--hud-gap-sm); + overflow: hidden; +} + +.tv-panel-toolbar-actions { + display: inline-flex; + align-items: center; + gap: var(--hud-gap-xs); + flex-shrink: 0; } .tv-panel-status { @@ -26,13 +102,6 @@ text-transform: uppercase; } -.tv-panel-controls { - display: flex; - gap: var(--hud-gap-sm); - align-items: center; - min-width: 0; -} - .tv-panel-select { flex: 1 1 auto; min-width: 0; @@ -53,48 +122,19 @@ color: #eef5fc; } -.tv-panel-actions { - display: flex; - gap: var(--hud-gap-xs); +.tv-panel-meta-wrap { + overflow: hidden; + max-height: calc(120px * var(--hud-scale)); + opacity: 1; + transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease; } -.tv-panel-action { - border: 1px solid rgba(201, 225, 247, 0.12); - border-radius: calc(12px * var(--hud-scale)); - background: rgba(255, 255, 255, 0.05); - color: var(--hud-text); - padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); - display: inline-flex; - align-items: center; - justify-content: center; - white-space: nowrap; - font-size: calc(0.84rem * var(--hud-scale)); - line-height: 1; - cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; -} - -.tv-panel-action--icon { - padding: calc(9px * var(--hud-scale)); - border-radius: calc(10px * var(--hud-scale)); -} - -.tv-panel-action--icon .material-symbols-rounded { - font-size: calc(18px * var(--hud-scale)); - line-height: 1; - font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; +.tv-panel-meta-wrap.is-collapsed { + max-height: 0; + opacity: 0; pointer-events: none; -} - -.tv-panel-action:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(225, 239, 255, 0.2); - color: var(--hud-accent-strong); -} - -.tv-panel-action:disabled { - opacity: 0.45; - cursor: not-allowed; + margin-top: 0; + margin-bottom: 0; } .tv-panel-meta { @@ -163,45 +203,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. */ diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 267833d0..c4882284 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -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" } } @@ -35,6 +36,7 @@ + @@ -50,7 +52,7 @@
-
+
@@ -61,7 +63,10 @@ layers 图层 +
@@ -69,18 +74,20 @@
@@ -91,7 +98,7 @@ 地形 Terrain
-
@@ -143,52 +150,47 @@
- -
-
-
- 🛰️ -

详情

- -
-
-
-
-
+
-
- - - - -
+ - - - +
+ +
+
+ +
+
+ +
+
+ +
- +
-
- - - +
+ 图例 + 海缆
- -
-
+
@@ -299,77 +311,165 @@
-
-
-
-

新闻直播

- 等待加载直播源 +
+
+
+ 媒体情报
- -
-
- -
- - -
-
-
-
暂无可用频道
-
当前未配置可播放新闻直播源
-
频道目录待同步
-
支持后台配置默认源与采集器补充源。
-
-
-
暂无可播放直播源,请先在系统配置中添加频道。
- - -
- -
- -
-
-
正在初始化全球态势数据...
-
同步卫星、海底光缆、登陆点与BGP态势数据
-
-
-
- diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx index abedf157..7f990d6f 100644 --- a/frontend/src/pages/DataList/DataList.tsx +++ b/frontend/src/pages/DataList/DataList.tsx @@ -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() { />
-
+ `共 ${count} 条`, }} /> - + diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index 6c07933b..4b1812c1 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -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 @@ -17,6 +19,7 @@ import { useWebSocket } from '../../hooks/useWebSocket' interface BuiltInDataSource { id: number + source: string name: string module: string priority: string @@ -116,18 +119,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, +): 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 { @@ -157,6 +181,19 @@ interface CustomDataSource { updated_at: string | null } +interface EditableDataSourceConfig { + id: number + name: string + description: string | null + source_type: string + endpoint: string + auth_type: string + auth_config: Record + headers: Record + config: Record + is_active?: boolean +} + interface ViewDataSource { id: number name: string @@ -182,6 +219,7 @@ function DataSources() { const [drawerVisible, setDrawerVisible] = useState(false) const [viewDrawerVisible, setViewDrawerVisible] = useState(false) const [editingConfig, setEditingConfig] = useState(null) + const [builtinEditingSource, setBuiltinEditingSource] = useState(null) const [viewingSource, setViewingSource] = useState(null) const [recordCount, setRecordCount] = useState(0) const [testing, setTesting] = useState(false) @@ -192,8 +230,85 @@ function DataSources() { const customTableRegionRef = useRef(null) const [builtinTableHeight, setBuiltinTableHeight] = useState(360) const [customTableHeight, setCustomTableHeight] = useState(360) + const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions() + const [customActionsCollapsed, customContainerRef] = useCollapsedActions() const [form] = Form.useForm() + const headersMapToList = useCallback((headers?: Record | null) => { + return Object.entries(headers || {}) + .filter(([key, value]) => key && value !== undefined && value !== null && String(value).trim() !== '') + .map(([key, value]) => ({ key, value })) + }, []) + + const headersListToMap = useCallback((headers?: Array<{ key?: string; value?: string }> | Record) => { + if (!headers) return {} + if (!Array.isArray(headers)) return headers + + return headers.reduce>((acc, item) => { + const key = item?.key?.trim() + const value = item?.value?.trim() + if (!key || value === undefined) return acc + acc[key] = value + return acc + }, {}) + }, []) + + const applyConfigToForm = useCallback((config?: Partial | null) => { + form.setFieldsValue({ + name: config?.name || '', + description: config?.description || '', + source_type: config?.source_type || 'http', + endpoint: config?.endpoint || '', + auth_type: config?.auth_type || 'none', + auth_config: config?.auth_config || {}, + headers: headersMapToList(config?.headers || {}), + config: config?.config || { timeout: 30, retry: 3 }, + }) + }, [form, headersMapToList]) + + const loadConfigDetail = useCallback(async (configId: number) => { + const res = await axios.get(`/api/v1/datasources/configs/${configId}`) + return res.data + }, []) + + const createDefaultConfigDraft = useCallback((overrides?: Partial) => ({ + source_type: 'http', + auth_type: 'none', + headers: {}, + config: { timeout: 30, retry: 3 }, + ...overrides, + }), []) + + const getBuiltinOverrideDescription = useCallback( + (source?: Pick | null) => + source ? `Built-in datasource override for ${source.name}` : undefined, + [], + ) + + const createFormPayload = useCallback((values: any) => ({ + ...values, + name: builtinEditingSource ? builtinEditingSource.source : values.name, + description: + values.description || + getBuiltinOverrideDescription(builtinEditingSource), + source_type: builtinEditingSource ? 'http' : values.source_type, + headers: headersListToMap(values.headers), + }), [builtinEditingSource, getBuiltinOverrideDescription, headersListToMap]) + + const closeDrawerAfterLoadError = useCallback(( + errorMessage: string, + options?: { clearBuiltin?: boolean; clearEditingConfig?: boolean }, + ) => { + messageApi.error(errorMessage) + setDrawerVisible(false) + if (options?.clearBuiltin) { + setBuiltinEditingSource(null) + } + if (options?.clearEditingConfig) { + setEditingConfig(null) + } + }, [messageApi]) + const fetchData = useCallback(async () => { setLoading(true) try { @@ -436,6 +551,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, { @@ -651,9 +801,11 @@ function DataSources() { const handleViewSource = async (source: BuiltInDataSource) => { try { - const [res, statsRes] = await Promise.all([ + const existingOverride = customSources.find((item) => item.name === source.source) + const [res, statsRes, overrideDetail] = await Promise.all([ axios.get(`/api/v1/datasources/${source.id}`), - axios.get(`/api/v1/datasources/${source.id}/stats`) + axios.get(`/api/v1/datasources/${source.id}/stats`), + existingOverride ? loadConfigDetail(existingOverride.id) : Promise.resolve(null), ]) const data = res.data setViewingSource({ @@ -661,10 +813,10 @@ function DataSources() { name: data.name, description: null, source_type: data.collector_class, - endpoint: data.endpoint || '', - auth_type: 'none', - headers: {}, - config: {}, + endpoint: overrideDetail?.endpoint || data.endpoint || '', + auth_type: overrideDetail?.auth_type || 'none', + headers: overrideDetail?.headers || {}, + config: overrideDetail?.config || {}, collector_class: data.collector_class, module: data.module, priority: data.priority, @@ -693,7 +845,8 @@ function DataSources() { const values = await form.validateFields() setTesting(true) setTestResult(null) - const res = await axios.post('/api/v1/datasources/configs/test', values) + const payload = createFormPayload(values) + const res = await axios.post('/api/v1/datasources/configs/test', payload) setTestResult(res.data) if (res.data.success) { messageApi.success('连接测试成功') @@ -711,16 +864,18 @@ function DataSources() { const handleSave = async () => { try { const values = await form.validateFields() + const payload = createFormPayload(values) if (editingConfig) { - await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, values) + await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, payload) messageApi.success('配置已更新') } else { - await axios.post('/api/v1/datasources/configs', values) + await axios.post('/api/v1/datasources/configs', payload) messageApi.success('配置已创建') } setDrawerVisible(false) form.resetFields() setEditingConfig(null) + setBuiltinEditingSource(null) setTestResult(null) fetchData() } catch (error: unknown) { @@ -740,6 +895,23 @@ function DataSources() { } } + const handleResetBuiltinOverride = async () => { + if (!builtinEditingSource || !editingConfig) return + try { + await axios.delete(`/api/v1/datasources/configs/${editingConfig.id}`) + messageApi.success(`已恢复 ${builtinEditingSource.name} 的默认配置`) + setDrawerVisible(false) + form.resetFields() + setEditingConfig(null) + setBuiltinEditingSource(null) + setTestResult(null) + fetchData() + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string } } } + messageApi.error(err.response?.data?.detail || '恢复默认失败') + } + } + const handleToggleCustom = async (id: number, current: boolean) => { try { await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current }) @@ -751,24 +923,53 @@ function DataSources() { } } - const openDrawer = (config?: CustomDataSource) => { + const openDrawer = async (config?: CustomDataSource) => { + setBuiltinEditingSource(null) setEditingConfig(config || null) - if (config) { - form.setFieldsValue({ - ...config, - auth_config: {}, - }) - } else { - form.resetFields() - form.setFieldsValue({ - source_type: 'http', - auth_type: 'none', - config: { timeout: 30, retry: 3 }, - headers: {}, - }) - } - setDrawerVisible(true) setTestResult(null) + setDrawerVisible(true) + + if (config) { + try { + const detail = await loadConfigDetail(config.id) + applyConfigToForm(detail) + } catch { + closeDrawerAfterLoadError('获取配置详情失败', { clearEditingConfig: true }) + } + return + } + + form.resetFields() + applyConfigToForm(createDefaultConfigDraft()) + } + + const openBuiltinConfigDrawer = async (source: BuiltInDataSource) => { + setBuiltinEditingSource(source) + setTestResult(null) + setDrawerVisible(true) + + const existingOverride = customSources.find((item) => item.name === source.source) + setEditingConfig(existingOverride || null) + + if (existingOverride) { + try { + const detail = await loadConfigDetail(existingOverride.id) + applyConfigToForm(detail) + } catch { + closeDrawerAfterLoadError('获取内置数据源配置失败', { + clearBuiltin: true, + clearEditingConfig: true, + }) + } + return + } + + form.resetFields() + applyConfigToForm(createDefaultConfigDraft({ + name: source.source, + description: getBuiltinOverrideDescription(source), + endpoint: source.endpoint || '', + })) } const handleCopyLink = async (value: string, successText: string) => { @@ -884,10 +1085,43 @@ function DataSources() { { title: '操作', key: 'action', - width: 200, fixed: 'right' as const, + width: builtinActionsCollapsed ? 40 : 228, + onCell: () => actionCellProps, render: (_: unknown, record: BuiltInDataSource) => ( - + , + onClick: () => { void openBuiltinConfigDrawer(record) }, + }, + { + key: 'trigger', + label: '触发', + icon: , + disabled: !record.is_active, + onClick: () => handleTrigger(record.id), + }, + { + key: 'toggle', + label: record.is_active ? '禁用' : '启用', + icon: record.is_active ? : , + danger: record.is_active, + onClick: () => handleToggle(record.id, record.is_active), + }, + ]} + > + - + ), }, ] @@ -945,30 +1179,56 @@ function DataSources() { { title: '操作', key: 'action', - width: 150, fixed: 'right' as const, + width: customActionsCollapsed ? 40 : 228, + onCell: () => actionCellProps, render: (_: unknown, record: CustomDataSource) => ( - - - + + handleDelete(record.id)}> + - + ), }, ] @@ -978,7 +1238,7 @@ function DataSources() { key: 'builtin', label: '内置数据源', children: ( -
+
采集实时进度
@@ -1064,9 +1324,9 @@ function DataSources() { ), children: ( -
+
-
@@ -1110,24 +1370,40 @@ function DataSources() {
{ setDrawerVisible(false) form.resetFields() setEditingConfig(null) + setBuiltinEditingSource(null) setTestResult(null) }} footer={
- + + {builtinEditingSource && editingConfig ? ( + + + + ) : null} + +
+
内置数据源
+ + + +
Collector Key
+ + + + + ) : ( + + + + )} - - - + {builtinEditingSource ? null : ( + + + + )} + + + @@ -1240,6 +1540,7 @@ function DataSources() { /> (null) const messagesContainerRef = useRef(null) + const messagesShellRef = useRef(null) const forceScrollToBottomRef = useRef(true) const selectedPreset = useMemo( @@ -575,7 +579,7 @@ function Playground() { /> )} > -
+ {providerStatus ? (
@@ -617,7 +621,7 @@ function Playground() { )} -
+
) @@ -678,7 +682,7 @@ function Playground() { title="AI Chatbox" extra={( - + -
- + + + ) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
@@ -845,31 +850,50 @@ function Playground() {
-
-
- {PLAYGROUND_PRESETS.map((preset) => ( - handleApplyPreset(preset)} - > - {preset.label} - - ))} +
+ {(composerFocused || !!inputValue) && ( +
+ {PLAYGROUND_PRESETS.map((preset) => ( + handleApplyPreset(preset)} + > + {preset.label} + + ))} +
+ )} +
+ 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) && ( + +
- 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) && (
{title} @@ -886,8 +910,9 @@ function Playground() { />
-
+ )}
+
@@ -1000,42 +1025,42 @@ function Playground() { /> -
+ {analysis.text_blocks.length ? (
文本块 -
+ {analysis.text_blocks.map((block, index) => (
{block}
))} -
+
) : null} {analysis.thinking_blocks.length ? (
Thinking Blocks -
+ {analysis.thinking_blocks.map((block, index) => (
{block}
))} -
+
) : null}
Raw Response -
+
{JSON.stringify(analysis.raw_response, null, 2)}
-
+
-
+ ) : null} diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index d684dff0..b27b907a 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -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 (
-
{children}
+ {children}
) @@ -106,11 +113,14 @@ function Settings() { const [securitySettings, setSecuritySettings] = useState(null) const [tvSettings, setTvSettings] = useState(null) const [savingTvSettings, setSavingTvSettings] = useState(false) + const [editingSource, setEditingSource] = useState(null) + const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780) const collectorTableRegionRef = useRef(null) const [collectorTableHeight, setCollectorTableHeight] = useState(360) const [systemForm] = Form.useForm() const [notificationForm] = Form.useForm() const [securityForm] = Form.useForm() + const [tvEditForm] = Form.useForm() const fetchSettings = async () => { try { @@ -204,90 +214,95 @@ function Settings() { } } - const updateTvSetting = (field: K, value: TVSettings[K]) => { - setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev)) - } - - const updateTvSourceField = ( - 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) => ( -
- updateTvSourceField(record.id, 'name', event.target.value)} /> - updateTvSourceField(record.id, 'provider', event.target.value)} - /> + width: 180, + render: (_: unknown, record: TVStreamSource) => ( +
+
{record.name}
+ {record.provider}
), }, { title: '区域 / 语言', key: 'locale', - width: 160, + width: 130, render: (_: unknown, record: TVStreamSource) => ( -
- updateTvSourceField(record.id, 'region', event.target.value)} - /> - updateTvSourceField(record.id, 'language', event.target.value)} - /> -
+ {record.region} · {record.language} ), }, { title: '类型', dataIndex: 'source_type', key: 'source_type', - width: 120, - render: (value: TVStreamSource['source_type'], record: TVStreamSource) => ( - updateTvSourceField(record.id, 'embed_url', event.target.value)} - /> - updateTvSourceField(record.id, 'stream_url', event.target.value)} - /> - updateTvSourceField(record.id, 'youtube_video_id', event.target.value)} - /> - updateTvSourceField(record.id, 'youtube_channel', event.target.value)} - /> -
- ), - }, - { - title: '官网', - dataIndex: 'homepage_url', - key: 'homepage_url', - width: 220, - render: (value: string, record: TVStreamSource) => ( - updateTvSourceField(record.id, 'homepage_url', event.target.value)} /> - ), + width: 90, + render: (value: string) => {value}, }, { title: '状态', key: 'status', - width: 110, + width: 130, render: (_: unknown, record: TVStreamSource) => ( -
- updateTvSourceField(record.id, 'is_enabled', checked)} /> - updateTvSourceField(record.id, 'is_fallback', checked)} /> +
+ {record.is_enabled ? '启用' : '禁用'} + {record.id === tvSettings?.default_source_id && 默认} + {record.is_fallback && 备用}
), }, @@ -506,20 +455,82 @@ function Settings() { title: '备注', dataIndex: 'notes', key: 'notes', - width: 220, - render: (value: string, record: TVStreamSource) => ( - updateTvSourceField(record.id, 'notes', event.target.value)} /> - ), + width: 200, + ellipsis: true, + render: (value: string) => {value || '—'}, }, { title: '操作', key: 'action', - width: 90, fixed: 'right' as const, + width: tvActionsCollapsed ? 40 : 258, + onCell: () => actionCellProps, render: (_: unknown, record: TVStreamSource) => ( - + , + disabled: record.id === tvSettings?.default_source_id, + onClick: () => setDefaultSource(record.id), + }, + { + key: 'edit', + label: '编辑', + icon: , + onClick: () => { + setEditingSource(record) + tvEditForm.setFieldsValue(record) + }, + }, + { type: 'divider' }, + { + key: 'delete', + label: '删除', + icon: , + danger: true, + disabled: record.id === tvSettings?.default_source_id, + onClick: () => { + void removeTvSource(record.id) + }, + }, + ]} + > + + + + ), }, ] @@ -610,51 +621,111 @@ function Settings() { key: 'tv', label: '电视直播', children: ( -
- -
-
-
-
- 默认直播源 -
- - +
+ + +
+ + +
- + ), diff --git a/frontend/src/pages/Tasks/Tasks.tsx b/frontend/src/pages/Tasks/Tasks.tsx index dbb1f183..f7af6279 100644 --- a/frontend/src/pages/Tasks/Tasks.tsx +++ b/frontend/src/pages/Tasks/Tasks.tsx @@ -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() { } > -
+
- + ) diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx index 71d35e7d..43f7f6b1 100644 --- a/frontend/src/pages/Users/Users.tsx +++ b/frontend/src/pages/Users/Users.tsx @@ -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(null) - const tableRegionRef = useRef(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) => ( - - - - + , onClick: () => handleEdit(record) }, + { type: 'divider' }, + { key: 'delete', label: '删除', icon: , danger: true, onClick: () => handleDelete(record.id) }, + ]} + > + + + ), }, ] @@ -143,17 +136,19 @@ function Users() {

用户管理

-
-
+
+
- + /dev/null 2>&1 || return 1 + curl -s --max-time "$HTTP_CHECK_MAX_TIME" \ + "http://localhost:${ai_provider_port}/health" >/dev/null 2>&1 +} + ensure_database_services_healthy() { local retry=1 @@ -1130,7 +1138,15 @@ start_backend_service() { log_success "启动数据库已就绪" sleep 3 - start_ai_provider_service "$ai_provider_port" + # Backend depends on AI Provider reachability, but a backend-only restart + # should reuse the existing healthy provider instead of rebuilding or + # restarting it. + if ai_provider_service_healthy "$ai_provider_port"; then + log_note "AI Provider 已健康,复用现有服务,跳过启动/重建" + else + log_note "AI Provider 当前不健康,先执行托底启动" + start_ai_provider_service "$ai_provider_port" + fi if [ "$backend_port_requested" -eq 1 ]; then kill_port_if_requested "$backend_port" "后端" diff --git a/pyproject.toml b/pyproject.toml index 7b23fa4b..465a631c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.27.0" +version = "0.33.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 03261efe..62571cb4 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.27.0" +version = "0.33.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },