Compare commits

...

6 Commits

Author SHA1 Message Date
rayd1o
58671e7bc3 release: bump version to 0.74.4
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-09-13 10:27:00 +08:00
rayd1o
a54fcdbeed release: bump version to 0.74.3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
2026-09-13 02:17:55 +08:00
rayd1o
1dd2921674 release: bump version to 0.74.2
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-07-01 23:40:00 +08:00
linkong
d30f7d08c5 release: bump version to 0.74.1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-06-30 18:54:34 +08:00
linkong
5bdb55f3f1 release: bump version to 0.74.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-06-30 13:52:52 +08:00
linkong
fbecf30513 release: bump version to 0.73.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-06-29 17:04:05 +08:00
144 changed files with 11658 additions and 2654 deletions

View File

@@ -1,139 +0,0 @@
---
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
---
# /cleanup — 垃圾代码审查与清理
分析当前工作区git diff中的未提交代码找出并修复常见垃圾代码**不得改变任何运行逻辑**。
## 检查范围
`$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
## 节省上下文规则
优先用确定性的 CLI 检查缩小范围,不要一上来把完整文件或大 diff 读入上下文:
```bash
git diff --name-only HEAD
git diff --unified=0 HEAD -- <path>
git diff --check
rg -n "TODO|FIXME|console\.log|debugger|print\(" <changed-paths>
```
只有 focused diff 不足以安全判断或修改时,才读取完整文件。
## 审查清单
按优先级检查以下问题(只报告在本次 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 — 逐文件阅读并分析
先从 focused diff 开始:
```bash
git diff --unified=0 HEAD -- <file>
```
`rg``git diff --check`、编译器或 linter 输出确认确定性问题。只有需要上下文时才用 Read 读取完整文件。对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式。
### Step 3 — 报告问题清单
在修改前,先以列表形式输出所有发现的问题:
```
发现 N 个问题:
[文件] js/foo.js
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
[文件] js/bar.js
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
...
```
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
### Step 4 — 执行修复
对每个问题,使用 Edit 工具进行**最小化修改**
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
- **命名问题**:重命名变量,更新所有使用处
- **死代码**:直接删除
- **尾部空白/风格**:修正
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
**修复原则:**
- 只改在审查清单中发现的问题,不做额外优化
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
- 改完后用 `grep` 验证旧的坏代码已消失
- 优先做精确补丁;只有仓库已有对应格式化流程时,才运行格式化工具
### Step 5 — 输出总结
```
清理完成:
修复了 N 个问题:
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 144处引用
✓ controls.js — export updateLayerButtonState移除 main.js 中的重复实现
...
未修改的问题(需人工确认):
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
```
## 约束
- **禁止**改变函数签名、接口定义、导出 API除非问题正是私有函数应被 export
- **禁止**添加新功能、新抽象、新参数
- **禁止**修改注释内容(只删除注释掉的死代码)
- **禁止**修改测试文件逻辑
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"

View File

@@ -1,104 +0,0 @@
---
description: Create or update repository documentation from current code changes
argument-hint: Optional: topic to document, or leave empty to infer from git diff
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
---
# /docs — Documentation Workflow
## Goal
Create or update documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep this command generic. Repository-specific coverage rules live in the repository and must be loaded separately.
## Repository Rules
Before deciding scope, check whether the repository has a documentation rules file:
```bash
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
```
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
## Workflow
### Step 1 — Understand The Change
```bash
git diff HEAD --stat
git diff HEAD --name-only
git log --oneline -10
rg --files docs
```
If `$ARGUMENTS` specifies a topic, focus on that topic. Otherwise infer the documentation topic from the changed files. Do not read the full repository diff by default; inspect focused files only:
```bash
git diff HEAD -- <path>
rg -n "class |def |function |export |router|@router|interface |type " <path>
```
### Step 2 — Decide Scope
- Prefer updating an existing relevant document over creating a duplicate.
- Use one document for one coherent topic.
- Split documents only when the change crosses meaningful domains.
- Keep filenames lowercase and hyphenated.
- Apply the repository-specific rules file before writing.
#### Document Audience Routing (Planet)
In this repository, classify the action's performer before picking a target file:
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
### Step 3 — Write
Explain:
- Background/problem: what was wrong or missing before.
- Core design decisions and rationale.
- Operational or user-facing impact.
- Relevant code paths, only when useful for future maintainers.
Style:
- Follow the repositorys existing language and heading conventions.
- Use fenced code blocks with language tags.
- Prefer tables for comparisons or parameter lists.
- Keep snippets concise and relevant.
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
### Step 4 — Verify
- Read the completed docs once for clarity and stale statements.
- Verify referenced paths exist with `test -e` or `rg --files`.
- Run applicable checks from `docs/documentation-coverage-rules.md`.
- Check Markdown links use readable user-facing titles unless repository rules allow otherwise.
### Step 5 — Report
Summarize changed docs and verification:
```md
Updated:
- path/to/doc.md — what changed
Verified:
- checks that passed
- checks that could not be run, if any
```
## Hard Constraints
- Do not leave placeholder docs.
- Do not duplicate bilingual files byte-for-byte.
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
- Do not write changelog-style lists without the reasoning and tradeoffs behind the change.
- Keep docs maintainable and concise.

View File

@@ -1,93 +0,0 @@
---
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
argument-hint: 建议填写任务目标;若同时给出成功标准更好
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
---
# /goal-driven — 目标驱动执行模式
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
适用场景:
- 长周期实现任务
- 高复杂度工程任务
- 可被明确验收的研究、实现、迁移、验证类工作
不适用场景:
- 纯脑暴
- 无法定义成功标准的模糊任务
- 很小的一次性修改
## 输入要求
`$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
启动时先输出:
```md
Goal
- ...
Criteria for success
- ...
Plan
1. ...
2. ...
3. ...
Verification
- ...
```
## 执行规则
1. 先把任务固化为两个核心块:
- `Goal`
- `Criteria for success`
2. 成功标准必须尽量客观,可验证,可落地。
优先写成:
- 需要交付什么
- 需要通过哪些测试或验证
- 如何判断结果真的完成
3. 进入持续执行循环:
- 完成一个阶段
- 检查当前结果是否满足成功标准
- 若未满足,明确剩余差距并继续推进
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
5. 如果验证失败:
- 明确指出哪条成功标准没满足
- 继续工作,不要把阶段性进展误判为完成
6. 只有在以下情况之一才能停止:
- 成功标准已满足
- 用户明确要求停止
## 执行风格
- 重证据,轻口头判断
- 优先使用确定性工具证据:`rg``git diff --stat``git diff -- <path>`、测试、构建、lint、`curl`、数据库查询等能直接证明成功标准的方式
- 不把大段命令输出粘进回复;保留在工具调用里,回复只总结关键证据
- 重验收,轻自我感觉
- 优先用测试、日志、产物、对比结果来证明完成
- 对长期任务保持“未达标就继续”的节奏
## 简版模板
```md
Goal: [[[[[在此填写最终目标]]]]]
Criteria for success: [[[[[在此填写成功标准]]]]]
循环执行:
1. 推进任务
2. 检查是否满足成功标准
3. 若未满足,继续工作
4. 直到满足标准或用户明确停止
```

View File

@@ -1,160 +0,0 @@
---
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`
## 节省上下文规则
发版判断应以确定性 CLI 证据为主,优先使用紧凑命令和定点读取:
```bash
git status --short
git diff --stat HEAD
git diff --name-only HEAD
rg -n "version|^## |^Released:|当前开发版本|current" VERSION frontend/package.json pyproject.toml docs/CHANGELOG.md docs/version-history.md
```
除非需要判断某个代码变更是否属于本次发版,否则不要读取完整 diff。
## 执行步骤
### Step 1 — 环境检查
```bash
git branch --show-current # 确认在 dev 分支
git status --short # 检查是否有无关的未暂存修改
cat VERSION # 读取当前版本
```
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
### Step 2 — 确定发版类型与新版本号
-`$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
- 否则根据 `git diff --stat HEAD``git diff --name-only HEAD`、必要的 focused diff 和 `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 文件有修改:先用 `git diff --name-only HEAD -- '*.py'` 列出,再运行 `python3 -m py_compile <changed_files>`
- Frontend 文件有修改:先用 `git diff --name-only HEAD -- frontend` 判断范围,再运行项目标准检查(若无则跳过并说明)
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
```bash
cat VERSION
rg -n "\"version\":|^version =|version = " frontend/package.json pyproject.toml uv.lock
```
### Step 7 — 提交前预览
展示将要提交的文件列表:
```bash
git diff --stat HEAD
```
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
### Step 8 — Commit & Push用户确认后
```bash
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
# 若有代码变更也一并 stage
git add <code_files>
git commit -m "release: bump version to x.x.x"
git tag vx.x.x
git push origin dev
git push origin vx.x.x
```
commit message 固定格式:`release: bump version to x.x.x`
### Step 9 — 完成确认
输出摘要:
```
✓ 版本号已更新0.26.2 → 0.26.3
✓ CHANGELOG 已更新
✓ version-history 已更新
✓ uv.lock 已重新生成
✓ 验证通过
✓ commit: release: bump version to 0.26.3
✓ tag: v0.26.3
✓ 已 push 到 origin/dev
```
## 注意事项
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

301
AGENTS.md
View File

@@ -1,6 +1,6 @@
# AGENTS.md
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
**Planet agent harness. Defines behavior for coding agents working in this repository.**
---
@@ -10,26 +10,32 @@ This file is the single authoritative agent guide for the Planet repository.
The older lowercase `agents.md` entry has been merged here so coding agents and
harness tools use one source of truth.
### Read First
### Source Of Truth
- `rules.md` is the mandatory repository rule source. Always load `core`,
`security`, and `workflow`; load only task-relevant modules after that.
- `AGENTS.md` defines the local agent operating mode and evidence gates.
- `project_context.md` is background, not a rule source. Prefer newer
implementation docs when it disagrees with current code.
- `.codex/skills/` is the active specialized workflow layer for cleanup, docs,
goal-driven work, and release.
- Do not duplicate long workflow text across harness files. Durable constraints
belong in `rules.md`; task procedures belong in skills or scripts.
Read these files before changing code:
1. `rules.md` - mandatory repository rules. Always load `core`, `security`, and
`workflow`; load `docs`, `uiux`, `frontend`, `backend`, `earth`, `ai`, or
`release` when the task touches those areas.
2. `AGENTS.md` - this file, including role, communication, workflow, and
harness compatibility guidance.
3. `project_context.md` - static project background. Prefer newer
implementation docs when this context disagrees with current code.
4. `README.md` - current architecture, startup, and toolchain summary.
5. `docs/HARNESS.md` - harness workflow, conflict policy, and validation tiers.
6. `CODEMAP.md` - codebase entry points, ownership boundaries, and deeper docs.
1. `rules.md`
2. `AGENTS.md`
3. `project_context.md`
4. `README.md`
5. `docs/HARNESS.md`
6. `CODEMAP.md`
For documentation work, also read `docs/documentation-coverage-rules.md`.
### Start Safely
Before editing:
Before broad edits:
```bash
git status --short
@@ -61,8 +67,9 @@ Full local validation:
scripts/harness/validate.sh
```
`validate.sh` includes the quick check and the frontend Bun build. Docker image
smoke builds are intentionally opt-in:
`validate.sh` includes quick checks, frontend Bun build, and frontend smoke
unless disabled by its documented environment flags. Docker image smoke builds
are intentionally opt-in:
```bash
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
@@ -81,6 +88,10 @@ the user's login interactive shell instead of assuming a specific dotfile.
rendered smoke evidence for public pages, auth guards, authenticated admin
route/section availability, safe navigation/search/tab interactions, mobile
layout, and 125% / 150% zoom, not only a build.
- Admin or Docs layout changes must load `rules.md` `uiux` and preserve the
one-screen (`一屏` / `首屏`) height chain: route roots use `height: 100%`,
intermediate wrappers keep `min-height: 0`, and only the intended child owns
scrolling.
- `aiprovider` is a protocol/provider adapter; keep business prompts and product
workflows in the backend.
- Earth rendering depends on layer order, depth behavior, picking, and
@@ -99,229 +110,71 @@ behavior and document the compatibility note in `docs/harness-audit.md` or
---
## Identity
## Operating Mode
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
- Python FastAPI backend
- React admin console, public Docs UI, and browser Web Earth shell
- AI Provider model gateway
- Multi-source data collection
- Future physical display directions such as UE5 / Cesium remain optional
roadmap work, not the active local development loop
- Default to acting directly when the user gives a clear task.
- Ask before acting only when the missing decision is risky, cannot be
discovered from repository context, and no conservative assumption is safe.
- Read relevant files before editing.
- Prefer focused CLI evidence: `rg`, `git diff --stat`, `git diff --name-only`,
focused file reads, tests, builds, linters, and harness scripts.
- Keep changes scoped to the requested area. Do not mix cleanup, feature work,
release work, and documentation unless the task requires it.
---
## Communication Style
## Evidence Gates
### Tone
- **Professional but concise**
- Technical accuracy with clarity
- No unnecessary verbosity
- Use code comments sparingly (explain **why**, not **what**)
### When Responding
1. **Answer directly** - 1-3 sentences for simple questions
2. **Use code blocks** for all code snippets
3. **Include file:line_number** references when discussing code
4. **Never** start with "I am an AI assistant" or similar phrases
5. **Never** add unnecessary preambles/postambles
### Examples
**Good:**
```
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
```
**Bad:**
```
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
```
- Visual inputs are blocking evidence. If the user provides a screenshot, image,
mock, browser capture, or visual reference, obtain evidence from the artifact
before interpreting intent or editing code.
- Path resolution is part of the task. If the path cannot be opened, first try
reasonable local equivalents such as WSL/Windows path conversion,
workspace-relative lookup, absolute paths, and attached-file locations.
- Never guess from prompt text, filenames, previous context, logs, OCR, or
memory when a visual artifact was provided but cannot be accessed.
- OCR is acceptable evidence for text-only visual questions or non-multimodal
environments; state that OCR was used as the fallback. Layout, color, spacing,
pixel, and rendering issues need real visual inspection or a clear limitation
note.
- If a visual artifact still cannot be inspected, say so and pause that
visual-dependent part of the work.
- Claims of completion need evidence: a relevant test, build, lint, screenshot,
diff, direct file check, or harness result.
- For UI and rendering changes, verify the rendered result when local tooling
allows it.
---
## Operational Mode
## Communication
### Plan Mode (default for complex tasks)
- Analyze requirements
- Propose architecture
- Confirm with user before execution
- **DO NOT** write code until approved
### Build Mode (after user approval)
- Execute the approved plan
- Write code, run commands
- Verify results
- Report completion concisely
### Read-Only Mode
- Analyze code
- Explain functionality
- Answer questions
- **DO NOT** modify files
- Match the user's language. Use Chinese for Chinese requests unless the user
asks otherwise.
- Keep updates short and specific: what is being inspected, edited, or verified.
- Final responses should summarize changed files and verification, with blockers
stated plainly.
- Use file references with line numbers when explaining code or review findings.
---
## Decision Framework
## Quality Bar
### When to Ask Before Acting
- Unclear requirements
- Multiple implementation approaches
- Architecture changes
- Dependency additions
- Anything that could break existing functionality
### When to Act Directly
- Clear, approved requirements
- Routine tasks (linting, formatting, running tests)
- Following established patterns
- Fixing obvious bugs
### When to Refuse
- Malicious code requests
- Security violations (secrets, credentials)
- Anything that violates `rules.md`
- Prefer existing project patterns over new abstractions.
- Remove stale branches, mocks, compatibility paths, and duplicated helpers once
a stable path exists.
- Centralize prompts, constants, defaults, and shared request/response handling.
- Do not add secrets, generated runtime output, or local environment files.
- Frontend commands use Bun only. Do not use `npm`, `pnpm`, or `yarn`.
- Run the smallest relevant verification for the changed scope and report
anything skipped.
---
## Working Principles
## Prohibited
### 1. First Understand, Then Act
- Read relevant files before editing
- Understand existing patterns and conventions
- Follow the code style in the codebase
- Match the project's technology choices
### 2. Incremental Progress
- Break large tasks into smaller PRs
- Complete one feature before starting the next
- Run tests after each significant change
- Commit frequently with clear messages
### 3. Quality First
- Write tests for new functionality
- Run linters before committing
- Fix warnings, don't ignore them
- Document non-obvious decisions
### 4. Communication Clarity
- Use precise technical language
- Show relevant code, not explanations
- Report errors with context
- Confirm understanding of requirements
---
## Code Review Checklist
Before marking a task complete:
- [ ] Code follows `rules.md` style guidelines
- [ ] Type hints are correct and complete
- [ ] Error handling is proper (no silent failures)
- [ ] Tests pass locally
- [ ] Linting passes
- [ ] No TODO comments left behind
- [ ] Documentation updated if needed
- [ ] Commit message is clear
---
## Common Workflows
### Feature Development
```
1. Understand requirements
2. Check existing patterns in codebase
3. Design solution (brief mental model)
4. Write code following rules.md
5. Write/run tests
6. Lint and format
7. Commit with clear message
8. Report completion
```
### Bug Fix
```
1. Reproduce the bug (write failing test)
2. Locate the source
3. Fix the issue
4. Verify test passes
5. Check for regressions
6. Commit fix
```
### Refactoring
```
1. Understand current behavior
2. Design target state
3. Make incremental changes
4. Preserve tests
5. Verify functionality
6. Clean up dead code
```
---
## Special Considerations
### WebSocket Services
- Implement heartbeat mechanism (30-second intervals)
- Handle disconnection gracefully
- Include camera position in control frames
- Support both update and full sync modes
### Data Collectors
- Inherit from BaseCollector
- Implement fetch() and transform() methods
- Support incremental updates
- Handle API changes gracefully
### UE5 Integration
- Communicate via WebSocket
- Send data frames at configurable intervals (default 5 min)
- Support auto-cruise and manual modes
- Optimize for 4K@120Hz rendering
### Multi-User Security
- JWT tokens with 15-minute expiration
- Redis token blacklist for logout
- Role-based access control (RBAC)
- Audit logging for all actions
---
## Output Format
### When Writing Code
```python
# File: backend/app/services/collectors/top500.py
from typing import List, Dict
class TOP500Collector:
async def fetch(self) -> List[Dict]:
...
```
### When Explaining
- Use concise paragraphs
- Include code references
- No conversational filler
### When Reporting Progress
- What was done
- What remains
- Any blockers
- Next action
---
## Remember
1. **Rules are hard constraints** - follow `rules.md` absolutely
2. **Context provides understanding** - use `project_context.md` for background
3. **Role defines behavior** - follow `AGENTS.md` for how to work
4. **Quality over speed** - Enterprise systems require precision
5. **Communicate clearly** - Precision in, precision out
- Do not skip visual evidence handling when a visual artifact was provided.
- Do not preserve obsolete harness files just because they already exist.
- Do not invent behavior not present in code, docs, or verified external
sources.
- Do not rewrite unrelated files during cleanup.
- Do not mark a task complete without checking concrete success criteria.

View File

@@ -168,10 +168,12 @@
## 快速启动
入口需要先具备 `zsh``curl` 和可访问的软件源。Ubuntu / Ubuntu WSL 上,`init` 会自动检测并补装 Docker Engine、Compose v2 和 Buildx启动 Docker 服务并配置当前用户的访问权限;需要系统权限时会提示输入 sudo 密码。其他系统请先准备可用的 Docker 环境。
```bash
# 新机器或空项目首次初始化
./planet.sh init
# 会自动安装/检查 uv、bun同步 Python/前端依赖
# 会先准备 Docker / Compose / Buildx安装/检查 uv、bun同步 Python/前端依赖
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
# 会启动 PostgreSQL/Redis并创建表、默认数据源和本地默认用户
@@ -322,7 +324,7 @@ ipconfig
## 启动容错参数
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck
`planet.sh` 为依赖安装、数据库、AI Provider 启动提供有限次重试。数据库先检查容器健康,再验证后端实际连接;`aiprovider` 直接以宿主机 `/health` 就绪为准。后端进程退出或应用初始化失败时立即停止等待,避免重复消耗健康检查预算
可通过环境变量临时调整:

View File

@@ -1 +1 @@
0.72.0
0.74.4

View File

@@ -7,9 +7,6 @@ ARG AI_PROVIDER_BUILD_FINGERPRINT=unknown
FROM ${UV_IMAGE} AS uv
FROM ${PYTHON_IMAGE}
ARG AI_PROVIDER_BUILD_FINGERPRINT
LABEL planet.aiprovider.build-fingerprint="${AI_PROVIDER_BUILD_FINGERPRINT}"
COPY --from=uv /uv /uvx /bin/
WORKDIR /app
@@ -28,13 +25,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=secret,id=planet_uv_config,target=/root/.config/uv/uv.toml,required=false \
uv sync --frozen --no-dev
uv sync --frozen --only-group aiprovider
COPY aiprovider /app/aiprovider
ARG AI_PROVIDER_BUILD_FINGERPRINT
LABEL planet.aiprovider.build-fingerprint="${AI_PROVIDER_BUILD_FINGERPRINT}"
EXPOSE 8010
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8010/health >/dev/null || exit 1
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]
CMD ["/app/.venv/bin/python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -75,6 +75,7 @@ logger = get_logger(__name__, service="api")
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
SECRET_REVEAL_ROLES = {UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value}
LLM_PROVIDER_PRESET_CATEGORY_PREFIX = "llm_provider_preset:"
DEFAULT_SETTINGS = {
"system": {
@@ -2044,8 +2045,18 @@ async def reset_provider_credential_guide(
@router.get("/integrations/ai-provider/presets")
async def get_ai_provider_presets(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"data": list_fallback_llm_provider_presets()}
presets = list_fallback_llm_provider_presets()
saved = await get_setting_payloads(
db, [f"{LLM_PROVIDER_PRESET_CATEGORY_PREFIX}{preset['provider']}" for preset in presets]
)
return {
"data": [
{**preset, **saved[f"{LLM_PROVIDER_PRESET_CATEGORY_PREFIX}{preset['provider']}"]}
for preset in presets
]
}
@router.post("/integrations/ai-provider/presets/{provider}/refresh")
@@ -2056,19 +2067,39 @@ async def refresh_ai_provider_preset(
):
try:
provider_id = _normalize_provider_id(provider)
get_fallback_llm_provider_preset(provider_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
api_key = None
if provider_id == "opencode-go":
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(
provider_id
)
api_key, _api_key_source = _resolve_provider_api_key(provider_id, provider_config)
return {"data": await refresh_llm_provider_preset(provider_id, api_key=api_key)}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
refreshed = await refresh_llm_provider_preset(provider_id, api_key=api_key)
except Exception as exc:
fallback = get_fallback_llm_provider_preset(provider)
fallback["refresh_error"] = str(exc)
return {"data": fallback}
logger.warning(
"LLM provider catalog refresh failed",
extra={
"event": "settings.ai_provider.catalog.failed",
"context": {
"provider": provider_id,
"error_type": type(exc).__name__,
},
},
)
raise HTTPException(
status_code=502,
detail="模型列表刷新失败,已保留上次模型列表。",
) from exc
refreshed["refreshed_at"] = to_iso8601_utc(datetime.now(UTC))
await save_setting_payload(db, f"{LLM_PROVIDER_PRESET_CATEGORY_PREFIX}{provider_id}", refreshed)
return {"data": refreshed}
@router.put("/integrations")

View File

@@ -7,6 +7,7 @@ from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.services.tv_catalog import get_tv_catalog_page
from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url
router = APIRouter()
@@ -34,9 +35,13 @@ def _should_strip_hls_metadata_line(line: str) -> bool:
@router.get("/streams")
async def list_public_tv_streams(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
q: str = Query("", max_length=200),
selected_id: str | None = Query(None, max_length=200),
db: AsyncSession = Depends(get_db),
):
return await get_public_tv_payload(db)
return await get_tv_catalog_page(db, offset=offset, limit=limit, q=q, selected_id=selected_id)
@router.get("/proxy")

View File

@@ -1121,6 +1121,7 @@ async def build_vessel_snapshot_response(
safe_limit = _safe_vessel_limit(limit)
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
observed_since = datetime.now(UTC) - timedelta(minutes=safe_since_minutes)
snapshot_started_at = datetime.now(UTC)
features, diagnostics = await _load_raw_vessel_snapshot_features(
db,
bbox=bbox,
@@ -1137,6 +1138,7 @@ async def build_vessel_snapshot_response(
"features": features,
"count": len(features),
"stats": _build_vessel_stats(features),
"generated_at": to_iso8601_utc(snapshot_started_at),
"diagnostics": {
**diagnostics,
"filtered_count": len(features),

View File

@@ -161,7 +161,7 @@ async def websocket_endpoint(
if is_anonymous:
channels = [channel for channel in channels if channel in supported_channels]
vessel_subscription = None
if "vessels" in channels and "bbox" in payload_data:
if "vessels" in channels:
try:
vessel_subscription = manager.subscribe_vessels(websocket, payload_data)
except ValueError as exc:

View File

@@ -4,11 +4,14 @@ import asyncio
from datetime import UTC, datetime
from typing import Dict, Any
from app.core.logging import get_logger
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
EARTH_UPDATES_CHANNEL = "earth_updates"
VESSEL_STATE_QUERY_BATCH_SIZE = 1000
logger = get_logger(__name__, service="websocket")
class DataBroadcaster:
@@ -114,16 +117,22 @@ class DataBroadcaster:
return
pending = self._pending_vessel_updates
self._pending_vessel_updates = {}
vessels = []
for item in pending.values():
vessel = dict(item)
source = vessel.pop("_source", None)
action = vessel.pop("_action", "upsert")
created = vessel.pop("_created", None)
vessel["source"] = source
vessel["action"] = action
vessel["created"] = created
vessels.append(vessel)
try:
vessels = await self._load_current_vessel_updates(list(pending))
except Exception:
# Preserve newer updates that arrived during the failed database read.
self._pending_vessel_updates = {**pending, **self._pending_vessel_updates}
raise
if not vessels:
return
vessels = [
{
**vessel,
"action": vessel.get("action", "upsert"),
"created": pending.get(str(vessel["mmsi"]), {}).get("_created"),
}
for vessel in vessels
]
await manager.broadcast_vessels(
{
"action": "upsert",
@@ -133,12 +142,31 @@ class DataBroadcaster:
}
)
async def _load_current_vessel_updates(self, keys: list[str]) -> list[Dict[str, Any]]:
from app.db.session import async_session_factory
from app.services.vessel_ais_aggregation import get_current_vessels_by_mmsi
mmsis = [int(key) for key in keys if key.isdigit()]
vessels = []
async with async_session_factory() as db:
for offset in range(0, len(mmsis), VESSEL_STATE_QUERY_BATCH_SIZE):
vessels.extend(await get_current_vessels_by_mmsi(
db, mmsis[offset:offset + VESSEL_STATE_QUERY_BATCH_SIZE]
))
present = {int(vessel["mmsi"]) for vessel in vessels}
vessels.extend({"mmsi": mmsi, "action": "remove"} for mmsi in mmsis if mmsi not in present)
return vessels
async def broadcast_vessels_periodically(self):
while self.running:
try:
await self.flush_vessel_updates()
except Exception:
pass
except Exception as exc:
logger.exception_event(
"Failed to flush vessel updates",
event="vessels.broadcast.failed",
context={"error": str(exc)},
)
await asyncio.sleep(self._vessel_flush_interval)
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):

View File

@@ -63,6 +63,8 @@ class ConnectionManager:
def unsubscribe(self, websocket: WebSocket, channels: list[str]):
for channel in {str(channel).strip() for channel in channels if str(channel).strip()}:
if channel == "vessels":
self.vessel_subscriptions.pop(websocket, None)
subscribers = self.channel_subscriptions.get(channel)
if subscribers is not None:
subscribers.discard(websocket)
@@ -88,7 +90,8 @@ class ConnectionManager:
return subscription
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
bbox = config.get("bbox")
global_scope = config.get("scope") == "global"
bbox = [-180, -90, 180, 90] if global_scope else config.get("bbox")
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
try:
@@ -103,7 +106,7 @@ class ConnectionManager:
raise ValueError("bbox longitude values must be between -180 and 180")
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
raise ValueError("bbox latitude values must be between -90 and 90")
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
if not global_scope and (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
zoom = int(config.get("zoom") or 1)
@@ -116,10 +119,11 @@ class ConnectionManager:
if str(item).strip()
}
return {
"scope": "global" if global_scope else "viewport",
"bbox": (lon_min, lat_min, lon_max, lat_max),
"zoom": zoom,
"limit": limit,
"type": vessel_types,
"type": sorted(vessel_types),
"last_sent_at": None,
}
@@ -152,7 +156,9 @@ class ConnectionManager:
vessel
for vessel in vessels
if self._vessel_matches_subscription(vessel, subscription)
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
]
if subscription.get("scope") != "global":
matched = matched[:subscription["limit"]]
if not matched:
continue
subscription["last_sent_at"] = datetime.now(UTC)
@@ -162,16 +168,24 @@ class ConnectionManager:
"timestamp": subscription["last_sent_at"].isoformat(),
"payload": {
**data,
"vessels": matched,
"vessels": [],
"subscription": {
"bbox": list(subscription["bbox"]),
"zoom": subscription["zoom"],
"limit": subscription["limit"],
"scope": subscription.get("scope", "viewport"),
},
},
}
try:
await connection.send_json(message)
for offset in range(0, len(matched), MAX_VESSEL_WS_MESSAGE_ITEMS):
await connection.send_json({
**message,
"payload": {
**message["payload"],
"vessels": matched[offset:offset + MAX_VESSEL_WS_MESSAGE_ITEMS],
},
})
except Exception:
self.unsubscribe_all(connection)
@@ -180,6 +194,8 @@ class ConnectionManager:
vessel: dict[str, Any],
subscription: dict[str, Any],
) -> bool:
if vessel.get("action") == "remove" and subscription.get("scope") == "global":
return True
try:
lon = float(vessel.get("lon"))
lat = float(vessel.get("lat"))

View File

@@ -35,7 +35,7 @@ class NewsLiveStreamsCollector(BaseCollector):
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
DEFAULT_IPTV_ORG_MAX_SOURCES = 0 # Zero keeps the complete matching channel catalog.
async def fetch(self) -> list[dict[str, Any]]:
request_url = (self._resolved_url or "").strip()
@@ -445,7 +445,7 @@ class NewsLiveStreamsCollector(BaseCollector):
"reference_date": datetime.now(UTC).isoformat(),
}
)
if len(normalized) >= max_sources:
if max_sources > 0 and len(normalized) >= max_sources:
break
return normalized

View File

@@ -56,6 +56,8 @@ def build_earth_update_from_db_payload(payload: dict[str, Any]) -> dict[str, Any
source_has_adapter = bool(get_earth_update_layers_for_source(source))
effective_source = source if source_has_adapter else (table_name if table_name else source)
operation = payload.get("operation")
if "vessels" in layers and operation in {"DELETE", "TRUNCATE"}:
refresh_strategy = "reload"
update: dict[str, Any] = {
"event": "earth.layer.changed",
"action": "database_changed",
@@ -113,6 +115,8 @@ class PendingEarthDbChange:
operation = payload.get("operation")
if operation:
self.operations.add(str(operation))
if "vessels" in self.layers and operation in {"DELETE", "TRUNCATE"}:
self.refresh_strategy = "reload"
entity_keys = payload.get("entity_keys")
if not isinstance(entity_keys, list):
entity_key = payload.get("entity_key")
@@ -176,6 +180,17 @@ class EarthDbChangeDispatcher:
if not update:
return False
if payload.get("table") == "vessel_current_state" and payload.get("operation") == "DELETE":
from app.core.websocket.broadcaster import broadcaster
keys = payload.get("entity_keys")
if not isinstance(keys, list):
keys = [payload.get("entity_key")]
broadcaster.enqueue_vessel_update({
"action": "remove",
"vessels": [{"mmsi": key} for key in keys if key is not None],
})
source = update["source"]
pending = self._pending.get(source)
if pending is None:

View File

@@ -25,6 +25,7 @@ EARTH_LAYER_ADAPTERS: tuple[EarthLayerAdapter, ...] = (
layers=("vessels",),
cache_patterns=("vessels*", "summary*"),
derived_models=("ais_raw_observations", "ais_conflict_records", "ais_source_health"),
refresh_strategy="delta",
),
EarthLayerAdapter(
sources=frozenset(

View File

@@ -865,9 +865,9 @@ def _get_locale_text(
if isinstance(fallback, dict):
value = _coerce_str(fallback.get(key))
if value:
if locale == "en-US" and _contains_cjk_text(value):
return ""
return value
if locale == "en-US" and _is_chinese_language(item.content_language):
return item.title if key == "title" else item.summary
return ""

View File

@@ -9,6 +9,11 @@ import httpx
MODELS_DEV_URL = "https://models.dev/api.json"
OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models"
class LLMProviderCatalogError(RuntimeError):
"""The upstream catalog cannot supply a usable model list."""
OPENCODE_GO_MODEL_PROVIDER_APIS = {
"minimax-m2.7": "anthropic-messages",
"minimax-m2.5": "anthropic-messages",
@@ -171,9 +176,9 @@ async def refresh_llm_provider_preset(provider: str, api_key: str | None = None)
str(item.get("id"))
for item in data
if isinstance(item, dict) and item.get("id")
][:120]
]
if not model_ids:
model_ids = fallback["models"]
raise LLMProviderCatalogError("The provider returned an empty model catalog")
return {
**fallback,
"model": fallback["model"] if fallback["model"] in model_ids else model_ids[0],
@@ -184,7 +189,7 @@ async def refresh_llm_provider_preset(provider: str, api_key: str | None = None)
models_dev_key = MODELS_DEV_PROVIDER_KEYS.get(fallback["provider"])
if not models_dev_key:
return fallback
raise LLMProviderCatalogError("Live catalog refresh is unavailable for this provider")
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(
@@ -194,12 +199,23 @@ async def refresh_llm_provider_preset(provider: str, api_key: str | None = None)
response.raise_for_status()
catalog = response.json()
upstream = catalog.get(models_dev_key)
upstream = catalog.get(models_dev_key) if isinstance(catalog, dict) else None
if not isinstance(upstream, dict):
return fallback
raise LLMProviderCatalogError("The provider is missing from the model catalog")
upstream_models = upstream.get("models") if isinstance(upstream.get("models"), dict) else {}
model_ids = list(upstream_models.keys())[:80]
# Catalog insertion order is not release order; old entries can appear first.
model_ids = sorted(
(
model_id
for model_id, model in upstream_models.items()
if model_id and isinstance(model, dict)
),
key=lambda model_id: (str(upstream_models[model_id].get("release_date") or ""), model_id),
reverse=True,
)
if not model_ids:
raise LLMProviderCatalogError("The provider returned an empty model catalog")
base_url = upstream.get("api") or fallback["base_url"]
if fallback["provider"] == "deepseek" and base_url == "https://api.deepseek.com":
base_url = "https://api.deepseek.com/v1"
@@ -208,8 +224,8 @@ async def refresh_llm_provider_preset(provider: str, api_key: str | None = None)
**fallback,
"label": upstream.get("name") or fallback["label"],
"base_url": base_url,
"model": model_ids[0] if model_ids else fallback["model"],
"models": model_ids or fallback["models"],
"model": model_ids[0],
"models": model_ids,
"api_key_env": (upstream.get("env") or [fallback["api_key_env"]])[0],
"source": MODELS_DEV_URL,
}

View File

@@ -0,0 +1,148 @@
"""Search and paginate the public live TV catalog at the database boundary."""
from typing import Any
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
from app.models.collected_data import CollectedData
from app.services.tv_streams import (
TV_LIVE_SOURCE_COLLECTOR,
TV_LIVE_SOURCE_DATA_TYPE,
_build_collected_tv_source,
build_public_tv_payload,
get_tv_settings_payload,
)
def _source_key():
return func.coalesce(
func.nullif(CollectedData.extra_data["id"].as_string(), ""),
func.nullif(CollectedData.source_id, ""),
CollectedData.entity_key,
)
def _collected_catalog_query(configured_ids: list[str]) -> Select[tuple[CollectedData]]:
metadata = CollectedData.extra_data
source_id = _source_key()
enabled = func.lower(func.trim(func.coalesce(metadata["is_enabled"].as_string(), "true")))
ranked = (
select(
CollectedData.id,
func.row_number()
.over(
partition_by=source_id,
order_by=CollectedData.id.desc(),
)
.label("source_rank"),
)
.where(
CollectedData.source == TV_LIVE_SOURCE_COLLECTOR,
CollectedData.data_type == TV_LIVE_SOURCE_DATA_TYPE,
CollectedData.is_current.is_(True),
CollectedData.is_valid == 1,
CollectedData.deleted_at.is_(None),
enabled.notin_(("false", "0", "no", "off")),
source_id.notin_(configured_ids),
)
.subquery()
)
return (
select(CollectedData)
.join(ranked, ranked.c.id == CollectedData.id)
.where(ranked.c.source_rank == 1)
)
def _filter_catalog_query(
query: Select[tuple[CollectedData]], terms: list[str]
) -> Select[tuple[CollectedData]]:
metadata = CollectedData.extra_data
searchable = func.lower(
func.concat_ws(
" ",
CollectedData.name,
CollectedData.title,
CollectedData.source_id,
metadata["name"].as_string(),
metadata["provider"].as_string(),
metadata["region"].as_string(),
metadata["country"].as_string(),
metadata["language"].as_string(),
)
)
for term in terms:
query = query.where(searchable.contains(term, autoescape=True))
return query
def _matches_source(source: dict[str, Any], terms: list[str]) -> bool:
searchable = " ".join(
str(source.get(key) or "") for key in ("id", "name", "provider", "region", "language")
).lower()
return all(term in searchable for term in terms)
async def get_tv_catalog_page(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 50,
q: str = "",
selected_id: str | None = None,
) -> dict[str, Any]:
settings = await get_tv_settings_payload(db)
payload = build_public_tv_payload(settings, [])
configured = payload["sources"]
query = _collected_catalog_query([source["id"] for source in settings["sources"]])
if selected_id:
selected = next((source for source in configured if source["id"] == selected_id), None)
if selected is None:
record = await db.scalar(query.where(_source_key() == selected_id).limit(1))
selected = _build_collected_tv_source(record, 0) if record else None
if selected:
payload["selected_source"] = selected
summary = (
await db.execute(
select(func.count(), func.max(CollectedData.collected_at))
.select_from(CollectedData)
.where(CollectedData.id.in_(query.with_only_columns(CollectedData.id)))
)
).one()
total_collected, latest_update = summary
terms = q.lower().split()
matched_configured = [source for source in configured if _matches_source(source, terms)]
filtered_query = _filter_catalog_query(query, terms)
matched_collected = (
await db.scalar(select(func.count()).select_from(filtered_query.subquery()))
if terms
else total_collected
)
sources = matched_configured[offset : offset + limit]
remaining = limit - len(sources)
if remaining:
rows = await db.scalars(
filtered_query.order_by(func.lower(CollectedData.name), CollectedData.id)
.offset(max(0, offset - len(matched_configured)))
.limit(remaining)
)
sources.extend(
_build_collected_tv_source(record, index) for index, record in enumerate(rows)
)
total = len(matched_configured) + matched_collected
next_offset = offset + len(sources)
return {
**payload,
"sources": sources,
"source_count": len(configured) + total_collected,
"latest_updated_at": (
to_iso8601_utc(latest_update) if latest_update else payload["latest_updated_at"]
),
"total": total,
"offset": offset,
"limit": limit,
"has_more": next_offset < total,
"next_offset": next_offset if next_offset < total else None,
}

View File

@@ -11,7 +11,7 @@ from app.core.time import to_iso8601_utc
from app.models.collected_data import CollectedData
from app.models.system_setting import SystemSetting
DEFAULT_TV_SOURCE_ID = "cgtn-en"
DEFAULT_TV_SOURCE_ID = "aljazeera-mubasher"
TV_SETTINGS_CATEGORY = "tv"
TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
@@ -300,8 +300,12 @@ def normalize_tv_settings(payload: dict[str, Any] | None) -> dict[str, Any]:
]
if not any(source["id"] == DEFAULT_TV_SOURCE_ID for source in normalized_sources):
default_source = next(
source for source in DEFAULT_TV_SETTINGS["sources"]
if source["id"] == DEFAULT_TV_SOURCE_ID
)
normalized_sources.append(
normalize_tv_source(DEFAULT_TV_SETTINGS["sources"][0], index=len(normalized_sources))
normalize_tv_source(default_source, index=len(normalized_sources))
)
default_source_exists = any(

View File

@@ -614,6 +614,18 @@ async def get_current_vessels_snapshot(
return [item.to_dict() for item in result.scalars().all()]
async def get_current_vessels_by_mmsi(
db: AsyncSession, mmsis: list[int]
) -> list[dict[str, Any]]:
"""Read canonical render state for the vessels changed by a stream flush."""
if not mmsis:
return []
result = await db.execute(
select(VesselCurrentState).where(VesselCurrentState.mmsi.in_(mmsis))
)
return [_jsonable(item.to_dict()) for item in result.scalars().all()]
async def aggregate_vessel_observations(
db: AsyncSession,
observations: Iterable[AISRawObservation],

View File

@@ -63,6 +63,21 @@ def test_build_earth_update_maps_derived_tables_to_layers():
assert vessel_update is not None
assert vessel_update["source"] == "vessel_position"
assert vessel_update["layers"] == ["vessels"]
assert vessel_update["refresh_strategy"] == "reload"
def test_vessel_stream_changes_do_not_request_full_layer_rebuilds():
from app.services.earth_db_change_listener import PendingEarthDbChange
for source in ("aisstream_vessels", "barentswatch_vessels"):
update = build_earth_update_from_db_payload({
"source": source, "table": "vessel_current_state", "operation": "UPDATE",
})
assert update["refresh_strategy"] == "delta"
pending = PendingEarthDbChange(source=source, layers=["vessels"], refresh_strategy="delta")
pending.add({"operation": "UPDATE"})
pending.add({"operation": "DELETE"})
assert pending.refresh_strategy == "reload"
def test_build_earth_update_maps_interactable_delete_to_delta():

View File

@@ -384,7 +384,14 @@ def test_parse_chinese_rss_marks_source_language_and_keeps_zh_localization():
assert items[0].content_language == "zh-CN"
assert items[0].localizations["zh-CN"]["title"] == "中国电商平台发布季度增长数据"
assert payload_zh["display_title"] == "中国电商平台发布季度增长数据"
assert payload_en["display_title"] == "中国电商平台发布季度增长数据"
assert payload_en["display_title"] == ""
items[0].localizations["en-US"] = {
"title": "Chinese e-commerce platform reports quarterly growth",
"summary": "The platform said cross-border orders rose year over year.",
}
payload_en_ready = _serialize_item(items[0], active_region="global", locale="en-US")
assert payload_en_ready["display_title"] == "Chinese e-commerce platform reports quarterly growth"
def test_default_news_sources_include_business_and_ecommerce_sources():

View File

@@ -0,0 +1,64 @@
import httpx
import pytest
from app.services import llm_provider_catalog as catalog
def mock_catalog(monkeypatch, payload):
client_type = httpx.AsyncClient
transport = httpx.MockTransport(lambda request: httpx.Response(200, json=payload))
monkeypatch.setattr(
catalog.httpx, "AsyncClient", lambda **kwargs: client_type(transport=transport, **kwargs)
)
@pytest.mark.asyncio
async def test_refresh_orders_by_release_date_before_choosing_default(monkeypatch):
mock_catalog(
monkeypatch,
{
"minimax": {
"models": {
"MiniMax-M2": {"release_date": "2025-10-27"},
"MiniMax-M3": {"release_date": "2026-06-01"},
"MiniMax-M2.7": {"release_date": "2026-03-18"},
"undated-model": {},
}
}
},
)
refreshed = await catalog.refresh_llm_provider_preset("minimax")
assert refreshed["model"] == "MiniMax-M3"
assert refreshed["models"] == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2", "undated-model"]
assert refreshed["source"] == catalog.MODELS_DEV_URL
@pytest.mark.asyncio
async def test_refresh_does_not_truncate_new_models(monkeypatch):
models = {f"older-{index}": {"release_date": "2025-01-01"} for index in range(85)}
models["latest"] = {"release_date": "2026-06-01"}
mock_catalog(monkeypatch, {"minimax": {"models": models}})
refreshed = await catalog.refresh_llm_provider_preset("minimax")
assert refreshed["model"] == "latest"
assert len(refreshed["models"]) == 86
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [{}, {"minimax": {"models": {}}}, {"minimax": []}])
async def test_invalid_catalog_fails_instead_of_claiming_fallback_is_fresh(monkeypatch, payload):
mock_catalog(monkeypatch, payload)
with pytest.raises(catalog.LLMProviderCatalogError):
await catalog.refresh_llm_provider_preset("minimax")
@pytest.mark.asyncio
async def test_empty_opencode_catalog_is_a_refresh_failure(monkeypatch):
mock_catalog(monkeypatch, {"data": []})
with pytest.raises(catalog.LLMProviderCatalogError):
await catalog.refresh_llm_provider_preset("opencode-go", api_key="test-key")

View File

@@ -1,5 +1,7 @@
from copy import deepcopy
from types import SimpleNamespace
import httpx
import pytest
from app.api.v1 import settings as settings_api
@@ -24,6 +26,71 @@ from app.api.v1.settings import (
from app.services.llm_provider_catalog import get_fallback_llm_provider_preset
@pytest.mark.asyncio
async def test_refreshed_presets_survive_listing_without_changing_runtime_settings(monkeypatch):
stored = {
"external_integrations": {
"ai_provider": {
"default_provider": "minimax",
"service_token": "internal-token",
"providers": {"minimax": {"model": "custom-model", "api_key": "provider-key"}},
}
}
}
runtime_before = deepcopy(stored["external_integrations"])
async def fake_save(_db, category, payload):
stored[category] = deepcopy(payload)
return stored[category]
async def fake_get_many(_db, categories):
return {category: deepcopy(stored.get(category, {})) for category in categories}
async def fake_refresh(provider, api_key=None):
return {
**get_fallback_llm_provider_preset(provider),
"model": "new-model",
"models": ["new-model", "older-model"],
"source": "https://models.dev/api.json",
}
monkeypatch.setattr(settings_api, "save_setting_payload", fake_save)
monkeypatch.setattr(settings_api, "get_setting_payloads", fake_get_many)
monkeypatch.setattr(settings_api, "refresh_llm_provider_preset", fake_refresh)
user = SimpleNamespace(id=1, role="admin")
await settings_api.refresh_ai_provider_preset("minimax", user, object())
await settings_api.refresh_ai_provider_preset("openai", user, object())
listed = await settings_api.get_ai_provider_presets(user, object())
presets = {preset["provider"]: preset for preset in listed["data"]}
assert presets["minimax"]["models"] == ["new-model", "older-model"]
assert presets["openai"]["model"] == "new-model"
assert presets["anthropic"]["source"] == "fallback"
assert presets["minimax"]["refreshed_at"]
assert stored["external_integrations"] == runtime_before
assert "provider-key" not in str(listed)
assert "internal-token" not in str(listed)
@pytest.mark.asyncio
async def test_failed_preset_refresh_is_an_error_and_does_not_save(monkeypatch):
async def fail_refresh(*args, **kwargs):
raise httpx.ConnectError("upstream error with secret-value")
async def fail_save(*args, **kwargs):
pytest.fail("failed refresh must preserve the last saved preset")
monkeypatch.setattr(settings_api, "refresh_llm_provider_preset", fail_refresh)
monkeypatch.setattr(settings_api, "save_setting_payload", fail_save)
with pytest.raises(settings_api.HTTPException) as error:
await settings_api.refresh_ai_provider_preset("minimax", SimpleNamespace(id=1), object())
assert error.value.status_code == 502
assert "secret-value" not in error.value.detail
@pytest.fixture(autouse=True)
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
env_file = tmp_path / ".env"

View File

@@ -0,0 +1,160 @@
from datetime import UTC, datetime
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from app.db.session import Base
from app.models.collected_data import CollectedData
from app.models.data_snapshot import DataSnapshot
from app.models.system_setting import SystemSetting
from app.models.task import CollectionTask
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
from app.services.tv_catalog import get_tv_catalog_page
from app.services.tv_streams import normalize_tv_settings
class CatalogSession:
"""Execute the real catalog queries against an isolated SQLite database."""
def __init__(self, session):
self.session = session
async def execute(self, query):
return self.session.execute(query)
async def scalar(self, query):
return self.session.scalar(query)
async def scalars(self, query):
return self.session.scalars(query)
@pytest.fixture
def catalog_db():
engine = create_engine("sqlite:///:memory:")
@event.listens_for(engine, "connect")
def register_functions(connection, _record):
connection.create_function(
"concat_ws",
-1,
lambda sep, *args: sep.join(str(arg) for arg in args if arg is not None),
)
Base.metadata.create_all(
engine,
tables=[
CollectionTask.__table__,
DataSnapshot.__table__,
CollectedData.__table__,
SystemSetting.__table__,
],
)
with Session(engine) as session:
for index in range(135):
session.add(
CollectedData(
source="news_live_streams",
source_id=f"channel-{index:03}",
data_type="news_live_stream",
name=f"Channel {index:03}",
collected_at=datetime(2026, 9, 13, tzinfo=UTC),
is_current=True,
is_valid=1,
extra_data={
"stream_url": "https://example.invalid/live.m3u8",
"region": "Canada",
},
)
)
session.flush()
yield CatalogSession(session)
engine.dispose()
@pytest.mark.asyncio
async def test_pages_include_entire_catalog_without_overlap(catalog_db):
first = await get_tv_catalog_page(catalog_db, limit=50)
second = await get_tv_catalog_page(catalog_db, offset=first["next_offset"], limit=50)
third = await get_tv_catalog_page(catalog_db, offset=second["next_offset"], limit=50)
ids = [source["id"] for page in (first, second, third) for source in page["sources"]]
assert [len(page["sources"]) for page in (first, second, third)] == [50, 50, 45]
assert len(set(ids)) == first["source_count"] == 145
assert ids[-1] == "channel-134"
assert third["next_offset"] is None and not third["has_more"]
beyond = await get_tv_catalog_page(catalog_db, offset=200)
assert beyond["sources"] == [] and not beyond["has_more"]
@pytest.mark.asyncio
async def test_search_finds_later_pages_and_treats_wildcards_literally(catalog_db):
payload = await get_tv_catalog_page(catalog_db, q="CANADA 134")
assert [source["id"] for source in payload["sources"]] == ["channel-134"]
assert payload["total"] == 1 and payload["source_count"] == 145
assert (await get_tv_catalog_page(catalog_db, q="%"))["total"] == 0
@pytest.mark.asyncio
async def test_selection_survives_refresh_when_outside_first_page(catalog_db):
payload = await get_tv_catalog_page(catalog_db, selected_id="channel-134")
assert payload["selected_source"]["id"] == "channel-134"
assert "channel-134" not in [source["id"] for source in payload["sources"]]
assert payload["default_source_id"] == "aljazeera-mubasher"
default = (await get_tv_catalog_page(catalog_db, selected_id="removed"))["selected_source"]
assert default["id"] == "aljazeera-mubasher" and default["source_type"] == "hls"
@pytest.mark.asyncio
async def test_catalog_hides_inactive_records_and_deduplicates_ids(catalog_db):
for name, values in [
("Disabled", {"extra_data": {"is_enabled": False}}),
("Historical", {"is_current": False}),
("Invalid", {"is_valid": 0}),
("Deleted", {"deleted_at": datetime.now(UTC)}),
("Replacement", {"source_id": "channel-134"}),
]:
record = dict(
source="news_live_streams",
source_id=name,
name=name,
data_type="news_live_stream",
is_current=True,
is_valid=1,
)
catalog_db.session.add(CollectedData(**{**record, **values}))
catalog_db.session.flush()
payload = await get_tv_catalog_page(catalog_db, q="replacement")
assert payload["source_count"] == 145
assert [source["id"] for source in payload["sources"]] == ["channel-134"]
def test_missing_builtin_default_adds_aljazeera_without_losing_custom_source():
settings = normalize_tv_settings({"sources": [{"id": "custom", "name": "Custom"}]})
assert settings["default_source_id"] == "aljazeera-mubasher"
assert {source["id"] for source in settings["sources"]} == {"custom", "aljazeera-mubasher"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config, expected", [({}, 135), ({"max_sources": 0}, 135), ({"max_sources": 7}, 7)]
)
async def test_collector_keeps_all_matching_channels_unless_explicitly_limited(
monkeypatch, config, expected
):
collector = NewsLiveStreamsCollector()
channels = [
{"id": f"channel-{i}", "name": f"Channel {i}", "categories": ["news"]} for i in range(135)
]
channels.append({"id": "sport", "name": "Sports", "categories": ["sports"]})
streams = [
{"channel": channel["id"], "url": "https://example.invalid/live.m3u8"}
for channel in channels
]
monkeypatch.setattr(
collector, "_gather_iptv_org_payloads", AsyncMock(return_value=(channels, streams, []))
)
records = await collector._fetch_iptv_org("https://example.invalid/channels.json", config)
assert len(records) == expected
assert all(record["source_id"] != "sport" for record in records)

View File

@@ -1,5 +1,9 @@
import pytest
import importlib
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.websocket.manager import ConnectionManager
from app.core.websocket.broadcaster import DataBroadcaster
@@ -113,6 +117,11 @@ async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
broadcaster_module = importlib.import_module("app.core.websocket.broadcaster")
monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels)
broadcaster = DataBroadcaster()
async def load_current(keys):
assert keys == ["1"]
return [{"mmsi": 1, "lat": 60.0, "lon": 11.0, "source": "barentswatch_vessels"}]
monkeypatch.setattr(broadcaster, "_load_current_vessel_updates", load_current)
broadcaster.enqueue_vessel_update(
{
"source": "aisstream_vessels",
@@ -129,10 +138,67 @@ async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
assert sent[0]["vessels"] == [
{
"mmsi": 1,
"lat": 59.1,
"lon": 10.1,
"source": "aisstream_vessels",
"lat": 60.0,
"lon": 11.0,
"source": "barentswatch_vessels",
"action": "upsert",
"created": None,
}
]
@pytest.mark.asyncio
async def test_global_vessel_subscription_delivers_every_item_in_bounded_frames():
manager = ConnectionManager()
socket = FakeWebSocket()
config = manager.subscribe_vessels(socket, {"scope": "global", "zoom": 4})
json.dumps(config)
vessels = [{"mmsi": index, "lat": 60, "lon": 10} for index in range(2501)]
await manager.broadcast_vessels({"vessels": vessels})
assert [len(frame["payload"]["vessels"]) for frame in socket.sent] == [1000, 1000, 501]
assert [item for frame in socket.sent for item in frame["payload"]["vessels"]] == vessels
manager.unsubscribe(socket, ["vessels"])
await manager.broadcast_vessels({"vessels": vessels})
assert len(socket.sent) == 3
@pytest.mark.asyncio
async def test_global_vessel_removal_does_not_require_coordinates():
manager = ConnectionManager()
socket = FakeWebSocket()
manager.subscribe_vessels(socket, {"scope": "global", "zoom": 4})
await manager.broadcast_vessels({"vessels": [{"mmsi": 123, "action": "remove"}]})
assert socket.sent[0]["payload"]["vessels"] == [{"mmsi": 123, "action": "remove"}]
def test_anonymous_earth_can_confirm_global_vessel_subscription(monkeypatch):
websocket_module = importlib.import_module("app.api.v1.websocket")
monkeypatch.setattr(websocket_module, "manager", ConnectionManager())
app = FastAPI()
app.include_router(websocket_module.router)
with TestClient(app) as client, client.websocket_connect("/ws") as socket:
assert socket.receive_json()["type"] == "connection_established"
socket.send_json({
"type": "subscribe",
"data": {"channels": ["earth_updates", "vessels"], "scope": "global", "zoom": 4},
})
response = socket.receive_json()
assert response["type"] == "subscription_confirmed"
assert response["data"]["vessels"]["scope"] == "global"
assert response["data"]["vessels"]["type"] == []
@pytest.mark.asyncio
async def test_vessel_flush_retries_without_overwriting_newer_queued_updates(monkeypatch):
broadcaster = DataBroadcaster()
broadcaster.enqueue_vessel_update({"vessels": [{"mmsi": 1, "lat": 59, "lon": 10}]})
async def fail_read(_keys):
broadcaster.enqueue_vessel_update({"vessels": [{"mmsi": 1, "lat": 61, "lon": 12}]})
raise RuntimeError("database unavailable")
monkeypatch.setattr(broadcaster, "_load_current_vessel_updates", fail_read)
with pytest.raises(RuntimeError, match="database unavailable"):
await broadcaster.flush_vessel_updates()
assert broadcaster._pending_vessel_updates["1"]["lat"] == 61

View File

@@ -8,6 +8,109 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.74.4] — 2026-09-13
Released: 2026-09-13
### Highlights
- 保留 Earth 全量对象与交互,优化海缆、登陆点和卫星绘制,并让 AIS / BarentsWatch 船只通过确认状态增量更新。
- 新闻直播支持完整频道目录搜索和无限滚动;模型目录刷新可保存结果,启动流程减少不必要的依赖安装与等待。
### Added / Fixed / Improved
- 海缆和登陆点合批绘制,卫星 SGP4 计算移入 Worker、呼吸动画移入 GPU并缩小动态文本的翻译扫描范围。
- 船舶全局订阅按 MMSI 合并、拆包和原位更新,保留选择状态,并处理删除、重连及旧快照覆盖。
- 新闻直播取消默认 120 项采集截断,增加数据库分页、完整目录搜索和固定计数栏,默认源改为半岛电视台 HLS。
- 算力中心定位队列在当前 Earth 页面内独立于详情面板继续运行;模型刷新保存新目录并保留当前模型、凭证草稿与失败前目录。
- AI Provider 镜像使用独立依赖组,容器直接运行已安装环境;启动提前验证数据库、及时识别后端失败,并保留可复用容器。
---
## [0.74.3] — 2026-09-13
Released: 2026-09-13
### Highlights
- Ubuntu / WSL 新机器初始化会自动检测并准备 Docker Engine、Compose v2、Buildx 和当前用户权限,减少手工安装步骤。
- 数据库初始化先核对容器端口与后端真实连接,连接和认证通过后才创建表和默认数据。
### Added / Fixed / Improved
- 区分 Docker CLI 缺失、服务未安装、daemon 不可用及 socket 权限不足,修正未安装 Docker 时误提示启动 socket 的诊断。
- 自动补齐缺失的 Docker 依赖并启动本地服务,以原用户身份刷新 Docker 组权限;保留参数和 PATH不依赖 sg。
- 通过 Compose 同步已有 PostgreSQL / Redis 容器配置,保留端口冲突等具体错误;端口映射异常时最多保留数据卷重建一次 PostgreSQL。
- 新增后端数据库只读连接检查,对认证、库名和网络失败给出不含密码或完整连接串的诊断。
- 将 Docker 与数据库启动隔离回归测试接入快速检查,并同步 README、harness 和中英文运维说明。
---
## [0.74.2] — 2026-07-01
Released: 2026-07-01
### Highlights
- 收敛 agent harness 到 `rules.md``AGENTS.md``docs/HARNESS.md``.codex/skills/`,删除重复维护的旧 Claude command 入口。
- 强化视觉证据规则:截图或视觉引用路径打不开时必须先处理 WSL/Windows 路径、相对路径和附件位置,而不是跳过后猜测。
- 明确 OCR 可作为文本类视觉证据或非多模态环境 fallback同时要求布局、颜色、像素和渲染类问题保留真实视觉验证或明确限制说明。
### Added / Fixed / Improved
- `AGENTS.md` 替换旧 opencode/默认 Plan Mode 内容,保留最新单一入口和 harness 验证说明。
- `rules.md``docs/HARNESS.md` 同步 Visual Evidence Gate补齐路径解析、访问失败报告和 OCR fallback 边界。
- 删除 `.claude/commands/*` 中与 `.codex/skills/*` 重复的旧 cleanup/docs/goal-driven/release 入口,并更新文档受众计划中的旧路径引用。
---
## [0.74.1] — 2026-06-30
Released: 2026-06-30
### Highlights
-`/earth-content` 的品牌标识上传收敛到 `Logo 地址``标题图地址` 字段内,移除旧的全局“选择资产/上传”工具栏。
- 新增字段级图片拖拽反馈,拖到对应字段时直接提示将图片复制为 Logo 或标题图。
- 对齐品牌上传按钮到现有 Tactile UI primary 按钮样式,并同步中英文使用手册、快速开始和控制台上下文文档。
### Added / Fixed / Improved
- `BrandAssetInput` 支持字段内选择文件、拖拽上传、单字段 loading 和上传后回写草稿 URL。
- `FieldGrid` 支持按字段注入自定义输入控件,同时复用统一草稿提交路径。
- 品牌上传拖拽态改为低饱和 tactile 配色,上传按钮保持蓝色轻立体样式,容器内上下/右侧留白对齐为 3px。
- 补齐品牌上传相关 legacy UI 英文翻译、术语对照和用户文档。
---
## [0.74.0] — 2026-06-30
Released: 2026-06-30
### Highlights
- 扩展统一 i18n 到 Web Earth、控制台、认证页和公开 Docs 的更多动态入口,减少英文界面中文残留。
- 强化 Earth HUD 的通知胶囊、品牌栏、语言 switch、图例、tooltip、详情卡、新闻和 TV 文案展示,避免英文态裁切或错位。
- 将容易遗漏的 i18n 入口和视觉回归加入 harness让 smoke 覆盖通知位置、内容宽度、语言切换状态和动态文案。
### Added / Fixed / Improved
- 新增 Earth runtime i18n 入口,统一高清材质、启动状态、错误提示和图层状态文案来源。
- 补齐国家名、属性名、卫星 legend、详情页 tooltip、新闻/TV 默认文案和 API 错误提示的英文翻译与回退。
- 更新控制台与 Earth 布局规则,保留 brand 尺寸语义,同时让标题、副标题和通知胶囊按内容完整显示。
- 扩展 frontend smoke 与 harness 文档固化一屏高度链、i18n 动态入口、胶囊/tag overflow 和截图证据要求。
- 更新 Earth 新闻本地化服务与测试,确保英文界面新闻内容不再回退中文 UI 文案。
---
## [0.73.0] — 2026-06-29
Released: 2026-06-29
### Highlights
- 新增前端统一 i18n 基础设施让认证页、Docs UI、控制台外壳、导航、搜索和核心共享组件共用 `zh-CN` / `en-US` 语言状态。
- 控制台侧边栏偏好面板接入语言与主题切换,并修复一屏高度链、账号区、状态指示器和英文态文案裁切问题。
- 扩展 harness 与 smoke 覆盖,确保 admin shell 高度、移动/缩放布局、语言切换、搜索、Docs 和核心控制台交互在发布前被验证。
### Added / Fixed / Improved
- 新增 `frontend/src/i18n/`,用 `i18next` / `react-i18next` 维护资源、locale 映射、Docs 兼容和过渡期 legacy UI 翻译桥。
- 将 AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast、MarkdownRenderer 和 Users 页迁移到统一翻译资源。
- 补齐 Planet Content、Collected Data、System Logs、Datasources、Settings 和 Collection Management 等英文态残留翻译,并覆盖动态计数字符串。
- 改进控制台侧边栏账号区、语言 switch、状态 pill 自适应宽度和 admin shell overflow ownership避免首屏溢出和状态词裁切。
- 更新 i18n 计划、控制台前端上下文、harness 文档和规则,记录语言迁移边界、状态指示器布局约束和一屏验证要求。
---
## [0.72.0] — 2026-06-29
Released: 2026-06-29

View File

@@ -24,6 +24,11 @@ static checks, Playwright smoke coverage, and remaining manual review areas, so
an agent can distinguish a proved harness pass from a rule that still needs
human-quality inspection.
When the user describes work with product words rather than module names, use
the `rules.md` **Agent Discovery Index** before deciding which modules to load.
It maps Chinese phrases such as `一屏`, `高度没控住`, `文档`, `数据源`,
`地球`, `模型供应商`, and `发版` to the required rule modules.
## Starting Work
Recommended startup flow:
@@ -79,7 +84,7 @@ git diff --unified=0 HEAD -- <path>
| Doctor | `scripts/harness/doctor.sh` | Checks required files, required tools, optional delivery tools, and forbidden frontend lockfiles. |
| Security | `scripts/harness/security-check.sh` | Checks that environment/private-key files are not tracked and scans for high-confidence committed secret tokens. |
| Backend Rules | `scripts/harness/backend-rules-check.sh` | Checks backend app Python for direct `print()`, `breakpoint()`, and `pdb.set_trace()` debug calls so service code uses structured logging. |
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, same-category style owner warnings, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
| Docs Consistency | `scripts/harness/docs-consistency-check.sh` | Checks frontend Docs metadata against backend Gatekeeper metadata, public Docs registration, full technical-doc bilingual file pairs, public doc links, readable link titles, language-scoped technical links, README/project-context admin stack drift, supported credential collector contracts, manual console route coverage against the actual admin manifest, documented UI route drift, documented `?section=` deep-link validity against the actual admin section config in technical docs and active plan docs, and the harness rules-coverage notes. |
| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, security scan, backend/frontend/doc consistency checks, and CI backend smoke tests. |
| Full | `scripts/harness/validate.sh` | Runs quick check, frontend Bun install/build, Playwright route smoke, optional Helm checks, and opt-in Docker image smoke builds. |
@@ -103,7 +108,12 @@ and authenticated `super_admin` rendering for every admin route plus core
Authenticated admin
checks run at desktop size, mobile size, and 125% / 150% zoom; desktop and
mobile passes also fail on global horizontal overflow so table/detail panels
must keep overflow ownership inside their own scroll regions. The smoke also
must keep overflow ownership inside their own scroll regions. To enforce the
existing `rules.md` `uiux` one-screen workspace rule, admin shell pages have a
hard rendered check: the shell must resolve to the viewport height through the
root 100% height chain, `#root`/document/body must not gain vertical overflow,
and the desktop sidebar account/preferences area must remain inside the first
viewport while the nav owns any excess scrolling. The smoke also
derives the sidebar menu from the actual admin route manifest and clicks every
visible `super_admin` menu entry on both desktop and mobile viewports, then
exercises safe interaction paths for admin search, section tabs, the AI settings
@@ -120,6 +130,71 @@ PLANET_HARNESS_FRONTEND_SMOKE=0 scripts/harness/validate.sh
PLANET_HARNESS_FRONTEND_SMOKE_PORT=4174 scripts/harness/validate.sh
```
### Visual Evidence And Style Consistency
- Treat user-provided screenshots and images as primary visual evidence. If a
screenshot contradicts written text, inspect the image first and explicitly
call out the mismatch before deciding what to change.
- Path resolution is part of visual evidence handling. If a referenced
screenshot path cannot be opened, try reasonable local equivalents first:
WSL/Windows path conversion, workspace-relative paths, absolute paths, current
thread attachments, repository files, and obvious local attachment/download
locations.
- If the image still cannot be found or opened, report the exact path/access
blocker instead of guessing. Do not infer image content from the filename, alt
text, surrounding prose, logs, or memory.
- OCR is acceptable evidence for text-only questions or non-multimodal
environments; state when OCR was the fallback. Layout, color, spacing, pixel,
and rendering issues still require a real visual inspection or an explicit
"could not verify visually" note.
- Same-category UI surfaces must use one visual system per product area. Badges,
chips, pills, tags, status labels, small buttons, cards, panels, and toolbar
controls should reuse the shared component, shared token, or established CSS
owner for that area instead of introducing a page-local lookalike.
- `scripts/harness/frontend-rules-check.sh` warns when semantic
`badge` / `chip` / `pill` / `tag` / `status` selectors appear outside the
approved React and Earth CSS owner files. A warning means the reviewer should
either move the style into the shared owner or document why this is a genuinely
new visual family.
### Earth I18n Harness Rules
Earth i18n work must validate rendered behavior, not only static text lookup.
Agents often miss dynamic strings that are created after initial page load, so
the smoke treats these as first-class i18n surfaces:
- **Visible text and attributes**: translated checks must include `innerText`
plus `title`, `aria-label`, `placeholder`, and `alt`. Tooltips and icon-only
buttons are user-facing copy, not implementation details.
- **Dynamic detail cards**: info cards opened from Earth markers, cruise cards,
BGP markers, compute centers, vessels, and news must render field labels,
status values, source tags, action buttons, and disabled/tooltips in the active
language.
- **English content safety**: English mode must not fall back to Chinese news
titles, summaries, feed names, measure words, or generic status labels. If no
English localization exists, hide the item or use a neutral English fallback.
- **Brand assets**: locale switching must update both text and image assets.
The default Earth HUD brand uses `title-zh.png` for Chinese and `title-en.png`
for English while keeping the same top-left layout and logo position.
- **Controls and state**: switch/segmented-control visuals must follow the real
checked/pressed state after both direct clicks and programmatic panel changes.
A control is not valid if the state changes but the thumb, active pill, or
`aria-*` state stays stale.
- **Runtime copy entrypoints**: dynamic status, loading, startup, and error copy
must enter through `earthMessage(...)` plus the centralized
`EARTH_MESSAGE_TEMPLATES` map. Do not hide direct strings behind
`showStatusMessage`, `queueStatusMessage`, `showGestureStatusMessage`,
`showError`, `setLoadingMessage`, `resolveStartupMessage`, `startupMessage`,
or `earth:status` events.
- **Capsules and tags**: pills, tags, chips, badges, and small buttons must not
overflow their panel. Prefer a slightly wider owning panel for important
status information; otherwise use `min-width: 0`, wrapping, or ellipsis with a
translated tooltip.
Current frontend smoke explicitly covers the Earth English locale flow: brand
image swap, settings language controls, panel switch visual sync, English news
filtering, English detail-card text and tooltips, and TV default/source labels.
## Environment Requirements
Required for normal development:
@@ -145,9 +220,26 @@ Optional for delivery smoke:
- Docker daemon for image builds
- Helm for chart lint/template checks
If a required local tool is missing, do not install system software
automatically. Report the gap and point to `./planet.sh init` or
`scripts/bootstrap-dev.sh` as the existing bootstrap path.
For routine harness validation, do not install missing system software
automatically. Report the gap and point to the explicit bootstrap entry points.
`./planet.sh init` can install missing Docker Engine, Compose v2, and Buildx on
Ubuntu / Ubuntu WSL, start the local service, and configure Docker group access.
This bootstrap behavior is intentional; do not invoke it merely to make harness
checks pass. `scripts/bootstrap-dev.sh` only prepares application dependencies.
Docker bootstrap regression checks use isolated command stubs and never install
packages or modify the host daemon:
```bash
uv run --frozen --project . python scripts/harness/test_docker_bootstrap.py
uv run --frozen --project . python scripts/harness/test_database_startup.py
```
Database startup regressions also run in quick-check. They cover Compose
reconciliation of existing containers, visible startup errors, published-port
checks, bounded recreation that preserves volumes, and the backend connection
gate before schema initialization. Their command stubs and driver mocks do not
modify the host Docker environment.
## What Agents Must Not Change Automatically

View File

@@ -102,6 +102,8 @@ The repository uses `.gitea/workflows/`, not `.github/workflows/`.
| Admin/docs shell layouts can reintroduce brittle viewport sizing after a responsive fix. | Changed the admin and Docs route shells to use the existing `html/body/#root` 100% height chain, and added a frontend rules failure for exact `100vh` / `100vw` shell sizing in those CSS files. |
| Compact workspaces can drift back into card-in-card layouts or implicit AntD `Space` wrappers. | Added frontend rules failures for nested `Card` components, AntD imports, and `<Space>` layout primitives in active frontend source. |
| Connection-test controls can drift back into detached toolbar buttons. | Added a shared `ConnectionTestInput` suffix pattern for AI Provider and WebSearch Base URL fields, disabled WebSearch configuration/test controls when the tool is off, and made the frontend rules check fail detached AI/WebSearch connection-test buttons. |
| Same-category UI styles can fragment into page-local lookalikes. | Added same-category style owner warnings for semantic `badge`, `chip`, `pill`, `tag`, and `status` CSS selectors outside the approved shared React and Earth CSS owner files. |
| Visual fixes can go wrong when agents guess from missing screenshot paths. | Documented screenshots and images as primary visual evidence: if the path is not available, agents must search alternate attachment/local locations or report the blocker instead of inferring image content. |
| Public docs can reference stale admin section URLs. | Added docs consistency validation for documented `?section=` links and rendered smoke coverage for documented AI / collector deep links. |
| Active plan docs can preserve old admin deep-link assumptions after the technical docs are corrected. | Extended docs consistency checks to active `docs/plans/*.md` files for stale admin tab-query terms and actual `?section=` validity; corrected the docs audience split plan to current section routes. |
| Top-level README can drift from the actual frontend stack while technical docs stay current. | Updated README from Ant Design Pro to Tactile UI / Radix primitives / lucide-react and added README stale admin-stack terms to docs consistency checks. |
@@ -120,6 +122,10 @@ command fails when the rule regresses. "Smoke" means the rendered product route
or interaction is opened with Playwright. "Manual" means the rule is still a
judgment call and must be inspected during review.
Before using this matrix, start from `rules.md`'s **Agent Discovery Index** when
the user describes work with Chinese/product terms instead of module names. The
index is the routing layer; this table is the coverage/evidence layer.
| `rules.md` Area | Rule Surface | Harness Evidence | Remaining Review |
| --- | --- | --- | --- |
| `core` | Remove stale transitional paths, duplicated helpers, and naming drift after large changes. | `scripts/harness/docs-consistency-check.sh` blocks known stale stack terms, old `?tab=` links, public Docs metadata drift, and README/project context drift. `scripts/harness/frontend-rules-check.sh` blocks repeated detached AI/WebSearch connection-test buttons by requiring `ConnectionTestInput`. | Naming quality, function size, and whether a new abstraction is worth keeping remain manual review items. |
@@ -129,8 +135,9 @@ judgment call and must be inspected during review.
| `workflow` | Agents should find `bun` and `uv` even when non-interactive `PATH` is incomplete. | `scripts/harness/lib.sh` checks the current `PATH`, then asks `$SHELL`, `zsh`, and `bash` login interactive shells for the command path without hardcoding a dotfile. `doctor.sh`, `quick-check.sh`, and `validate.sh` all source it. | System package installation remains outside harness scope and should be reported instead of auto-fixed. |
| `docs` | Keep public Docs whitelist-driven and synchronized with backend authorization metadata. | `docs-consistency-check.sh` compares frontend Docs metadata against backend Gatekeeper metadata, verifies files exist for both languages, checks public link titles, and blocks missing zh/en technical doc pairs. `frontend-smoke.mjs` opens every Chinese Docs catalog slug plus detail/search/language/theme interactions. | Quality of prose, examples, and whether a doc should be public are still editorial review items. |
| `docs` | User manuals must match real console routes and deep links. | `docs-consistency-check.sh` compares manual console tables with `frontend/src/admin/routes/manifest.tsx` and validates documented `?section=` links from actual `AdminRoutes.tsx` plus `PlainResourcePages.tsx` section config. | Screenshots and UI-copy nuance are not exhaustively validated. |
| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` warns on suspicious `overflow: hidden`, blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS, and `frontend-smoke.mjs` checks every admin route at desktop, mobile, and 125% / 150% zoom. Desktop/mobile smoke also fails global horizontal overflow. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. |
| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` fails missing admin shell height-chain declarations (`.admin-theme-root`, `.admin`, `.admin__sider`, `.admin__nav-scroll`, `.admin__account`, `.admin__content`, `.admin__content-inner`), warns on suspicious `overflow: hidden`, and blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS. `frontend-smoke.mjs` checks every admin route at desktop/mobile and verifies `.admin` equals viewport height, `#root`/document/body have no vertical overflow, and desktop sidebar account/preferences stay in the first viewport. Zoom passes still cover 125% / 150% rendering. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. |
| `uiux` | Controls use expected patterns and accessible icon buttons. | `frontend-rules-check.sh` blocks icon `Button` without `aria-label` and `title`, native `<button>` without explicit `type`, nested Cards, AntD imports, `<Space>`, and detached connection-test buttons. Smoke exercises search, tabs, dialogs, data toggles, and connection-test actions. | Native buttons with visible text are not treated as icon-only by static checks; semantics still need review when adding custom controls. |
| `uiux` | Same-category visual surfaces should use one style system per product area. | `frontend-rules-check.sh` warns when semantic `badge`, `chip`, `pill`, `tag`, or `status` selectors appear outside approved shared React and Earth CSS owner files. `docs/HARNESS.md` also makes screenshots/images primary evidence for visual fixes and forbids guessing when an image path is unavailable. | Final visual cohesion across screenshots still needs human/Playwright review, especially for page-specific cards, panels, and toolbar controls that static selector checks cannot classify perfectly. |
| `uiux` | Text should fit, avoid viewport-scaled font sizes, and keep letter spacing at zero. | `frontend-rules-check.sh` fails viewport/container-width font-size units and non-zero `letter-spacing` / `letterSpacing`. `frontend-smoke.mjs` checks rendered routes for global overflow across desktop/mobile. | Per-element text clipping without page-level overflow is not exhaustively detected and needs visual review for changed screens. |
| `frontend` | Keep shared behavior in reusable components and existing project patterns. | `frontend-rules-check.sh` enforces shared `ConnectionTestInput`, route/link/search consistency, no debug output, native button safety, Tactile/Radix/lucide direction instead of AntD/Space, and whitelist-driven public Docs. `bun x tsc --noEmit` and `bun run build` verify TypeScript/build health. | Broad casts, inline styles, and overflow issues are warnings when context may be legitimate; review changed lines before accepting them. |
| `frontend` | Responsive adaptations must preserve the primary action path. | `frontend-smoke.mjs` clicks every visible admin menu entry on desktop and mobile, opens protected routes unauthenticated and authenticated, verifies root/unknown route fallback, and exercises core auth flows. | Deep feature workflows beyond smoke data, such as destructive or long-running actions, require targeted tests before behavior changes. |
@@ -147,8 +154,8 @@ judgment call and must be inspected during review.
| `scripts/harness/doctor.sh` | Environment and repository-shape check. |
| `scripts/harness/security-check.sh` | High-confidence secret and tracked environment/key file check. |
| `scripts/harness/backend-rules-check.sh` | Backend app debug-call guard for direct stdout/debugger usage. |
| `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin/docs shell viewport sizing, viewport-font, zero-letter-spacing, and UI rules static check. |
| `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin shell one-screen height-chain declarations, admin/docs shell viewport sizing, same-category style owner warnings, viewport-font, zero-letter-spacing, and UI rules static check. |
| `scripts/harness/docs-consistency-check.sh` | Frontend/backend Docs metadata alignment, public Docs metadata, full technical-doc bilingual pair, link-title, language-scoped technical link, supported credential collector contracts, manual console route coverage, documented route, admin-config-derived section deep-link consistency, and harness rules-coverage note check. |
| `scripts/harness/frontend-smoke.mjs` | Playwright route, Docs detail/language/theme/search interaction, public auth form interaction, desktop/mobile/zoom rendering, safe admin navigation/search/tab/dialog/Earth News interactions, and authenticated admin route/section smoke for the built frontend preview. |
| `scripts/harness/frontend-smoke.mjs` | Playwright route, Docs detail/language/theme/search interaction, public auth form interaction, desktop/mobile/zoom rendering, admin shell one-screen/overflow checks, safe admin navigation/search/tab/dialog/Earth News interactions, and authenticated admin route/section smoke for the built frontend preview. |
| `scripts/harness/quick-check.sh` | Fast deterministic local validation. |
| `scripts/harness/validate.sh` | Full local validation wrapper with optional delivery smoke. |

View File

@@ -22,6 +22,7 @@
当前重点入口:
- [控制台 i18n 接入计划](/home/ray/dev/linkong/planet/docs/plans/admin-console-i18n-plan.md)
- [Earth Mobile Drawer UI Plan](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
- [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
- [Earth Renderer Architecture Separation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)

View File

@@ -0,0 +1,62 @@
# 控制台 i18n 接入计划
**状态**:基础设施已落地,大型业务页迁移继续进行
**创建日期**2026-06-29
**核心目标**:把 Docs 已有的中英文文档能力提升为前端统一 i18n 体系让未登录认证页、Docs UI、控制台外壳、导航、搜索和核心工作台文案共用同一个语言状态。
## 背景
Docs 站点已经有 `zh` / `en` 文档目录、Gatekeeper 权限和 `/api/v1/docs/{lang}/{slug}` 内容接口,但语言状态只保存在 `docs-lang`不影响控制台。控制台页面、搜索索引、toast、dialog、表格和认证页仍以中文硬编码为主导致用户切到英文文档后控制台仍是中文。
本计划把前端语言偏好收敛到 `planet-locale`,默认 `zh-CN`,支持 `en-US`。Docs 继续使用后端现有 `zh` / `en` 文档接口,通过前端映射与全局 locale 对齐。
## 设计决策
- 使用 `i18next``react-i18next` 作为统一 i18n 层避免长期维护自研插值、hook 和资源加载逻辑。
- 前端统一语言枚举为 `zh-CN` / `en-US`Docs 请求继续转换为 `zh` / `en`,新闻接口继续使用已有 `zh-CN` / `en-US` 口径。
- 语言偏好首版只保存在浏览器 `localStorage`,不新增后端用户设置字段。
- `docs-lang` 保留为兼容读取和写入项,让已访问过 Docs 的浏览器能平滑迁移。
- 静态路由、导航、搜索目标和通用组件使用显式翻译 key大型业务页在迁移期间通过 legacy UI 翻译桥补足常见硬编码文案。
## 分期
### P1统一语言基础设施
-`frontend/src/i18n/` 下维护 locale 类型、资源、初始化和 `useLocale()`
-`frontend/src/main.tsx` 里初始化 i18n并同步 `document.documentElement.lang`
- 在认证页和控制台侧边栏偏好面板提供语言切换入口。
### P2高复用界面迁移
- 迁移 Docs UI、AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast 和 MarkdownRenderer。
- 搜索索引按当前语言展示,同时保留中英文关键词以免降低可发现性。
- 用户管理页作为独立业务页示范迁移表头、按钮、toast、校验提示、角色和 Gatekeeper 标签。
当前已完成统一 `planet-locale`、Docs 兼容映射、认证页和控制台外壳语言入口、共享组件 key 化,以及 legacy UI 翻译桥。后续工作集中在把大型业务页从过渡桥迁移到显式 key。
### P3大型业务页收敛
- 分批把 Dashboard、DataList、Logs 和 PlainResourcePages 的配置块改为显式翻译 key。
- 过渡期保留 legacy UI 翻译桥,只处理 admin/auth 容器里的精确静态文本和属性。
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥。
### P4移除过渡桥
-`rg -n "[\\p{Han}]" frontend/src/admin frontend/src/pages frontend/src/components` 只剩业务数据示例、中文文档标题或必须保留的中文品牌词时,删除 legacy UI 翻译桥。
- 增加 key 完整性检查,确保 `zh-CN``en-US` 资源结构一致。
## 验证
- `cd frontend && bun run build`
- `scripts/harness/frontend-rules-check.sh`
- `scripts/harness/docs-consistency-check.sh`
- `scripts/harness/quick-check.sh`
- 前端 smoke 需要覆盖登录页、Docs、Admin 侧边栏语言切换、侧边栏和搜索结果在中英文下渲染。
## 相关文件
- `frontend/src/i18n/`:统一 locale、资源和过渡桥。
- `frontend/src/pages/Docs/Docs.tsx`Docs 语言状态改为读取全局 locale。
- `frontend/src/admin/components/layout/AdminLayout.tsx`:控制台侧边栏语言切换、导航和搜索文案。
- `frontend/src/admin/search/indexers.ts`Admin 搜索目标本地化。
- `docs/technical/{zh,en}/frontend-admin-frontend-context.md`:当前实现上下文。

View File

@@ -3,7 +3,7 @@
**状态**:待实施
**创建日期**2026-05-12
**校正日期**2026-06-26控制台深链已从旧 tab 查询口径更新为当前 `?section=` 口径。
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md``.claude/commands/docs.md`,让以后写文档时自动按受众归档。
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md``.codex/skills/docs/SKILL.md`,让以后写文档时自动按受众归档。
## 背景
@@ -80,7 +80,7 @@
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
## .claude/commands/docs.md 增量
## `.codex/skills/docs/SKILL.md` 增量
在 "Step 2 — Decide Scope" 后插一段:
@@ -100,7 +100,7 @@
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
- `docs/documentation-coverage-rules.md` — 加受众分层段
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
- `.codex/skills/docs/SKILL.md` — 加 Document Audience Routing 段
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook``DOCS_METADATA``docs_admin` 组)
## 依赖

View File

@@ -2,6 +2,8 @@
## 当前状态
最新实现继续保留分桶 `Points` 与全局数据范围,并已接通 `vessels` 全局实时订阅:后端读取确认状态后按 MMSI 拆包推送,前端原位修改缓冲;快照用于首次加载、重连和删除校准。旧快照不能覆盖较新的更新或删除,旋转与缩放仍不触发视口请求。下方 Sprite 问题分析和阶段方案保留为历史背景,当前约束以[地球前端上下文](../technical/zh/earth-frontend-context.md)为准。
该计划的前端核心部分已经在 `0.44.1` 落地,但最终实现不是原文设想的 `InstancedBufferGeometry` quad而是更稳的分桶 `THREE.Points` 方案:
- 普通船只按 moving / anchored 和 `VESSEL_COURSE_BINS` 航向分桶,使用 `PointsMaterial` 批量绘制。

View File

@@ -95,12 +95,15 @@ The AI settings page uses:
- `POST /api/v1/settings/integrations/ai-provider/connect`
- `GET /api/v1/settings/integrations/ai-provider/secrets`
- `GET /api/v1/settings/integrations/ai-provider/presets`
- `POST /api/v1/settings/integrations/ai-provider/presets/{provider}/refresh`
- `GET /api/v1/settings/ai-prompts`
- `PUT /api/v1/settings/ai-prompts/{task_key}`
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview.
`backend/app/services/llm_provider_catalog.py` refreshes catalogs from models.dev for most providers and from OpenCode Go's own models endpoint for that provider. Entries from models.dev are sorted by release date, newest first, with undated entries last. Successful results and `refreshed_at` are stored per provider in the `system_settings` category `llm_provider_preset:<provider>`. Listing prefers saved catalogs and uses bundled presets for providers that have not been refreshed. Refresh does not write the active model, protocol, URLs, or credentials in `external_integrations`. Failure returns 502 and preserves the previous catalog without exposing raw upstream errors. The frontend reloads catalog state while retaining the form draft.
Admin keeps the AI page aligned with the legacy information architecture:
- `Model Providers`

View File

@@ -111,6 +111,12 @@ Snapshot lists should not show every snapshot of the same collector as separate
Credential guides are maintained by `backend/app/services/credential_guides.py`. The console uses read / generate / reset actions to load or create Markdown instructions. The frontend should render the guide Markdown for operators, not expose generation prompts or raw metadata.
### News Live Stream Catalog
The IPTV-org adapter in `news_live_streams` retains its news-category filters. Its default `max_sources` is `0`, meaning all matching channels; an explicit positive value still limits collection. Existing configurations retaining the old `120` limit must change it to `0` and collect again to populate the complete catalog.
`GET /api/v1/tv/streams` uses database pagination in `tv_catalog.py`: `offset` defaults to `0`, `limit` defaults to `50` with a maximum of `100`, and whitespace-separated `q` terms match channel names, providers, regions, and languages. Built-in and configured sources come first. Collected sources are deduplicated by channel ID, exclude configured overrides, and are filtered, counted, sorted, and paginated in SQL. The response's `total` counts matches, while `source_count` counts the complete available catalog; use `has_more` and `next_offset` for subsequent pages. `selected_id` can also return the selected channel outside the current page without consuming its quota. `tv_streams.py` remains the owner of default and fallback sources.
## IV. Data Format (stored in CollectedData table)
```python
@@ -352,7 +358,11 @@ GET /api/v1/visualization/vessels/{mmsi}/conflicts
`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, and caps `limit` at `5000`. The Earth frontend uses a global bbox for current state and does not refetch on camera viewport changes. The endpoint reads `vessel_current_state` and reports `diagnostics.source = "vessel_current_state"`. The old `/api/v1/visualization/geo/vessels` route has been removed.
High-frequency AIS updates must not become per-delta full-layer rebuilds. If Earth uses the `/ws` `vessels` channel, it should send low-frequency reload/dirty hints and let the frontend merge snapshot refreshes. Tracks and conflicts still read historical facts through the single-vessel APIs.
AISStream and BarentsWatch share the `/ws` `vessels` delta channel. Earth subscribes with `scope: "global"` on its existing WebSocket, without changing subscriptions as the camera moves. The backend coalesces notifications by MMSI for one second, then reads confirmed `vessel_current_state` rows. Frames contain at most 1000 items; further frames carry the remaining vessels rather than truncating them. Raw source messages must not overwrite confirmed client positions. Deleting current-state rows also produces MMSI-based remove notifications.
The frontend coalesces short bursts per MMSI and uses `Interactable.updateItems()` to update position and color buffers in place. Heading-bucket changes touch only affected buckets, growing capacity when needed. Existing marker identities, selections, and materials survive. Initial entry, reconnects, deletion hints, and the minute reconciliation still use snapshots without first clearing the layer. A bounded snapshot must not treat truncated vessels as deleted, and older responses must not overwrite newer stream updates or removals received while the request was in flight. Snapshots include query-start `generated_at`; it is compared with stream frame time to prevent stale cached snapshots from rolling back new vessels or removals.
Ordinary vessel writes use the `delta` strategy on `earth_updates`; while the dedicated channel is connected, they no longer request whole-layer reloads. Deletion or disconnected reconciliation uses `reload` while reusing existing objects. Hiding the layer unsubscribes and clears queued changes. Tracks, conflicts, and audit data continue through the single-vessel historical APIs.
### Layer APIs And Global Stats

View File

@@ -70,7 +70,10 @@ The unified event model is `earth.layer.changed`:
| --- | --- |
| `clear_then_reload` | Clear local frontend layer objects first, then force a refetch. Prefer this for deletes. |
| `reload` | Keep old objects until fresh data returns. Use it for location, metadata, or non-destructive updates. |
| `delta` | Used only for `earth_interactables`; upsert or remove objects by id. |
| `delta` | `earth_interactables` upserts/removes by id; ordinary vessel writes update confirmed state by MMSI through the dedicated `vessels` channel without clearing the layer. |
Vessel deletion still emits a `reload` reconciliation hint; individual `vessel_current_state` deletions also enter the vessel remove channel. Source notifications identify changed MMSIs, while transmitted values come from current state. Global subscriptions use `scope: "global"`; message-size limits split frames instead of discarding remaining vessels.
APIs must return HTTP 200 with an empty collection for real zero-data states; 5xx is reserved for real endpoint failures. After a delete event, if refetch fails, the frontend should keep the cleared state and show a lightweight error instead of restoring stale objects.

View File

@@ -321,7 +321,7 @@ The new vessel list entry point is no longer the legacy `/api/v1/visualization/g
GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000
```
That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. If the `/ws` `vessels` channel is connected, it should act as a reload/dirty hint for merged refreshes, not as a per-AIS-delta full-layer rebuild path.
That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. Realtime updates use the global `/ws` `vessels` subscription to deliver canonical state by MMSI and update existing markers in place. Larger updates are split across frames without dropping vessels. Initial load, reconnection, and deletion reconciliation still use snapshots; see [Collector Architecture](backend-collectors.md).
## Custom REST / WebSocket Mapping Runtime

View File

@@ -75,7 +75,11 @@ This is currently the most critical UI control entry point for the Earth fronten
Earth settings are now grouped by `data-settings-tab` and `data-settings-tab-panel`. Desktop and mobile share the same category semantics: Runtime, Display, Panels, Motion, Shortcuts, and System. When adding a setting, first choose its category, then add the DOM, persistence field, and restore logic; do not keep growing one long undifferentiated panel.
The news category selector in Display reuses the same chip-selector pattern as Cruise Modules. It only filters news categories for the current browser on the Earth frontend. It does not toggle layers, basemap, boundaries, TV, data points, BGP, vessels, satellites, or compute centers; those remain owned by the layer panel, media panel, and admin configuration. `controls.js` persists only `shared.newsCategoryFilters` and broadcasts `earth:news-category-filters-change`; `news.js` sends the selected categories to `/api/v1/news/earth-feed?categories=...&locale=zh-CN`, so Web and UE clients share the same backend category filtering path.
Earth runs inside an independent iframe / static application, so it cannot directly reuse React Admin's `react-i18next` context. `public/earth` uses its own [i18n.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/i18n.js) runtime to read and write the global `planet-locale`, while keeping the legacy `docs-lang` in sync. This keeps Docs, the console, and Earth on the same language preference. The language switch belongs in the Settings `System` tab, and desktop/mobile both use the same `data-earth-locale` buttons; do not put language selection into layer, display, or runtime-mode settings.
The news category selector in Display reuses the same chip-selector pattern as Cruise Modules. It only filters news categories for the current browser on the Earth frontend. It does not toggle layers, basemap, boundaries, TV, data points, BGP, vessels, satellites, or compute centers; those remain owned by the layer panel, media panel, and admin configuration. `controls.js` persists only `shared.newsCategoryFilters` and broadcasts `earth:news-category-filters-change`; `news.js` sends the selected categories and current locale to `/api/v1/news/earth-feed?categories=...&locale=...`, so Web and UE clients share the same backend category filtering path.
In English mode, Earth news must render only English title/summary text. Chinese source items without `en-US` localization are filtered from the visible cards/ticker/cruise until the backend enrichment finishes, and source/feed labels fall back to English-safe names instead of rendering Chinese labels.
The news panel, ticker, and news cruise must consume `items` / `cruise_items` from the same `/api/v1/news/earth-feed` response instead of keeping separate regional caches. `news.js` builds a refresh request key from region, category, source, and limit; only concurrent requests with the same key reuse the promise, and stale responses from an older region are dropped by token. Source filtering is also region-scoped: when the user moves from Asia Pacific to Europe or another region, source IDs saved for the old region must not be appended to the next fetch. After the new payload arrives, the saved source list is intersected with the available `sources`; if the intersection is empty, the current region falls back to all available sources. This keeps the ticker, panel, and cruise cards aligned after region switches.
@@ -155,6 +159,8 @@ Each module is responsible for its own:
`tv.js` owns the live / aggregation-news tabs inside `media-panel`. Toolbar open and tab-switch actions write back through `earth:tv-visibility-change` and `earth:tv-tab-change`: panel visibility remains viewport-scoped at `views.<scope>.panelVisibility.media-panel`, while the active tab is stored at `shared.mediaPanelActiveTab`. Refreshing the page therefore restores the user's last live/news state. Temporary hides from `closeTransientMobileOverlays()` carry `persist:false` and do not overwrite the preference.
`tv-source-menu.js` reuses HUD and legend-list styling in a popover with search, a scrolling list, and a fixed count footer. `tv.js` requests 50 entries from `/api/v1/tv/streams` and caches channel details; search runs against the complete backend catalog, and pagination uses `next_offset`. Cancellation and a request generation counter prevent stale results from replacing a newer search. Catalog refresh uses `selected_id` to restore a channel outside the first page.
`brand.js` manages Earth HUD brand resources. Static assets provide the default brand; runtime overrides come from `/api/v1/earth/brand`, and uploaded images are served from `/earth-brand-assets/...`. The frontend must treat logo/title images and text fallback separately: if an image fails, show the text title; if text fields are empty, rely on backend defaults so the HUD brand area never renders blank. The console Earth Content page owns saving and resetting brand configuration; the Earth frontend only consumes it.
`about.js` manages the About card inside Earth settings. Frontend defaults remain as a fallback, while runtime content is loaded from `/api/v1/earth/about`. If the request fails or fields are missing, the renderer must fall back per field so the settings page never renders an empty card. Admin exposes an Earth Content `About` tab; saving uses `PUT /api/v1/earth/about`, and restoring defaults uses `DELETE /api/v1/earth/about`.
@@ -175,6 +181,8 @@ The compute-center layer row has a notification badge for GeoJSON `unresolved` r
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
`runUnresolvedComputeCenterBatch()` owns a queue of entity contexts rather than panel DOM. `locationCollectStateCache` retains candidates, progress, and save results so rebuilt cards can hydrate the current state. `earth:compute-center-location-batch-change` updates the badge, `savedCandidate` excludes completed entries, and a ✓ entry remains after completion. Queue state lives only in the current Earth page: refresh, close, or route navigation interrupts unfinished work, while saved coordinates remain authoritative in the backend.
The `预览 / 保存` buttons on each candidate row use a single delegated `click` handler per candidate root (the `[data-collect-cache-key]` block in the details card, or `[data-unresolved-item]` in the unresolved queue), guarded by a `data-candidate-actions-bound` flag so it cannot be double-bound. Direct `pointerup` / `click` listeners on individual buttons and overlapping delegated handlers were removed. Candidate objects are no longer JSON-stringified into an HTML attribute and parsed back; buttons only carry `data-candidate-index`, and the handler resolves the candidate object from a module-level `Map` keyed by cache-key. This removes the entire class of failures caused by HTML entity escaping of `&` / `<` / `"` in candidate fields. Clicking `预览` dispatches `earth:preview-location-candidate`; `main.js`'s `previewLocationCandidate()` calls `showComputeCenterLocationPreview()`, which attaches a hollow breathing-ring sprite pair at the candidate coordinates (visually mirroring the BGP event ring) and focuses the camera on the candidate. Previewing another candidate replaces the ring; saving clears it and `spawnSavedComputeCenterLocation()` immediately spawns the formal compute-center interactable. Note that `main.js` has no module-level `earth` variable — every location-save / preview handler must call `const earth = getEarth();` first, otherwise the event handler throws a `ReferenceError` that the surrounding `.catch` swallows, producing the failure mode where the button "does nothing".
The `earth:compute-center-location-saved` reconciliation pipeline is deliberately silent on background-refresh failures. `spawnComputeCenterAfterLocationSave()` already presents the success toast and locked state; `refreshComputeCentersAfterLocationSave()` only reloads backend data when the scene is ready and no longer emits its own `已保存` toast. `handleComputeCenterLocationSaved()` runs refresh in the background after a successful spawn; only when spawn returns `null` (scene not ready) or throws does refresh take over the success toast. A refresh error is only `console.warn`'d — it must never surface as a `保存失败` message, because the save itself succeeded and the refresh is a follow-up sync.
@@ -187,7 +195,11 @@ The backend snapshot endpoint still requires `bbox`, but the Earth runtime treat
Vessel markers are rendered through `createInteractableLayer()` as batched `THREE.Points`, with `cluster` and `avoidance` explicitly disabled. Dense waterways may overlap. Dragging or inertial rotation skips hover picking; normal hover uses screen-space nearest-point picking. Do not reconnect vessels to dynamic screen clustering or per-frame Points rebuilds, because those interaction costs are what make a 3000-marker layer feel heavy.
If the `/ws` `vessels` channel is used by Earth, it should be a low-frequency reload/dirty hint only. Do not create multiple viewport subscriptions, and do not turn every AIS delta into a full layer rebuild.
AISStream and BarentsWatch share the `/ws` `vessels` delta channel. Earth subscribes with `scope: "global"` on its existing WebSocket, without changing subscriptions as the camera moves. The backend coalesces notifications by MMSI for one second, then reads confirmed `vessel_current_state` rows. Frames contain at most 1000 items; further frames carry the remaining vessels rather than truncating them. Raw source messages must not overwrite confirmed client positions. Deleting current-state rows also produces MMSI-based remove notifications.
The frontend coalesces short bursts per MMSI and uses `Interactable.updateItems()` to update position and color buffers in place. Heading-bucket changes touch only affected buckets, growing capacity when needed. Existing marker identities, selections, and materials survive. Initial entry, reconnects, deletion hints, and the minute reconciliation still use snapshots without first clearing the layer. A bounded snapshot must not treat truncated vessels as deleted, and older responses must not overwrite newer stream updates or removals received while the request was in flight. Snapshots include query-start `generated_at`; it is compared with stream frame time to prevent stale cached snapshots from rolling back new vessels or removals.
Ordinary vessel writes use the `delta` strategy on `earth_updates`; while the dedicated channel is connected, they no longer request whole-layer reloads. Deletion or disconnected reconciliation uses `reload` while reusing existing objects. Hiding the layer unsubscribes and clears queued changes. Tracks, conflicts, and audit data continue through the single-vessel historical APIs.
The legacy `/api/v1/visualization/geo/vessels` route has been removed. Frontend code should keep using `PATHS.vesselsApi` and can verify the current-state path through `diagnostics.source == "vessel_current_state"`.
@@ -217,6 +229,14 @@ The cruise sequencer handles generic logic: current target, queue order, camera
All material, layer, satellite, BGP, cable, terrain, celestial, and other style parameters are maintained here. Do not scatter magic numbers in module files.
## Render hot paths
`cable-batches.js` batches the complete cable and landing-point sets. `cables.js` still owns original business objects, picking, occlusion, and selection state. Do not make these interaction proxies draw individually again or return a render batch as the detail-card business object.
Satellite breathing runs in vertex shaders. `satellite-position-worker.js` moves full SGP4 position and initial trail calculations off the main thread; the main thread applies snapshots to interaction coordinates and render buffers. `satellite-propagation.js` supplies the same mathematics to the Worker, predicted orbits, and synchronous fallback. Reload, clear, and altitude changes must terminate prior work so stale snapshots cannot resurrect cleared layers.
The `i18n.js` MutationObserver translates only added subtrees, changed text, or changed attributes. Local clocks, status labels, and download progress must not synchronize all locale controls. Global locale synchronization belongs to initialization, locale changes, or subtrees containing new locale controls.
## Current Style Layers
CSS files in `frontend/public/earth/css/` each correspond to a specific component scope. Do not write global Earth styles into `base.css` unless they genuinely apply to everything.
@@ -242,6 +262,8 @@ Earth settings are stored in `localStorage`. The key is typically a namespaced s
Settings that affect visual layers and surface interaction (terrain opacity, day/night mode, satellite display style, satellite idle breathing, real satellite altitude, track display, hover tooltip mode, etc.) are read during initialization and applied immediately.
Language preference is the exception: Earth language is not a private `planet.earth.settings.v2` field. It shares `planet-locale` with Docs/Admin. When language changes, `i18n.js` updates `document.documentElement.lang`, translates static and dynamically inserted DOM, synchronizes language button state, and passes the current locale to news requests. The news module refreshes after a language change so an English UI does not reuse a Chinese news payload.
The surface hover tooltip preference is persisted by `controls.js` as `shared.surfaceHoverInfoMode`, while `main.js` composes the actual tooltip in the globe-surface hover branch. `Country` shows country details only when a country polygon is hit and stays silent over ocean; `Position` shows latitude, longitude, and sampled terrain elevation and clears country-boundary hover; `Full` shows country + position on land and position over ocean.
The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. `maxRealAltitudeOffset = 25` is a visual cap tuned for the current camera and `earthRadius = 100`: GEO / MEO remain clearly higher than LEO, but the highest orbits stay within about 25% beyond the globe radius so selection targets, red trails, and the globe do not feel disconnected.
@@ -327,7 +349,7 @@ If future cable, satellite, or news cruise is added, do not copy a new set of `m
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) owns the Earth zoom state, and every zoom entry point must ultimately call `setZoomLevel()` to write the camera distance. Do not write `camera.position.z` from other modules, or the zoom percentage, drag sensitivity, and Interactable clustering thresholds will diverge again.
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps the projection-based behavior for high-frequency realtime layers such as vessels, and `none` disables clustering. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps projection-based clustering, and `none` disables it. Vessels explicitly disable clustering and avoidance so incremental updates do not rebuild cluster topology. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
Wheel input has two paths. Traditional mouse wheels keep the 10% step and short animation, using `wheelZoomTarget` as the logical base for continuous wheel input. Trackpads and high-precision wheels use the pixel delta for continuous zoom and call `setZoomLevel()` directly instead of passing through the 10% stepped animation. The trackpad path also filters a short-window, old-direction residual delta after a real direction change so inertia tails do not pull a just-reversed zoom back in the previous direction.

View File

@@ -143,6 +143,8 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
## Submarine Cables and Landing Points
`cable-batches.js` batches cable lines and landing points separately. The Sprite material and size parameters below remain owned by picking and selection proxies in `cables.js`; their color, opacity, scale, and visibility update the style texture or instance attributes. Drawing uses `LineSegments` and instanced billboards with the existing textures, colors, render order, and globe occlusion rules.
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Default cable color | `CABLE_COLORS.default` | `0xffff44` | Used when no data color available |

View File

@@ -144,6 +144,8 @@ The Web Earth client and UE client both consume `GET /api/v1/news/earth-feed`. T
- `categories`: comma-separated news category keys, for example `business,ecommerce`. Omit it when all categories are selected.
- `locale`: display locale, currently `zh-CN` or `en-US`, defaulting to `zh-CN`. Chinese RSS items are stored as Chinese source content and enriched with `en-US`; English RSS items are enriched with `zh-CN`.
For `locale=en-US`, the API must not fall back to Chinese source title/summary. If a Chinese source item has not yet received an `en-US` localization, `display_title` and `display_summary` stay empty so the Earth client can show an English pending/empty state instead of mixing languages.
Examples:
```http

View File

@@ -20,7 +20,7 @@ Note: the layer control panel order and the registration / startup load order ar
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. |
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
| 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. |
| 1 | Submarine cables / landing points | `cables.js`, `cable-batches.js` | All cable segments share one `LineSegments`; all landing points use instanced billboards; render order `1` and altitude offset `0.2` are unchanged | Original `Line` / `Sprite` objects retain per-item picking and selection state but no longer draw individually; landing-point sphere occlusion remains, with `depthTest: false` on the batch | Style textures and instance attributes preserve color, pulse, size, visibility, and click behavior. |
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
| 2.05 | Grid lines | `earth.js` | `CONFIG.earthRadius + 0.14` | Raycast disabled | Low-opacity lines over HD texture. |
| 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`; claim lines have no extra lift | Raycast disabled | Line geometry still has its own `renderOrder`, but it shares the exact same radius as the HD texture shell to avoid parallax while the globe rotates. |
@@ -36,6 +36,14 @@ Note: the layer control panel order and the registration / startup load order ar
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw a closed inertial orbit; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
| 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. |
## Full-set rendering and updates
- Batching changes GPU submission, not the number of satellites, cable segments, or landing points. It does not filter data by the visible hemisphere. Every adjacent cable vertex pair remains present, and picking still returns the original business object.
- Satellite foreground and backdrop remain two `Points` draws. Vertex shaders compute breathing from static per-point parameters and a shared frame-time uniform; existing hover/locked state still controls point masking.
- `satellite-position-worker.js` uses the same Three.js / SGP4 versions as the main thread. `satellite-propagation.js` owns shared orbit, display-altitude, and fallback calculations. Full positions and initial trail samples use transferable arrays; only one calculation is in flight. Reload, clear, and altitude-mode changes terminate the previous Worker.
- Worker startup failure or `SATELLITE_CONFIG.workerStartupTimeoutMs` expiry falls back to the shared synchronous calculation. Counts, APIs, the existing update interval, and trail length are unchanged.
- Verify full draw ranges, per-item picking, locked overlays, trails, rear-side occlusion, visibility toggles, and resource release after clearing. Frame-time comparisons require identical data, view, and resolution.
## Toggle Behavior
| Toggle | Behavior |

View File

@@ -98,6 +98,8 @@ Admin status labels should use [StatusText](/home/ray/dev/linkong/planet/fronten
`StatusText` is an indicator-light pill: the pill background and border stay on the component base color, while only the dot and text use the status color. `Badge` does not carry the indicator-light meaning, so it may use a light same-tone background and border for stronger hierarchy.
Status indicators must show the full state word. In lists, hierarchy groups, and detail headers, the title/description area should shrink or wrap while the status pill keeps content-sized width and does not get compressed by flex/grid layout; do not truncate state words such as `Configured` or `Available` just to save horizontal space.
| Tone | Color variable | Meaning | Examples |
| --- | --- | --- | --- |
| `success` | `--an-success` | available, successful, connected, enabled | log source `Available`, collection `Success` |
@@ -216,7 +218,31 @@ Current constraints:
- Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components
- Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement
### 6. `MarkdownRenderer`
### 6. Console i18n
Files:
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
Purpose:
- Share one `zh-CN` / `en-US` language state across the console, auth pages, and Docs UI
- Store the language preference in `planet-locale` while keeping compatibility with the old `docs-lang`
- Keep Docs API requests mapped to the backend's existing `zh` / `en` document interface
- Provide language switchers in the console sidebar preferences panel and auth panel
Current constraints:
- New console copy should be added to `resources.ts`, then consumed with `useTranslation()` or `useLocale()`
- Routes, menus, search indexes, and shared components must use explicit translation keys
- `LegacyI18nBridge` is transitional and only handles exact static text and attributes inside admin/auth containers
- Business data, raw logs, API field names, provider ids, commands, and Markdown body content are not translated by the legacy bridge
- Future large-page migrations should shrink the legacy dictionary rather than grow it
### 7. `MarkdownRenderer`
File:
@@ -361,6 +387,7 @@ Current boundary:
`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx), but its ownership is separate from System Settings:
- `TV Livestream` owns the Earth media-panel source configuration.
- `Branding` owns Earth HUD brand assets. `Logo URL` and `Title Image URL` use inline upload controls inside the fields; the upload buttons keep the primary `TactileButton` style, and dropping an image onto the matching field shows a low-saturation drag reaction instead of the old global asset-picker toolbar.
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.

View File

@@ -123,6 +123,9 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
### AISStream Realtime Vessels
Once the vessel layer is open, subsequent AISStream and BarentsWatch positions update automatically. Selected vessels stay selected during updates, and reconnecting automatically reconciles the display without repeatedly toggling the layer.
`AISStream Realtime Vessels` is the global AIS WebSocket collector. A passing connection test only confirms API key + endpoint format. Actual global vessel data requires the backend `aisstream_vessels` collector to stay connected and write to `ais_raw_observations`.
Steps:
@@ -162,6 +165,8 @@ Providers and models accept presets or arbitrary custom IDs. Common fields:
The plug icon at the end of the Base URL input runs a connection test. A passing test echoes the model's short reply.
Use the refresh icon at the top right to update the available models. A successful refresh saves the catalog for later visits; catalogs with release dates show newer models first. Refresh preserves the current model, credentials, URLs, and unsaved edits. Select a model and click Save to change the model used by the application. A failed refresh displays an error and keeps the previous catalog.
### Tools
- **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out
@@ -192,7 +197,7 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. Uploaded images are saved as Earth brand assets and read by the Earth page immediately.
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. The `Logo URL` and `Title Image URL` fields each include their own Upload button, and image files can be dropped directly onto the matching field. After upload, the field receives the new asset URL; save the brand configuration to make the Earth page use it.
- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata.
- **TV Livestream**: manages sources shown in the Earth media panel.
- **News Content**: browses news grouped by RSS source and manual group. RSS items remain read-only; manual groups support create, JSON import, edit, delete, and reprocess.
@@ -282,6 +287,10 @@ AIS vessel legend colors by type: cargo, tanker, passenger, fishing, military, m
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
### Choosing a News Live Stream
Open the live tab in the media panel and click the current channel to open the search menu. Search covers the complete catalog. The list loads 50 channels at a time and appends another page when you scroll to the bottom. A fixed footer shows the loaded and matching channel counts; a failed request can be retried below the list. The default source is Al Jazeera Mubasher, using its HLS playback URL. Other channels remain subject to availability of their playback service.
### Coordinate Candidate Collection
Compute center and BGP observer detail cards support automatic coordinate-candidate collection. Click the object then use "Collect Coordinate Candidates" or "Re-collect Coordinates". The backend assembles candidates from source coordinates, public-org registry APIs, and online geocoders. When regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback. BGP observers' stored coordinates only fill query context; they are not returned as candidates.
@@ -298,6 +307,8 @@ Recommended single-object flow:
Adopt All is for batch processing the compute-center unresolved queue. It starts from the top and adopts the highest-confidence candidate. Records without factual support remain in the queue. When WebSearch is disabled, single locate and Adopt All are disabled because location validation depends on factual lookup.
The batch continues within the current Earth page when you close the candidate panel, inspect another object, or switch browser tabs. Reopening the queue restores progress and results. Saved entries are not collected again, and a ✓ entry beside the layer remains available after completion. Refreshing, closing, or navigating away from Earth interrupts unfinished work; coordinates already saved remain stored.
### Settings
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only / recognized-gesture whitelist, shortcut enablement and remapping, default globe size, terrain opacity, reset.

View File

@@ -28,6 +28,9 @@ This document standardizes terms used across Intelligent Planet, the console, ba
| AI Provider | AI Provider | Service name |
| tool | 工具 | Web Search, OCR, and similar integrations |
| Playground | Playground | Interactive debugging entry |
| branding | 品牌标识 | Earth HUD brand configuration section under `/earth-content` |
| brand assets | 品牌资源 | Logo, title image, and related HUD copy assets |
| title image | 标题图 | Earth HUD title image |
## Data Types

View File

@@ -2,6 +2,10 @@
## Background
Use `zsh ./planet.sh start --non-motion-agent` for daily startup; use `init` when preparing a new environment. When investigating latency, distinguish initial dependency downloads, container readiness, and application initialization using the stage timestamps.
Before preparing the AI Provider image, startup verifies the backend's actual database connection and recreates a missing port mapping once while preserving the volume. A terminated backend process or Uvicorn initialization, ASGI loading, import, or syntax failure stops waiting and identical retries immediately. Normally slow initialization keeps its existing timeout budget. AI Provider readiness probes the host `/health` endpoint directly, without waiting for Docker's first scheduled health check.
`planet.sh` manages start, stop, restart, health checks, and logs for all local services. The previous implementation had several startup issues:
1. AI Provider rebuilt every time, even when code had not changed.
@@ -37,27 +41,9 @@ write_ai_provider_build_stamp() {
}
```
### Faster Fingerprint
### Fingerprint Scope
The previous implementation tarred the whole `aiprovider/` directory before hashing, which could take seconds in large trees. The new version uses `find + stat` and reads only file metadata:
```bash
compute_ai_provider_build_fingerprint() {
find aiprovider \
-type f \
! -path '*/__pycache__/*' \
! -name '.env' \
! -name '.env.*' \
! -name '*.pyc' \
! -name '*.pyo' \
| LC_ALL=C sort \
| xargs -r stat --format="%Y %s %n" 2>/dev/null
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
}
```
This is roughly 10 times faster for many-small-file workloads while preserving the same practical rebuild signal. `.env` and `.env.*` are excluded because runtime model, key, and Base URL changes should not force an image rebuild.
The fingerprint hashes file contents from `aiprovider/`, the Dockerfile, the root manifest and lockfile, and the provider dependency information. It does not traverse frontend assets or downloaded data. `.env` and `.env.*` are excluded because they are runtime configuration. The Dockerfile applies its fingerprint label after dependency installation so a changed build marker alone does not invalidate dependency layers.
### Docker Build Context
@@ -83,13 +69,15 @@ The Dockerfile copies only AI Provider inputs:
```dockerfile
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
uv sync --frozen --only-group aiprovider
COPY aiprovider /app/aiprovider
```
`uv sync` uses a BuildKit cache mount. The first build may still depend on network speed, but later builds reuse `/root/.cache/uv`.
The `aiprovider` dependency group in the root `pyproject.toml` uses the same `uv.lock` and installs only the API, HTTP client, settings, and ASGI runtime dependencies. The image excludes backend collectors and OpenCV / MediaPipe motion dependencies. The build fingerprint label comes after dependency installation and code copying, so a fingerprint change alone does not invalidate dependency layers. The container starts the installed `.venv/bin/python` directly, without runtime dependency synchronization. Dependency changes must update this group and the lockfile and validate image imports and `/health`.
### Runtime Configuration
Before starting AI Provider, `planet.sh` generates a current-user runtime env-file and passes it to Compose or the manual `docker run` fallback. The default path is `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`. Configuration priority:
@@ -117,7 +105,7 @@ When the fingerprint matches, the script skips `docker compose build` and starts
docker start planet_aiprovider
```
`docker stop` stops the container without deleting the image. `cleanup_exit_containers` removes exited containers but not images, so the next `docker start` can reuse the existing image.
`docker stop` stops the container without deleting the image. `start` and `restart` retain stopped containers instead of scanning and deleting all exited containers on the host. An unchanged AI Provider can be reused, while Compose still reconciles database configuration. Existing recreation paths remain responsible for image or configuration changes.
## Issue 2: Slow Port Cleanup

View File

@@ -2,6 +2,48 @@
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
## Docker Initialization and Access
Initialize a new machine before starting the application services:
```bash
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
```
The entry point still requires `zsh`, `curl`, and reachable package repositories. Before synchronizing Python and frontend dependencies, `init` prepares Docker:
- Reuse working Docker, Compose v2, and Buildx (at least 0.17.0).
- On Ubuntu / Ubuntu WSL, use apt to install the missing parts of `docker.io`, `docker-compose-v2`, and `docker-buildx`. When Docker CE CLI is already installed, use the configured Docker CE repository and corresponding plugin packages to keep the package family consistent.
- If the local daemon is unavailable, check that `docker.service` exists, then enable and start it. WSL must have systemd enabled; unavailable service management produces an explicit Docker preparation error.
- If the current user cannot read and write the Docker socket, check for `usermod`, install its `passwd` package when needed, and add the user to the `docker` group. This group grants privileged control of the local Docker engine. The script uses `sudo` to refresh group access as the original user and continue the original command with its arguments preserved. It does not depend on `sg` or run application processes as root.
When elevation is needed, sudo authentication runs in the foreground. Missing sudo for an unprivileged user, authentication failure, repository errors, or insufficient versions after installation stop initialization with a specific error.
Subsequent `planet.sh start` and other service commands in the same old terminal also refresh Docker group membership when it has been granted but is not yet active. Open a new Ubuntu session to use `docker` directly in the terminal.
When Docker Desktop is present but its WSL integration is unavailable, the script asks the operator to start Desktop and enable WSL Integration for the distribution. Unreachable remote or rootless endpoints produce a diagnostic for that environment; neither case installs a second local engine automatically. Automatic installation on other operating systems is not currently supported.
`planet.sh` calls `scripts/lib/docker-bootstrap.zsh` for preparation. Missing CLI, missing service units, socket permissions, and stopped daemons receive separate diagnostics. Advice to start `docker.socket` is shown only after confirming that the unit exists. Verify the result with:
```bash
docker info
docker compose version
docker buildx version
```
## Database Initialization and Connection Checks
`init` and `start` reconcile PostgreSQL / Redis containers through Compose, including port configuration on existing containers. A plain `docker start` cannot apply configuration changes. Compose failures retain their specific errors, such as an occupied port, instead of falling back to an old container and reporting success.
The container's `pg_isready` check only establishes that the server accepts connections; it does not validate the host backend's address and credentials. Once containers are healthy, both initialization and backend startup run `scripts/check_database_connection.py` using the backend's effective `DATABASE_URL`. It checks the local PostgreSQL published port and executes a read-only `SELECT 1`. Startup performs this check before preparing the AI Provider image and stops immediately on failure. Initialization only creates tables and seed data after the check passes.
- If the actual local port mapping is still missing or mismatched, the script recreates PostgreSQL once from Compose while preserving its data volume, then checks again. A second failure stops initialization.
- Authentication, database-name, and network failures stop before schema changes. Diagnostics show the host, port, and database name without passwords, full connection strings, or raw driver exceptions.
- A process-level `DATABASE_URL` overrides `backend/.env`. Changing `POSTGRES_PASSWORD` alone updates neither the connection string nor the password stored in an existing data volume. Existing environment files are retained and their effective configuration must be checked.
- Explicit external databases do not require a local container mapping. Host networking also does not require published ports. Both still require the real connection check.
For `port is already allocated` or `address already in use`, inspect `docker ps` port information and `ss -ltnp '( sport = :5432 )'`. With WSL mirrored networking, also inspect Windows listeners. Initialization does not kill other database services to acquire a port, delete data volumes, or reset passwords.
## First Startup
```bash

View File

@@ -33,10 +33,11 @@ The default role is `viewer`: you can sign in but only see public pages. For col
After landing on the `/admin` dashboard, here's a recommended walk-through:
1. `/collection-management?section=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
4. `/alerts/system`: verify system alerts look right
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. The refresh icon updates and saves the available model catalog while preserving your form; select a model and click Save to apply it. WebSearch / OCR tools are optional
3. `/earth-content?section=brand`: maintain the Earth HUD logo and title image in Branding. The Upload button inside each URL field opens a file picker, and image files can also be dropped directly onto the matching field. Save the brand configuration after upload
4. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
5. `/alerts/system`: verify system alerts look right
6. `/users` (super_admin only): open accounts for teammates or adjust their groups
## 4. Open Earth
@@ -49,6 +50,9 @@ Once in, verify:
- The globe renders, and the right-side layer panel can toggle layers
- Search finds cables, satellites, compute centers, BGP events
- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth
- Close the candidate panel or switch browser tabs during batch location, then return to see progress; refreshing or leaving Earth interrupts unfinished work
- The vessel layer receives ongoing AISStream / BarentsWatch positions and reconciles after reconnecting
- The live-stream menu searches the complete channel catalog and loads more on scroll; Al Jazeera Mubasher is the default
- Mouse drag, wheel zoom, and the zoom percentage indicator work
- The settings panel can switch rotate / cruise / motion modes; motion settings can select the input source and allowed gestures; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display

View File

@@ -95,12 +95,15 @@ AI 配置页使用的接口:
- `POST /api/v1/settings/integrations/ai-provider/connect`
- `GET /api/v1/settings/integrations/ai-provider/secrets`
- `GET /api/v1/settings/integrations/ai-provider/presets`
- `POST /api/v1/settings/integrations/ai-provider/presets/{provider}/refresh`
- `GET /api/v1/settings/ai-prompts`
- `PUT /api/v1/settings/ai-prompts/{task_key}`
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
模型目录刷新由 `backend/app/services/llm_provider_catalog.py` 获取上游目录;多数供应商读取 models.devOpenCode Go 使用自己的模型接口。models.dev 条目按发布日期倒序排列,缺少日期的条目排在后面。成功结果和 `refreshed_at` 按供应商保存在 `system_settings``llm_provider_preset:<provider>` 分类中,列表接口优先返回已保存目录,未刷新过的供应商使用内置预设。刷新只更新目录,不写入 `external_integrations` 中的当前模型、协议、地址或凭证。失败返回 502 并保留上次目录;上游异常原文不返回客户端。前端重新加载目录,可选模型读取目录状态,表单草稿独立保留。
Admin 的 AI 页面按业务信息架构组织为:
- `模型供应商`

View File

@@ -102,6 +102,12 @@ AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
### 新闻直播频道目录
`news_live_streams` 的 IPTV-org 适配器保留现有新闻分类筛选,`max_sources` 默认值为 `0`,表示不截断匹配频道;显式正数仍限制采集数量。已有配置若保留旧的 `120`,需要改为 `0` 并重新采集才能补齐目录。
`GET /api/v1/tv/streams``tv_catalog.py` 提供数据库分页:`offset` 默认 `0``limit` 默认 `50`、最大 `100``q` 按空白拆词并匹配频道名、来源、地区和语言。内置及配置源优先,采集源按频道标识去重并排除配置覆盖项,再在数据库中搜索、计数、排序和分页,避免全表读取后切片。响应的 `total` 是匹配总数,`source_count` 是完整可用目录数量;后续页使用 `next_offset``has_more``selected_id` 可额外取得不在当前页的已选频道,不占本页配额。默认和兜底源继续由 `tv_streams.py` 统一管理。
## 四、数据格式 (统一存储到 CollectedData 表)
```python
@@ -382,7 +388,11 @@ GET /api/v1/visualization/vessels/{mmsi}/conflicts
`/api/v1/vessels/snapshot` 必须携带 `bbox``zoom`,后端最大 `limit=5000`。Earth 前端使用全球 bbox 读取当前状态,不随相机视口变化反复请求。接口消费 `vessel_current_state`,并在 `diagnostics.source` 返回 `vessel_current_state`;旧 `/api/v1/visualization/geo/vessels` 路由已移除。
高频 AIS 更新不要直接推送成每条 delta 的整层重建。`/ws``vessels` channel 如用于 Earth应广播低频 reload/dirty 提示,由前端合并刷新 snapshot轨迹和冲突详情仍按单船接口读取历史事实
AISStream 与 BarentsWatch 共用 `/ws``vessels` 增量通道。Earth 使用现有 WebSocket 连接订阅 `scope: "global"`,不跟随镜头改变订阅范围。后端按 MMSI 合并一秒内的通知,再读取 `vessel_current_state` 的已确认状态;单帧最多 1000 项,超过时继续发送后续帧,不截断不同船只。原始源消息不能直接覆盖客户端的确认位置。当前状态删除也会产生按 MMSI 的 remove 通知
前端将短时间内同一 MMSI 的变更合并为最新值,通过 `Interactable.updateItems()` 原位修改位置和颜色缓冲。航向分桶变更只更新受影响的桶,容量不足才扩容;保留已有 marker 身份、锁定状态和材质。首次进入、重连、删除提示及每分钟巡检仍用 snapshot 校准,但不先清空整个图层。收到有数量上限的快照时,不能把被截断的船只当成删除;快照返回期间到达的较新更新和删除也不能被旧响应覆盖。快照携带查询开始时的 `generated_at`,与实时帧时间一起用于识别缓存旧快照,避免新船或删除状态被回滚。
`earth_updates` 中船舶普通写入的策略是 `delta`;专用通道连通时不再触发整层重拉。删除或断连后的校准使用 `reload`,并复用现有对象。关闭图层会取消船舶订阅并清空待应用变更。轨迹、冲突详情和审计继续按单船接口读取历史事实。
### 图层接口与全量统计分离

View File

@@ -70,7 +70,10 @@ DB 变化不再默认创建 `earth_refresh` 任务,因此不会被同 source
| --- | --- |
| `clear_then_reload` | 先清前端本地图层对象,再强制重拉接口。删除数据时优先使用。 |
| `reload` | 保留旧对象直到新数据返回,适合定位、元数据或非破坏性更新。 |
| `delta` | 只用于 `earth_interactables`按 id upsertremove。 |
| `delta` | `earth_interactables` 按 id upsert/remove;船舶普通写入由专用 `vessels` 通道按 MMSI 更新确认状态,不触发整层清空。 |
船舶删除仍发出 `reload` 校准提示,`vessel_current_state` 的逐项删除同时进入船舶 remove 通道。源通知只决定哪些 MMSI 需要更新,推送值以当前状态表为准。全局订阅使用 `scope: "global"`;消息大小上限用于拆包,不用于丢弃其余船只。
接口在真实 0 数据时必须返回 200 和空集合;只有真实接口异常才返回 5xx。前端收到删除事件后如果重拉失败应保持已清空状态并显示轻量错误不恢复旧对象。

View File

@@ -323,7 +323,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000
```
该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox不随当前镜头视口反复请求。实时更新如接入 `/ws``vessels` channel应作为 reload/dirty 提示触发合并刷新,不能把每条 AIS delta 直接变成整层重建
该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox不随当前镜头视口反复请求。实时更新使用 `/ws``vessels` 全局订阅,按 MMSI 推送确认状态并原位更新;超出单帧上限时拆包,保留所有更新。首次加载、重连和删除校准仍使用快照,详见[采集器架构](backend-collectors.md)
## 自定义 REST / WebSocket 映射运行时

View File

@@ -56,7 +56,7 @@ React 路由入口:
- 各图层集成
- Earth 级别状态同步
Earth 收到 `/ws``earth_updates` 时只把它当作刷新提示,真实数据仍通过 `/api/v1/visualization/...` 接口重新 GET。数据库驱动的刷新由后端 listener 直接清理缓存再广播,不再默认经过 `earth_refresh` 作业队列;前端收到 `database_changed` 后会按 layer 读取 `clear_then_reload``reload``delta` 策略。`clear_then_reload` 必须先清 Three.js 对象再 no-store 重拉summary 只做一致性校验,不能用 `0` 作为跳过图层重拉的理由。技术链路见 [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md),业务数据流见 [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md)。
Earth 收到 `/ws``earth_updates` 时只把它当作刷新提示,普通图层仍通过 `/api/v1/visualization/...` 接口重新 GET;船舶使用下文的 `vessels` 专用确认状态增量通道。数据库驱动的刷新由后端 listener 直接清理缓存再广播,不再默认经过 `earth_refresh` 作业队列;前端收到 `database_changed` 后会按 layer 读取 `clear_then_reload``reload``delta` 策略。`clear_then_reload` 必须先清 Three.js 对象再 no-store 重拉summary 只做一致性校验,不能用 `0` 作为跳过图层重拉的理由。技术链路见 [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md),业务数据流见 [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md)。
### 3. 地球控制层
@@ -75,7 +75,11 @@ Earth 收到 `/ws` 的 `earth_updates` 时只把它当作刷新提示,真实
Earth 设置面板现在按 `data-settings-tab``data-settings-tab-panel` 分类组织。桌面端和移动端使用同一组分类语义:运行、显示、面板、动捕、快捷键、系统。新增设置项时应先判断它属于哪个分类,再补 DOM、持久化字段和恢复逻辑不要把所有控件继续堆到一个长面板里。
`显示` 分类里的新闻类型选择复用巡航模块的 chip 选择器形态只控制星球端当前浏览器的新闻分类显示。它不会打开或关闭图层、底图、边界、TV、数据点、BGP、船舶、卫星或算力中心这些仍由图层面板、媒体面板和控制台配置各自负责。`controls.js` 只持久化 `shared.newsCategoryFilters` 并广播 `earth:news-category-filters-change``news.js` 会把选中的类型拼到 `/api/v1/news/earth-feed?categories=...&locale=zh-CN`,让 Web 和 UE 走同一套后端类型过滤
Earth 运行在独立 iframe / 静态应用里,不能直接复用 React Admin 的 `react-i18next` 上下文。`public/earth` 自己通过 [i18n.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/i18n.js) 读取并写回全局 `planet-locale`,同时同步旧的 `docs-lang`这样 Docs、控制台和 Earth 的语言偏好保持一致。语言切换控件属于设置里的 `系统` tab桌面和移动端都使用同一组 `data-earth-locale` 按钮;不要把语言选择塞进图层、显示或运行模式设置里
`显示` 分类里的新闻类型选择复用巡航模块的 chip 选择器形态只控制星球端当前浏览器的新闻分类显示。它不会打开或关闭图层、底图、边界、TV、数据点、BGP、船舶、卫星或算力中心这些仍由图层面板、媒体面板和控制台配置各自负责。`controls.js` 只持久化 `shared.newsCategoryFilters` 并广播 `earth:news-category-filters-change``news.js` 会把选中的类型和当前 locale 拼到 `/api/v1/news/earth-feed?categories=...&locale=...`,让 Web 和 UE 走同一套后端类型过滤。
英文模式下Earth 新闻只能渲染英文标题和摘要。中文来源新闻如果还没有 `en-US` 本地化,会先从可见卡片、滚动条和巡航中隐藏,直到后端增强完成;来源和 feed 标签也必须回退到英文安全名称,避免英文界面混入中文标签。
新闻面板、滚动条和新闻巡航必须消费同一次 `/api/v1/news/earth-feed` 响应里的 `items` / `cruise_items`,不能各自缓存区域状态。`news.js` 的刷新请求以区域、类型、来源和数量生成 request key只有 key 相同的并发请求才复用 promise旧区域请求返回时会被 token 丢弃。来源过滤也必须按区域作用域处理:当用户从亚太切到欧洲等其它区域时,旧区域保存的来源 ID 不允许继续拼到下一次 fetch 里;拿到新 payload 后再与 `sources` 列表做交集,若没有交集则回退到当前区域所有可用来源。这样滚动条、面板和巡航才会在区域切换后展示同一批新闻。
@@ -166,6 +170,8 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
`tv.js` 管理 `media-panel` 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 `earth:tv-visibility-change``earth:tv-tab-change` 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 `views.<scope>.panelVisibility.media-panel`,当前 tab 存在 `shared.mediaPanelActiveTab`,因此刷新页面后能恢复用户上次打开的直播或新闻状态。`closeTransientMobileOverlays()` 这类临时收起会带 `persist:false`,不会覆盖用户偏好。
`tv-source-menu.js` 复用 HUD 面板与图例列表样式,使用 popover 显示搜索、滚动列表和固定计数栏。`tv.js` 按 50 条请求 `/api/v1/tv/streams`,维护当前频道缓存;搜索交给后端完整目录,翻页用 `next_offset`。菜单通过取消请求和请求代数屏蔽过期响应,不能让旧搜索覆盖新输入。刷新目录时通过 `selected_id` 恢复不在第一页的频道。
`brand.js` 管理智能星球 HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的智能星球内容页负责保存和重置品牌配置,智能星球前端只消费结果。
`about.js` 管理智能星球设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值避免设置页出现空白。控制台的智能星球内容页提供“关于”tab保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`
@@ -205,6 +211,14 @@ TV 预览需要尽量复用 Earth 运行时的直播卡片结构和状态标签
新闻巡航摘要的未来计划保存在仓库路径 `docs/plans/earth-news-cruise-summary-plan.md`,不作为公开 Docs 页面入口。
## 渲染热路径
`cable-batches.js` 将全部海缆线段与全部登陆点分别合批绘制;`cables.js` 继续拥有原始业务对象、拾取、遮挡和选择状态。避免在后续功能中把这些代理对象重新加入逐对象绘制,或把绘制批次当作业务对象返回给详情卡。
卫星的逐点呼吸在 GPU 中计算。全量 SGP4 位置和初始轨迹计算通过 `satellite-position-worker.js` 离开主线程;主线程只接收位置快照、更新共享交互坐标及绘制缓冲。轨道数学由 `satellite-propagation.js` 同时供 Worker、预测轨道和同步降级路径使用避免多套公式漂移。数据重载、清空和高度切换必须同步终止旧计算不能让旧快照恢复已清空的图层。
`i18n.js` 的 MutationObserver 只翻译新增子树、变动文本或变动属性。局部时钟、状态和下载进度更新不能触发整页语言控件同步;全局语言同步只属于初始化、语言切换或包含新语言控件的子树。
## 当前样式分层
Earth 的 CSS 不是一份大样式表,而是分层管理:
@@ -340,7 +354,13 @@ AIS 船只图层入口:
AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment不能在前端凭颜色之外的信息臆造细分类。
`/api/v1/visualization/geo/vessels` 路由已移除。前端打开船只图层时只应拉取一次全局 `/api/v1/vessels/snapshot`API 参数里的 bbox 是后端接口约束Earth 运行时传全球范围,不表示当前镜头视口。`/ws``vessels` channel 如启用,只作为低频 reload/dirty 提示,不能把每条 AIS delta 直接变成整层重建。后端通过 `diagnostics.source == "vessel_current_state"` 暴露当前状态链路
`/api/v1/visualization/geo/vessels` 路由已移除;初始数据仍通过全球 bbox 的 `/api/v1/vessels/snapshot` 读取
AISStream 与 BarentsWatch 共用 `/ws``vessels` 增量通道。Earth 使用现有 WebSocket 连接订阅 `scope: "global"`,不跟随镜头改变订阅范围。后端按 MMSI 合并一秒内的通知,再读取 `vessel_current_state` 的已确认状态;单帧最多 1000 项,超过时继续发送后续帧,不截断不同船只。原始源消息不能直接覆盖客户端的确认位置。当前状态删除也会产生按 MMSI 的 remove 通知。
前端将短时间内同一 MMSI 的变更合并为最新值,通过 `Interactable.updateItems()` 原位修改位置和颜色缓冲。航向分桶变更只更新受影响的桶,容量不足才扩容;保留已有 marker 身份、锁定状态和材质。首次进入、重连、删除提示及每分钟巡检仍用 snapshot 校准,但不先清空整个图层。收到有数量上限的快照时,不能把被截断的船只当成删除;快照返回期间到达的较新更新和删除也不能被旧响应覆盖。快照携带查询开始时的 `generated_at`,与实时帧时间一起用于识别缓存旧快照,避免新船或删除状态被回滚。
`earth_updates` 中船舶普通写入的策略是 `delta`;专用通道连通时不再触发整层重拉。删除或断连后的校准使用 `reload`,并复用现有对象。关闭图层会取消船舶订阅并清空待应用变更。轨迹、冲突详情和审计继续按单船接口读取历史事实。
新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox``zoom` 和受控 `limit`,响应会返回 `visible_count``returned_count``diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products``/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。
@@ -376,6 +396,8 @@ AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但
详情卡里的坐标候选状态由 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 按 `entityType:entityId` 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;`一键采用` 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态避免旧候选在刷新后继续误导用户。
批量定位由 `runUnresolvedComputeCenterBatch()` 持有实体上下文队列,不持有面板 DOM`locationCollectStateCache` 保存候选、进度和保存结果,重建卡片时重新 hydrate。`earth:compute-center-location-batch-change` 同步任务状态与气泡入口,成功条目根据 `savedCandidate` 排除,全部完成后保留 ✓ 入口。状态只存在当前 Earth 页面内存中,刷新、关闭或路由离开会终止未完成队列;已保存坐标仍以后端为准。
候选行的 `预览 / 保存` 按钮采用单一的事件委托模型:每个候选根(详情卡里的 `[data-collect-cache-key]` 块,或待定位列表里的 `[data-unresolved-item]`)只挂一个 `click` 监听,由 `data-candidate-actions-bound` 幂等标记,不再混用 `pointerup` / `click` 直绑或重复委托。候选对象不再以 JSON 字符串塞进 HTML 属性后再 `JSON.parse`,按钮只携带 `data-candidate-index`handler 通过 cache-key 在模块内存的 `Map` 里取出原对象,避开 HTML 实体转义对 `&` / `<` / `"` 的破坏。点击 `预览` 会派发 `earth:preview-location-candidate`,由 `main.js``previewLocationCandidate()` 调用 `showComputeCenterLocationPreview()`:在候选经纬度上挂双层空心呼吸 sprite视觉参考 BGP 事件 ring并把视角聚焦到候选坐标切换到另一个候选会替换为新呼吸圈保存时立即清除并由 `spawnSavedComputeCenterLocation()` 即时生成正式算力中心交互图标。注意 `main.js` 没有模块级 `earth` 变量,所有 location-save / preview 处理函数必须先 `const earth = getEarth();`,否则会在事件 handler 里抛 `ReferenceError``.catch` 静默掉,外观上等同于按钮“没有反应”。
`earth:compute-center-location-saved` 之后的图层校准链路对后台刷新失败保持沉默:`spawnComputeCenterAfterLocationSave()` 已经把 toast 和 locked 状态都给了用户,`refreshComputeCentersAfterLocationSave()` 只在场景就绪时重新拉取后端数据,本身不再吐 `已保存` toast`handleComputeCenterLocationSaved()` 在 spawn 成功路径让 refresh 静默后台运行,只在 spawn 返回 `null`(场景未就绪)或抛错时才让 refresh 接管成功 toastrefresh 自身报错只走 `console.warn`,绝不冒泡成 `保存失败` 文案——保存请求本身已经成功,刷新失败属于后续同步问题。
@@ -388,7 +410,7 @@ asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文
跨 Interactable 的同坐标关系也在公共层记录,但真实位置必须始终以 `icon_base_position` 为准。缩放、避让、聚合和后续 spiderfy 展开都只能改变屏幕表现,不能写回 `marker.position``THREE.Points` 里的业务锚点;巡航定位、详情卡、搜索定位和 picking 返回对象都必须落回真实经纬度。多个图标归入同一个经纬度 key 时,公共层只写 `icon_avoidance_*` 元数据,供业务层弱化 halo 或显示聚合提示;真正的低缩放聚合应通过独立 cluster glyph / screen layout 层实现,而不是把对象沿地表切平面挪开。
`Interactable` 的单点显示只由全局地图缩放决定170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 现在由 `cluster.strategy` 决定:`stable-spherical` 使用离散 zoom band 和 3D 球面分桶BGP、算力中心和 Earth interactable 在同一 band 内旋转或细微缩放时不会重新计算聚合拓扑;`dynamic-screen` 保留屏幕空间聚类,适合船只这类实时高频图层`none` 关闭聚类。稳定球面聚类的 cluster 圆点刚性落在成员 3D 质心投影上,不参与 2D 避让避免缩放时被推离真实地理位置。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points不改变每个 marker 的真实经纬度。
`Interactable` 的单点显示只由全局地图缩放决定170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 现在由 `cluster.strategy` 决定:`stable-spherical` 使用离散 zoom band 和 3D 球面分桶BGP、算力中心和 Earth interactable 在同一 band 内旋转或细微缩放时不会重新计算聚合拓扑;`dynamic-screen` 保留屏幕空间聚类;`none` 关闭聚类。船只显式关闭聚类与避让,保证增量更新时不重建聚类拓扑。稳定球面聚类的 cluster 圆点刚性落在成员 3D 质心投影上,不参与 2D 避让避免缩放时被推离真实地理位置。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points不改变每个 marker 的真实经纬度。
接口细节、生命周期和接入示例见:
@@ -457,6 +479,8 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
也就是说Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`
语言偏好例外Earth 语言不是 `planet.earth.settings.v2` 的私有字段,而是与 Docs/Admin 共用 `planet-locale`。切换语言时,`i18n.js` 会更新 `document.documentElement.lang`、翻译静态和动态插入的 DOM、同步语言按钮状态并向新闻请求传递当前 locale新闻模块在语言变化后会重新刷新避免英文界面复用中文新闻 payload。
地表 hover 提示由 `controls.js` 持久化为 `shared.surfaceHoverInfoMode`,实际 tooltip 在 `main.js` 的地表 hover 分支组合。`国家` 模式只在命中国家时显示国家信息,海洋区域不显示地表 tooltip`位置` 模式只显示纬度、经度和地形采样海拔,并清除国家边界 hover`完整` 模式在陆地显示国家 + 位置,在海洋显示位置。
卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。`maxRealAltitudeOffset = 25` 是当前相机和 `earthRadius = 100` 下的视觉上限:它让 GEO / MEO 比 LEO 明显更高,但把最高轨道控制在地球半径外约 25%,避免选择点、红色轨迹和主体地球之间出现过大的空场。

View File

@@ -149,6 +149,8 @@
## 海缆与登陆点
`cable-batches.js` 将海缆线与登陆点分别合批绘制。下表的 Sprite 材质和尺寸仍属于 `cables.js` 中的拾取、选择代理;每帧把颜色、透明度、缩放和可见性同步到样式纹理或实例属性。实际绘制使用 `LineSegments` 和实例化 billboard沿用原纹理、色彩、层级与球体遮挡规则。
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |

View File

@@ -144,6 +144,8 @@ JSON 导入首版只支持数组:
- `categories`:逗号分隔的新闻类型 key例如 `business,ecommerce`。全选时可以不传。
- `locale`:展示语言,支持 `zh-CN``en-US`,默认 `zh-CN`。中文 RSS 会以中文原文入库,并由后台补 `en-US`;英文 RSS 则由后台补 `zh-CN`
`locale=en-US` 时,接口不能回退展示中文原文标题或摘要。中文来源新闻尚未生成 `en-US` 本地化时,`display_title``display_summary` 保持为空,由 Earth 前端展示英文待处理或空态,避免英文界面混入中文新闻内容。
示例:
```http

View File

@@ -21,7 +21,7 @@
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 |
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
| 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
| 1 | 海缆 / 登陆点 | `cables.js`, `cable-batches.js` | 海缆全量线段合并为一个 `LineSegments`;登陆点全量使用实例化 billboard`renderOrder = 1`半径偏移 `0.2` 保持不变 | 原始 `Line` / `Sprite` 仅作为逐项拾取和选择状态对象,材质不再单独绘制;登陆点仍按球体遮挡判断背面可见性,批量材质 `depthTest: false` | 样式通过线缆样式纹理和登陆点实例属性同步;保留颜色、脉冲、尺寸、显隐与点击语义。 |
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`claim 线不再额外抬高 | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制,但半径与高清材质壳完全一致,避免转动地球时与高清贴图出现视差。 |
@@ -37,6 +37,14 @@
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制闭合惯性轨道;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
## 全量绘制与更新约束
- 合批只改变 GPU 提交方式,不减少卫星、线段或登陆点数量,也不按相机半球裁剪数据。海缆每对相邻顶点都保留,拾取仍返回原始业务对象。
- 卫星普通点与背景点保持两个 `Points` 绘制呼吸动画由顶点着色器使用静态逐点参数和每帧统一时间计算。hover / locked 的隐藏标记仍由原有选择状态控制。
- `satellite-position-worker.js` 使用与主线程相同的 Three.js / SGP4 版本,通过 `satellite-propagation.js` 共享轨道、显示高度和 fallback 计算;全量位置及初始轨迹以可转移数组交给主线程。主线程维持一个在途计算,不堆积过期帧;重载、清空和高度模式变更会终止旧 Worker。
- Worker 启动失败或超过 `SATELLITE_CONFIG.workerStartupTimeoutMs` 时退回共享的同步计算,避免图层无限等待。计数、数据接口、原有更新周期和轨迹长度不变。
- 验证时同时检查全量 draw range、逐项拾取、锁定覆盖层、轨迹、背面遮挡、开关及清空后的资源释放比较帧耗时必须使用相同数据、相同视角和分辨率。
## 开关联动
| 开关 | 行为 |

View File

@@ -98,6 +98,8 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
`StatusText` 是带圆点的指示灯:胶囊背景和边框保持组件原色,只让圆点和文字变成状态色。`Badge` 不带指示灯语义,可以使用同 tone 的浅色背景和边框强化信息层级。
状态指示器必须完整显示状态词。列表、树形组和详情栏里的状态列应让标题/描述区域收缩或换行,状态 pill 本身使用内容自适应宽度并禁止被 flex/grid 挤压;不要为了紧凑把 `Configured` / `Available` 这类状态裁成省略号。
| Tone | 颜色变量 | 语义 | 示例 |
| --- | --- | --- | --- |
| `success` | `--an-success` | 可用、成功、已连接、已启用 | 日志源 `可用`、采集 `成功` |
@@ -216,7 +218,31 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
- 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色
- 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉
### 6. `MarkdownRenderer`
### 6. 控制台 i18n
文件:
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
用途:
- 控制台、认证页和 Docs UI 共用 `zh-CN` / `en-US` 语言状态
- 语言偏好保存在 `planet-locale`,同时兼容旧的 `docs-lang`
- Docs 请求仍映射到后端现有 `zh` / `en` 文档接口
- 控制台侧边栏偏好面板和认证页面板提供语言切换入口
当前约束:
- 新增控制台文案优先写入 `resources.ts`,组件使用 `useTranslation()``useLocale()`
- 路由、菜单、搜索索引和通用组件必须使用显式翻译 key
- `LegacyI18nBridge` 只作为过渡层,负责 admin/auth 容器内未迁移的精确静态文本和属性
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥
- 后续迁移大型业务页时应减少 legacy 字典,而不是继续扩大它
### 7. `MarkdownRenderer`
文件:
@@ -392,6 +418,7 @@ AI 配置不再挂在 `/settings` 下;`/playground` 应跳转到 `/ai?section=
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
- `品牌标识` 管理 Earth HUD 的品牌资源。`Logo 地址``标题图地址` 使用字段内上传控件,上传按钮保持 `TactileButton` 的 primary 样式;图片拖到对应字段时显示低饱和拖拽反应,避免回到旧的全局“选择资产/上传”工具栏。
- `国界精度` 管理 Earth 静态国界资产provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
- `地球底图``图层资源``三维素材``新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。

View File

@@ -126,6 +126,9 @@
### AISStream 实时船舶
在智能星球打开船只图层后AISStream 与 BarentsWatch 的后续位置会自动更新。更新时会保留已选中的船只;短暂断线重连后会自动校准,无需反复关闭、开启图层。
`AISStream 实时船舶` 是全球 AIS WebSocket 采集器。连接测试通过只说明 API Key 和 endpoint 格式可用;真正的全球船只数据来自后台 `aisstream_vessels` collector 长连接运行并写入 `ais_raw_observations`
操作步骤:
@@ -165,6 +168,8 @@ provider 和模型既可选预设也可直接输入自定义 id/name。常用字
Base URL 输入框尾端的插头图标会触发连接测试。测试通过会显示当前模型返回的简短回复。
点击右上角的刷新图标可更新“可选模型”。成功后目录会保存,重新打开页面仍可使用;有发布日期的目录按新到旧排列。刷新保留当前模型、密钥、地址和未保存的修改。点击一个可选模型后,再点“保存”才会改变实际使用的模型。刷新失败时会显示错误并保留上次目录。
### 工具
- **WebSearch**provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰
@@ -191,7 +196,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
`/earth-content` 位于控制台“运维与配置”下,面向智能星球前端体验资源:
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为智能星球品牌资产并立即供智能星球页面读取。
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述`Logo 地址``标题图地址` 字段内各有独立的“上传”按钮,也可以把图片直接拖到对应字段;上传成功后字段会写入新的资产地址,保存品牌配置后供智能星球页面读取。
- **关于**:维护智能星球设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
- **电视直播**:维护智能星球媒体面板里的直播源。
- **新闻内容**:按 RSS 来源和手动新闻组查看新闻。RSS 新闻保持只读;手动新闻组可以新增、批量导入 JSON、编辑、删除和重新处理。
@@ -281,6 +286,10 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。
### 新闻直播源选择
打开媒体面板的直播页,点击当前频道可展开搜索菜单。搜索会检索完整频道库;列表每次加载 50 个频道,滚动到底部自动追加。底部固定显示已加载数量和搜索结果总数,加载失败时可在列表下方重试。默认新闻直播源为 Al Jazeera Mubasher半岛电视台使用 HLS 播放地址;其他频道仍取决于各自的播放服务是否可用。
### 位置候选采集
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后用"自动采集坐标候选"或"重新自动采集坐标"按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选;常规来源没有候选时使用当前默认 AI Provider 做 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
@@ -297,6 +306,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
一键定位用于批量处理算力中心待定位队列。它会从列表顶部开始采用最高置信候选;仍没有事实依据的记录会保留在队列中。未开启 WebSearch 时,单个定位和一键定位会置灰,因为位置核验依赖事实查询。
在当前智能星球页面内,关闭候选面板、查看其他对象或切换浏览器标签页不会取消队列;返回候选列表后可继续查看进度和结果。已保存条目不会再次采集,全部完成后仍可通过图层旁的 ✓ 入口查看结果。刷新、关闭页面或跳转离开智能星球会中断尚未完成的队列,已经保存的坐标会保留。
### 设置
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼 / 识别动作白名单、快捷键启用与改键、地球默认大小、地形透明度、重置设置。

View File

@@ -28,6 +28,9 @@
| AI Provider | AI Provider | 服务名,保留英文 |
| tool | 工具 | Web Search、OCR 等工具配置 |
| Playground | Playground | 交互调试入口,保留英文 |
| branding | 品牌标识 | `/earth-content` 中的 Earth HUD 品牌配置分区 |
| brand assets | 品牌资源 | Logo、标题图和相关 HUD 文案资源 |
| title image | 标题图 | Earth HUD 标题图片,不写作“标题图片地址”以外的混合名 |
## 数据类型

View File

@@ -2,6 +2,10 @@
## 背景
日常启动使用 `zsh ./planet.sh start --non-motion-agent`;新环境首次准备才需要 `init`。排查耗时时,应区分首次依赖下载、容器就绪和应用初始化,结合阶段日志时间判断。
当前启动流程在准备 AI Provider 镜像前验证后端实际数据库连接,端口映射缺失时保留数据卷重建一次。后端进程退出,或 Uvicorn 日志出现应用初始化失败、ASGI 加载失败、导入或语法错误时会立即停止等待和重复启动正常的慢启动仍保留原有等待预算。AI Provider 直接探测宿主机 `/health`,无需再等待 Docker 周期性健康检查首次运行。
`planet.sh` 管理所有服务的启动/停止/重启。原有实现存在以下问题:
1. AI Provider 每次都重新构建(即使代码未变)
@@ -37,27 +41,9 @@ write_ai_provider_build_stamp() {
}
```
### fingerprint 计算提速
### fingerprint 检查范围
原实现对整个 `aiprovider/` 打 tar 包再算 SHA大目录下耗时可达数秒。改为 `find + stat`(只读文件元信息,不读内容):
```bash
compute_ai_provider_build_fingerprint() {
find aiprovider \
-type f \
! -path '*/__pycache__/*' \
! -name '.env' \
! -name '.env.*' \
! -name '*.pyc' \
! -name '*.pyo' \
| LC_ALL=C sort \
| xargs -r stat --format="%Y %s %n" 2>/dev/null
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
}
```
速度提升约 10 倍大量小文件场景误报率相同mtime+size 变化 ≡ 文件被修改)。
当前指纹使用内容 SHA覆盖 `aiprovider/`、Dockerfile、根依赖清单与锁文件以及代理服务相关依赖信息不会遍历前端资源或下载数据。`.env` 配置不参与镜像内容指纹。构建标记只用于判断是否需要构建Dockerfile 中的指纹标签位于依赖安装之后,以保留前置依赖层缓存。
`.env``.env.*` 被排除在 fingerprint 外。它们属于运行期配置,不应该因为修改模型、密钥或 Base URL 触发镜像重建。
@@ -85,13 +71,15 @@ Dockerfile 也从全仓复制改为只复制 AI Provider 代码:
```dockerfile
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
uv sync --frozen --only-group aiprovider
COPY aiprovider /app/aiprovider
```
`uv sync` 使用 BuildKit cache mount 后,首次构建仍可能受网络影响;后续构建会复用 `/root/.cache/uv`,依赖下载不再重复从零开始。
`aiprovider` 依赖组在根 `pyproject.toml` 定义,并由同一份 `uv.lock` 锁定,只安装 FastAPI、HTTP 客户端、配置读取和 ASGI 服务所需依赖。镜像不安装后端采集或 OpenCV / MediaPipe 动捕依赖。构建指纹标签放在依赖安装和代码复制之后,指纹改变不会单独使依赖层缓存失效。容器直接启动已安装的 `.venv/bin/python`,运行时不再执行 `uv sync`。修改代理服务的依赖时,应同步更新该依赖组和锁文件,并验证镜像导入及 `/health`
### 运行期配置来源
`planet.sh` 启动 AI Provider 前会生成受当前用户保护的运行期 env-file并把它传给 Compose 或手动 `docker run` fallback。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`。配置优先来自:
@@ -119,7 +107,7 @@ fingerprint 一致时不执行 `docker compose build`,而是:
docker start planet_aiprovider # 启动已存在的容器,几秒内完成
```
`docker stop` 停容器,不删镜像`cleanup_exit_containers` 删已退出容器,不删镜像。下次 `docker start` 会从现有镜像直接创建并启动容器
`docker stop` 停容器,不删镜像`start``restart` 保留已停止的容器,不再扫描删除全机已退出容器;未变化的 AI Provider 可直接复用,数据库仍由 Compose 同步配置。只有需要更新镜像或容器配置时才按原有流程重建
## 问题二:杀端口速度慢

View File

@@ -2,6 +2,48 @@
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
## Docker 初始化与访问权限
新机器应先执行初始化,再启动应用服务:
```bash
zsh ./planet.sh init --non-motion-agent && zsh ./planet.sh start --non-motion-agent
```
脚本入口仍需先安装 `zsh``curl`,并保证软件源可访问。`init` 在同步 Python 和前端依赖之前准备 Docker
- 已有可用的 Docker、Compose v2 和 Buildx至少 0.17.0)时直接复用。
- Ubuntu / Ubuntu WSL 缺少依赖时,通过 apt 安装 `docker.io``docker-compose-v2``docker-buildx` 中缺失的部分。若已安装 Docker CE CLI则使用已配置的 Docker CE 软件源和对应插件包,避免混用软件包系列。
- 本地 Docker daemon 未运行时,确认 `docker.service` 存在后启用并启动它。WSL 必须启用 systemd如果服务管理不可用脚本会在 Docker 准备阶段明确报错。
- 当前用户不能读写 Docker socket 时,检查并补装提供 `usermod``passwd` 包,将用户加入 `docker` 组。该组拥有管理本机 Docker 的高权限。脚本使用 `sudo` 以原用户身份刷新组权限并继续原命令,保留参数,不依赖 `sg`,也不会把应用进程改为 root 用户运行。
需要提权时,脚本会在前台请求 sudo 认证。普通用户缺少 sudo、认证失败、软件源不可用或安装后版本仍不满足要求时初始化会停止并报告具体原因。
同一旧终端随后执行 `planet.sh start` 等命令时,也会检测已加入但尚未生效的 Docker 组权限并刷新。若要在终端直接使用 `docker`,重新打开 Ubuntu 会话即可。
Docker Desktop 已存在但 WSL 集成不可用时,脚本提示启动 Desktop 并启用当前发行版的 WSL Integration。已有远程或 rootless endpoint 无法连接时,提示检查当前环境;这些情况不会自动安装另一套本地引擎。其他操作系统的自动安装暂未支持。
安装逻辑由 `planet.sh` 调用 `scripts/lib/docker-bootstrap.zsh`;缺少 CLI、没有服务单元、socket 权限不足和 daemon 未启动会分别诊断。仅在确认 `docker.socket` 单元存在时才给出启动该单元的建议。验证准备结果可执行:
```bash
docker info
docker compose version
docker buildx version
```
## 数据库初始化与连接检查
`init``start` 会先通过 Compose 同步 PostgreSQL / Redis 容器配置,包括已有容器的端口映射;仅执行 `docker start` 无法应用配置变化。Compose 同步失败时会保留具体错误,例如端口被占用,不会继续复用旧容器并报告成功。
容器内部的 `pg_isready` 只检查服务是否接受连接,不能证明宿主机上的后端使用正确地址和密码。容器健康后,`init` 和后端启动流程通过 `scripts/check_database_connection.py` 读取与后端相同的有效 `DATABASE_URL`,检查本地 PostgreSQL 的实际发布端口并执行只读 `SELECT 1`。启动流程在准备 AI Provider 镜像之前完成此检查;失败会立即停止。`init` 通过检查后才创建表和默认数据。
- 如果本地实际端口映射仍缺失或不匹配,脚本会保留数据卷,按 Compose 配置重建一次 PostgreSQL 并重新检查;再次失败就停止。
- 认证、库名或网络错误会在建表前停止,诊断只显示目标主机、端口和库名,不输出密码、完整连接串或驱动异常原文。
- 进程环境变量中的 `DATABASE_URL` 优先于 `backend/.env`。单独修改 `POSTGRES_PASSWORD` 不会自动更新连接串,也不会改变已有数据卷内的密码。已有环境文件会保留,需要核对其有效配置。
- 显式配置的外部数据库不要求本地容器端口匹配host 网络模式也不要求发布端口,两者仍须通过实际连接检查。
出现 `port is already allocated``address already in use` 时,检查 `docker ps` 的端口信息和 `ss -ltnp '( sport = :5432 )'`WSL 镜像网络下还需检查 Windows 侧监听。初始化不会为了占用数据库端口而自动结束其他数据库服务,也不会删除数据卷或重设密码。
## 首次启动
```bash

View File

@@ -33,10 +33,11 @@
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
1. `/collection-management?section=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
2. `/ai?section=integrations`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
2. `/ai?section=integrations`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。右上角刷新图标会更新并保存可选模型目录,同时保留当前表单;选中新模型后点“保存”生效。WebSearch / OCR 工具可选
3. `/earth-content?section=brand`:在“品牌标识”里维护智能星球的 Logo 和标题图;对应地址字段内的“上传”按钮支持选择文件,也支持把图片直接拖到字段上,保存后会应用到智能星球 HUD
4. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
5. `/alerts/system`:看系统告警是否正常
6. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
## 4. 打开智能星球
@@ -49,6 +50,9 @@
- 地球正常显示,右侧图层面板可以打开/关闭
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在智能星球上预览
- 一键定位期间关闭候选面板或切换浏览器标签页,再返回可查看进度;刷新或离开智能星球页面会中断未完成队列
- 船只图层持续接收 AISStream / BarentsWatch 位置,断线重连后自动校准
- 直播菜单可搜索完整频道库,滚到底部继续加载;默认频道为半岛电视台
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;动捕设置可以选择输入源和允许识别的动作;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示

View File

@@ -16,12 +16,18 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.72.0`
- `dev` 当前开发分支历史推导到:`0.74.4`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.74.4` | improvement | `dev` | `v0.74.4` | Earth 全量渲染与船舶增量更新优化,直播目录搜索和分页,定位队列状态恢复、模型目录保存及启动提速 |
| `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 |
| `0.74.2` | bugfix | `dev` | `pending` | 收敛 agent harness 到根规则和 Codex skills删除旧 Claude command 重复入口,并强化视觉证据路径解析与 OCR fallback 规则 |
| `0.74.1` | improvement | `dev` | `pending` | 将品牌标识上传收敛到 Logo/标题图字段内,新增字段级拖拽反馈和 Tactile UI primary 上传按钮,并同步中英文使用文档 |
| `0.74.0` | feature | `dev` | `pending` | 扩展统一 i18n 到 Web Earth 动态入口、控制台/API 错误和公开页面,修复 Earth 通知胶囊、语言 switch、品牌栏、legend、tooltip、新闻/TV 英文态裁切与中文残留,并加入 harness 回归覆盖 |
| `0.73.0` | feature | `dev` | `pending` | 新增前端统一 i18n、控制台语言/主题偏好入口、英文态 legacy 过渡翻译和 admin 一屏/状态指示器布局验证 |
| `0.72.0` | feature | `dev` | `pending` | 新增完整 agent harness、单一 AGENTS 入口、Earth News smoke 覆盖和 collector 结构化日志清理,并同步控制台/Earth/Docs 响应式维护文档 |
| `0.71.1` | bugfix | `dev` | `pending` | 修复 Earth 新闻区域切换、滚动条/面板/巡航一致性和新闻精修队列饿死问题,并补充 agent harness 与双语维护文档 |
| `0.71.0` | feature | `dev` | `pending` | Motion Agent 升级为 Web/UE 共用双向控制与真实识别服务,新增 Earth 手动新闻工作流、来源多样化,并完善启动/测试 harness 与双语文档 |

View File

@@ -21,6 +21,7 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.10",
"echarts": "^6.0.0",
"i18next": "26.3.3",
"lucide-react": "^1.16.0",
"mermaid": "^11.15.0",
"pbf": "^4.0.1",
@@ -29,6 +30,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.76.0",
"react-i18next": "17.0.8",
"react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1",
@@ -84,6 +86,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -556,6 +560,10 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
@@ -632,6 +640,8 @@
"react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="],
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -702,6 +712,8 @@
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.72.0",
"version": "0.74.4",
"private": true,
"packageManager": "bun@1",
"dependencies": {
@@ -20,6 +20,7 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.10",
"echarts": "^6.0.0",
"i18next": "26.3.3",
"lucide-react": "^1.16.0",
"mermaid": "^11.15.0",
"pbf": "^4.0.1",
@@ -28,6 +29,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.76.0",
"react-i18next": "17.0.8",
"react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1",

View File

@@ -1100,7 +1100,6 @@
display: none;
}
.earth-mobile-tv-select,
.earth-mobile-settings-slider {
width: 100%;
}
@@ -1157,15 +1156,18 @@
.earth-mobile-tv-overview-tags {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 4px;
margin-top: 1px;
overflow: hidden;
min-width: 0;
overflow: visible;
}
.earth-mobile-tv-overview-tag {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
min-height: 20px;
padding: 0 6px;
border-radius: 999px;
@@ -1173,8 +1175,9 @@
background: rgba(255, 255, 255, 0.06);
color: var(--hud-text);
font-size: 0.62rem;
line-height: 1;
white-space: nowrap;
line-height: 1.15;
overflow-wrap: anywhere;
white-space: normal;
}
.earth-mobile-tv-overview-tag--status {
@@ -1211,14 +1214,6 @@
margin-top: 0;
}
.earth-mobile-tv-select {
border: 1px solid rgba(201, 225, 247, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--hud-text);
padding: 10px 12px;
}
.earth-mobile-tv-player {
position: relative;
aspect-ratio: 16 / 9;
@@ -1817,11 +1812,15 @@
.earth-mobile-settings-pill {
position: relative;
z-index: 1;
min-width: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--hud-text-soft);
padding: 10px 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.earth-mobile-settings-pill.is-active {
@@ -1835,6 +1834,9 @@
}
.earth-mobile-settings-chip {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
border: 1px solid rgba(212, 227, 244, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
@@ -1844,6 +1846,9 @@
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition:
background 0.18s ease,
@@ -1900,11 +1905,13 @@
transition: transform 0.18s ease;
}
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track {
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track,
.earth-mobile-settings-switch.is-checked .earth-mobile-settings-switch-track {
background: rgba(122, 180, 255, 0.34);
}
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track::after {
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track::after,
.earth-mobile-settings-switch.is-checked .earth-mobile-settings-switch-track::after {
transform: translate(16px, -50%);
}
@@ -2145,10 +2152,10 @@ label.is-disabled.earth-mobile-settings-card {
.earth-status-message,
.earth-error-message {
position: absolute;
top: calc(var(--hud-offset) + calc(2px * var(--hud-scale)));
top: calc(var(--hud-offset) + calc(44px * var(--hud-scale)));
left: min(
calc(var(--hud-offset) + calc(340px * var(--hud-scale)) + calc(12px * var(--hud-scale))),
calc(100vw - min(calc(440px * var(--hud-scale)), 74vw) - var(--hud-offset))
calc(100vw - min(calc(620px * var(--hud-scale)), 82vw) - var(--hud-offset))
);
transform: translateY(-18px);
display: none;
@@ -2172,8 +2179,8 @@ label.is-disabled.earth-mobile-settings-card {
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);
min-width: 0;
max-width: min(calc(620px * var(--hud-scale)), 82vw);
color: var(--hud-text);
opacity: 0;
transition:
@@ -2236,6 +2243,7 @@ label.is-disabled.earth-mobile-settings-card {
align-items: center;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
/* Loading: three-dot sequential pulse */
@@ -2869,6 +2877,7 @@ label.is-disabled.earth-mobile-settings-card {
.earth-settings-segmented-btn {
position: relative;
z-index: 1;
min-width: 0;
border: 0;
background: transparent;
color: var(--hud-text-soft);
@@ -2878,6 +2887,9 @@ label.is-disabled.earth-mobile-settings-card {
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.02em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition:
color 0.18s ease,
@@ -2979,6 +2991,9 @@ label.is-disabled.earth-mobile-settings-card {
}
.earth-settings-chip {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background:
@@ -2990,6 +3005,9 @@ label.is-disabled.earth-mobile-settings-card {
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.02em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition:
background 0.18s ease,
@@ -3253,12 +3271,18 @@ label.is-disabled.earth-mobile-settings-card {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(38px * var(--hud-scale));
height: calc(22px * var(--hud-scale));
flex: 0 0 auto;
cursor: pointer;
}
.earth-settings-switch input {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
cursor: pointer;
}
.earth-settings-switch-track {
@@ -3285,12 +3309,14 @@ label.is-disabled.earth-mobile-settings-card {
transition: transform 0.18s ease;
}
.earth-settings-switch input:checked + .earth-settings-switch-track {
.earth-settings-switch input:checked + .earth-settings-switch-track,
.earth-settings-switch.is-checked .earth-settings-switch-track {
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
border-color: rgba(223, 236, 252, 0.28);
}
.earth-settings-switch input:checked + .earth-settings-switch-track::after {
.earth-settings-switch input:checked + .earth-settings-switch-track::after,
.earth-settings-switch.is-checked .earth-settings-switch-track::after {
transform: translate(calc(16px * var(--hud-scale)), -50%);
}

View File

@@ -145,6 +145,22 @@
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
.hud-panel-brand .earth-brand--en .earth-brand__description {
font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
overflow: visible;
text-overflow: clip;
white-space: normal;
word-break: normal;
overflow-wrap: normal;
letter-spacing: 0;
}
.hud-panel-brand .earth-brand--en .earth-brand__subtitle {
font-size: calc(0.58rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.15;
}
.hud-panel-brand .earth-brand--en .earth-brand__description {
font-size: calc(0.5rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.15;
}
/* ── Info detail panel (floating, positioned near click by JS) ── */
@@ -152,7 +168,7 @@
.hud-panel-info {
position: absolute;
z-index: 50;
width: min(calc(300px * var(--hud-scale)), calc(100vw - 32px));
width: min(calc(340px * var(--hud-scale)), calc(100vw - 32px));
border-radius: 0;
padding: 0;
overflow: hidden;
@@ -349,12 +365,17 @@
}
.info-card-label {
min-width: 0;
max-width: 42%;
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.18s ease;
user-select: none;
-webkit-user-select: none;
@@ -365,17 +386,35 @@
}
.info-card-value {
min-width: 0;
color: var(--hud-text);
font-weight: 600;
font-size: calc(0.82rem * var(--hud-scale));
line-height: 1.45;
text-align: right;
max-width: calc(180px * var(--hud-scale));
max-width: calc(220px * var(--hud-scale));
word-break: break-word;
user-select: none;
-webkit-user-select: none;
}
.info-card-source-tag {
display: inline-flex;
max-width: 100%;
min-width: 0;
margin-left: calc(4px * var(--hud-scale));
padding: 0 calc(5px * var(--hud-scale));
border: 1px solid rgba(214, 229, 245, 0.14);
border-radius: 999px;
color: var(--hud-text-soft);
font-size: calc(0.64rem * var(--hud-scale));
line-height: 1.35;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Type-specific header accent colors */
.info-card.cable .info-card-header {
background: rgba(255, 200, 0, 0.12);
@@ -630,14 +669,21 @@
.info-card-compute-candidate-preview.is-loading::before,
.info-card-unresolved-item.is-locating .info-card-unresolved-index::before {
content: "";
display: inline-block;
flex-shrink: 0;
width: calc(10px * var(--hud-scale));
height: calc(10px * var(--hud-scale));
vertical-align: middle;
border: 1.5px solid rgba(201, 220, 255, 0.35);
border-top-color: #c9dcff;
border-radius: 999px;
animation: info-card-location-spin 0.8s linear infinite;
}
.info-card-compute-candidate-preview.is-loading::before {
margin-inline-end: 4px;
}
.info-card-unresolved-item.is-locating .info-card-unresolved-index {
color: transparent;
}
@@ -681,15 +727,25 @@
justify-content: space-between;
gap: 8px;
align-items: center;
min-width: 0;
}
.info-card-compute-candidate-precision {
flex: 0 1 auto;
min-width: 0;
max-width: 44%;
color: #cfe1ff;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-card-compute-candidate-preview {
position: relative;
flex: 0 1 auto;
min-width: 0;
max-width: calc(96px * var(--hud-scale));
background: transparent;
color: #c9dcff;
border: 1px solid rgba(214, 229, 245, 0.18);
@@ -697,6 +753,9 @@
cursor: pointer;
padding: 2px 6px;
font-size: calc(0.68rem * var(--hud-scale));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-card-compute-candidate-preview:hover {
@@ -796,9 +855,3 @@
.info-card-unresolved-adopt:hover {
background: rgba(255, 171, 81, 0.16);
}
.info-card-unresolved-empty {
padding: calc(10px * var(--hud-scale)) 0;
color: var(--hud-text-soft);
font-size: calc(0.74rem * var(--hud-scale));
}

View File

@@ -4,7 +4,7 @@
/* Lives inside .earth-left-column — narrower than brand panel intentionally */
border-radius: 0;
padding: 0;
width: calc(260px * var(--hud-scale));
width: calc(276px * var(--hud-scale));
z-index: 10;
overflow: hidden;
margin-top: calc(12px * var(--hud-scale));
@@ -184,7 +184,7 @@
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line);
transition: background 0.14s ease;
min-height: calc(56px * var(--hud-scale));
@@ -241,6 +241,9 @@
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Toggle switch ────────────────────────────────────────────── */
@@ -329,12 +332,13 @@
appearance: none;
position: absolute;
top: calc(4px * var(--hud-scale));
left: calc(19px * var(--hud-scale));
left: calc(21px * var(--hud-scale));
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: calc(16px * var(--hud-scale));
max-width: calc(38px * var(--hud-scale));
height: calc(16px * var(--hud-scale));
padding: 0 calc(4px * var(--hud-scale));
border: 1px solid rgba(255, 226, 186, 0.62);
@@ -348,6 +352,9 @@
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition:
filter 0.16s ease,

View File

@@ -5,7 +5,7 @@
left: var(--hud-offset);
border-radius: 0;
padding: 0;
width: min(calc(200px * var(--hud-scale)), calc(100vw - 32px));
width: min(calc(280px * var(--hud-scale)), calc(100vw - 32px));
z-index: 10;
overflow: hidden;
}
@@ -51,14 +51,17 @@
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
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);
color: var(--hud-accent-strong);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.08em;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Bar action buttons ───────────────────────────────────────── */
@@ -92,6 +95,7 @@
.legend-list {
display: flex;
flex-direction: column;
min-width: 0;
padding: calc(4px * var(--hud-scale)) 0;
overflow-y: auto;
max-height: calc(220px * var(--hud-scale));
@@ -110,6 +114,7 @@
display: flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
min-width: 0;
padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
}
@@ -135,6 +140,9 @@
}
.legend-label {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
color: var(--hud-text);
font-size: calc(0.78rem * var(--hud-scale));
font-weight: 400;
@@ -142,6 +150,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0;
}
/* ── Layout-expanded ──────────────────────────────────────────── */
@@ -156,7 +165,7 @@
position: fixed;
left: 8px;
bottom: calc(84px + var(--safe-bottom));
width: min(172px, calc(100vw - 16px));
width: min(208px, calc(100vw - 16px));
z-index: 205;
}

View File

@@ -306,6 +306,8 @@
display: inline-flex;
align-items: center;
gap: calc(6px * var(--hud-scale));
min-width: 0;
max-width: 100%;
min-height: calc(30px * var(--hud-scale));
border: 1px solid rgba(201, 225, 247, 0.1);
border-radius: calc(12px * var(--hud-scale));
@@ -330,6 +332,11 @@
}
.news-filter-pill strong {
min-width: 0;
max-width: calc(160px * var(--hud-scale));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--hud-accent-strong);
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 700;
@@ -391,6 +398,8 @@
}
.news-filter-chip {
min-width: 0;
max-width: 100%;
border: 1px solid rgba(201, 225, 247, 0.12);
border-radius: calc(14px * var(--hud-scale));
padding: calc(7px * var(--hud-scale)) calc(10px * var(--hud-scale));
@@ -398,6 +407,10 @@
background: rgba(255, 255, 255, 0.04);
font: inherit;
font-size: calc(0.74rem * var(--hud-scale));
line-height: 1.25;
text-align: center;
overflow-wrap: anywhere;
white-space: normal;
cursor: pointer;
}
@@ -539,6 +552,7 @@
.news-story-meta {
justify-content: space-between;
min-width: 0;
}
.news-story-tags {
@@ -549,6 +563,7 @@
.news-story-time,
.news-story-origin,
.news-story-tag {
min-width: 0;
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
}
@@ -563,6 +578,7 @@
.news-story-origin {
color: rgba(188, 212, 238, 0.56);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -582,9 +598,13 @@
}
.news-story-tag {
max-width: 100%;
border-radius: 999px;
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
background: rgba(255, 255, 255, 0.04);
line-height: 1.25;
overflow-wrap: anywhere;
white-space: normal;
}
.news-story-tag--breaking {

View File

@@ -109,10 +109,196 @@
font-size: calc(0.84rem * var(--hud-scale));
}
.tv-panel-select option,
.tv-panel-select optgroup {
background: #0a1422;
color: #eef5fc;
.tv-source-trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: var(--hud-gap-xs);
text-align: left;
cursor: pointer;
font-family: inherit;
height: calc(36px * var(--hud-scale));
padding-block: 0;
line-height: 1;
}
.tv-source-trigger__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tv-source-trigger > .material-symbols-rounded {
flex: 0 0 auto;
font-size: calc(16px * var(--hud-scale));
color: var(--hud-text-soft);
}
.tv-source-trigger[aria-expanded="true"] {
border-color: var(--hud-border-hover);
background: rgba(120, 180, 255, 0.12);
}
.tv-source-trigger:focus-visible,
.tv-source-menu__more:focus-visible {
outline: 2px solid var(--hud-accent-strong);
outline-offset: 2px;
}
.tv-source-menu {
position: fixed;
inset: auto;
margin: 0;
padding: 0;
border-radius: 0;
color: var(--hud-text);
font-family: inherit;
}
.tv-source-menu:popover-open {
display: flex;
flex-direction: column;
}
.tv-source-menu__search {
display: flex;
align-items: center;
gap: var(--hud-gap-xs);
flex: 0 0 auto;
padding: calc(10px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line);
}
.tv-source-menu__search > .material-symbols-rounded {
color: var(--hud-text-soft);
font-size: calc(18px * var(--hud-scale));
}
.tv-source-menu__search input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--hud-text);
font: inherit;
font-size: calc(0.78rem * var(--hud-scale));
}
.tv-source-menu__search:focus-within {
box-shadow: inset 0 -1px 0 var(--hud-accent-strong);
}
.tv-source-menu__search input::placeholder {
color: var(--hud-text-soft);
}
.tv-source-menu__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.tv-source-menu__list {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overscroll-behavior: contain;
}
.tv-source-menu__option {
width: 100%;
flex: 0 0 auto;
border: 0;
background: transparent;
color: var(--hud-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.tv-source-menu__option.is-active {
background: rgba(120, 180, 255, 0.1);
}
.tv-source-menu__option[aria-selected="true"] {
background: rgba(120, 180, 255, 0.15);
}
.tv-source-menu__option > .material-symbols-rounded {
flex: 0 0 auto;
font-size: calc(14px * var(--hud-scale));
}
.tv-source-menu__check {
color: var(--hud-accent-strong);
visibility: hidden;
}
.tv-source-menu__option[aria-selected="true"] .tv-source-menu__check {
visibility: visible;
}
.tv-source-menu__default,
.tv-source-menu__count,
.tv-source-menu__more {
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
}
.tv-source-menu__default {
flex: 0 0 auto;
white-space: nowrap;
}
.tv-source-menu__warning {
color: #ffd166;
}
.tv-source-menu__count,
.tv-source-menu__empty,
.tv-source-menu__more {
flex: 0 0 auto;
padding: calc(8px * var(--hud-scale)) calc(10px * var(--hud-scale));
}
.tv-source-menu__count {
border-top: 1px solid var(--hud-line);
}
.tv-source-menu__empty {
color: var(--hud-text-muted);
font-size: calc(0.78rem * var(--hud-scale));
}
.tv-source-menu__more {
border: 0;
background: rgba(120, 180, 255, 0.06);
font-family: inherit;
cursor: pointer;
}
.tv-source-menu [hidden] {
display: none;
}
.earth-mobile-page--tv > .tv-source-trigger {
flex: 0 0 auto;
width: 100%;
height: 40px;
}
.layout-mode-mobile .tv-source-menu__search input,
.layout-mode-mobile .tv-source-menu__option .legend-label {
font-size: 14px;
}
.layout-mode-mobile .tv-source-menu__search,
.layout-mode-mobile .tv-source-menu__option {
min-height: 40px;
}
.tv-panel-meta-wrap {
@@ -154,7 +340,9 @@
}
.tv-panel-tag {
flex: 0 0 auto;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
border: 1px solid rgba(137, 179, 217, 0.22);
border-radius: calc(999px * var(--hud-scale));
background: rgba(108, 153, 192, 0.12);
@@ -164,6 +352,8 @@
font-weight: 700;
line-height: 1.35;
letter-spacing: 0.04em;
overflow-wrap: anywhere;
white-space: normal;
}
.tv-panel-tag--status {

View File

@@ -511,7 +511,7 @@
<span class="hud-panel-title hud-panel__title tv-panel-header-title">Live 新闻</span>
</div>
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
<button id="tv-source-select" class="tv-panel-select tv-source-trigger" type="button" aria-label="选择新闻直播源" aria-haspopup="dialog" aria-expanded="false"></button>
<div class="tv-panel-toolbar-actions">
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
<span class="material-symbols-rounded">refresh</span>
@@ -815,7 +815,7 @@
<span class="earth-mobile-page-kicker">TV</span>
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
<button id="mobile-tv-source-select" class="tv-panel-select tv-source-trigger" type="button" aria-label="选择移动端新闻直播源" aria-haspopup="dialog" aria-expanded="false"></button>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
@@ -1211,6 +1211,16 @@
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="system" hidden>
<div class="earth-mobile-settings-title">系统</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">星球语言</span>
<span class="earth-mobile-settings-subtitle">同步 Docs 和控制台语言偏好</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="星球语言">
<button type="button" class="earth-mobile-settings-pill is-active" data-earth-locale="zh-CN" aria-pressed="true">中文</button>
<button type="button" class="earth-mobile-settings-pill" data-earth-locale="en-US" aria-pressed="false">English</button>
</div>
</div>
<div class="earth-mobile-settings-actions">
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开控制台</a>
@@ -1769,6 +1779,16 @@
<section class="earth-settings-section" data-settings-tab-panel="system" hidden>
<div class="earth-settings-section-title">系统</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">星球语言</span>
<span class="earth-settings-item-subtitle">同步 Docs 和控制台语言偏好</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="星球语言">
<button type="button" class="earth-settings-segmented-btn is-active" data-earth-locale="zh-CN" aria-pressed="true">中文</button>
<button type="button" class="earth-settings-segmented-btn" data-earth-locale="en-US" aria-pressed="false">English</button>
</div>
</div>
<a
class="earth-settings-item earth-settings-link"
href="/admin"

View File

@@ -17,11 +17,25 @@ const BRANDS = {
logoSrc: "/earth/assets/brand/earth-logo.png",
titleSrc: "/earth/assets/brand/title-en.png",
titleText: "Intelligent Planet Program",
subtitle: "Physical-Universe Holography",
description: "Satellites · Cables · Compute Infra",
subtitle: "Reality Layer Situational Awareness System",
description: "Satellites · Subsea Cables · Compute Infrastructure",
},
};
const DEFAULT_BRAND_TITLE_BY_VARIANT = {
zh: BRANDS.zh.titleSrc,
en: BRANDS.en.titleSrc,
};
const LOCALIZED_FIELD_NAMES = {
ariaLabel: ["aria_label", "ariaLabel"],
titleAlt: ["title_alt", "titleAlt"],
titleSrc: ["title_src", "titleSrc"],
titleText: ["title_text", "titleText"],
subtitle: ["subtitle"],
description: ["description"],
};
export function getDefaultBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
}
@@ -35,18 +49,65 @@ function escapeHtml(value = "") {
.replace(/'/g, "&#39;");
}
function hasCjkText(value = "") {
return /[\u3400-\u9fff]/.test(String(value ?? ""));
}
function readConfigValue(config, keys = []) {
for (const key of keys) {
if (config[key] !== undefined && config[key] !== null && config[key] !== "") {
return config[key];
}
}
return undefined;
}
function readLocalizedConfigValue(config, fieldName, variant, fallback) {
const keys = LOCALIZED_FIELD_NAMES[fieldName] || [fieldName];
const localeSuffix = variant === "en" ? "en" : "zh";
const localeKeys = keys.flatMap((key) => [
`${key}_${localeSuffix}`,
`${key}${localeSuffix.charAt(0).toUpperCase()}${localeSuffix.slice(1)}`,
]);
const localized = readConfigValue(config, localeKeys);
if (localized !== undefined) return localized;
const generic = readConfigValue(config, keys);
return generic ?? fallback;
}
function normalizeBrandConfig(config = {}, variant = DEFAULT_BRAND_LANGUAGE) {
const defaults = getDefaultBrandConfig(variant);
const sourceTitleSrc = readLocalizedConfigValue(config, "titleSrc", variant, undefined);
const normalized = {
...defaults,
...config,
ariaLabel: config.aria_label ?? config.ariaLabel ?? defaults.ariaLabel,
titleAlt: config.title_alt ?? config.titleAlt ?? defaults.titleAlt,
ariaLabel: readLocalizedConfigValue(config, "ariaLabel", variant, defaults.ariaLabel),
titleAlt: readLocalizedConfigValue(config, "titleAlt", variant, defaults.titleAlt),
logoSrc: config.logo_src ?? config.logoSrc ?? defaults.logoSrc,
titleSrc: config.title_src ?? config.titleSrc ?? defaults.titleSrc,
titleText: config.title_text ?? config.titleText ?? defaults.titleText,
titleSrc: sourceTitleSrc ?? defaults.titleSrc,
titleText: readLocalizedConfigValue(config, "titleText", variant, defaults.titleText),
subtitle: readLocalizedConfigValue(config, "subtitle", variant, defaults.subtitle),
description: readLocalizedConfigValue(config, "description", variant, defaults.description),
};
if (
variant === "en" &&
(
!sourceTitleSrc ||
sourceTitleSrc === DEFAULT_BRAND_TITLE_BY_VARIANT.zh ||
hasCjkText(normalized.titleText) ||
hasCjkText(normalized.titleAlt) ||
hasCjkText(normalized.ariaLabel)
)
) {
normalized.titleSrc = DEFAULT_BRAND_TITLE_BY_VARIANT.en;
normalized.titleAlt = defaults.titleAlt;
normalized.titleText = defaults.titleText;
normalized.ariaLabel = defaults.ariaLabel;
normalized.subtitle = defaults.subtitle;
normalized.description = defaults.description;
}
if (!normalized.titleText) normalized.titleText = defaults.titleText;
if (!normalized.ariaLabel) normalized.ariaLabel = normalized.titleText;
if (!normalized.titleAlt) normalized.titleAlt = normalized.titleText;

View File

@@ -0,0 +1,176 @@
import * as THREE from "three";
const CABLE_STYLE_TEXTURE_MAX_WIDTH = 1024;
// Keep the original Line/Sprite objects for picking and selection. Only their
// drawing is replaced: every segment and landing point remains in the batch.
function ownBatch(object, disposeExtra = () => {}) {
object.frustumCulled = false;
object.raycast = () => {};
return {
object,
dispose() {
object.parent?.remove(object);
object.geometry.dispose();
object.material.dispose();
disposeExtra();
},
};
}
export function createCableLineBatch(lines) {
if (!lines.length) return null;
const vertexCount = lines.reduce((count, line) =>
count + Math.max(0, line.geometry.attributes.position.count - 1) * 2, 0);
const positions = new Float32Array(vertexCount * 3);
const styleIndices = new Float32Array(vertexCount);
const width = Math.min(lines.length, CABLE_STYLE_TEXTURE_MAX_WIDTH);
const height = Math.ceil(lines.length / width);
const styles = new Float32Array(width * height * 4);
const styleTexture = new THREE.DataTexture(styles, width, height, THREE.RGBAFormat, THREE.FloatType);
styleTexture.needsUpdate = true;
let cursor = 0;
lines.forEach((line, index) => {
const source = line.geometry.attributes.position;
for (let segment = 0; segment < source.count - 1; segment += 1) {
for (const endpoint of [segment, segment + 1]) {
positions[cursor * 3] = source.getX(endpoint);
positions[cursor * 3 + 1] = source.getY(endpoint);
positions[cursor * 3 + 2] = source.getZ(endpoint);
styleIndices[cursor] = index;
cursor += 1;
}
}
line.material.visible = false;
line.updateMatrix();
line.matrixAutoUpdate = false;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("styleIndex", new THREE.BufferAttribute(styleIndices, 1));
const material = new THREE.ShaderMaterial({
uniforms: {
styles: { value: styleTexture },
styleSize: { value: new THREE.Vector2(width, height) },
},
transparent: true,
depthTest: true,
depthWrite: true,
vertexShader: `
uniform sampler2D styles;
uniform vec2 styleSize;
attribute float styleIndex;
varying vec4 vStyle;
void main() {
vec2 uv = (vec2(mod(styleIndex, styleSize.x), floor(styleIndex / styleSize.x)) + 0.5) / styleSize;
vStyle = texture2D(styles, uv);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec4 vStyle;
void main() {
if (vStyle.a <= 0.0) discard;
gl_FragColor = vStyle;
}
`,
});
material.linewidth = lines[0].material.linewidth;
const object = new THREE.LineSegments(geometry, material);
object.name = "cable-line-batch";
object.renderOrder = lines[0].renderOrder;
object.onBeforeRender = () => {
let changed = false;
lines.forEach((line, index) => {
const { color, opacity } = line.material;
const offset = index * 4;
const alpha = line.visible ? opacity : 0;
if (styles[offset] !== Math.fround(color.r) || styles[offset + 1] !== Math.fround(color.g)
|| styles[offset + 2] !== Math.fround(color.b) || styles[offset + 3] !== Math.fround(alpha)) {
styles[offset] = color.r;
styles[offset + 1] = color.g;
styles[offset + 2] = color.b;
styles[offset + 3] = alpha;
changed = true;
}
});
if (changed) styleTexture.needsUpdate = true;
};
return ownBatch(object, () => styleTexture.dispose());
}
export function createLandingPointBatch(markers, texture) {
if (!markers.length) return null;
const geometry = new THREE.InstancedBufferGeometry();
const quad = new THREE.PlaneGeometry(1, 1);
geometry.index = quad.index;
geometry.attributes.position = quad.attributes.position;
geometry.attributes.uv = quad.attributes.uv;
const centers = new Float32Array(markers.length * 3);
const sizes = new THREE.InstancedBufferAttribute(new Float32Array(markers.length * 2), 2)
.setUsage(THREE.DynamicDrawUsage);
const styles = new THREE.InstancedBufferAttribute(new Float32Array(markers.length * 4), 4)
.setUsage(THREE.DynamicDrawUsage);
markers.forEach((marker, index) => {
marker.position.toArray(centers, index * 3);
marker.material.visible = false;
});
geometry.setAttribute("center", new THREE.InstancedBufferAttribute(centers, 3));
geometry.setAttribute("size", sizes);
geometry.setAttribute("style", styles);
geometry.instanceCount = markers.length;
const material = new THREE.ShaderMaterial({
uniforms: { map: { value: texture } },
transparent: true,
depthTest: false,
depthWrite: false,
vertexShader: `
attribute vec3 center;
attribute vec2 size;
attribute vec4 style;
varying vec2 vUv;
varying vec4 vStyle;
void main() {
vUv = uv;
vStyle = style;
vec2 scale = vec2(length(modelMatrix[0].xyz), length(modelMatrix[1].xyz));
vec4 viewPosition = modelViewMatrix * vec4(center, 1.0);
viewPosition.xy += position.xy * size * scale;
gl_Position = style.a > 0.0 ? projectionMatrix * viewPosition : vec4(2.0, 2.0, 2.0, 1.0);
}
`,
fragmentShader: `
uniform sampler2D map;
varying vec2 vUv;
varying vec4 vStyle;
void main() {
vec4 color = texture2D(map, vUv) * vStyle;
if (color.a < 0.01) discard;
gl_FragColor = color;
}
`,
});
const object = new THREE.Mesh(geometry, material);
object.name = "landing-point-batch";
object.renderOrder = markers[0].renderOrder;
object.onBeforeRender = () => {
let sizeChanged = false;
let styleChanged = false;
markers.forEach((marker, index) => {
const { color, opacity } = marker.material;
const alpha = marker.visible ? opacity : 0;
if (sizes.getX(index) !== Math.fround(marker.scale.x) || sizes.getY(index) !== Math.fround(marker.scale.y)) {
sizes.setXY(index, marker.scale.x, marker.scale.y);
sizeChanged = true;
}
if (styles.getX(index) !== Math.fround(color.r) || styles.getY(index) !== Math.fround(color.g)
|| styles.getZ(index) !== Math.fround(color.b) || styles.getW(index) !== Math.fround(alpha)) {
styles.setXYZW(index, color.r, color.g, color.b, alpha);
styleChanged = true;
}
});
if (sizeChanged) sizes.needsUpdate = true;
if (styleChanged) styles.needsUpdate = true;
};
return ownBatch(object);
}

View File

@@ -1,6 +1,7 @@
// cables.js - Cable loading and rendering module
import * as THREE from "three";
import { createCableLineBatch, createLandingPointBatch } from "./cable-batches.js";
import {
CONFIG,
@@ -13,6 +14,7 @@ import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js";
import { setLegendItems, setLegendMode } from "./legend.js";
import { earthMessage } from "./i18n.js";
export let cableLines = [];
export let landingPoints = [];
@@ -22,6 +24,8 @@ let landingPointSourceFeatureCount = 0;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
let cableLineBatch = null;
let landingPointBatch = null;
const _lpEarthWorldPos = new THREE.Vector3();
const _lpWorldPos = new THREE.Vector3();
const _lpCameraRel = new THREE.Vector3();
@@ -302,6 +306,8 @@ function calculateGreatCirclePoints(
}
export function clearCableLines(earthObj = null) {
cableLineBatch?.dispose();
cableLineBatch = null;
cableLines.forEach((line) => disposeObject(line, earthObj));
cableLines = [];
cableSourceFeatureCount = 0;
@@ -310,6 +316,8 @@ export function clearCableLines(earthObj = null) {
}
export function clearLandingPoints(earthObj = null) {
landingPointBatch?.dispose();
landingPointBatch = null;
landingPoints.forEach((point) => disposeObject(point, earthObj));
landingPoints = [];
landingPointSourceFeatureCount = 0;
@@ -324,7 +332,7 @@ export function clearCableData(earthObj = null) {
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
const { silent = false } = options;
if (!silent) {
showStatusMessage("正在加载电缆数据...", "warning");
showStatusMessage(earthMessage("loading.cableData"), "warning");
}
const response = await fetch(PATHS.cablesApi, { cache: "no-store" });
@@ -403,6 +411,11 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
}
}
cableLineBatch = createCableLineBatch(cableLines);
if (cableLineBatch) {
cableLineBatch.object.visible = cablesVisible;
earthObj.add(cableLineBatch.object);
}
const cableCount = data.features.length;
const inServiceCount = data.features.filter(
(feature) =>
@@ -423,7 +436,7 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
});
if (!silent) {
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
showStatusMessage(earthMessage("status.loadedCables", { count: cableLines.length }), "success");
}
return cableLines.length;
}
@@ -507,12 +520,17 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
landingPoints.push(marker);
}
landingPointBatch = createLandingPointBatch(landingPoints, markerTexture);
if (landingPointBatch) {
landingPointBatch.object.visible = cablesVisible;
earthObj.add(landingPointBatch.object);
}
const validCount = landingPoints.length;
setEarthStatValue("landing-point-count", `${validCount}`);
if (!silent) {
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
showStatusMessage(earthMessage("status.loadedLandingPoints", { count: validCount }), "success");
}
return validCount;
}
@@ -532,7 +550,7 @@ export function handleCableClick(cable) {
rfs: data.rfs,
});
showStatusMessage(`已锁定: ${data.name}`, "info");
showStatusMessage(earthMessage("status.locked", { name: data.name }), "info");
}
export function clearCableSelection() {
@@ -710,6 +728,8 @@ export function resetLandingPointVisualState(camera = null) {
export function toggleCables(show) {
cablesVisible = show;
if (cableLineBatch) cableLineBatch.object.visible = show;
if (landingPointBatch) landingPointBatch.object.visible = show;
cableLines.forEach((cable) => {
cable.visible = cablesVisible;
});

View File

@@ -389,6 +389,7 @@ export const CABLE_STATE = {
};
export const SATELLITE_CONFIG = {
workerStartupTimeoutMs: 10000,
maxCount: -1,
initialLoadCount: null,
hydrateFullAfterInitialLoad: false,

View File

@@ -109,6 +109,7 @@ import {
setLayerButtonState,
updateLayerButtonState,
} from "./layer-button-state.js";
import { earthMessage, translateText } from "./i18n.js";
import {
DEFAULT_MOTION_PROVIDER,
MOTION_GESTURES,
@@ -428,12 +429,12 @@ function getShortcutForAction(actionId) {
function getAutoRotateShortcutStatusMessage(isActive) {
if (rotationMode === ROTATION_MODE.CRUISE) {
return isActive ? "巡航已恢复" : "巡航已暂停";
return earthMessage("status.runtimePaused", { label: "巡航", active: isActive });
}
if (rotationMode === ROTATION_MODE.MOTION) {
return isActive ? "动捕已恢复" : "动捕已暂停";
return earthMessage("status.runtimePaused", { label: "动捕", active: isActive });
}
return isActive ? "旋转已恢复" : "旋转已暂停";
return earthMessage("status.runtimePaused", { label: "旋转", active: isActive });
}
function getShortcutOwnerByBinding(binding, { excludeActionId = null } = {}) {
@@ -731,7 +732,7 @@ function toggleLayoutExpandedFromShortcut() {
const container = document.getElementById("container");
if (!(container instanceof HTMLElement)) return;
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
showStatusMessage(earthMessage("status.layoutExpanded", { expanded }), "info");
}
async function toggleLayerFromShortcut(layerId) {
@@ -739,11 +740,17 @@ async function toggleLayerFromShortcut(layerId) {
if (!definition) return;
const button = getLayerButton(layerId);
if (button?.disabled || button?.classList.contains("is-disabled")) {
showStatusMessage(`${definition.label}当前不可用`, "warning");
showStatusMessage(earthMessage("status.layerUnavailable", { layer: definition.label }), "warning");
return;
}
await definition.setVisible(!definition.getVisible());
showStatusMessage(`${definition.label}${definition.getVisible() ? "已显示" : "已隐藏"}`, "info");
showStatusMessage(
earthMessage("status.layerVisibility", {
layer: definition.label,
visible: definition.getVisible(),
}),
"info",
);
}
function executeKeyboardShortcut(actionId, event = null) {
@@ -1378,7 +1385,7 @@ function getZoomResetTooltipText(zoom) {
}
function getZoomResetStatusMessage(zoom) {
return `缩放已重置到${formatZoomPercent(zoom)}`;
return earthMessage("status.zoomReset", { zoom: formatZoomPercent(zoom) });
}
function normalizeAutoRotationSpeed(value) {
@@ -2090,7 +2097,7 @@ export function setEarthNewsCategoryEnabled(
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(Boolean(enabled) ? "新闻类型已显示" : "新闻类型已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "新闻类型", visible: Boolean(enabled) }), "info");
}
return true;
}
@@ -2147,7 +2154,13 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
if (!suppressStatus) {
const labels = normalizedModules.map((moduleId) => CRUISE_MODULE_LABELS[moduleId] || moduleId);
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
showStatusMessage(
earthMessage("status.modulesChanged", {
label: "巡航模块",
value: labels.map((label) => translateText(label)).join(" + "),
}),
"info",
);
}
return normalizedModules;
@@ -2178,7 +2191,7 @@ export function setCruiseQueueMode(
: normalizedMode === CRUISE_QUEUE_MODES.RANDOM
? "随机"
: "默认";
showStatusMessage(`巡航队列已切换为:${label}`, "info");
showStatusMessage(earthMessage("status.valueChanged", { label: "巡航队列", value: label }), "info");
}
return normalizedMode;
@@ -2203,7 +2216,7 @@ export function setCruiseRegionOrder(
if (persist) persistEarthSettings();
if (!suppressStatus) {
showStatusMessage("巡航大区顺序已更新", "info");
showStatusMessage(earthMessage("status.updated", { label: "巡航大区顺序" }), "info");
}
return normalizedOrder;
@@ -2237,7 +2250,7 @@ export function setSatelliteDisplayStyle(
normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT
? "真实地表覆盖"
: "自身发光";
showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info");
showStatusMessage(earthMessage("status.valueChanged", { label: "卫星显示风格", value: nextLabel }), "info");
}
return normalizedStyle;
@@ -2256,7 +2269,7 @@ export function setSatelliteIdleBreathingEnabled(
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
showStatusMessage(earthMessage("status.booleanSetting", { label: "卫星呼吸闪烁", enabled }), "info");
}
return enabled;
}
@@ -2275,7 +2288,9 @@ export function setSatelliteRealAltitudeEnabled(
}
if (!suppressStatus) {
showStatusMessage(
enabled ? "卫星真实高度已开启" : "卫星已切换为旧版同层高度",
enabled
? earthMessage("status.booleanSetting", { label: "卫星真实高度", enabled })
: earthMessage("status.valueChanged", { label: "卫星", value: "旧版同层高度" }),
"info",
);
}
@@ -2295,7 +2310,7 @@ export function setInteractableCompactDotsEnabled(
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
showStatusMessage(earthMessage("status.booleanSetting", { label: "低缩放彩色圆点", enabled }), "info");
}
return enabled;
}
@@ -2330,7 +2345,7 @@ export function setSurfaceHoverInfoMode(
: normalizedMode === SURFACE_HOVER_INFO_MODES.POSITION
? "位置"
: "完整";
showStatusMessage(`悬停提示已切换为:${label}`, "info");
showStatusMessage(earthMessage("status.valueChanged", { label: "悬停提示", value: label }), "info");
}
return normalizedMode;
@@ -2508,12 +2523,13 @@ export function setMotionDebugEnabled(
persistEarthSettings();
}
if (!suppressStatus && changed) {
const message = motionDebugEnabled
? rotationMode === ROTATION_MODE.MOTION
? "动捕调试模式已开启"
: "动捕调试模式将在下次进入动捕时开启"
: "动捕调试模式已关闭";
showStatusMessage(message, "info");
showStatusMessage(
earthMessage("status.motionDebugMode", {
enabled: motionDebugEnabled,
pending: rotationMode !== ROTATION_MODE.MOTION,
}),
"info",
);
}
return motionDebugEnabled;
}
@@ -2537,12 +2553,7 @@ export function setMotionProvider(
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionProvider === "motion_agent"
? "动捕输入源已切换为 Motion Agent"
: "动捕输入源已切换为浏览器摄像头",
"info",
);
showStatusMessage(earthMessage("status.motionProvider", { provider: motionProvider }), "info");
}
return motionProvider;
}
@@ -2566,10 +2577,7 @@ export function setMotionDebugSkeletonOnly(
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面",
"info",
);
showStatusMessage(earthMessage("status.motionDebugView", { skeletonOnly: motionDebugSkeletonOnly }), "info");
}
return motionDebugSkeletonOnly;
}
@@ -2597,7 +2605,7 @@ export function setMotionEnabledGestures(
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage("动捕识别动作已更新", "info");
showStatusMessage(earthMessage("status.motionGesturesUpdated"), "info");
}
return getMotionEnabledGestures();
}
@@ -2626,7 +2634,7 @@ function resetEarthSettings() {
}
}
void applyEarthSettings(defaults).then(() => {
showStatusMessage("Earth 设置已重置", "info");
showStatusMessage(earthMessage("status.settingsReset"), "info");
});
}
@@ -2638,7 +2646,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("地形已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "真实地形", visible: false }), "info");
}
return false;
}
@@ -2651,7 +2659,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
statusText: "加载中",
});
if (!silent) {
showStatusMessage("正在加载真实地形数据...", "info");
showStatusMessage(earthMessage("loading.realTerrainData"), "info");
}
await ensureTerrainReady();
}
@@ -2661,7 +2669,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形已显示", "success");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "真实地形", visible: true }), "success");
}
return true;
} catch (error) {
@@ -2670,7 +2678,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形暂时不可用", "error");
showStatusMessage(earthMessage("status.terrainUnavailable"), "error");
}
return false;
}
@@ -2688,7 +2696,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
}
await setSatellitesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
if (!enabled && !silent) {
showStatusMessage("卫星已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "卫星", visible: false }), "info");
} else if (enabled) {
setEarthStatValue("satellite-count", `${getSatelliteCount()}`);
}
@@ -2718,7 +2726,7 @@ function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = fa
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "经纬线", visible: enabled }), "info");
}
return enabled;
}
@@ -2806,7 +2814,7 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "BGP观测", visible: enabled }), "info");
}
return enabled;
}
@@ -2822,7 +2830,7 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "算力中心已显示" : "算力中心已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "算力中心", visible: enabled }), "info");
}
return enabled;
}
@@ -2891,7 +2899,7 @@ function setTrailsDisplayEnabled(enabled, { persist = true, silent = false } = {
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "轨迹", visible: enabled }), "info");
}
return enabled;
}
@@ -3001,7 +3009,7 @@ function getBuiltinLayerDefinitions() {
startupMode: "preload",
startupAlwaysLoad: true,
startupLabel: "海陆基座",
startupMessage: "正在加载海陆基座...",
startupMessage: earthMessage("startup.landOceanBase"),
getVisible: () => getShowCountryBoundaries(),
setVisible: (visible, options = {}) =>
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
@@ -3018,7 +3026,7 @@ function getBuiltinLayerDefinitions() {
startupPriority: 30,
startupMode: "visible",
startupLabel: "高清材质",
startupMessage: "正在启用高清材质...",
startupMessage: earthMessage("startup.hdTexture"),
getVisible: () => getHighResTextureEnabled(),
setVisible: (visible, options = {}) =>
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
@@ -3053,8 +3061,8 @@ function getBuiltinLayerDefinitions() {
startupMode: "visible",
startupLabel: "海缆",
startupMessage: {
prepare: "正在加载登陆点...",
load: "正在加载海缆...",
prepare: earthMessage("startup.landingPoints"),
load: earthMessage("startup.cables"),
},
getVisible: () => getShowCables(),
setVisible: (visible, options = {}) =>
@@ -3072,7 +3080,7 @@ function getBuiltinLayerDefinitions() {
startupPriority: 60,
startupMode: "preload",
startupLabel: "算力中心",
startupMessage: "正在加载算力中心...",
startupMessage: earthMessage("startup.computeCenters"),
getVisible: () => getShowComputeCenters(),
setVisible: (visible, options = {}) =>
setComputeCentersLayerEnabled(getLayerButton("computeCenters"), visible, options),
@@ -3089,7 +3097,7 @@ function getBuiltinLayerDefinitions() {
startupPriority: 70,
startupMode: "preload",
startupLabel: "BGP态势",
startupMessage: "正在加载BGP态势...",
startupMessage: earthMessage("startup.bgp"),
getVisible: () => getShowBGP(),
setVisible: (visible, options = {}) =>
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
@@ -3106,7 +3114,7 @@ function getBuiltinLayerDefinitions() {
startupPriority: 65,
startupMode: "visible",
startupLabel: "船只",
startupMessage: "正在加载船只...",
startupMessage: earthMessage("startup.vessels"),
getVisible: () => getVesselsEnabled(),
setVisible: (visible, options = {}) =>
setVesselsLayerEnabled(getLayerButton("vessels"), visible, options),
@@ -3123,7 +3131,7 @@ function getBuiltinLayerDefinitions() {
startupPriority: 80,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
startupMessage: earthMessage("startup.satellites"),
getVisible: () => getSatellitesEnabled(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
@@ -3140,7 +3148,7 @@ function getBuiltinLayerDefinitions() {
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
startupMessage: earthMessage("startup.terrain"),
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
@@ -3345,7 +3353,10 @@ export function showZoomStatusCapsule({ force = false, zoom = null } = {}) {
const currentZoom = Number.isFinite(Number(zoom))
? clampEarthZoomLevel(zoom)
: syncZoomLevelFromCamera(activeCamera);
showGestureStatusMessage(`缩放 ${Math.round(currentZoom * 100)}%`, "info");
showGestureStatusMessage(
earthMessage("status.zoomPercent", { percent: Math.round(currentZoom * 100) }),
"info",
);
}
function cancelSettingsSheetAnimation() {
@@ -3602,10 +3613,17 @@ function syncSettingsToggle(panelId, visible) {
inputs.forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = visible;
syncSettingsSwitchVisual(input, visible);
}
});
}
function syncSettingsSwitchVisual(input, visible = input?.checked === true) {
if (!(input instanceof HTMLInputElement)) return;
const switchShell = input.closest(".earth-settings-switch, .earth-mobile-settings-switch");
switchShell?.classList.toggle("is-checked", Boolean(visible));
}
function syncAllHudPanelToggles() {
HUD_PANEL_IDS.forEach((panelId) => {
const panel = document.getElementById(panelId);
@@ -3775,12 +3793,15 @@ function startBoundaryBuildPolling() {
setHighPrecisionBoundariesEnabled(true);
await reloadCountryBoundaries({ suppressStatus: true });
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage("高精国界已下载并应用", "info");
showStatusMessage(earthMessage("status.boundaryDownloaded"), "info");
}
}
} catch (error) {
stopBoundaryBuildPolling();
showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning");
showStatusMessage(
earthMessage("status.failure", { label: "高清国界进度读取失败", error: error.message || error }),
"warning",
);
}
}, 1000);
}
@@ -3791,7 +3812,7 @@ async function startBoundaryPrecisionBuild() {
});
boundaryBuildAttemptedThisSession = true;
await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" });
showStatusMessage("高精国界构建已启动", "info");
showStatusMessage(earthMessage("status.boundaryBuildStarted"), "info");
startBoundaryBuildPolling();
}
@@ -3806,7 +3827,10 @@ async function setupBoundaryPrecisionControls() {
}
} catch (error) {
renderBoundaryPrecisionStatus({});
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
showStatusMessage(
earthMessage("status.failure", { label: "高清国界状态读取失败", error: error.message || error }),
"warning",
);
}
els.buildButtons.forEach((buildButton) => {
@@ -3820,14 +3844,17 @@ async function setupBoundaryPrecisionControls() {
if (getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(true);
await reloadCountryBoundaries({ suppressStatus: true });
showStatusMessage("已切换到高精国界", "info");
showStatusMessage(earthMessage("status.boundaryPrecision", { high: true }), "info");
await refreshBoundaryPrecisionStatus().catch(() => {});
return;
}
await startBoundaryPrecisionBuild();
} catch (error) {
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
showStatusMessage(
earthMessage("status.failure", { label: "高精国界切换失败", error: error.message || error }),
"warning",
);
}
});
});
@@ -3838,7 +3865,10 @@ async function setupBoundaryPrecisionControls() {
await startBoundaryPrecisionBuild();
} catch (error) {
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
showStatusMessage(
earthMessage("status.failure", { label: "高精国界重建启动失败", error: error.message || error }),
"warning",
);
}
});
});
@@ -3849,10 +3879,13 @@ async function setupBoundaryPrecisionControls() {
if (!getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(false);
await reloadCountryBoundaries({ suppressStatus: true });
showStatusMessage("已切换到低精国界", "info");
showStatusMessage(earthMessage("status.boundaryPrecision", { high: false }), "info");
await refreshBoundaryPrecisionStatus().catch(() => {});
} catch (error) {
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
showStatusMessage(
earthMessage("status.failure", { label: "低精国界切换失败", error: error.message || error }),
"warning",
);
}
});
});
@@ -4043,7 +4076,7 @@ function setShortcutBinding(actionId, binding, { persist = true } = {}) {
if (!normalizedBinding) return false;
const owner = getShortcutOwnerByBinding(normalizedBinding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
showStatusMessage(earthMessage("status.shortcutConflict", { owner: owner.label }), "warning");
return false;
}
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
@@ -4083,7 +4116,7 @@ function setShortcutEnabled(actionId, enabled, { persist = true } = {}) {
if (enabled) {
const owner = getShortcutOwnerByBinding(currentShortcut.binding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
showStatusMessage(earthMessage("status.shortcutConflict", { owner: owner.label }), "warning");
renderShortcutSettings();
return false;
}
@@ -4110,7 +4143,7 @@ function resetAllShortcutBindings() {
capturingShortcutActionId = null;
renderShortcutSettings();
persistEarthSettings();
showStatusMessage("快捷键已恢复默认", "info");
showStatusMessage(earthMessage("status.shortcutsReset"), "info");
}
function moveCruiseRegionInOrder(region, targetRegion) {
@@ -4270,11 +4303,15 @@ function setupSettingsControls() {
const toggleInputs = document.querySelectorAll("[data-settings-panel]");
toggleInputs.forEach((input) => {
if (input instanceof HTMLInputElement) {
syncSettingsSwitchVisual(input);
}
bindListener(input, "change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
const panelId = target.dataset.settingsPanel;
if (!panelId) return;
syncSettingsSwitchVisual(target, target.checked);
setHudPanelVisibility(panelId, target.checked);
});
});
@@ -5247,7 +5284,10 @@ function setupRotateControls(camera) {
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
showStatusMessage(
earthMessage("status.runtimePaused", { label, active: isRotating }),
"info",
);
});
updateRotateUI();
@@ -5518,7 +5558,7 @@ function setupTerrainControls() {
bindListener(layoutBtn, "click", () => {
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
showStatusMessage(earthMessage("status.layoutExpanded", { expanded }), "info");
});
const mediaVisible =
@@ -5552,7 +5592,13 @@ function setupKeyboardControls() {
if (!nextBinding) return;
if (setShortcutBinding(capturingShortcutActionId, nextBinding)) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(capturingShortcutActionId);
showStatusMessage(`${definition?.label || "快捷键"}已设置为 ${getShortcutDisplayLabel(nextBinding)}`, "info");
showStatusMessage(
earthMessage("status.shortcutSet", {
label: definition?.label || "快捷键",
binding: getShortcutDisplayLabel(nextBinding),
}),
"info",
);
capturingShortcutActionId = null;
syncShortcutCaptureUi();
}
@@ -6012,9 +6058,9 @@ function updateRotateUI() {
? "动捕"
: "自动旋转";
if (tooltip) {
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
tooltip.textContent = translateText(autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`);
}
btn.title = `${getRotationModeLabel()} · ${activeLabel}`;
btn.title = translateText(`${getRotationModeLabel()} · ${activeLabel}`);
}
syncRotationModeButtons();
@@ -6056,7 +6102,7 @@ export function setAutoRotationSpeed(value, { persist = true, suppressStatus = f
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(`旋转转速已设为 ${formatAutoRotationSpeed(normalizedSpeed)}`, "info");
showStatusMessage(earthMessage("status.rotateSpeed", { speed: formatAutoRotationSpeed(normalizedSpeed) }), "info");
}
return normalizedSpeed;
}
@@ -6079,7 +6125,7 @@ export function setRotationMode(nextMode, { persist = true, suppressStatus = fal
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(`已切换到${getRotationModeLabel(normalizedMode)}`, "info");
showStatusMessage(earthMessage("status.switchedTo", { label: getRotationModeLabel(normalizedMode) }), "info");
}
}
@@ -6151,7 +6197,7 @@ export function focusEarthView(camera, options = {}) {
earthObj.rotation.y = nextRotation.y;
earthObj.rotation.z = nextRotation.z;
if (!suppressStatus) {
showStatusMessage("视角已重置", "info");
showStatusMessage(earthMessage("status.viewReset"), "info");
}
resolve();
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,12 @@
// info-card.js - Unified info card module
import { showStatusMessage } from './ui.js';
import {
earthMessage,
getEarthLocale,
hasCjkText,
localizeCountryName,
translateText,
} from './i18n.js';
import {
getNewsDisplaySummary,
getNewsDisplayTitle,
@@ -57,7 +64,7 @@ function setLocationCollectState(contextOrKey, patch = {}) {
: getLocationCollectCacheKey(contextOrKey);
if (!key) return null;
if (typeof contextOrKey !== 'string') {
locationCollectContextCache.set(key, contextOrKey);
rememberLocationCollectContext(contextOrKey);
}
if (Array.isArray(patch.candidates)) {
locationCollectCandidatesByKey.set(key, patch.candidates);
@@ -85,8 +92,8 @@ function clearLocationCollectState(contextOrKey) {
}
function getWebSearchDisabledTitle(capability = computeCenterLocationCapability) {
if (!capability) return '正在检查 WebSearch 状态,稍候即可定位。';
return capability.reason || 'WebSearch 未开启,无法进行事实核查定位。';
if (!capability) return infoText('正在检查 WebSearch 状态,稍候即可定位。');
return infoText(capability.reason || 'WebSearch 未开启,无法进行事实核查定位。');
}
function isComputeCenterLocationBlocked() {
@@ -125,7 +132,18 @@ function updateComputeCenterLocationCapabilityDom() {
button.disabled = blocked || button.classList.contains('is-loading');
button.classList.toggle('is-websearch-blocked', blocked);
button.title = blocked ? title : button.dataset.readyTitle || button.title || '';
button.setAttribute('aria-disabled', blocked ? 'true' : 'false');
button.setAttribute('aria-disabled', String(button.disabled));
});
const hasActiveCollection = Array.from(locationCollectStateCache.entries())
.some(([key, state]) => key.startsWith('compute_center:') && state.loading);
document.querySelectorAll('[data-unresolved-adopt-all], [data-unresolved-item] button')
.forEach((button) => {
const root = button.closest('[data-unresolved-item]');
const busy = computeCenterUnresolvedBatchState.running || (root
? getLocationCollectState(root.dataset.collectCacheKey)?.loading === true
: hasActiveCollection);
button.disabled = busy || (blocked && button.matches('[data-requires-web-search]'));
button.setAttribute('aria-disabled', String(button.disabled));
});
}
@@ -134,7 +152,7 @@ function setLocationButtonLoading(button, loading, label) {
button.classList.toggle('is-loading', Boolean(loading));
button.toggleAttribute('aria-busy', Boolean(loading));
if (label) {
button.dataset.loadingLabel = label;
button.dataset.loadingLabel = infoText(label);
}
updateComputeCenterLocationCapabilityDom();
}
@@ -157,9 +175,14 @@ function setUnresolvedBatchState(patch = {}) {
...patch,
};
updateUnresolvedBatchDom();
window.dispatchEvent(new CustomEvent('earth:compute-center-location-batch-change'));
return computeCenterUnresolvedBatchState;
}
export function getComputeCenterLocationBatchState() {
return { ...computeCenterUnresolvedBatchState };
}
function getCandidateForButton(button) {
if (!(button instanceof HTMLElement)) return null;
const root = button.closest('[data-collect-cache-key]');
@@ -175,6 +198,10 @@ function updateLocationCollectDomFromState(key) {
if (!key) return;
const state = getLocationCollectState(key);
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
if (state?.savedCandidate && root.matches('[data-unresolved-item]')) {
removeResolvedUnresolvedItem(root.closest('.info-card-unresolved-list').parentElement, root);
return;
}
hydrateLocationCollectRoot(root, state);
ensureCandidateActionBindings(root, locationCollectContextCache.get(key));
});
@@ -188,14 +215,27 @@ function formatInfoCardValue(field, rawValue) {
if (IDENTIFIER_FIELD_KEYS.has(field.key)) {
value = String(value);
} else if (typeof value === 'number') {
value = value.toLocaleString();
value = value.toLocaleString(getEarthLocale());
}
if (field.key === 'country') {
value = localizeCountryName(value) || value;
}
if (field.unit && value !== '-') {
value = value + ' ' + field.unit;
value = value + ' ' + translateText(field.unit);
} else if (typeof value === 'string') {
value = translateText(value);
}
return value;
}
function infoText(value, fallback = '') {
const translated = translateText(value);
if (getEarthLocale() === 'en-US' && hasCjkText(translated) && fallback) {
return fallback;
}
return translated;
}
function escapeInfoCardHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
@@ -299,18 +339,18 @@ function renderNewsCardContent(content, data) {
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(label)}</span>
<span>${escapeInfoCardHtml(infoText(label))}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="info-card-news-layout">
<div class="info-card-news-kicker">新闻信号</div>
<div class="info-card-news-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="info-card-news-summary-shell">
<div class="info-card-news-summary-label">概要</div>
<div class="info-card-news-summary-label">${escapeInfoCardHtml(infoText('概要'))}</div>
<div class="info-card-news-summary" data-news-summary></div>
</div>
</div>
@@ -332,18 +372,18 @@ function renderMobileNewsCardContent(content, data) {
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid info-card-news-meta-grid--mobile">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(label)}</span>
<span>${escapeInfoCardHtml(infoText(label))}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">新闻信号</div>
<div class="earth-mobile-news-detail-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">概要</div>
<div class="earth-mobile-news-detail-summary-label">${escapeInfoCardHtml(infoText('概要'))}</div>
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
</div>
</div>
@@ -371,7 +411,7 @@ function renderMobileDetailContent(type, config, data) {
const value = formatInfoCardValue(field, data[field.key]);
html += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-label">${escapeInfoCardHtml(infoText(field.label))}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
@@ -428,7 +468,7 @@ function renderDefaultCardContent(content, config, data) {
const sourceLabel = getFieldSourceLabel(data, field.key);
html += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-label">${escapeInfoCardHtml(infoText(field.label))}</span>
<span class="info-card-value">${value}${sourceLabel}</span>
</div>
`;
@@ -519,8 +559,8 @@ function buildLocationCollectContext(config, data) {
function renderLocationCollectSection(context) {
const buttonLabel = context.needsConfirmation
? '重新自动采集坐标'
: '自动采集坐标候选';
? infoText('重新自动采集坐标')
: infoText('自动采集坐标候选');
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
ensureComputeCenterLocationCapability();
@@ -559,7 +599,7 @@ function hydrateLocationCollectRoot(root, state) {
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
const candidatesEl = root.querySelector('[data-collect-candidates], [data-unresolved-candidates]');
const button = root.querySelector('[data-collect-action="run"], [data-unresolved-collect]');
if (statusEl) statusEl.textContent = state?.statusText || '';
if (statusEl) statusEl.textContent = infoText(state?.statusText || '');
if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state);
if (button instanceof HTMLButtonElement) {
button.classList.toggle('is-loading', state?.loading === true);
@@ -572,7 +612,7 @@ function hydrateLocationCollectRoot(root, state) {
function rememberLocationCollectContext(context) {
const key = getLocationCollectCacheKey(context);
if (!key) return '';
locationCollectContextCache.set(key, context);
locationCollectContextCache.set(key, { ...locationCollectContextCache.get(key), ...context });
return key;
}
@@ -593,7 +633,6 @@ function getLocationCollectContextForRoot(root, fallbackContext) {
...parsed,
entityType: 'compute_center',
entityId: parsed.sourceId,
isUnresolved: true,
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(parsed.sourceId, candidate, parsed);
@@ -654,15 +693,17 @@ function ensureCandidateActionBindings(rootOrChild, context) {
if (typeof actionContext.save !== 'function') return;
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
button.disabled = true;
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
if (statusEl) statusEl.textContent = infoText('正在保存所选坐标...');
try {
const saveResult = await actionContext.save(candidate);
setLocationCollectState(actionContext, {
loading: false,
statusText: '坐标已保存',
statusText: infoText('坐标已保存'),
candidates: [],
savedCandidate: candidate,
saveResult,
});
if (statusEl) statusEl.textContent = '坐标已保存';
if (statusEl) statusEl.textContent = infoText('坐标已保存');
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
@@ -674,18 +715,9 @@ function ensureCandidateActionBindings(rootOrChild, context) {
},
}),
);
if (actionContext.entityType === 'compute_center' && actionContext.isUnresolved === true) {
const itemRoot = root.closest('[data-unresolved-item]');
if (itemRoot) {
removeResolvedUnresolvedItem(
itemRoot.closest('#info-card-content') || document,
itemRoot,
);
}
}
} catch (error) {
console.error('save compute-center location failed', error);
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
if (statusEl) statusEl.textContent = infoText(`保存失败:${error?.message || error}`);
button.disabled = false;
}
});
@@ -695,14 +727,14 @@ function formatLocationCollectFailure(result) {
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
const llmReason = result?.llm_failure_reason;
if (llmReason) {
return `常规来源无结果LLM 兜底未生成可用候选:${llmReason}`;
return infoText(`常规来源无结果LLM 兜底未生成可用候选:${llmReason}`);
}
const attempted = Array.isArray(result?.attempted_queries) ? result.attempted_queries : [];
const attemptedLlm = attempted.some((query) => String(query || '').startsWith('llm_factcheck:'));
if (attemptedLlm) {
return `常规来源无结果LLM 兜底已尝试但没有返回可用候选。${regularReason}`;
return infoText(`常规来源无结果LLM 兜底已尝试但没有返回可用候选。${regularReason}`);
}
return regularReason;
return infoText(regularReason);
}
function bindLocationCollectControls(content, context) {
@@ -728,7 +760,7 @@ function bindLocationCollectControls(content, context) {
setLocationButtonLoading(button, true, '正在定位');
setLocationCollectState(context, {
loading: true,
statusText: '正在采集坐标候选...',
statusText: infoText('正在采集坐标候选...'),
candidates: [],
});
try {
@@ -736,7 +768,7 @@ function bindLocationCollectControls(content, context) {
if (!result?.success) {
setLocationCollectState(context, {
loading: false,
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
statusText: infoText(`未能采集到坐标:${formatLocationCollectFailure(result)}`),
candidates: [],
result,
});
@@ -745,7 +777,7 @@ function bindLocationCollectControls(content, context) {
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
setLocationCollectState(context, {
loading: false,
statusText: `共找到 ${candidates.length} 个候选位置`,
statusText: infoText(`共找到 ${candidates.length} 个候选位置`),
candidates,
result,
});
@@ -753,7 +785,7 @@ function bindLocationCollectControls(content, context) {
console.error('collect-location failed', error);
setLocationCollectState(context, {
loading: false,
statusText: `采集失败:${error?.message || error}`,
statusText: infoText(`采集失败:${error?.message || error}`),
candidates: [],
});
} finally {
@@ -773,22 +805,22 @@ function renderCollectCandidateRow(candidate, isBest, index) {
? `${Math.round(Number(candidate.confidence) * 100)}%`
: '-';
const safeIndex = Number.isFinite(Number(index)) ? Number(index) : 0;
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || '候选');
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || infoText('候选'));
const sourceLabel = escapeInfoCardHtml(candidate.source || '');
return `
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-name">${name}</span>
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(precisionLabel)}</span>
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(infoText(precisionLabel))}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-source">${sourceLabel}</span>
<span class="info-card-compute-candidate-confidence">置信 ${escapeInfoCardHtml(confidence)}</span>
<span class="info-card-compute-candidate-confidence">${escapeInfoCardHtml(infoText(`置信 ${confidence}`))}</span>
</div>
<div class="info-card-compute-candidate-line">
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">预览</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">保存</button>
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">${escapeInfoCardHtml(infoText('预览'))}</button>
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">${escapeInfoCardHtml(infoText('保存'))}</button>
</div>
</div>
`;
@@ -803,7 +835,7 @@ function getUnresolvedComputeCenterContext(item) {
entityId: item?.source_id || item?.id || '',
sourceId: item?.source_id || item?.id || '',
recordId: item?.id || item?.record_id || '',
name: item?.name || item?.title || '未命名算力中心',
name: item?.name || item?.title || infoText('未命名算力中心'),
site_type: item?.site_type || metadata.site_type || '',
operator: item?.operator || item?.vendor || metadata.operator || '',
site: item?.site || metadata.site || metadata.organization || '',
@@ -814,19 +846,11 @@ function getUnresolvedComputeCenterContext(item) {
}
function renderComputeCenterUnresolvedContent(content, data) {
const items = Array.isArray(data?.items) ? data.items : [];
const items = (Array.isArray(data?.items) ? data.items : [])
.filter((item) => !getLocationCollectState(getUnresolvedComputeCenterContext(item))?.savedCandidate);
ensureComputeCenterLocationCapability();
const blocked = isComputeCenterLocationBlocked();
const disabledTitle = getWebSearchDisabledTitle();
if (!items.length) {
content.innerHTML = `
<div class="info-card-unresolved-empty">
当前没有待定位算力中心
</div>
`;
return;
}
const rows = items
.map((item, index) => {
const context = getUnresolvedComputeCenterContext(item);
@@ -835,7 +859,7 @@ function renderComputeCenterUnresolvedContent(content, data) {
const cached = getLocationCollectState(cacheKey);
const meta = [context.site || context.operator, context.city, context.country]
.filter(Boolean)
.join(' · ') || '缺少可用地址字段';
.join(' · ') || infoText('缺少可用地址字段');
return `
<div class="info-card-unresolved-item" data-unresolved-item data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<div class="info-card-unresolved-main">
@@ -847,10 +871,10 @@ function renderComputeCenterUnresolvedContent(content, data) {
<button type="button" class="info-card-compute-candidate-preview"
data-unresolved-collect
data-requires-web-search="true"
data-ready-title="采集坐标候选"
title="${escapeInfoCardHtml(blocked ? disabledTitle : '采集坐标候选')}"
data-ready-title="${escapeInfoCardHtml(infoText('采集坐标候选'))}"
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('采集坐标候选'))}"
${blocked ? 'disabled aria-disabled="true"' : ''}
data-context-json="${contextJson}">采集</button>
data-context-json="${contextJson}">${escapeInfoCardHtml(getEarthLocale() === 'en-US' ? 'Collect' : '采集')}</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates>
@@ -863,14 +887,17 @@ function renderComputeCenterUnresolvedContent(content, data) {
content.innerHTML = `
<div class="info-card-unresolved-summary">
<span data-unresolved-summary-text>${items.length} 个算力中心没有可信坐标</span>
<span data-unresolved-summary-text>${escapeInfoCardHtml(infoText(items.length
? `${items.length} 个算力中心没有可信坐标`
: '当前没有待定位算力中心'))}</span>
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt"
data-unresolved-adopt-all
${items.length ? '' : 'hidden'}
data-requires-web-search="true"
data-ready-title="一键定位并采用最高置信候选"
title="${escapeInfoCardHtml(blocked ? disabledTitle : '一键定位并采用最高置信候选')}"
data-ready-title="${escapeInfoCardHtml(infoText('一键定位并采用最高置信候选'))}"
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('一键定位并采用最高置信候选'))}"
${blocked ? 'disabled aria-disabled="true"' : ''}>
一键定位
${escapeInfoCardHtml(infoText('一键定位'))}
</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-batch-status>${escapeInfoCardHtml(computeCenterUnresolvedBatchState.statusText || '')}</div>
@@ -884,12 +911,13 @@ function renderComputeCenterUnresolvedContent(content, data) {
}
function updateUnresolvedSummary(content) {
if (!content?.isConnected || !content.querySelector('[data-unresolved-summary-text]')) return;
const remainingCount = content.querySelectorAll('[data-unresolved-item]').length;
const summaryText = content.querySelector('[data-unresolved-summary-text]');
if (summaryText) {
summaryText.textContent = remainingCount > 0
? `${remainingCount} 个算力中心没有可信坐标`
: '当前没有待定位算力中心';
? infoText(`${remainingCount} 个算力中心没有可信坐标`)
: infoText('当前没有待定位算力中心');
}
const adoptAllButton = content.querySelector('[data-unresolved-adopt-all]');
if (adoptAllButton instanceof HTMLButtonElement) {
@@ -948,8 +976,7 @@ async function collectUnresolvedComputeCenterCandidates(context, options = {}) {
async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel = '') {
setLocationCollectState(context, {
loading: true,
statusText: progressLabel || '正在一键定位并采用最高置信候选...',
candidates: [],
statusText: infoText(progressLabel || '正在一键定位并采用最高置信候选...'),
});
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(
context,
@@ -958,7 +985,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
if (!result?.success) {
setLocationCollectState(context, {
loading: false,
statusText: `未找到可采用候选:${formatLocationCollectFailure(result)}`,
statusText: infoText(`未找到可采用候选:${formatLocationCollectFailure(result)}`),
candidates: [],
result,
});
@@ -966,7 +993,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
}
setLocationCollectState(context, {
loading: true,
statusText: `找到 ${candidates.length} 个候选,正在保存最高置信位置...`,
statusText: infoText(`找到 ${candidates.length} 个候选,正在保存最高置信位置...`),
candidates,
result,
});
@@ -974,7 +1001,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
if (!bestCandidate) {
setLocationCollectState(context, {
loading: false,
statusText: '未找到包含有效经纬度的候选',
statusText: infoText('未找到包含有效经纬度的候选'),
candidates,
result,
});
@@ -983,7 +1010,7 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
const saveResult = await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
setLocationCollectState(context, {
loading: false,
statusText: '坐标已保存,等待图层刷新',
statusText: infoText('坐标已保存,等待图层刷新'),
candidates,
result,
savedCandidate: bestCandidate,
@@ -1015,6 +1042,67 @@ function getBestLocationCandidate(candidates) {
})[0] || null;
}
// The queue owns only entity contexts, so replacing or hiding a card cannot
// detach the running work from its progress and results.
async function runUnresolvedComputeCenterBatch(contexts) {
setUnresolvedBatchState({
running: true,
total: contexts.length,
processed: 0,
saved: 0,
missed: 0,
statusText: infoText(`一键定位进行中 0/${contexts.length}...`),
});
let savedCount = 0;
let missedCount = 0;
try {
for (const [index, context] of contexts.entries()) {
const progressText = infoText(`正在定位并采用最高置信候选 ${index + 1}/${contexts.length}...`);
setUnresolvedBatchState({ statusText: progressText });
try {
const outcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
if (outcome.saved) savedCount += 1;
else missedCount += 1;
} catch (error) {
console.error('adopt unresolved compute-center location failed', error);
setLocationCollectState(context, {
loading: false,
statusText: infoText(`一键定位失败:${error?.message || error}`),
candidates: [],
});
missedCount += 1;
} finally {
setUnresolvedBatchState({
processed: index + 1,
saved: savedCount,
missed: missedCount,
statusText: infoText(`一键定位进行中 ${index + 1}/${contexts.length},已保存 ${savedCount}${missedCount ? `,失败 ${missedCount}` : ''}`),
});
}
}
const finalStatus = savedCount > 0
? infoText(`已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `${missedCount} 个仍需手动处理` : ''}`)
: infoText(`${missedCount} 个都没有可自动采用的候选,需要手动处理`);
setUnresolvedBatchState({ statusText: finalStatus });
if (savedCount > 0) {
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: 'compute_center',
entityId: 'batch',
savedCount,
missedCount,
},
}),
);
}
} finally {
setUnresolvedBatchState({ running: false });
}
}
function bindComputeCenterUnresolvedControls(content) {
content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => {
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
@@ -1025,13 +1113,13 @@ function bindComputeCenterUnresolvedControls(content) {
...context,
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: async (candidate) => {
const mod = await import('./compute-centers.js');
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
},
};
ensureCandidateActionBindings(itemRoot, actionContext);
hydrateLocationCollectRoot(itemRoot, getLocationCollectState(context));
});
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
@@ -1047,12 +1135,13 @@ function bindComputeCenterUnresolvedControls(content) {
const candidatesEl = itemRoot?.querySelector('[data-unresolved-candidates]');
const context = JSON.parse(button.dataset.contextJson || '{}');
if (!context.sourceId || !statusEl || !candidatesEl) return;
if (computeCenterUnresolvedBatchState.running || getLocationCollectState(context)?.loading) return;
itemRoot?.classList.add('is-locating');
setLocationButtonLoading(button, true, '正在定位');
setLocationCollectState(context, {
loading: true,
statusText: '正在采集坐标候选...',
statusText: infoText('正在采集坐标候选...'),
candidates: [],
});
try {
@@ -1060,7 +1149,7 @@ function bindComputeCenterUnresolvedControls(content) {
if (!result?.success) {
setLocationCollectState(context, {
loading: false,
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
statusText: infoText(`未能采集到坐标:${formatLocationCollectFailure(result)}`),
candidates: [],
result,
});
@@ -1068,7 +1157,7 @@ function bindComputeCenterUnresolvedControls(content) {
}
setLocationCollectState(context, {
loading: false,
statusText: `共找到 ${candidates.length} 个候选位置`,
statusText: infoText(`共找到 ${candidates.length} 个候选位置`),
candidates,
result,
});
@@ -1076,7 +1165,6 @@ function bindComputeCenterUnresolvedControls(content) {
...context,
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
};
ensureCandidateActionBindings(itemRoot, actionContext);
@@ -1084,7 +1172,7 @@ function bindComputeCenterUnresolvedControls(content) {
console.error('collect unresolved compute-center location failed', error);
setLocationCollectState(context, {
loading: false,
statusText: `采集失败:${error?.message || error}`,
statusText: infoText(`采集失败:${error?.message || error}`),
candidates: [],
});
} finally {
@@ -1108,102 +1196,11 @@ function bindComputeCenterUnresolvedControls(content) {
updateUnresolvedBatchDom();
return;
}
const statusEl = content.querySelector('[data-unresolved-batch-status]');
const buttons = Array.from(content.querySelectorAll('button'));
const pendingItems = Array.from(content.querySelectorAll('[data-unresolved-item]'))
.map((itemRoot) => {
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
return { itemRoot, context };
})
.filter(({ context }) => context.sourceId);
if (!pendingItems.length) return;
buttons.forEach((button) => { button.disabled = true; });
setLocationButtonLoading(adoptAllButton, true, '正在定位');
setUnresolvedBatchState({
running: true,
total: pendingItems.length,
processed: 0,
saved: 0,
missed: 0,
statusText: `一键定位进行中 0/${pendingItems.length}...`,
});
let savedCount = 0;
let missedCount = 0;
try {
for (const [index, { itemRoot, context }] of pendingItems.entries()) {
const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]');
itemRoot.classList.add('is-locating');
const progressText = `正在定位并采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
setUnresolvedBatchState({
statusText: progressText,
processed: index,
saved: savedCount,
missed: missedCount,
});
if (statusEl) statusEl.textContent = progressText;
try {
const saveOutcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
if (!saveOutcome.saved) {
if (itemStatusEl) itemStatusEl.textContent = getLocationCollectState(context)?.statusText || '未找到可采用候选';
missedCount += 1;
continue;
}
savedCount += 1;
removeResolvedUnresolvedItem(content, itemRoot);
} catch (error) {
console.error('adopt unresolved compute-center location failed', error);
setLocationCollectState(context, {
loading: false,
statusText: `一键定位失败:${error?.message || error}`,
candidates: [],
});
if (itemStatusEl) {
itemStatusEl.textContent = `一键采用失败:${error?.message || error}`;
}
missedCount += 1;
} finally {
itemRoot.classList.remove('is-locating');
setUnresolvedBatchState({
processed: index + 1,
saved: savedCount,
missed: missedCount,
statusText: `一键定位进行中 ${index + 1}/${pendingItems.length},已保存 ${savedCount}${missedCount ? `,失败 ${missedCount}` : ''}`,
});
}
}
const finalStatus = savedCount > 0
? `已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `${missedCount} 个仍需手动处理` : ''}`
: `${missedCount} 个都没有可自动采用的候选,需要手动处理`;
if (statusEl) statusEl.textContent = finalStatus;
setUnresolvedBatchState({
running: false,
processed: pendingItems.length,
saved: savedCount,
missed: missedCount,
statusText: finalStatus,
});
if (savedCount > 0) {
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
entityType: 'compute_center',
entityId: 'batch',
savedCount,
missedCount,
},
}),
);
}
} finally {
setUnresolvedBatchState({ running: false });
setLocationButtonLoading(adoptAllButton, false);
buttons.forEach((button) => { button.disabled = false; });
updateComputeCenterLocationCapabilityDom();
}
const contexts = Array.from(content.querySelectorAll('[data-unresolved-collect]'))
.map((button) => JSON.parse(button.dataset.contextJson || '{}'))
.filter((context) => context.sourceId && !getLocationCollectState(context)?.savedCandidate);
if (!contexts.length || contexts.some((context) => getLocationCollectState(context)?.loading)) return;
await runUnresolvedComputeCenterBatch(contexts);
});
}
}
@@ -1213,7 +1210,7 @@ function getFieldSourceLabel(data, fieldKey) {
if (!sources || typeof sources !== 'object') return '';
const source = sources[fieldKey];
if (!source) return '';
return ` <span class="info-card-source-tag" title="字段来源">${source}</span>`;
return ` <span class="info-card-source-tag" title="${escapeInfoCardHtml(infoText('字段来源'))}">${escapeInfoCardHtml(infoText(source))}</span>`;
}
function renderVesselEnrichmentSection(enrichment) {
@@ -1223,8 +1220,8 @@ function renderVesselEnrichmentSection(enrichment) {
if (!profile && !media) {
return `
<div class="info-card-enrichment info-card-enrichment--empty">
<div class="info-card-enrichment-title">船舶资料</div>
<div class="info-card-enrichment-status">资料缓存中</div>
<div class="info-card-enrichment-title">${escapeInfoCardHtml(infoText('船舶资料'))}</div>
<div class="info-card-enrichment-status">${escapeInfoCardHtml(infoText('资料缓存中'))}</div>
</div>
`;
}
@@ -1244,11 +1241,11 @@ function renderVesselEnrichmentSection(enrichment) {
inner += renderEnrichmentMeta('媒体', media);
}
if (!inner) {
inner = '<div class="info-card-enrichment-status">资料缓存中</div>';
inner = `<div class="info-card-enrichment-status">${escapeInfoCardHtml(infoText('资料缓存中'))}</div>`;
}
return `
<div class="info-card-enrichment">
<div class="info-card-enrichment-title">船舶资料</div>
<div class="info-card-enrichment-title">${escapeInfoCardHtml(infoText('船舶资料'))}</div>
${inner}
</div>
`;
@@ -1261,8 +1258,8 @@ function renderEnrichmentPayloadRows(payload) {
if (typeof value === 'object') continue;
rows += `
<div class="info-card-property">
<span class="info-card-label">${key}</span>
<span class="info-card-value">${String(value)}</span>
<span class="info-card-label">${escapeInfoCardHtml(infoText(key))}</span>
<span class="info-card-value">${escapeInfoCardHtml(infoText(String(value)))}</span>
</div>
`;
}
@@ -1271,45 +1268,45 @@ function renderEnrichmentPayloadRows(payload) {
function renderEnrichmentMeta(label, record) {
const parts = [];
if (record.source) parts.push(`来源 ${record.source}`);
if (record.fetched_at) parts.push(`更新 ${record.fetched_at}`);
if (record.source) parts.push(infoText(`来源 ${record.source}`));
if (record.fetched_at) parts.push(infoText(`更新 ${record.fetched_at}`));
if (record.confidence !== null && record.confidence !== undefined) {
parts.push(`置信 ${Number(record.confidence).toFixed(2)}`);
parts.push(infoText(`置信 ${Number(record.confidence).toFixed(2)}`));
}
if (!parts.length) return '';
return `<div class="info-card-enrichment-meta">${label}${parts.join(' · ')}</div>`;
return `<div class="info-card-enrichment-meta">${escapeInfoCardHtml(infoText(label))}: ${escapeInfoCardHtml(parts.join(' · '))}</div>`;
}
// ── Mobile popup ─────────────────────────────────────────────
function getMobilePopupTitle(type, data) {
switch (type) {
case 'cable': return data.name || '海缆';
case 'landing_point': return data.name || '登陆点';
case 'satellite': return data.name || '卫星';
case 'bgp': return data.anomaly_type || 'BGP事件';
case 'cable': return data.name || infoText('海缆');
case 'landing_point': return data.name || infoText('登陆点');
case 'satellite': return data.name || infoText('卫星');
case 'bgp': return data.anomaly_type || infoText('BGP事件');
case 'news': return getNewsCardTitle(data);
case 'bgp_collector': return data.collector || 'BGP观测站';
case 'compute_center_unresolved': return '待定位算力中心';
case 'supercomputer': return data.name || '超算';
case 'gpu_cluster': return data.name || 'GPU集群';
case 'vessel': return data.name || '船只';
default: return '详情';
case 'bgp_collector': return data.collector || infoText('BGP观测站');
case 'compute_center_unresolved': return infoText('待定位算力中心');
case 'supercomputer': return data.name || infoText('超算');
case 'gpu_cluster': return data.name || infoText('GPU集群');
case 'vessel': return data.name || infoText('船只');
default: return infoText('详情');
}
}
function getMobilePopupSubtitle(type, data) {
switch (type) {
case 'cable': return data.owner || data.status || '海缆';
case 'landing_point': return data.country || '登陆点';
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
case 'bgp': return data.severity || 'BGP路由异常';
case 'news': return getNewsCardSummaryPreview(data, 30) || '态势新闻';
case 'bgp_collector': return data.location || 'BGP观测站';
case 'compute_center_unresolved': return `${data?.totalCount || 0} 个待定位`;
case 'supercomputer': return data.country || '超级计算机';
case 'gpu_cluster': return data.country || 'GPU集群';
case 'vessel': return data.vessel_type || 'AIS 船只';
case 'cable': return infoText(data.owner || data.status || '海缆');
case 'landing_point': return localizeCountryName(data.country) || infoText('登陆点');
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : infoText('卫星');
case 'bgp': return infoText(data.severity || 'BGP路由异常');
case 'news': return getNewsCardSummaryPreview(data, 30) || infoText('态势新闻');
case 'bgp_collector': return data.location || infoText('BGP观测站');
case 'compute_center_unresolved': return infoText(`${data?.totalCount || 0} 个待定位`);
case 'supercomputer': return localizeCountryName(data.country) || infoText('超级计算机');
case 'gpu_cluster': return localizeCountryName(data.country) || infoText('GPU集群');
case 'vessel': return data.vessel_type || infoText('AIS 船只');
default: return '';
}
}
@@ -1809,8 +1806,8 @@ function mountCard() {
<div id="info-card" class="info-card">
<div class="info-card-header hud-panel-drag-handle">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
<h3 id="info-card-title">${escapeInfoCardHtml(infoText('详情'))}</h3>
<button class="info-card-close hud-panel-close" type="button" aria-label="${escapeInfoCardHtml(infoText('关闭详情'))}">
<span class="material-symbols-rounded">close</span>
</button>
</div>
@@ -1850,16 +1847,19 @@ function mountCard() {
const value = valueEl?.textContent?.trim();
if (!value || value === '-') {
showStatusMessage('无可复制内容', 'warning');
showStatusMessage(earthMessage("status.copyEmpty"), 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(`已复制${label.textContent}${value}`, 'success');
showStatusMessage(
earthMessage("status.copyValue", { label: label.textContent, value }),
'success',
);
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage('复制失败', 'error');
showStatusMessage(earthMessage("status.copyFailed"), 'error');
}
});
@@ -1885,7 +1885,7 @@ function positionPanel(panel, x, y, options = {}) {
const scale = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
) || 1;
const estW = Math.min(300 * scale, vpW - 32);
const estW = Math.min(340 * scale, vpW - 32);
const estH = Math.min(420 * scale, vpH * 0.7);
if (options.absolute === true) {
@@ -2000,11 +2000,11 @@ export function showInfoCard(type, data, options = {}) {
if (title) {
title.textContent = type === 'news'
? getNewsCardTitle(data)
: config.title;
: infoText(config.title);
}
if (typeLabel) {
typeLabel.textContent = type === 'news'
? '新闻信号'
? infoText('新闻信号')
: type.replaceAll('_', ' ');
}
@@ -2041,7 +2041,7 @@ export function showInfoCard(type, data, options = {}) {
icon.textContent = config.icon;
title.textContent = type === 'news'
? getNewsCardTitle(data)
: config.title;
: infoText(config.title);
if (type === 'news') {
renderNewsCardContent(content, data);

View File

@@ -8,6 +8,7 @@ const assetImageLoadPromises = new Map();
const surfaceAvoidanceBuckets = new Map();
const interactableLayerControllers = new Map();
const DEFAULT_AVOIDANCE_PRECISION = 4;
const POINT_BUCKET_MIN_CAPACITY = 32;
const COMPACT_DOT_ZOOM_THRESHOLD = 1.7;
const COMPACT_DOT_POINT_SIZE = 12;
const COMPACT_DOT_RADIUS_RATIO = 0.26;
@@ -812,6 +813,8 @@ export function createInteractableLayer(options = {}) {
const markers = [];
const pointObjects = [];
const markerById = new Map();
const pointSlots = new WeakMap();
const clusterPointObjects = [];
const textureCache = new Map();
let pointsGroup = null;
@@ -1145,6 +1148,7 @@ export function createInteractableLayer(options = {}) {
}
function beginClusterUpdate() {
if (!clusterConfig.enabled) return;
clusterUpdateActive = true;
ownedClusterRecords.length = 0;
markers.forEach((marker) => {
@@ -1356,18 +1360,80 @@ export function createInteractableLayer(options = {}) {
const bucketMarkers = points.userData?.markers || [];
const colorAttribute = points.geometry?.getAttribute("color");
if (!colorAttribute?.array) return;
let firstChanged = -1;
let lastChanged = -1;
bucketMarkers.forEach((marker, index) => {
const pointColor =
compactDotMode || icon.colorable !== false
? getMarkerColor(marker)
: "#ffffff";
const [r, g, b] = colorToRgbArray(pointColor);
colorAttribute.array[index * 3] = r;
colorAttribute.array[index * 3 + 1] = g;
colorAttribute.array[index * 3 + 2] = b;
if (colorAttribute.getX(index) !== Math.fround(r)
|| colorAttribute.getY(index) !== Math.fround(g)
|| colorAttribute.getZ(index) !== Math.fround(b)) {
colorAttribute.setXYZ(index, r, g, b);
if (firstChanged < 0) firstChanged = index;
lastChanged = index;
}
});
colorAttribute.needsUpdate = true;
if (firstChanged >= 0) markAttributeRange(colorAttribute, firstChanged * 3, (lastChanged - firstChanged + 1) * 3);
}
function addPointBucket(bucketKey, bucketMarkers, compactDotMode = false) {
const count = bucketMarkers.length;
const positions = new Float32Array(count * 3);
const colorValues = new Float32Array(count * 3);
bucketMarkers.forEach((marker, index) => {
positions[index * 3] = marker.position.x;
positions[index * 3 + 1] = marker.position.y;
positions[index * 3 + 2] = marker.position.z;
const pointColor =
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
const [r, g, b] = colorToRgbArray(pointColor);
colorValues[index * 3] = r;
colorValues[index * 3 + 1] = g;
colorValues[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage));
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3).setUsage(THREE.DynamicDrawUsage));
geometry.computeBoundingSphere();
const material = applyIconAnchor(
new THREE.PointsMaterial({
map: compactDotMode
? createCompactDotTexture(bucketMarkers[0])
: createPointTexture(bucketKey, bucketMarkers),
size:
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
getPointSizeMultiplier(bucketMarkers[0]),
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
depthWrite,
depthTest,
alphaTest,
}),
);
const points = new THREE.Points(geometry, material);
points.renderOrder = renderOrder;
points.frustumCulled = false;
points.userData = {
type: `${id}_points`,
id,
bucketKey,
markers: bucketMarkers,
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
transitionStartedAt: getNowMs(),
};
pointObjects.push(points);
pointsGroup.add(points);
bucketMarkers.forEach((marker, index) => pointSlots.set(marker, { points, index }));
return points;
}
function buildPoints(camera = null) {
@@ -1391,58 +1457,7 @@ export function createInteractableLayer(options = {}) {
});
buckets.forEach((bucketMarkers, bucketKey) => {
const count = bucketMarkers.length;
const positions = new Float32Array(count * 3);
const colorValues = new Float32Array(count * 3);
bucketMarkers.forEach((marker, index) => {
positions[index * 3] = marker.position.x;
positions[index * 3 + 1] = marker.position.y;
positions[index * 3 + 2] = marker.position.z;
const pointColor =
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
const [r, g, b] = colorToRgbArray(pointColor);
colorValues[index * 3] = r;
colorValues[index * 3 + 1] = g;
colorValues[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3));
geometry.computeBoundingSphere();
const material = applyIconAnchor(
new THREE.PointsMaterial({
map: compactDotMode
? createCompactDotTexture(bucketMarkers[0])
: createPointTexture(bucketKey, bucketMarkers),
size:
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
getPointSizeMultiplier(bucketMarkers[0]),
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
depthWrite,
depthTest,
alphaTest,
}),
);
const points = new THREE.Points(geometry, material);
points.renderOrder = renderOrder;
points.frustumCulled = false;
points.userData = {
type: `${id}_points`,
id,
bucketKey,
markers: bucketMarkers,
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
transitionStartedAt: getNowMs(),
};
pointObjects.push(points);
pointsGroup.add(points);
addPointBucket(bucketKey, bucketMarkers, compactDotMode);
});
group.add(pointsGroup);
@@ -1612,6 +1627,7 @@ export function createInteractableLayer(options = {}) {
invalidateClusterTopology();
unregisterLayerAvoidance(id);
markers.length = 0;
markerById.clear();
clearRenderObjects();
disposeGroupChildren(group);
@@ -1636,6 +1652,7 @@ export function createInteractableLayer(options = {}) {
state: "normal",
};
markers.push(marker);
markerById.set(String(getItemId(marker.userData)), marker);
});
registerLayerAvoidance(id, markers, avoidanceConfig);
@@ -1649,6 +1666,116 @@ export function createInteractableLayer(options = {}) {
group.visible = wasVisible;
}
function markAttributeRange(attribute, offset, count) {
const range = attribute.updateRange;
const start = range.count < 0 ? offset : Math.min(offset, range.offset);
const end = range.count < 0 ? offset + count : Math.max(offset + count, range.offset + range.count);
range.offset = start;
range.count = end - start;
attribute.needsUpdate = true;
}
function syncPointBucket(bucketKey, bucketMarkers) {
let points = pointObjects.find((entry) => entry.userData.bucketKey === bucketKey);
if (!points) {
if (bucketMarkers.length) addPointBucket(bucketKey, bucketMarkers);
return;
}
const currentCapacity = points.geometry.attributes.position.count;
if (currentCapacity < bucketMarkers.length) {
const capacity = Math.max(bucketMarkers.length, currentCapacity * 2, POINT_BUCKET_MIN_CAPACITY);
points.geometry.dispose();
points.geometry = new THREE.BufferGeometry();
for (const name of ["position", "color"]) {
points.geometry.setAttribute(name, new THREE.BufferAttribute(new Float32Array(capacity * 3), 3)
.setUsage(THREE.DynamicDrawUsage));
}
}
points.userData.markers = bucketMarkers;
points.geometry.setDrawRange(0, bucketMarkers.length);
const positions = points.geometry.attributes.position;
bucketMarkers.forEach((marker, index) => {
positions.setXYZ(index, marker.position.x, marker.position.y, marker.position.z);
pointSlots.set(marker, { points, index });
});
if (bucketMarkers.length) markAttributeRange(positions, 0, bucketMarkers.length * 3);
updatePointColors(points, false);
}
function updateItems(items, { replace = false } = {}) {
if (clusterConfig.enabled || avoidanceConfig.enabled) {
throw new Error(`Incremental updates require an unclustered layer: ${id}`);
}
if (!pointsGroup) {
setData(items);
return;
}
const affectedBuckets = new Set();
const updatedMarkers = [];
const incomingIds = new Set();
const radius = CONFIG.earthRadius + altitudeOffset;
for (const item of items) {
const itemId = String(getItemId(item) ?? "");
if (!itemId) continue;
const position = normalizePosition(getPosition(item), radius);
if (!position) continue;
incomingIds.add(itemId);
let marker = markerById.get(itemId);
const previousBucket = marker ? getBucketKey(marker) : null;
if (!marker) {
marker = new THREE.Object3D();
markers.push(marker);
markerById.set(itemId, marker);
}
marker.position.copy(position);
marker.userData = {
...marker.userData,
...getUserData(item),
type: objectType,
icon_layer_id: id,
icon_kind: getKind(item),
icon_base_position: position,
state: marker.userData.state || "normal",
};
const nextBucket = getBucketKey(marker);
if (previousBucket !== nextBucket) {
if (previousBucket !== null) affectedBuckets.add(previousBucket);
affectedBuckets.add(nextBucket);
}
updatedMarkers.push(marker);
}
if (replace) {
let writeIndex = 0;
for (const marker of markers) {
const itemId = String(getItemId(marker.userData));
if (incomingIds.has(itemId)) markers[writeIndex++] = marker;
else {
affectedBuckets.add(getBucketKey(marker));
markerById.delete(itemId);
}
}
markers.length = writeIndex;
}
if (affectedBuckets.size) {
const buckets = new Map(Array.from(affectedBuckets, (key) => [key, []]));
markers.forEach((marker) => buckets.get(getBucketKey(marker))?.push(marker));
buckets.forEach((bucketMarkers, key) => syncPointBucket(key, bucketMarkers));
}
updatedMarkers.forEach((marker) => {
if (affectedBuckets.has(getBucketKey(marker))) return;
const slot = pointSlots.get(marker);
if (!slot) return;
const positions = slot.points.geometry.attributes.position;
if (positions.getX(slot.index) !== Math.fround(marker.position.x)
|| positions.getY(slot.index) !== Math.fround(marker.position.y)
|| positions.getZ(slot.index) !== Math.fround(marker.position.z)) {
positions.setXYZ(slot.index, marker.position.x, marker.position.y, marker.position.z);
markAttributeRange(positions, slot.index * 3, 3);
}
});
invalidateVisualState();
}
function upsertItem(item) {
const itemId = getItemId(item);
if (itemId === undefined || itemId === null || String(itemId).trim() === "") {
@@ -1722,6 +1849,7 @@ export function createInteractableLayer(options = {}) {
invalidateClusterTopology();
unregisterLayerAvoidance(id);
markers.length = 0;
markerById.clear();
clearRenderObjects();
disposeGroupChildren(group);
if (parent && group.parent === parent) {
@@ -1807,7 +1935,7 @@ export function createInteractableLayer(options = {}) {
points.material.needsUpdate = true;
}
updatePointColors(points, compactDotMode);
points.visible = visible;
points.visible = visible && points.userData.markers.length > 0;
const transitionProgress = getTransitionProgress(
points.userData?.transitionStartedAt,
clusterConfig.transitionMs,
@@ -1995,6 +2123,7 @@ export function createInteractableLayer(options = {}) {
getCount: () => markers.length,
isVisible: () => visible,
setData,
updateItems,
upsertItem,
removeItem,
preloadAssets,

View File

@@ -1,10 +1,13 @@
import { translateText } from "./i18n.js";
export function setButtonTooltip(button, text) {
const translatedText = translateText(text);
if (button instanceof HTMLElement) {
button.title = text;
button.title = translatedText;
}
const tooltip = button?.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = text;
tooltip.textContent = translatedText;
}
}

View File

@@ -32,6 +32,7 @@ import {
loadCountryBoundaries,
toggleCountryBoundaries,
} from "./country-boundaries.js";
import { earthMessage } from "./i18n.js";
/**
* Layer startup task registry.
@@ -101,7 +102,7 @@ function registerVesselStartupTask() {
if (!context.getShowVessels()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载船只..."),
resolveStartupMessage(layer, "load", earthMessage("startup.vessels")),
);
await context.yieldFrame(12);
try {
@@ -125,7 +126,7 @@ function registerCableStartupTask() {
if (!context.isCablesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
resolveStartupMessage(layer, "prepare", earthMessage("startup.landingPoints")),
);
await context.yieldFrame(12);
try {
@@ -137,7 +138,7 @@ function registerCableStartupTask() {
await context.yieldFrame(16);
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海缆..."),
resolveStartupMessage(layer, "load", earthMessage("startup.cables")),
);
await context.yieldFrame(12);
try {
@@ -161,7 +162,7 @@ function registerSatelliteStartupTask() {
if (!context.isSatellitesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载卫星..."),
resolveStartupMessage(layer, "load", earthMessage("startup.satellites")),
);
await context.yieldFrame(12);
try {
@@ -200,7 +201,7 @@ function registerSatelliteStartupTask() {
function registerBGPStartupTask() {
registerLayerStartupTask("bgp", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
resolveStartupMessage(layer, "load", earthMessage("startup.bgp")),
);
await context.yieldFrame(12);
try {
@@ -223,7 +224,7 @@ function registerEarthTextureStartupTask() {
if (!context.isEarthTextureVisible()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载地球纹理..."),
resolveStartupMessage(layer, "load", earthMessage("startup.hdTexture")),
);
await context.yieldFrame(12);
try {
@@ -241,7 +242,7 @@ function registerCloudStartupTask() {
if (!context.isCloudsEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载大气云图..."),
resolveStartupMessage(layer, "load", earthMessage("startup.clouds")),
);
await context.yieldFrame(12);
try {
@@ -257,7 +258,7 @@ function registerCloudStartupTask() {
function registerCountryBoundaryStartupTask() {
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
resolveStartupMessage(layer, "load", earthMessage("startup.landOceanBase")),
);
await context.yieldFrame(12);
try {
@@ -283,7 +284,7 @@ function registerCountryBoundaryStartupTask() {
function registerComputeCenterStartupTask() {
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
resolveStartupMessage(layer, "load", earthMessage("startup.computeCenters")),
);
await context.yieldFrame(12);
try {

View File

@@ -1,8 +1,9 @@
import { createHUDPanel } from "./hud-panels.js";
import { getEarthLocale, translateText } from "./i18n.js";
const LEGEND_MODES = {
cables: { title: "海缆" },
satellites: { title: "卫星" },
cables: { title: "海缆", compactTitleEn: "Cables" },
satellites: { title: "卫星", compactTitleEn: "Orbits" },
countryBoundaries: { title: "国界" },
computeCenters: { title: "算力" },
vessels: { title: "船只" },
@@ -11,6 +12,7 @@ const LEGEND_MODES = {
let currentLegendMode = "cables";
let legendPanel = null;
let legendLocaleListenerBound = false;
let legendItemsByMode = {
cables: [],
satellites: [],
@@ -40,6 +42,14 @@ export function initLegend() {
});
}
if (!legendLocaleListenerBound) {
legendLocaleListenerBound = true;
window.addEventListener("earth:locale-change", () => {
syncCurrentLabel(currentLegendMode);
renderLegend(currentLegendMode);
});
}
syncCurrentLabel(currentLegendMode);
renderLegend(currentLegendMode);
}
@@ -68,24 +78,38 @@ export function setLegendItems(mode, items) {
}
function syncCurrentLabel(mode) {
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
const nextLabel = getLegendModeTitle(mode);
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
.forEach((labelEl) => {
if (labelEl) {
labelEl.textContent = nextLabel;
const translatedLabel = getEarthLocale() === "en-US" ? nextLabel : translateText(nextLabel);
labelEl.textContent = translatedLabel;
labelEl.dataset.i18nOriginalTitle = translatedLabel;
labelEl.title = translatedLabel;
}
});
}
function getLegendModeTitle(mode) {
const definition = LEGEND_MODES[mode] || LEGEND_MODES.cables;
if (getEarthLocale() === "en-US" && definition.compactTitleEn) {
return definition.compactTitleEn;
}
return definition.title;
}
function renderLegend(mode) {
const items = legendItemsByMode[mode] || [];
const html = items
.map(
(item) => `
(item) => {
const label = escapeLegendHtml(translateText(item.label));
return `
<div class="legend-item">
<span class="legend-dot legend-dot--${item.shape || "dot"}" style="background:${item.color}; color:${item.color}"></span>
<span class="legend-label">${item.label}</span>
</div>`,
<span class="legend-label" title="${label}">${label}</span>
</div>`;
},
)
.join("");
@@ -99,3 +123,12 @@ function renderLegend(mode) {
mobileList.innerHTML = html;
}
}
function escapeLegendHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View File

@@ -1,5 +1,13 @@
import * as THREE from "three";
import {
earthMessage,
getEarthLocale,
initEarthI18n,
localizeCountryName,
onEarthLocaleChange,
translateText,
} from "./i18n.js";
import {
CONFIG,
CRUISE_MODULES,
@@ -192,6 +200,7 @@ import {
updateComputeCenterVisualState,
} from "./compute-centers.js";
import {
applyVesselRealtimeFrame,
clearVesselData,
clearVesselSelection,
getShowVessels,
@@ -201,6 +210,7 @@ import {
getVesselPointerIntersections as getVesselIconPointerIntersections,
getVesselRealtimeStats,
loadVessels,
setVesselRealtimeConnected,
setVesselMarkerState,
showVesselTrack,
startVesselRealtime,
@@ -266,6 +276,7 @@ import { createNewsCruiseAdapter } from "./news-cruise-adapter.js";
import { createMotionCruiseAdapter } from "./motion-cruise-adapter.js";
import { PresentationController } from "./presentation-controller.js";
import {
getComputeCenterLocationBatchState,
initInfoCard,
showInfoCard,
hideInfoCard,
@@ -304,6 +315,12 @@ import {
setMotionDebugPanelVisible,
} from "./motion-debug-panel.js";
initEarthI18n();
window.addEventListener("earth:status", (event) => {
const message = event.detail?.message;
if (message) showStatusMessage(message, event.detail?.type || "info");
});
const EARTH_RADIUS_KM = 6371;
const EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 = 398600.4418;
const SECONDS_PER_DAY = 86400;
@@ -343,6 +360,7 @@ let earthTexture = null;
let animationFrameId = null;
let initialized = false;
let destroyed = false;
let runtimeBrandConfig = null;
let isDataLoading = false;
let currentLoadToken = 0;
let cablesEnabled = true;
@@ -357,6 +375,21 @@ let calloutConnector = null;
let cruiseBGPAdapter = null;
let cruiseNewsAdapter = null;
let cruiseSequencer = null;
function getEarthBrandLanguage() {
return getEarthLocale() === "en-US" ? "en" : (HUD_CONFIG.brandLanguage || "zh");
}
function getLocalizedBrandConfig(config = null) {
if (config && typeof config === "object") {
return { ...config, variant: config.variant || getEarthBrandLanguage() };
}
return getEarthBrandLanguage();
}
function remountEarthBrand() {
mountBrand(document.getElementById("brand-root"), getLocalizedBrandConfig(runtimeBrandConfig));
}
let cruiseRandomQueueSignature = "";
let cruiseRandomQueueItems = [];
let earthUpdatesSocket = null;
@@ -419,6 +452,21 @@ const VESSEL_POINTER_RADIUS_PX = 22;
const INTERACTABLE_POINTER_RADIUS_PX = 24;
const MOTION_FOCUS_RADIUS_PX = 180;
const MOTION_FOCUS_REFRESH_MS = 350;
function isEnglishEarthLocale() {
return getEarthLocale() === "en-US";
}
function formatUnresolvedComputeTitle(count) {
return isEnglishEarthLocale()
? `${count} compute centers pending location`
: `${count} 个算力中心待定位`;
}
function formatUnresolvedComputeSuffix(count) {
if (count <= 0) return "";
return isEnglishEarthLocale() ? ` (${count} pending location)` : `${count} 个待定位)`;
}
const MOTION_MARKER_ANCHOR_SIZE_PX = 24;
const MOTION_SATELLITE_ANCHOR_SIZE_PX = 18;
const MOTION_CABLE_ANCHOR_SIZE_PX = 14;
@@ -881,7 +929,9 @@ function getPrimaryClusterHit(...intersectionGroups) {
function getClusterBriefHtml(hit) {
const count = Number(hit?.clusterCount || hit?.clusterMarkers?.length || 0);
return `<strong>共 ${count} 个对象</strong><br><span>放大后可查看单个图标</span>`;
const title = isEnglishEarthLocale() ? `${count} objects` : `${count} 个对象`;
const hint = isEnglishEarthLocale() ? "Zoom in to inspect individual markers" : "放大后可查看单个图标";
return `<strong>${title}</strong><br><span>${hint}</span>`;
}
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
@@ -1336,7 +1386,7 @@ async function applyMotionSharedCruiseFocus(direction = "next") {
const presented = await sequencer.presentSpecificItem(targetItem, { interrupt: true });
if (presented) {
showStatusMessage("动捕: 已切换巡航目标", "info");
showStatusMessage(earthMessage("status.motionCruiseTargetSwitched"), "info");
}
return Boolean(presented);
}
@@ -1465,7 +1515,10 @@ export async function applyMotionFocus(direction = "next") {
refreshMotionFocusCandidates({ force: true });
if (motionFocusCandidates.length === 0) {
const layer = getCurrentMotionFocusLayer();
showStatusMessage(layer ? `动捕: ${layer.label}当前视野没有可选目标` : "动捕: 当前没有可用图层", "info");
showStatusMessage(
earthMessage("status.motionNoTarget", { layer: layer?.label || "" }),
"info",
);
return false;
}
const delta = direction === "prev" ? -1 : 1;
@@ -1473,7 +1526,10 @@ export async function applyMotionFocus(direction = "next") {
const candidate = motionFocusCandidates[motionFocusIndex];
applyMotionFocusVisual(candidate);
await presentMotionCandidate(candidate);
showStatusMessage(`动捕: 已切换到${getMotionCandidateLabel(candidate)}`, "info");
showStatusMessage(
earthMessage("status.motionSwitchedTo", { label: getMotionCandidateLabel(candidate) }),
"info",
);
return true;
} finally {
releaseGate();
@@ -1486,7 +1542,7 @@ export async function applyMotionLayerSwitch(direction = "next") {
try {
const layers = getVisibleMotionLayerDefinitions();
if (layers.length === 0) {
showStatusMessage("动捕: 当前没有可切换的可见图层", "info");
showStatusMessage(earthMessage("status.motionNoVisibleLayer"), "info");
return false;
}
const currentIndex = layers.findIndex((layer) => layer.id === motionFocusLayerId);
@@ -1505,7 +1561,10 @@ export async function applyMotionLayerSwitch(direction = "next") {
if (candidate) {
await presentMotionCandidate(candidate);
}
showStatusMessage(`动捕: 已切换到${nextLayer.label}图层`, "info");
showStatusMessage(
earthMessage("status.motionSwitchedTo", { label: nextLayer.label, layer: true }),
"info",
);
return true;
} finally {
releaseGate();
@@ -1673,7 +1732,10 @@ function confirmMotionCandidate(candidate, { showMotionStatus = true } = {}) {
});
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
if (showMotionStatus) {
showStatusMessage(`动捕: 已确认${getMotionCandidateLabel(candidate)}`, "info");
showStatusMessage(
earthMessage("status.motionConfirmed", { label: getMotionCandidateLabel(candidate) }),
"info",
);
}
return true;
}
@@ -1693,7 +1755,7 @@ function showCableInfo(cable, coords) {
function getCableBriefHtml(cable) {
const name = cable.userData.name || "未知海缆";
const status = cable.userData.status || "";
return `<strong>${name}</strong>${status ? `<br>${status}` : ""}`;
return `<strong>${translateText(name)}</strong>${status ? `<br>${translateText(status)}` : ""}`;
}
function showSatelliteInfo(props, coords) {
@@ -1822,7 +1884,7 @@ function showVesselInfo(marker, coords) {
status: formatVesselStatus(marker.userData?.nav_status),
length: marker.userData?.length ?? "-",
received_at: marker.userData?.received_at
? new Date(marker.userData.received_at).toLocaleString("zh-CN", { hour12: false })
? new Date(marker.userData.received_at).toLocaleString(getEarthLocale(), { hour12: false })
: "-",
}, coords);
}
@@ -1846,31 +1908,31 @@ function getEarthInteractableBriefHtml(marker) {
const ud = marker?.userData || {};
const name = ud.label || ud.name || "交互点";
const kind = ud.kind || "数据点";
return `<strong>${name}</strong><br>${kind}`;
return `<strong>${translateText(name)}</strong><br>${translateText(kind)}`;
}
function getVesselBriefHtml(marker) {
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
const speed = marker.userData?.sog ?? "-";
const vesselType = marker.userData?.vessel_type_display || marker.userData?.vessel_type_name || "Vessel";
return `<strong>${name}</strong><br>${vesselType} · ${speed} kn`;
return `<strong>${translateText(name)}</strong><br>${translateText(vesselType)} · ${speed} kn`;
}
function getComputeCenterBriefHtml(marker) {
const name = marker.userData?.name || "算力中心";
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
const location = [marker.userData?.city, marker.userData?.country]
const location = [marker.userData?.city, localizeCountryName(marker.userData?.country)]
.filter(Boolean)
.join(", ");
const precision = marker.userData?.is_estimated ? " · 估算位置" : "";
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
const precision = marker.userData?.is_estimated ? ` · ${translateText("估算位置")}` : "";
return `<strong>${translateText(name)}</strong><br>${translateText(type)}${location ? ` · ${location}` : ""}${precision}`;
}
function getCountryBoundaryBriefHtml(country) {
const name = country?.nameZh || country?.name || "未知国家";
const name = localizeCountryName(country) || translateText("未知国家");
const code = country?.isoA3 || country?.isoA2 || "-";
const continent = country?.continent || "-";
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
const continent = translateText(country?.continent || "-");
return `<strong>${name}</strong><br>ISO: ${code}<br>${translateText("大洲")}: ${continent}`;
}
function getSurfacePositionBriefHtml(coords) {
@@ -1880,7 +1942,7 @@ function getSurfacePositionBriefHtml(coords) {
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
return `纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`;
return `${translateText("纬度")}: ${coords.lat}°<br>${translateText("经度")}: ${coords.lon}°<br>${translateText("海拔")}: ${elevText}`;
}
function showBGPInfo(marker, coords) {
@@ -1920,7 +1982,9 @@ function showBGPInfo(marker, coords) {
),
prefix:
Array.isArray(marker.userData.prefixes) && marker.userData.prefixes.length > 1
? `${marker.userData.prefixes[0]}${marker.userData.prefixes.length}`
? isEnglishEarthLocale()
? `${marker.userData.prefixes[0]} + ${marker.userData.prefixes.length - 1}`
: `${marker.userData.prefixes[0]}${marker.userData.prefixes.length}`
: marker.userData.prefix,
as_path_display:
Array.isArray(marker.userData.as_path) && marker.userData.as_path.length > 0
@@ -1932,7 +1996,9 @@ function showBGPInfo(marker, coords) {
: marker.userData.origin_asn,
new_origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 3
? `${marker.userData.affected_asns.length}个ASN`
? isEnglishEarthLocale()
? `${marker.userData.affected_asns.length} ASNs`
: `${marker.userData.affected_asns.length}个ASN`
: marker.userData.new_origin_asn,
confidence: formatBGPConfidence(marker.userData.confidence),
collector: marker.userData.collector,
@@ -1941,7 +2007,9 @@ function showBGPInfo(marker, coords) {
related_cables: relatedCables,
related_satellites:
marker.userData.related_satellite_count > 0
? `${marker.userData.related_satellite_count}颗事件附近卫星`
? isEnglishEarthLocale()
? `${marker.userData.related_satellite_count} nearby event satellites`
: `${marker.userData.related_satellite_count}颗事件附近卫星`
: "-",
location:
marker.userData.location ||
@@ -1983,7 +2051,8 @@ function showBGPCollectorInfo(marker, coords) {
function getBGPCollectorBriefHtml(marker) {
const name = marker.userData.collector || "观测站";
const count = marker.userData.anomaly_count ?? 0;
return `<strong>${name}</strong><br>${count} 条事件`;
const eventText = isEnglishEarthLocale() ? `${count} events` : `${count} 条事件`;
return `<strong>${translateText(name)}</strong><br>${eventText}`;
}
function getSearchCardCoords() {
@@ -2157,7 +2226,10 @@ async function focusSearchLandingPoint(point) {
});
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
showLandingPointInfo(point, getSearchCardCoords());
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
showStatusMessage(
earthMessage("status.located", { target: "登陆点", name: point.userData?.name || "未知登陆点" }),
"info",
);
}
async function focusSearchSatellite(index) {
@@ -2191,7 +2263,13 @@ async function focusSearchSatellite(index) {
}
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
showStatusMessage(
earthMessage("status.located", {
target: "卫星",
name: sat.properties.name || sat.properties.norad_cat_id || "未知卫星",
}),
"info",
);
}
async function focusSearchBGPMarker(marker) {
@@ -2215,7 +2293,13 @@ async function focusSearchBGPMarker(marker) {
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
showBGPInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
showStatusMessage(
earthMessage("status.located", {
target: "BGP 事件",
name: marker.userData?.collector || "未知观测站",
}),
"info",
);
return;
}
@@ -2225,7 +2309,13 @@ async function focusSearchBGPMarker(marker) {
lockedObjectType = "bgp_collector";
showBGPCollectorCoverageOverlay(marker, earth);
showBGPCollectorInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
showStatusMessage(
earthMessage("status.located", {
target: "观测站",
name: marker.userData?.collector || "未知观测站",
}),
"info",
);
}
}
@@ -2247,7 +2337,10 @@ async function focusSearchComputeCenter(marker) {
lockedObjectType = "compute_center";
showComputeCenterInfo(marker, getSearchCardCoords());
showStatusMessage(
`已定位算力中心:${marker.userData?.name || "未知节点"}`,
earthMessage("status.located", {
target: "算力中心",
name: marker.userData?.name || "未知节点",
}),
"info",
);
}
@@ -2310,7 +2403,7 @@ async function spawnComputeCenterAfterLocationSave(detail = {}) {
lockedObject = result.marker;
lockedObjectType = "compute_center";
}
showStatusMessage("算力中心坐标已保存", "success");
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
return result;
}
@@ -2336,7 +2429,10 @@ async function focusSearchVessel(marker) {
console.warn("船只轨迹加载失败:", error);
});
showStatusMessage(
`已定位船只:${marker.userData?.name || marker.userData?.mmsi || "未知船只"}`,
earthMessage("status.located", {
target: "船只",
name: marker.userData?.name || marker.userData?.mmsi || "未知船只",
}),
"info",
);
}
@@ -2645,16 +2741,16 @@ async function loadEarthStatsSummary({
function updateComputeCenterHud(computeCenterResult) {
const computeBtn = document.getElementById("toggle-compute-centers");
const unresolvedCount = Number(computeCenterResult?.unresolvedCount) || 0;
const unresolvedTooltip =
unresolvedCount > 0 ? `${unresolvedCount} 个待定位)` : "";
const unresolvedTooltip = formatUnresolvedComputeSuffix(unresolvedCount);
if (computeBtn) {
const tooltip = getShowComputeCenters()
? `隐藏算力中心${unresolvedTooltip}`
: `显示算力中心${unresolvedTooltip}`;
setLayerButtonState(computeBtn, {
active: getShowComputeCenters(),
loading: false,
tooltip: getShowComputeCenters()
? `隐藏算力中心${unresolvedTooltip}`
: `显示算力中心${unresolvedTooltip}`,
tooltip: translateText(tooltip),
});
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
}
@@ -2664,11 +2760,12 @@ function updateComputeCenterHud(computeCenterResult) {
function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
const count = Math.max(0, Number(unresolvedCount) || 0);
const batch = getComputeCenterLocationBatchState();
const layerRow = computeBtn.closest(".layer-row");
if (!layerRow) return;
let badge = layerRow.querySelector("[data-compute-center-unresolved-badge]");
if (count <= 0) {
if (count <= 0 && batch.total <= 0) {
delete computeBtn.dataset.unresolvedCount;
badge?.remove();
return;
@@ -2689,9 +2786,13 @@ function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
};
computeBtn.dataset.unresolvedCount = String(count);
badge.textContent = count > 99 ? "99+" : String(count);
badge.title = `${count} 个算力中心待定位`;
badge.setAttribute("aria-label", `${count} 个算力中心待定位,点击查看`);
badge.textContent = count > 0 ? (count > 99 ? "99+" : String(count)) : (batch.running ? "…" : "✓");
const title = count > 0 ? formatUnresolvedComputeTitle(count) : translateText(batch.statusText);
badge.title = title;
badge.setAttribute(
"aria-label",
isEnglishEarthLocale() ? `${title}, click to view` : `${title},点击查看`,
);
}
function syncComputeCenterUnresolvedCount(unresolvedCount) {
@@ -2773,12 +2874,12 @@ function prepareToolInfoCard(type) {
if (hadCruisePresentation || restoreCruise) {
stopCruiseMode({ preserveCard: false });
showStatusMessage("已暂停巡航,正在打开候选列表", "info");
showStatusMessage(earthMessage("status.cruisePausedOpenCandidates"), "info");
} else if (hadMotionPresentation) {
presentationController?.dismiss?.("tool_card_open");
motionCruiseSequencer?.stop?.();
motionSharedCruiseSequencer?.stop?.();
showStatusMessage("已暂停动捕目标展示,正在打开候选列表", "info");
showStatusMessage(earthMessage("status.motionPausedOpenCandidates"), "info");
}
activeToolInfoCard = {
@@ -2809,7 +2910,7 @@ function resumeCruiseAfterToolInfoCard(context) {
return;
}
showStatusMessage("候选列表已关闭,巡航已恢复", "info");
showStatusMessage(earthMessage("status.candidatesClosedCruiseResumed"), "info");
ensureCruisePolling();
syncCruiseModuleKnownEventIds()
.catch((error) => {
@@ -3522,12 +3623,12 @@ function handleCruiseQueueSettingsChange() {
function handleCruiseNextCardShortcut() {
if (!isCruiseModeActive()) {
showStatusMessage("切换到巡航模式后可切换卡片", "info");
showStatusMessage(earthMessage("status.cruiseModeRequired"), "info");
return;
}
if (!getAutoRotate()) {
showStatusMessage("巡航已暂停,按空格恢复", "info");
showStatusMessage(earthMessage("status.cruisePausedSpace"), "info");
return;
}
@@ -3770,12 +3871,7 @@ function getSatellitePointerIntersections(event) {
function buildLoadErrorMessage(errors) {
if (errors.length === 0) return "";
return errors
.map(
({ label, reason }) =>
`${label}加载失败: ${reason?.message || String(reason)}`,
)
.join("");
return earthMessage("status.loadFailedList", { items: errors });
}
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
@@ -3864,6 +3960,25 @@ function applyBGPEmptyState() {
function updateVesselHud(result = {}) {
const count = Number(result.totalCount ?? getVesselCount() ?? 0);
setEarthStatValue("vessel-count", `${count}`);
if (getShowVessels()) startVisibleVesselRealtime();
}
function reconcileVesselSelection() {
if (lockedObjectType === "vessel" && lockedObject && !getVesselMarkers().includes(lockedObject)) {
clearLockedObject();
hideInfoCard();
}
}
function startVisibleVesselRealtime() {
startVesselRealtime(getEarth(), {
onUpdate: ({ totalCount }) => {
reconcileVesselSelection();
updateVesselToggleUi(true, totalCount);
updateStatsSummary();
},
});
syncVesselRealtimeSubscription();
}
function formatRelativeTime(value) {
@@ -3882,9 +3997,9 @@ function formatVesselLiveSummary() {
if (stream.connected) {
const lastUpdate = formatRelativeTime(stream.lastUpdateAt);
if (stream.updates > 0) {
return `AISStream 实时已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
return `船舶实时通道已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
}
return "AISStream 实时已连接 · 等待首批更新";
return "船舶实时通道已连接 · 等待首批更新";
}
const state = earthStatsSummary?.aisstreamConnectionState;
if (state === "connected") {
@@ -4039,12 +4154,7 @@ async function ensureVesselsEnabled() {
const zoom = getZoomLevel();
const result = await loadVessels(scene, earth, { zoom });
toggleVessels(true);
startVesselRealtime(earth, {
onUpdate: ({ totalCount }) => {
updateVesselToggleUi(true, totalCount);
updateStatsSummary();
},
});
startVisibleVesselRealtime();
updateVesselToggleUi(true, result.totalCount);
setLegendItems("vessels", getVesselLegendItems());
refreshLegend();
@@ -4055,6 +4165,7 @@ function disableVessels() {
vesselsEnabled = false;
stopVesselRealtime();
toggleVessels(false);
syncVesselRealtimeSubscription();
clearVesselSelection();
updateVesselToggleUi(false, 0);
setLegendItems("vessels", getVesselLegendItems());
@@ -4079,7 +4190,9 @@ function updateStatsSummary() {
getLandingPoints().length,
);
const satelliteCount = getAuthoritativeSatelliteCount();
const vesselCount = getAuthoritativeCount("vesselCount", getVesselCount());
const vesselCount = getVesselRealtimeStats().connected
? getVesselCount()
: getAuthoritativeCount("vesselCount", getVesselCount());
const computeCenterCount = getAuthoritativeCount(
"computeCenterCount",
getComputeCenterCount(),
@@ -4128,15 +4241,30 @@ export function init() {
destroyed = false;
initialized = true;
updateHudScale();
const brandRoot = document.getElementById("brand-root");
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
remountEarthBrand();
fetchEarthBrandConfig()
.then((brandConfig) => {
mountBrand(brandRoot, brandConfig);
runtimeBrandConfig = brandConfig;
remountEarthBrand();
})
.catch((error) => {
console.warn("Earth brand config unavailable, using defaults.", error);
});
cleanupFns.push(onEarthLocaleChange(() => {
remountEarthBrand();
updateCableToggleUi(getShowCables());
updateSatelliteToggleUi(getShowSatellites());
updateComputeCenterHud({
totalCount: getComputeCenterCount(),
unresolvedCount: getUnresolvedComputeCenters().length,
});
updateVesselToggleUi(getShowVessels());
updateBGPHud({
totalCount: getBGPCount(),
collectorCount: getBGPCollectorCount(),
});
updateStatsSummary();
}));
initTVPanel();
initEarthAbout();
initEarthOobe();
@@ -4280,7 +4408,7 @@ export function applyMotionRotate(axisOrDirection, directionOrIntensity = 1, may
up: "向上旋转",
down: "向下旋转",
}[direction] || "旋转";
showStatusMessage(`动捕: ${label}`, "info");
showStatusMessage(earthMessage("status.motionPrefix", { text: label }), "info");
return true;
}
@@ -4309,10 +4437,10 @@ export function applyMotionConfirm() {
}
if (lockedObject || lockedSatellite) {
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
showStatusMessage("动捕: 已确认当前目标", "info");
showStatusMessage(earthMessage("status.motionConfirmCurrent"), "info");
return true;
}
showStatusMessage("动捕: 请先选择目标", "info");
showStatusMessage(earthMessage("status.motionSelectTargetFirst"), "info");
return false;
} finally {
releaseGate();
@@ -4553,7 +4681,7 @@ async function refreshDataBackedEarthLayer(layer, options = {}) {
if (shouldClearBeforeReload) {
handler.clear?.();
}
if (!shouldClearBeforeReload && shouldClearLayerFromServerState(handler)) {
if (layer !== "vessels" && !shouldClearBeforeReload && shouldClearLayerFromServerState(handler)) {
handler.clear?.();
return true;
}
@@ -4652,6 +4780,7 @@ function handleEarthUpdateFrame(payload = {}) {
const isDatabaseChange = payload.action === "database_changed";
const strategy = typeof payload.refresh_strategy === "string" ? payload.refresh_strategy : "";
layers.forEach((layer) => {
if (layer === "vessels" && strategy === "delta" && getVesselRealtimeStats().connected) return;
pendingEarthUpdateLayers.add(layer);
if (strategy === "clear_then_reload" || !pendingEarthUpdateStrategies.has(layer)) {
pendingEarthUpdateStrategies.set(layer, strategy || "reload");
@@ -4674,6 +4803,11 @@ async function reconcileEarthDataFromServer() {
let changed = false;
for (const [layer, handler] of Object.entries(EARTH_DATA_LAYER_HANDLERS)) {
if (layer === "vessels" && handler.isVisible?.()) {
await refreshDataBackedEarthLayer(layer, { force: true });
changed = true;
continue;
}
if (shouldClearLayerFromServerState(handler) && handler.localCount?.() > 0) {
handler.clear?.();
changed = true;
@@ -4711,6 +4845,19 @@ function startEarthDataReconciliation() {
);
}
function syncVesselRealtimeSubscription() {
const socket = earthUpdatesSocket;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const enabled = getShowVessels();
if (Boolean(socket.__planetVesselSubscription) === enabled) return;
socket.__planetVesselSubscription = enabled;
socket.send(JSON.stringify({
type: enabled ? "subscribe" : "unsubscribe",
data: { channels: ["vessels"], scope: "global", zoom: 4 },
}));
if (!enabled) setVesselRealtimeConnected(false);
}
function connectEarthUpdatesRealtime() {
if (earthUpdatesSocket || destroyed) return;
if (!canAttemptEarthRealtime()) {
@@ -4732,6 +4879,7 @@ function connectEarthUpdatesRealtime() {
channel: EARTH_UPDATES_CHANNEL,
},
}));
syncVesselRealtimeSubscription();
};
socket.onmessage = (event) => {
let message;
@@ -4744,11 +4892,23 @@ function connectEarthUpdatesRealtime() {
socket.send(JSON.stringify({ type: "heartbeat" }));
return;
}
if (message.type !== "data_frame" || message.channel !== EARTH_UPDATES_CHANNEL) return;
handleEarthUpdateFrame(message.payload || {});
if (message.type === "subscription_confirmed" && message.data?.channels?.includes("vessels")) {
const connected = message.data.action === "subscribe" && message.data.vessels?.scope === "global";
setVesselRealtimeConnected(connected);
if (connected && getShowVessels()) {
loadVessels(scene, getEarth(), { zoom: getZoomLevel() }).then((result) => {
if (!destroyed && getShowVessels()) updateVesselHud(result);
}).catch((error) => console.warn("船舶重连校准失败:", error));
}
return;
}
if (message.type !== "data_frame") return;
if (message.channel === "vessels") applyVesselRealtimeFrame(message.payload || {}, message.timestamp);
else if (message.channel === EARTH_UPDATES_CHANNEL) handleEarthUpdateFrame(message.payload || {});
};
socket.onclose = () => {
if (earthUpdatesSocket === socket) {
setVesselRealtimeConnected(false);
earthUpdatesSocket = null;
}
if (!socket.__planetOpened) {
@@ -4783,7 +4943,7 @@ async function loadData() {
clearSatelliteData();
clearCountryBoundaryHover();
setLoadingMessage("正在初始化...");
setLoadingMessage(earthMessage("loading.initializing"));
setLoading(true);
await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
@@ -4860,7 +5020,7 @@ async function loadData() {
const terrainMessage = resolveStartupMessage(
terrainLayer,
"load",
"正在渲染地形...",
earthMessage("startup.terrain"),
);
setLoadingMessage(terrainMessage);
await yieldFrame(24);
@@ -4891,7 +5051,7 @@ async function loadData() {
queueStatusMessage(errorMessage, "error");
} else {
hideError();
queueStatusMessage("数据已加载", "success");
queueStatusMessage(earthMessage("status.dataLoaded"), "success");
}
applyDeferredLayerVisibilitySettings()
@@ -4935,13 +5095,13 @@ export async function setCablesEnabled(
clearSelectionAndInfo();
disableCables();
if (!suppressStatus) {
showStatusMessage("线缆已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "线缆", visible: false }), "info");
}
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage("正在加载线缆数据...");
setLoadingMessage(earthMessage("loading.cableData"));
setLoading(true);
hideError();
}
@@ -4949,19 +5109,20 @@ export async function setCablesEnabled(
try {
const cableCount = await ensureCablesEnabled();
if (!suppressStatus) {
showStatusMessage("线缆已显示", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "线缆", visible: true }), "info");
}
return cableCount;
} catch (error) {
cablesEnabled = false;
clearCableData(getEarth());
updateCableToggleUi(false);
const message = `线缆加载失败: ${error?.message || String(error)}`;
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "线缆", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "cables",
message,
message: `线缆加载失败: ${reason}`,
detail: error,
});
if (!suppressLoadingUi) {
@@ -4986,7 +5147,7 @@ export async function setCountryBoundariesEnabled(
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
if (!suppressStatus) {
showStatusMessage("国界已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "国界", visible: false }), "info");
}
return 0;
}
@@ -5002,18 +5163,19 @@ export async function setCountryBoundariesEnabled(
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
refreshLegend();
if (!suppressStatus) {
showStatusMessage("国界已显示", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "国界", visible: true }), "info");
}
return countryCount;
} catch (error) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
const message = `国界加载失败: ${error?.message || String(error)}`;
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "国界", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "country-boundaries",
message,
message: `国界加载失败: ${reason}`,
detail: error,
});
if (!suppressStatus) {
@@ -5082,7 +5244,7 @@ export async function setHighResTextureEnabled(enabled, { suppressStatus = false
}
}
if (!suppressStatus) {
showStatusMessage(enabled ? "高清材质已启用" : "高清材质已隐藏", "info");
showStatusMessage(earthMessage("status.layerEnabled", { layer: "高清材质", enabled }), "info");
}
return enabled;
}
@@ -5103,7 +5265,7 @@ export async function setAtmosphereCloudsEnabled(enabled, { suppressStatus = fal
}
}
if (!suppressStatus) {
showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "大气云图", visible: enabled }), "info");
}
return enabled;
}
@@ -5128,7 +5290,7 @@ export async function setSatellitesEnabled(
}
if (!suppressLoadingUi) {
setLoadingMessage("正在加载卫星数据...");
setLoadingMessage(earthMessage("loading.satelliteData"));
setLoading(true);
hideError();
}
@@ -5136,19 +5298,20 @@ export async function setSatellitesEnabled(
try {
const satelliteCount = await ensureSatellitesEnabled();
if (!suppressStatus) {
showStatusMessage("卫星已显示", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "卫星", visible: true }), "info");
}
return satelliteCount;
} catch (error) {
satellitesEnabled = false;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
const message = `卫星加载失败: ${error?.message || String(error)}`;
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "卫星", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "satellites",
message,
message: `卫星加载失败: ${reason}`,
detail: error,
});
if (!suppressLoadingUi) {
@@ -5178,13 +5341,13 @@ export async function setVesselsEnabled(
clearSelectionAndInfo();
disableVessels();
if (!suppressStatus) {
showStatusMessage("船只已隐藏", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "船只", visible: false }), "info");
}
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage("正在加载船只数据...");
setLoadingMessage(earthMessage("loading.vesselData"));
setLoading(true);
hideError();
}
@@ -5192,19 +5355,20 @@ export async function setVesselsEnabled(
try {
const vesselCount = await ensureVesselsEnabled();
if (!suppressStatus) {
showStatusMessage("船只已显示", "info");
showStatusMessage(earthMessage("status.layerVisibility", { layer: "船只", visible: true }), "info");
}
return vesselCount;
} catch (error) {
vesselsEnabled = false;
clearVesselData(getEarth());
updateVesselToggleUi(false, 0);
const message = `船只加载失败: ${error?.message || String(error)}`;
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "船只", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "vessels",
message,
message: `船只加载失败: ${reason}`,
detail: error,
});
if (!suppressLoadingUi) {
@@ -5258,13 +5422,13 @@ function setupEventListeners() {
// for the user-visible confirmation.
return refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage("算力中心坐标已保存", "success");
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
})
.catch((error) => {
// Swallow refresh failure: the save itself succeeded, so we
// must not surface this as a save failure.
console.warn("后台校准算力中心图层失败:", error);
showStatusMessage("坐标已保存,地图稍后同步", "info");
showStatusMessage(earthMessage("status.coordinatesSavedLater"), "info");
});
}
// Optimistic marker is on screen; reconcile in the background.
@@ -5275,10 +5439,10 @@ function setupEventListeners() {
})
.catch((error) => {
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
showStatusMessage("坐标已保存,正在同步地图...", "info");
showStatusMessage(earthMessage("status.coordinatesSavedSyncing"), "info");
refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage("算力中心坐标已保存", "success");
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
})
.catch((refreshError) => {
console.warn("后台校准算力中心图层失败:", refreshError);
@@ -5334,6 +5498,7 @@ function setupEventListeners() {
"earth:compute-center-unresolved-count-change",
handleComputeCenterUnresolvedCountChange,
);
bindListener(window, "earth:compute-center-location-batch-change", () => syncComputeCenterUnresolvedCount());
bindListener(window, "earth:motion-debug-mode-change", handleMotionDebugModeChange);
bindListener(window, MOTION_DEBUG_CLOSE_EVENT, handleMotionDebugClose);
getCruiseModuleDefinitions().forEach((module) => {
@@ -5945,7 +6110,11 @@ function onClick(event) {
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`,
earthMessage("status.bgpSelected", {
collector: clickedMarker.userData.collector,
regionCount: incidentSummary.regionCount,
cableCount: incidentSummary.cableCount,
}),
"info",
);
return;
@@ -5969,7 +6138,7 @@ function onClick(event) {
clickedMarker.userData.related_satellite_count = 0;
showBGPCollectorInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择观测站: ${clickedMarker.userData.collector}`,
earthMessage("status.selected", { target: "观测站", name: clickedMarker.userData.collector }),
"info",
);
return;
@@ -5986,7 +6155,10 @@ function onClick(event) {
setAutoRotate(false);
showComputeCenterInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择算力中心: ${clickedMarker.userData?.name || "未知节点"}`,
earthMessage("status.selected", {
target: "算力中心",
name: clickedMarker.userData?.name || "未知节点",
}),
"info",
);
return;
@@ -6006,7 +6178,10 @@ function onClick(event) {
console.warn("船只轨迹加载失败:", error);
});
showStatusMessage(
`已选择船只: ${clickedMarker.userData?.name || clickedMarker.userData?.mmsi}`,
earthMessage("status.selected", {
target: "船只",
name: clickedMarker.userData?.name || clickedMarker.userData?.mmsi,
}),
"info",
);
return;
@@ -6021,7 +6196,10 @@ function onClick(event) {
distancePxSq: 0,
}, { showMotionStatus: false })) {
showStatusMessage(
`已选择交互点: ${clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标"}`,
earthMessage("status.selected", {
target: "交互点",
name: clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标",
}),
"info",
);
}
@@ -6079,7 +6257,7 @@ function onClick(event) {
screen: { x: event.clientX, y: event.clientY },
distancePxSq: 0,
}, { showMotionStatus: false });
showStatusMessage("已选择: " + sat.properties.name, "info");
showStatusMessage(earthMessage("status.selected", { name: sat.properties.name }), "info");
return;
}
@@ -6232,6 +6410,7 @@ function animate() {
export function destroy() {
if (destroyed) return;
destroyed = true;
stopVesselRealtime();
currentLoadToken += 1;
isDataLoading = false;
setGlobeDraggingUiState(false);

View File

@@ -3,6 +3,7 @@ import * as THREE from "three";
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
import { showInfoCard, hideInfoCard } from "./info-card.js";
import { latLonToVector3 } from "./utils.js";
import { formatLocaleDateTime, getEarthLocale, hasCjkText } from "./i18n.js";
import {
createConnectorPath,
resolveConnectorAnchor,
@@ -20,8 +21,9 @@ import {
getNewsFetchChannelLabel,
getNewsFeedLabel,
getNewsLocationSourceLabel,
getNewsRegionLabel,
getNewsRegionDisplayLabel,
getNewsSourceTypeLabel,
getNewsSourceNameLabel,
} from "./news-locale.js";
const CRUISE_PRESENTATION_HIDE_MS = 220;
@@ -38,10 +40,10 @@ function getItemTimestamp(item) {
}
function formatPublishedAt(rawValue) {
if (!rawValue) return "刚刚同步";
if (!rawValue) return getEarthLocale() === "en-US" ? "Just synced" : "刚刚同步";
const parsed = new Date(rawValue);
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
return parsed.toLocaleString("zh-CN", {
if (Number.isNaN(parsed.getTime())) return getEarthLocale() === "en-US" ? "Just synced" : "刚刚同步";
return formatLocaleDateTime(parsed, {
hour12: false,
month: "2-digit",
day: "2-digit",
@@ -94,6 +96,10 @@ function mapNewsItemToCruiseEvent(item) {
const rawFeedName = item.feed_name || "";
const sourceType = item.source_type || (String(rawFeedName).startsWith("Global Monitor /") ? "aggregated" : "rss");
const regionLabel = getNewsRegionDisplayLabel(item.region, item.display_region);
const locationLabel = getEarthLocale() === "en-US" && hasCjkText(item.location_label)
? regionLabel
: item.location_label || regionLabel;
return {
id: `news:${item.id}`,
@@ -101,21 +107,21 @@ function mapNewsItemToCruiseEvent(item) {
type: "news",
title: getNewsDisplayTitle(item),
summary: getNewsDisplaySummary(item),
source: item.source || "",
source: getNewsSourceNameLabel({ id: item.source_id, name: item.source || "" }),
feedName: getNewsFeedLabel(rawFeedName),
rawFeedName,
feedSourceTypeLabel: getNewsSourceTypeLabel(sourceType),
fetchChannelLabel: getNewsFetchChannelLabel(rawFeedName, sourceType),
categoryLabel: getNewsCategoryLabel(item.category),
region: item.region || "global",
regionLabel: item.display_region || getNewsRegionLabel(item.region),
regionLabel,
url: item.url || "",
publishedAt: item.published_at || null,
publishedAtDisplay: formatPublishedAt(item.published_at),
latitude,
longitude,
locationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
sourceLocationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
locationLabel,
sourceLocationLabel: locationLabel,
targetLocationConfidence: item.location_meta?.target?.confidence ?? null,
targetLocationSource: item.location_source || "",
targetLocationSourceLabel: getNewsLocationSourceLabel(item.location_source),

View File

@@ -1,182 +1,414 @@
const DEFAULT_LOCALE = "zh-CN";
import { getEarthLocale, hasCjkText, normalizeLocale } from "./i18n.js";
const REGION_LABELS = {
americas: "美洲",
europe: "洲",
"middle-east-africa": "中东与非洲",
"asia-pacific": "亚太",
global: "全球",
"zh-CN": {
americas: "洲",
europe: "洲",
"middle-east-africa": "中东与非洲",
"asia-pacific": "亚太",
global: "全球",
},
"en-US": {
americas: "Americas",
europe: "Europe",
"middle-east-africa": "Middle East & Africa",
"asia-pacific": "Asia Pacific",
global: "Global",
},
};
const FEED_LABELS = {
"Global Monitor / World": "区域监测",
"Global Monitor / Americas": "区域监测",
"Global Monitor / Europe": "区域监测",
"Global Monitor / MEA": "区域监测",
"Global Monitor / APAC": "区域监测",
"zh-CN": {
"Global Monitor / World": "区域监测",
"Global Monitor / Americas": "区域监测",
"Global Monitor / Europe": "区域监测",
"Global Monitor / MEA": "区域监测",
"Global Monitor / APAC": "区域监测",
},
"en-US": {
"Global Monitor / World": "Regional Monitor",
"Global Monitor / Americas": "Regional Monitor",
"Global Monitor / Europe": "Regional Monitor",
"Global Monitor / MEA": "Regional Monitor",
"Global Monitor / APAC": "Regional Monitor",
},
};
const CATEGORY_LABELS = {
politics: "政治",
business: "商业",
ecommerce: "商",
finance: "金融",
sports: "体育",
technology: "科技",
military: "军事",
disaster: "灾害",
energy: "能源",
society: "社会",
culture: "文化",
other: "其他",
"zh-CN": {
politics: "政治",
business: "商",
ecommerce: "电商",
finance: "金融",
sports: "体育",
technology: "科技",
military: "军事",
disaster: "灾害",
energy: "能源",
society: "社会",
culture: "文化",
other: "其他",
},
"en-US": {
politics: "Politics",
business: "Business",
ecommerce: "E-commerce",
finance: "Finance",
sports: "Sports",
technology: "Technology",
military: "Military",
disaster: "Disaster",
energy: "Energy",
society: "Society",
culture: "Culture",
other: "Other",
},
};
const BREAKING_LEVEL_LABELS = {
watch: "关注",
breaking: "突发",
critical: "严重突发",
"zh-CN": {
watch: "关注",
breaking: "突发",
critical: "严重突发",
},
"en-US": {
watch: "Watch",
breaking: "Breaking",
critical: "Critical",
},
};
const BREAKING_SCOPE_LABELS = {
regional: "区域",
global: "全球",
"zh-CN": {
regional: "区域",
global: "全球",
},
"en-US": {
regional: "Regional",
global: "Global",
},
};
const SOURCE_TYPE_LABELS = {
rss: "RSS",
atom: "Atom",
aggregated: "Aggregated",
manual: "手动添加",
reference: "Reference",
"zh-CN": {
rss: "RSS",
atom: "Atom",
aggregated: "Aggregated",
manual: "手动添加",
reference: "Reference",
},
"en-US": {
rss: "RSS",
atom: "Atom",
aggregated: "Aggregated",
manual: "Manual",
reference: "Reference",
},
};
const LOCATION_SOURCE_LABELS = {
region_anchor: "区域锚点",
ai_inferred_target: "AI 推断位置",
headline_location_hint: "标题位置线索",
headline_country_hint: "标题国家线索",
"zh-CN": {
region_anchor: "区域锚点",
ai_inferred_target: "AI 推断位置",
headline_location_hint: "标题位置线索",
headline_country_hint: "标题国家线索",
},
"en-US": {
region_anchor: "Region Anchor",
ai_inferred_target: "AI-inferred Location",
headline_location_hint: "Headline Location Hint",
headline_country_hint: "Headline Country Hint",
},
};
const ENRICHMENT_STATUS_LABELS = {
pending: "待增强",
queued: "增强排队中",
attempted: "增强中",
success: "已汉化",
content_only: "已汉化",
location_only: "位置已增强",
unavailable: "AI 未配置",
provider_error: "增强失败",
parse_error: "增强解析失败",
no_result: "暂无增强结果",
"zh-CN": {
pending: "增强",
queued: "增强排队中",
attempted: "增强中",
success: "已汉化",
content_only: "已汉化",
location_only: "位置已增强",
unavailable: "AI 未配置",
provider_error: "增强失败",
parse_error: "增强解析失败",
no_result: "暂无增强结果",
},
"en-US": {
pending: "Pending",
queued: "Queued",
attempted: "Enhancing",
success: "Localized",
content_only: "Localized",
location_only: "Location Enhanced",
unavailable: "AI Unconfigured",
provider_error: "Enhancement Failed",
parse_error: "Parse Failed",
no_result: "No Enhancement",
},
};
const SOURCE_NAME_LABELS = {
"en-US": {
"36氪": "36Kr",
"亿邦动力": "Ebrun",
"商务数据中心": "MOFCOM Data Center",
"商务部电商动态": "MOFCOM E-Commerce",
"国家统计局数据发布": "National Bureau of Statistics",
"电商物流指数": "China E-Commerce Logistics Index",
},
};
const SOURCE_ID_LABELS = {
"en-US": {
"36kr": "36Kr",
ebrun: "Ebrun",
"mofcom-data": "MOFCOM Data Center",
"mofcom-ecommerce": "MOFCOM E-Commerce",
"stats-china-online-retail": "National Bureau of Statistics",
"china-ecommerce-logistics-index": "China E-Commerce Logistics Index",
},
};
const FEED_NAME_LABELS = {
"en-US": {
"综合资讯": "General",
"文章资讯": "Articles",
"最新快讯": "Newsflash",
"动态内容": "Updates",
"零售": "Retail",
"服务": "Services",
"数据": "Data",
"政策": "Policy",
"数据发布": "Data Releases",
},
};
const TITLE_PLACEHOLDERS = {
queued: "新闻汉化排队中",
attempted: "新闻汉化中",
provider_error: "新闻汉化失败,正在重试",
parse_error: "新闻解析失败,正在重试",
unavailable: "等待 AI 配置",
no_result: "新闻汉化待重试",
location_only: "新闻汉化待重试",
"zh-CN": {
queued: "新闻汉化排队中",
attempted: "新闻汉化",
provider_error: "新闻汉化失败,正在重试",
parse_error: "新闻解析失败,正在重试",
unavailable: "等待 AI 配置",
no_result: "新闻汉化待重试",
location_only: "新闻汉化待重试",
},
"en-US": {
queued: "English translation queued",
attempted: "English translation in progress",
provider_error: "English translation retrying",
parse_error: "English translation retrying",
unavailable: "Waiting for AI configuration",
no_result: "English translation pending",
location_only: "English translation pending",
},
};
const SUMMARY_PLACEHOLDERS = {
queued: "中文概要正在生成,请稍后刷新。",
attempted: "中文概要正在生成,请稍后刷新。",
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
parse_error: "中文概要解析失败,系统会重新提交增强任务。",
unavailable: "AI 服务配置完成后将生成中文概要。",
no_result: "中文概要暂未生成,系统会继续重试。",
location_only: "已完成位置增强,中文概要将继续重试。",
"zh-CN": {
queued: "中文概要正在生成,请稍后刷新。",
attempted: "中文概要正在生成,请稍后刷新。",
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
parse_error: "中文概要解析失败,系统会重新提交增强任务。",
unavailable: "AI 服务配置完成后将生成中文概要。",
no_result: "中文概要暂未生成,系统会继续重试。",
location_only: "已完成位置增强,中文概要将继续重试。",
},
"en-US": {
queued: "English summary is being generated. Refresh shortly.",
attempted: "English summary is being generated. Refresh shortly.",
provider_error: "English summary generation failed and will be retried.",
parse_error: "English summary parsing failed and will be retried.",
unavailable: "English summary will be generated after AI is configured.",
no_result: "English summary is pending and will be retried.",
location_only: "Location is ready; English summary is still pending.",
},
};
function normalizeText(value) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function getLocalization(item, locale = DEFAULT_LOCALE) {
function getLocale(locale = getEarthLocale()) {
return normalizeLocale(locale);
}
function getLabels(table, locale = getEarthLocale()) {
const normalizedLocale = getLocale(locale);
return table[normalizedLocale] || table["zh-CN"] || {};
}
function isEnglishLocale(locale = getEarthLocale()) {
return getLocale(locale) === "en-US";
}
function isEnglishContent(item) {
const language = String(item?.content_language || "").toLowerCase();
return language === "en" || language.startsWith("en-") || language.startsWith("en_");
}
function getPlaceholder(table, status, locale = getEarthLocale(), fallbackKey = "no_result") {
const labels = getLabels(table, locale);
return labels[status] || labels[fallbackKey] || "";
}
function safeEnglishText(value) {
const text = normalizeText(value);
return text && !hasCjkText(text) ? text : "";
}
function sourceIdFallback(id) {
const value = normalizeText(id);
if (!value) return "";
return value
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function getLocalization(item, locale = getEarthLocale()) {
const localizations = item?.localizations;
const localized = localizations && typeof localizations === "object"
? localizations[locale]
? localizations[getLocale(locale)]
: null;
return localized && typeof localized === "object" ? localized : {};
}
export function getNewsDisplayTitle(item, locale = DEFAULT_LOCALE) {
export function getNewsDisplayTitle(item, locale = getEarthLocale()) {
const normalizedLocale = getLocale(locale);
const localized = getLocalization(item, locale).title;
if (localized || item?.display_title) {
if (normalizedLocale === "zh-CN" && (item?.display_title || localized)) {
return normalizeText(item?.display_title || localized);
}
if (normalizedLocale === "en-US") {
return safeEnglishText(localized)
|| safeEnglishText(item?.display_title)
|| (isEnglishContent(item) ? safeEnglishText(item?.title) : "")
|| getPlaceholder(TITLE_PLACEHOLDERS, item?.enrichment_status, locale);
}
if (localized) {
return normalizeText(localized);
}
if (item?.title) {
return normalizeText(item.title);
}
return normalizeText(
TITLE_PLACEHOLDERS[item?.enrichment_status]
|| "新闻汉化中",
);
return normalizeText(getPlaceholder(TITLE_PLACEHOLDERS, item?.enrichment_status, locale, "attempted"));
}
export function getNewsDisplaySummary(item, locale = DEFAULT_LOCALE) {
export function getNewsDisplaySummary(item, locale = getEarthLocale()) {
const normalizedLocale = getLocale(locale);
const localized = getLocalization(item, locale).summary;
if (localized || item?.display_summary) {
if (normalizedLocale === "zh-CN" && (item?.display_summary || localized)) {
return normalizeText(item?.display_summary || localized);
}
if (normalizedLocale === "en-US") {
return safeEnglishText(localized)
|| safeEnglishText(item?.display_summary)
|| (isEnglishContent(item) ? safeEnglishText(item?.summary) : "")
|| getPlaceholder(SUMMARY_PLACEHOLDERS, item?.enrichment_status, locale);
}
if (localized) {
return normalizeText(localized);
}
if (item?.summary) {
return normalizeText(item.summary);
}
return normalizeText(
SUMMARY_PLACEHOLDERS[item?.enrichment_status]
|| "中文概要生成中,请稍后刷新。",
);
return normalizeText(getPlaceholder(SUMMARY_PLACEHOLDERS, item?.enrichment_status, locale, "attempted"));
}
export function isNewsContentReady(item, locale = DEFAULT_LOCALE) {
export function isNewsContentReady(item, locale = getEarthLocale()) {
const localized = getLocalization(item, locale);
const title = normalizeText(item?.display_title || localized.title || item?.title);
const summary = normalizeText(item?.display_summary || localized.summary || item?.summary);
const normalizedLocale = getLocale(locale);
if (normalizedLocale === "en-US") {
const title = safeEnglishText(localized.title)
|| safeEnglishText(item?.display_title)
|| (isEnglishContent(item) ? safeEnglishText(item?.title) : "");
const summary = safeEnglishText(localized.summary)
|| safeEnglishText(item?.display_summary)
|| (isEnglishContent(item) ? safeEnglishText(item?.summary) : "");
return Boolean(title && summary);
}
const title = normalizeText(
item?.display_title || localized.title || item?.title,
);
const summary = normalizeText(
item?.display_summary || localized.summary || item?.summary,
);
return Boolean(title && summary);
}
export function getNewsRegionLabel(region, fallback = "") {
return REGION_LABELS[region] || fallback || region || REGION_LABELS.global;
export function getNewsRegionLabel(region, fallback = "", locale = getEarthLocale()) {
const labels = getLabels(REGION_LABELS, locale);
return labels[region] || fallback || region || labels.global;
}
export function getNewsFeedLabel(feedName) {
return FEED_LABELS[feedName] || feedName || "聚合源";
export function getNewsFeedLabel(feedName, locale = getEarthLocale()) {
const normalizedLocale = getLocale(locale);
const mappedFeed = getLabels(FEED_NAME_LABELS, locale)[feedName];
if (mappedFeed) return mappedFeed;
if (normalizedLocale === "en-US" && hasCjkText(feedName)) return "News Feed";
return getLabels(FEED_LABELS, locale)[feedName]
|| feedName
|| (normalizedLocale === "en-US" ? "Aggregated Source" : "聚合源");
}
export function getNewsCategoryLabel(category) {
return CATEGORY_LABELS[category] || category || CATEGORY_LABELS.other;
export function getNewsCategoryLabel(category, locale = getEarthLocale()) {
const labels = getLabels(CATEGORY_LABELS, locale);
return labels[category] || category || labels.other;
}
export function getNewsBreakingLabel(level, scope = "regional") {
export function getNewsBreakingLabel(level, scope = "regional", locale = getEarthLocale()) {
const normalizedLevel = normalizeText(level).toLowerCase();
if (!normalizedLevel || normalizedLevel === "none") return "";
const levelLabel = BREAKING_LEVEL_LABELS[normalizedLevel] || level;
const scopeLabel = BREAKING_SCOPE_LABELS[normalizeText(scope).toLowerCase()] || BREAKING_SCOPE_LABELS.regional;
return `${scopeLabel}${levelLabel}`;
const levelLabels = getLabels(BREAKING_LEVEL_LABELS, locale);
const scopeLabels = getLabels(BREAKING_SCOPE_LABELS, locale);
const levelLabel = levelLabels[normalizedLevel] || level;
const scopeLabel = scopeLabels[normalizeText(scope).toLowerCase()] || scopeLabels.regional;
return getLocale(locale) === "en-US" ? `${scopeLabel} ${levelLabel}` : `${scopeLabel}${levelLabel}`;
}
export function getNewsSourceTypeLabel(sourceType) {
export function getNewsSourceTypeLabel(sourceType, locale = getEarthLocale()) {
const normalized = normalizeText(sourceType).toLowerCase();
return SOURCE_TYPE_LABELS[normalized] || sourceType || "RSS";
return getLabels(SOURCE_TYPE_LABELS, locale)[normalized] || sourceType || "RSS";
}
export function getNewsSourceNameLabel(sourceOrName, locale = getEarthLocale()) {
const normalizedLocale = getLocale(locale);
const source = sourceOrName && typeof sourceOrName === "object" ? sourceOrName : null;
const name = normalizeText(source ? source.name : sourceOrName);
if (normalizedLocale !== "en-US") return name;
const id = normalizeText(source?.id || source?.source_id);
return getLabels(SOURCE_ID_LABELS, locale)[id]
|| getLabels(SOURCE_NAME_LABELS, locale)[name]
|| safeEnglishText(name)
|| sourceIdFallback(id)
|| "News Source";
}
export function getNewsRegionDisplayLabel(region, fallback = "", locale = getEarthLocale()) {
return getNewsRegionLabel(region, isEnglishLocale(locale) ? "" : fallback, locale);
}
export function isRegionalMonitorFeed(feedName) {
return Object.prototype.hasOwnProperty.call(FEED_LABELS, feedName);
return Object.values(FEED_LABELS).some((labels) =>
Object.prototype.hasOwnProperty.call(labels, feedName),
);
}
export function getNewsFetchChannelLabel(feedName, sourceType = "") {
const normalized = normalizeText(sourceType).toLowerCase();
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return "区域监测";
if (normalized === "manual") return "手动添加";
if (normalized === "atom") return "单源 Atom";
if (normalized === "reference") return "配置保留";
return "单源 RSS";
const english = getEarthLocale() === "en-US";
if (isRegionalMonitorFeed(feedName) || normalized === "aggregated") return english ? "Regional Monitor" : "区域监测";
if (normalized === "manual") return english ? "Manual" : "手动添加";
if (normalized === "atom") return english ? "Single-source Atom" : "单源 Atom";
if (normalized === "reference") return english ? "Reserved" : "配置保留";
return english ? "Single-source RSS" : "单源 RSS";
}
export function getNewsLocationSourceLabel(source) {
return LOCATION_SOURCE_LABELS[source] || source || "位置来源";
export function getNewsLocationSourceLabel(source, locale = getEarthLocale()) {
return getLabels(LOCATION_SOURCE_LABELS, locale)[source]
|| source
|| (getLocale(locale) === "en-US" ? "Location Source" : "位置来源");
}
export function getNewsEnrichmentStatusLabel(statusOrItem) {
@@ -184,9 +416,11 @@ export function getNewsEnrichmentStatusLabel(statusOrItem) {
const status = item ? item.enrichment_status : statusOrItem;
const language = String(item?.content_language || "").toLowerCase();
if (language.startsWith("zh") && status !== "success" && status !== "content_only") {
if (status === "queued" || status === "attempted") return "英文补译中";
if (status === "provider_error" || status === "parse_error" || status === "no_result") return "中文原文";
return "中文原文";
if (status === "queued" || status === "attempted") return getEarthLocale() === "en-US" ? "English Translation Pending" : "英文补译中";
if (status === "provider_error" || status === "parse_error" || status === "no_result") return getEarthLocale() === "en-US" ? "Chinese Original" : "中文原文";
return getEarthLocale() === "en-US" ? "Chinese Original" : "中文原文";
}
return ENRICHMENT_STATUS_LABELS[status] || status || "增强状态";
return getLabels(ENRICHMENT_STATUS_LABELS)[status]
|| status
|| (getEarthLocale() === "en-US" ? "Enhancement Status" : "增强状态");
}

View File

@@ -1,4 +1,11 @@
import { showStatusMessage } from "./ui.js";
import {
applyEarthI18n,
earthMessage,
getEarthLocale,
onEarthLocaleChange,
translateText,
} from "./i18n.js";
import {
getNewsDisplaySummary,
getNewsDisplayTitle,
@@ -6,8 +13,11 @@ import {
getNewsBreakingLabel,
getNewsEnrichmentStatusLabel,
getNewsFetchChannelLabel,
getNewsFeedLabel,
getNewsRegionLabel,
getNewsRegionDisplayLabel,
getNewsSourceTypeLabel,
getNewsSourceNameLabel,
isNewsContentReady,
} from "./news-locale.js";
import {
@@ -129,17 +139,18 @@ function formatCoord(value, positiveLabel, negativeLabel) {
}
function formatRelativeTime(raw) {
if (!raw) return "刚刚同步";
const english = getEarthLocale() === "en-US";
if (!raw) return english ? "Just synced" : "刚刚同步";
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return "刚刚同步";
if (Number.isNaN(date.getTime())) return english ? "Just synced" : "刚刚同步";
const diff = Date.now() - date.getTime();
const minutes = Math.max(1, Math.round(diff / 60000));
if (minutes < 60) return `${minutes} 分钟前`;
if (minutes < 60) return english ? `${minutes}m ago` : `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
if (hours < 24) return english ? `${hours}h ago` : `${hours} 小时前`;
const days = Math.round(hours / 24);
return `${days} 天前`;
return english ? `${days}d ago` : `${days} 天前`;
}
export function updateNewsToggleUI(visible) {
@@ -371,7 +382,7 @@ function escapeNewsHtml(value) {
function getDisplayableNewsItems(items) {
return Array.isArray(items)
? items.filter(isNewsContentReady)
? items.filter((item) => isNewsContentReady(item))
: [];
}
@@ -430,25 +441,30 @@ function normalizeNewsSourceType(value) {
function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
const feedName = String(item?.feed_name || "").trim();
const sourceName = String(item?.source || feedName || "NEWS").trim();
const rawSourceName = String(item?.source || feedName || "NEWS").trim();
const sourceId = String(item?.source_id || "").trim();
const sourceConfig = sourcesById.get(sourceId) || sourcesByName.get(feedName) || null;
const sourceType = normalizeNewsSourceType(item?.source_type || sourceConfig?.source_type)
|| (feedName.startsWith("Global Monitor /") ? "aggregated" : "rss");
const sourceTypeLabel = getNewsSourceTypeLabel(sourceType);
const channelLabel = getNewsFetchChannelLabel(feedName, sourceType);
const sourceGroupName = String(sourceConfig?.name || "").trim();
const sourceName = getNewsSourceNameLabel(
sourceConfig || { id: sourceId, name: rawSourceName },
);
const feedLabel = getNewsFetchChannelLabel(feedName, sourceType);
const sourceGroupName = sourceConfig ? getNewsSourceNameLabel(sourceConfig) : "";
const originLabel = sourceGroupName
? `${sourceGroupName} · ${channelLabel}`
: feedName && feedName !== sourceName
? `${feedName} · ${sourceTypeLabel}`
? `${feedLabel} · ${sourceTypeLabel}`
: `${channelLabel} · ${sourceTypeLabel}`;
const english = getEarthLocale() === "en-US";
const tooltip = [
`媒体来源:${sourceName}`,
sourceGroupName ? `来源组:${sourceGroupName}` : "",
feedName ? `RSS 来源:${feedName}` : "",
`源类型:${sourceTypeLabel}`,
`抓取通道:${channelLabel}`,
`${english ? "Media source" : "媒体来源"}: ${sourceName}`,
sourceGroupName ? `${english ? "Source group" : "来源组"}: ${sourceGroupName}` : "",
feedName ? `${english ? "RSS feed" : "RSS 来源"}: ${getNewsFeedNameForTooltip(feedName)}` : "",
`${english ? "Source type" : "源类型"}: ${sourceTypeLabel}`,
`${english ? "Fetch channel" : "抓取通道"}: ${channelLabel}`,
].filter(Boolean).join("\n");
return {
sourceName,
@@ -461,6 +477,43 @@ function getNewsSourceDescriptor(item, sourcesByName, sourcesById) {
};
}
function getNewsFeedNameForTooltip(feedName) {
return getEarthLocale() === "en-US"
? getNewsFeedLabel(feedName)
: feedName;
}
function getFocusRegionText(focus = {}) {
return getNewsRegionDisplayLabel(focus.region, focus.display_region);
}
function getFocusLabelText(focus = {}) {
if (getEarthLocale() === "en-US") {
return getNewsRegionDisplayLabel(focus.region, focus.label);
}
return focus.label || "全球焦点";
}
function formatNewsSourceCount(enabledCount, totalCount) {
if (getEarthLocale() === "en-US") return `${enabledCount || totalCount} / ${totalCount} sources`;
return `${enabledCount || totalCount} / ${totalCount} 路来源`;
}
function formatNewsStatus({ displayCount, totalCount, stale, hasErrors }) {
if (getEarthLocale() === "en-US") {
if (displayCount !== totalCount) return `Showing ${displayCount} / ${totalCount} stories`;
if (stale) return `Showing the latest available news cache, ${totalCount} stories`;
return hasErrors
? `Aggregated ${totalCount} stories; some sources are unavailable`
: `Aggregated ${totalCount} situation stories`;
}
if (displayCount !== totalCount) return `展示 ${displayCount} / ${totalCount} 条态势新闻`;
if (stale) return `当前显示最近一次可用新闻缓存,共 ${totalCount}`;
return hasErrors
? `已聚合 ${totalCount} 条,部分源不可用`
: `已聚合 ${totalCount} 条态势新闻`;
}
function getEnabledNewsCategoryKeys(filters = activeNewsCategoryFilters) {
if (!filters || typeof filters !== "object") return [...NEWS_CATEGORY_KEYS].sort();
return Object.entries(filters)
@@ -548,10 +601,10 @@ function setNewsSourceFilters(enabledIds, { persist = true } = {}) {
}
function summarizeSelection(enabledCount, totalCount) {
if (totalCount <= 0) return "暂无";
if (enabledCount <= 0) return "未选";
if (enabledCount === totalCount) return "全部";
return `${enabledCount}`;
if (totalCount <= 0) return translateText("暂无");
if (enabledCount <= 0) return translateText("未选");
if (enabledCount === totalCount) return translateText("全部");
return translateText(`${enabledCount}`);
}
function syncFilterSummaries(nextPayload = payload) {
@@ -567,10 +620,12 @@ function syncFilterSummaries(nextPayload = payload) {
});
document.querySelectorAll('[data-news-filter-summary="limit"]').forEach((el) => {
const total = Array.isArray(nextPayload?.items) ? nextPayload.items.length : 0;
el.textContent = newsFullListMode ? "全部" : `${Math.min(NEWS_SUMMARY_LIMIT, total || NEWS_SUMMARY_LIMIT)}`;
el.textContent = newsFullListMode
? translateText("全部")
: translateText(`${Math.min(NEWS_SUMMARY_LIMIT, total || NEWS_SUMMARY_LIMIT)}`);
});
document.querySelectorAll("[data-news-view-mode-label]").forEach((el) => {
el.textContent = newsFullListMode ? "返回摘要" : "查看全部";
el.textContent = translateText(newsFullListMode ? "返回摘要" : "查看全部");
});
}
@@ -601,7 +656,9 @@ function renderCategoryFilterChips() {
function renderSourceFilterChips() {
const sources = Array.isArray(payload?.sources) ? payload.sources : [];
const enabled = new Set(getEnabledNewsSourceIds());
if (!sources.length) return `<span class="news-filter-popover__hint">暂无可筛选来源。</span>`;
if (!sources.length) {
return `<span class="news-filter-popover__hint">${escapeNewsHtml(translateText("暂无可筛选来源。"))}</span>`;
}
return sources
.map((source) => {
const id = String(source?.id || "").trim();
@@ -613,15 +670,15 @@ function renderSourceFilterChips() {
type="button"
data-news-source-toggle="${escapeNewsHtml(id)}"
aria-pressed="${active ? "true" : "false"}"
>${escapeNewsHtml(source?.name || id)}</button>
>${escapeNewsHtml(getNewsSourceNameLabel(source || { id }))}</button>
`;
})
.join("");
}
function renderFilterPopover(kind) {
const title = kind === "source" ? "新闻来源" : "新闻类型";
const hint = kind === "source" ? "按大来源筛选,不影响后台抓取。" : "按新闻内容分类筛选。";
const title = translateText(kind === "source" ? "新闻来源" : "新闻类型");
const hint = translateText(kind === "source" ? "按大来源筛选,不影响后台抓取。" : "按新闻内容分类筛选。");
const content = kind === "source" ? renderSourceFilterChips() : renderCategoryFilterChips();
activeFilterPopover = kind;
@@ -675,19 +732,19 @@ function renderTicker(nextPayload) {
const focus = nextPayload?.focus || {};
if (tickerRegion instanceof HTMLElement) {
tickerRegion.textContent = focus.display_region || getNewsRegionLabel(focus.region);
tickerRegion.textContent = getFocusRegionText(focus);
tickerRegion.style.color = focus.accent || "";
}
if (items.length === 0) {
tickerTrack.textContent = "正在准备全球态势新闻...";
tickerTrack.textContent = translateText("正在准备全球态势新闻...");
tickerTrack.style.removeProperty("--news-ticker-duration");
return;
}
const visibleItems = getDisplayableNewsItems(items).slice(0, 6);
if (visibleItems.length === 0) {
tickerTrack.textContent = "当前新闻类型没有可显示新闻...";
tickerTrack.textContent = translateText("当前新闻类型没有可显示新闻...");
tickerTrack.style.removeProperty("--news-ticker-duration");
return;
}
@@ -695,7 +752,10 @@ function renderTicker(nextPayload) {
tickerTrack.innerHTML = tickerItems
.map((item) => `
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
<span class="earth-news-ticker__source">${escapeTickerText(getNewsSourceNameLabel({
id: item.source_id,
name: item.source || item.feed_name || "NEWS",
}))}</span>
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
</span>
`)
@@ -711,7 +771,7 @@ function renderEmptyState(message) {
empty.textContent = message;
}
if (status) {
status.textContent = "等待聚合新闻源";
status.textContent = translateText("等待聚合新闻源");
}
if (openBtn) openBtn.disabled = true;
renderTicker({ items: [], focus: payload?.focus || { region: "global" } });
@@ -762,7 +822,7 @@ function renderPayload(nextPayload) {
}
if (regionChip) {
regionChip.textContent = focus.display_region || getNewsRegionLabel(focus.region);
regionChip.textContent = getFocusRegionText(focus);
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
}
@@ -772,25 +832,22 @@ function renderPayload(nextPayload) {
return;
}
focusLabel.textContent = focus.label || "全球焦点";
focusLabel.textContent = getFocusLabelText(focus);
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
} else {
focusCoords.textContent = "跟随当前视角自动聚焦";
focusCoords.textContent = translateText("跟随当前视角自动聚焦");
}
const enabledSourceCount = getEnabledNewsSourceIds(nextPayload).length;
sourceCount.textContent = `${enabledSourceCount || sources.length} / ${sources.length} 路来源`;
if (displayItems.length !== items.length) {
status.textContent = `展示 ${displayItems.length} / ${items.length} 条态势新闻`;
} else if (nextPayload?.stale) {
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length}`;
} else {
status.textContent = nextPayload?.errors?.length
? `已聚合 ${items.length} 条,部分源不可用`
: `已聚合 ${items.length} 条态势新闻`;
}
sourceCount.textContent = formatNewsSourceCount(enabledSourceCount, sources.length);
status.textContent = formatNewsStatus({
displayCount: displayItems.length,
totalCount: items.length,
stale: Boolean(nextPayload?.stale),
hasErrors: Boolean(nextPayload?.errors?.length),
});
if (feedAnchor) {
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
@@ -806,8 +863,8 @@ function renderPayload(nextPayload) {
if (empty) {
empty.hidden = false;
empty.textContent = items.length === 0
? "当前未拉到可用新闻,请稍后刷新或切换视角区域。"
: "当前新闻类型没有可显示新闻。";
? translateText("当前未拉到可用新闻,请稍后刷新或切换视角区域。")
: translateText("当前新闻类型没有可显示新闻。");
}
return;
}
@@ -827,7 +884,7 @@ function renderPayload(nextPayload) {
const title = getNewsDisplayTitle(item);
const summaryText = getNewsDisplaySummary(item);
const leadText = summaryText || title;
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
const regionLabel = getNewsRegionDisplayLabel(item.region, item.display_region);
const categoryLabel = getNewsCategoryLabel(item.category);
const statusLabel = getNewsEnrichmentStatusLabel(item);
const breakingLabel = getNewsBreakingLabel(breakingLevel, breakingScope);
@@ -962,17 +1019,24 @@ async function fetchNews(lat, lon, context = {}) {
const categorySignature = context.categorySignature ?? getNewsCategorySignature();
const sourceSignature = context.sourceSignature ?? getNewsSourceSignatureForFetch(lat, lon);
if (categorySignature === "__none__" || sourceSignature === "__none__") {
const locale = getEarthLocale();
return {
...(payload || {}),
generated_at: new Date().toISOString(),
focus: payload?.focus || { lat, lon, region: "global", label: "全球焦点", display_region: "全球" },
focus: payload?.focus || {
lat,
lon,
region: "global",
label: translateText("全球焦点"),
display_region: getNewsRegionLabel("global"),
},
sources: payload?.sources || [],
filters: {
region: payload?.focus?.region || "global",
categories: categorySignature === "__none__" ? [] : getEnabledNewsCategoryKeys(),
sources: sourceSignature === "__none__" ? [] : getEnabledNewsSourceIds(),
limit: getNewsLimit(),
locale: "zh-CN",
locale,
},
items: [],
cruise_items: [],
@@ -986,7 +1050,7 @@ async function fetchNews(lat, lon, context = {}) {
if (categorySignature) url.searchParams.set("categories", categorySignature);
if (sourceSignature) url.searchParams.set("sources", sourceSignature);
url.searchParams.set("limit", String(getNewsLimit()));
url.searchParams.set("locale", "zh-CN");
url.searchParams.set("locale", getEarthLocale());
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -1011,6 +1075,7 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
categorySignature,
sourceSignature,
getNewsLimit(),
getEarthLocale(),
].join("|");
if (refreshPromise && refreshRequestKey === requestKey) return refreshPromise;
@@ -1034,9 +1099,10 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
const { status } = getElements();
if (status) {
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
status.textContent = translateText("当前区域暂无可用新闻,已完成一次聚合尝试");
}
}
applyEarthI18n();
return nextPayload;
})
.catch((error) => {
@@ -1045,12 +1111,12 @@ async function refreshNews(lat, lon, { silent = false } = {}) {
}
console.error("加载 Earth RSS 新闻失败:", error);
const message = error?.name === "AbortError"
? "新闻聚合请求超时,请稍后重试"
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
? translateText("新闻聚合请求超时,请稍后重试")
: `${translateText("新闻聚合暂时不可用")}: ${error?.message || (getEarthLocale() === "en-US" ? "Unknown error" : "未知错误")}`;
if (!payload) {
renderEmptyState(message);
} else if (!silent) {
showStatusMessage("态势新闻同步失败", "error");
showStatusMessage(earthMessage("status.newsSyncFailed"), "error");
}
throw error;
})
@@ -1185,7 +1251,7 @@ export function initNewsPanel() {
initialized = true;
updateNewsToggleUI(true);
renderEmptyState("正在准备全球态势新闻聚合源...");
renderEmptyState(translateText("正在准备全球态势新闻聚合源..."));
const { ticker, hudCloseBtn } = getElements();
ticker?.addEventListener("click", (event) => {
@@ -1252,6 +1318,13 @@ export function initNewsPanel() {
lastFetchAt = 0;
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
});
onEarthLocaleChange(() => {
syncFilterSummaries();
if (activeFilterPopover) renderFilterPopover(activeFilterPopover);
lastFetchAt = 0;
refreshRequestKey = "";
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
});
setupNewsHudResize();
connectNewsRealtime();
@@ -1260,9 +1333,9 @@ export function initNewsPanel() {
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
showStatusMessage(earthMessage("status.newsRefresh", { ok: true }), "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
showStatusMessage(earthMessage("status.newsRefresh", { ok: false }), "error");
}
});
});

View File

@@ -0,0 +1,55 @@
import { SATELLITE_CONFIG } from "./constants.js";
import {
createSatellitePropagator,
getSatelliteSampleTimeMs,
SATELLITE_POSITION_UPDATE_INTERVAL_MS,
} from "./satellite-propagation.js";
let satellites = [];
let propagator = null;
self.onmessage = async ({ data }) => {
try {
if (data.type === "init") {
// Import maps belong to documents; resolve their URLs on the main thread
// so the worker uses exactly the same Three.js and SGP4 versions.
const [three, orbitalMath] = await Promise.all([
import(data.threeUrl),
import(data.satelliteUrl),
]);
satellites = data.satellites;
propagator = createSatellitePropagator(three, orbitalMath);
propagator.setRealAltitudeEnabled(data.realAltitude);
self.postMessage({ type: "ready" });
return;
}
if (data.type !== "update" || !propagator) return;
const count = satellites.length;
const positions = new Float32Array(count * 3);
const trailSeed = data.seedTrails
? new Float32Array(count * SATELLITE_CONFIG.trailLength * 3)
: null;
for (let index = 0; index < count; index += 1) {
const satellite = satellites[index];
const timestamp = getSatelliteSampleTimeMs(data.baseTime, index, count);
const time = new Date(timestamp);
const position = propagator.computeSatellitePosition(satellite, time)
|| propagator.generateFallbackPosition(satellite, index, count, time);
position.toArray(positions, index * 3);
if (trailSeed) {
for (let step = 0; step < SATELLITE_CONFIG.trailLength; step += 1) {
const pastTime = new Date(timestamp
- (SATELLITE_CONFIG.trailLength - 1 - step) * SATELLITE_POSITION_UPDATE_INTERVAL_MS);
const past = propagator.computeSatellitePosition(satellite, pastTime)
|| propagator.generateFallbackPosition(satellite, index, count, pastTime);
past.toArray(trailSeed, (index * SATELLITE_CONFIG.trailLength + step) * 3);
}
}
}
const transfers = [positions.buffer];
if (trailSeed) transfers.push(trailSeed.buffer);
self.postMessage({ type: "positions", baseTime: data.baseTime, positions, trailSeed }, transfers);
} catch (error) {
self.postMessage({ type: "error", message: error?.message || String(error) });
}
};

View File

@@ -0,0 +1,290 @@
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
export const SATELLITE_POSITION_UPDATE_INTERVAL_MS = 250;
export const EARTH_RADIUS_KM = 6378.137;
export function getSatelliteSampleTimeMs(baseTime, index, count) {
return Math.trunc(baseTime) + (index / count) * 2 * Math.PI * 0.1 * 1000 * 60 * 10;
}
export function createSatellitePropagator(THREE, { twoline2satrec, propagate, eciToEcf, gstime }) {
let satelliteSatrecCache = new WeakMap();
let satelliteRealAltitudeEnabled = true;
const FALLBACK_MIN_MEAN_MOTION = 12;
const FALLBACK_MEAN_MOTION_SPREAD = 4;
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
gstime(time),
);
} catch (error) {
return null;
}
}
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
const earthFixedPosition = convertEciPositionToSceneVector(
positionEci,
siderealTime,
);
const x = earthFixedPosition.x;
const y = earthFixedPosition.y;
const z = earthFixedPosition.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(
positionEci.x * positionEci.x +
positionEci.y * positionEci.y +
positionEci.z * positionEci.z,
);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
return null;
}
const scale = displayRadius / sceneRadius;
return new THREE.Vector3(x * scale, y * scale, z * scale);
}
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
siderealTime,
);
} catch (error) {
return null;
}
}
function convertEciPositionToSceneVector(positionEci, siderealTime) {
const positionEcf = eciToEcf(positionEci, siderealTime);
return new THREE.Vector3(
positionEcf.x,
positionEcf.z,
-positionEcf.y,
);
}
function getCompressedRealAltitudeOffset(radiusKm) {
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
const clampedAltitudeKm = Math.min(
altitudeKm,
SATELLITE_CONFIG.maxDisplayAltitudeKm,
);
const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm);
const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) /
Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm);
return THREE.MathUtils.lerp(
SATELLITE_CONFIG.minRealAltitudeOffset,
SATELLITE_CONFIG.maxRealAltitudeOffset,
THREE.MathUtils.clamp(normalizedAltitude, 0, 1),
);
}
function buildSatrecFromProperties(props, fallbackTime) {
if (props.tle_line1 && props.tle_line2) {
// Prefer source-provided TLE lines so the client does not need to rebuild them.
const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
if (!satrec.error) {
return satrec;
}
}
const tleLines = buildTleLinesFromElements(props, fallbackTime);
if (!tleLines) {
return null;
}
return twoline2satrec(tleLines.line1, tleLines.line2);
}
function getOrBuildSatrec(props, fallbackTime) {
// Properties are immutable within a loaded snapshot. Avoid constructing and
// hashing long TLE strings for every satellite on every position update.
const cacheable = props?.epoch || (props?.tle_line1 && props?.tle_line2);
if (cacheable && satelliteSatrecCache.has(props)) {
return satelliteSatrecCache.get(props);
}
const satrec = buildSatrecFromProperties(props, fallbackTime);
if (cacheable) {
satelliteSatrecCache.set(props, satrec);
}
return satrec;
}
function computeTleChecksum(line) {
let sum = 0;
for (const char of line.slice(0, 68)) {
if (char >= "0" && char <= "9") {
sum += Number(char);
} else if (char === "-") {
sum += 1;
}
}
return String(sum % 10);
}
function buildTleLinesFromElements(props, fallbackTime) {
if (!props?.norad_cat_id) {
return null;
}
const requiredValues = [
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
];
if (requiredValues.some((value) => value === null || value === undefined)) {
return null;
}
const epochDate =
props.epoch && String(props.epoch).length >= 10
? new Date(props.epoch)
: fallbackTime;
if (Number.isNaN(epochDate.getTime())) {
return null;
}
const epochYear = epochDate.getUTCFullYear() % 100;
const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
const msOfDay =
epochDate.getUTCHours() * 3600000 +
epochDate.getUTCMinutes() * 60000 +
epochDate.getUTCSeconds() * 1000 +
epochDate.getUTCMilliseconds();
const dayFraction = msOfDay / 86400000;
const epochStr =
String(epochYear).padStart(2, "0") +
String(dayOfYear).padStart(3, "0") +
dayFraction.toFixed(8).slice(1);
const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
.toString()
.padStart(7, "0");
// Keep a local fallback for historical rows that do not have stored TLE lines yet.
const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
props.inclination,
)
.toFixed(4)
.padStart(
8,
)} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
props.arg_of_perigee,
)
.toFixed(4)
.padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
props.mean_motion,
)
.toFixed(8)
.padStart(11)}00000`;
return {
line1: line1Core + computeTleChecksum(line1Core),
line2: line2Core + computeTleChecksum(line2Core),
};
}
function generateFallbackPosition(satellite, index, total, time = new Date()) {
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
const hash = String(noradId)
.split("")
.reduce((a, b) => a + b.charCodeAt(0), 0);
const randomOffset = (hash % 1000) / 1000;
const rawMeanMotion = Number(satellite.properties?.mean_motion);
const meanMotion =
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
? rawMeanMotion
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
const normalizedIndex = index / total;
const elapsedDays = Number.isFinite(time?.getTime?.())
? time.getTime() / FALLBACK_ORBIT_DAY_MS
: Date.now() / FALLBACK_ORBIT_DAY_MS;
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
const theta =
normalizedIndex * Math.PI * 2 * 10 +
(raan * Math.PI) / 180 +
fallbackPhase;
const phi =
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
const adjustedPhi = Math.abs(phi % Math.PI);
const adjustedTheta = theta + randomOffset * Math.PI * 2;
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
const y = radius * Math.cos(adjustedPhi);
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
return new THREE.Vector3(x, y, z);
}
return {
computeSatellitePosition,
computeSatelliteInertialOrbitPosition,
generateFallbackPosition,
getOrBuildSatrec,
reset() { satelliteSatrecCache = new WeakMap(); },
setRealAltitudeEnabled(enabled) { satelliteRealAltitudeEnabled = enabled; },
};
}

View File

@@ -15,6 +15,16 @@ import {
updateIridiumFootprintAdapter,
} from "./iridium-footprint-adapter.js";
import {
createSatellitePropagator,
EARTH_RADIUS_KM,
getSatelliteSampleTimeMs,
SATELLITE_POSITION_UPDATE_INTERVAL_MS,
} from "./satellite-propagation.js";
const satellitePropagator = createSatellitePropagator(THREE, { twoline2satrec, propagate, eciToEcf, gstime });
const { computeSatellitePosition, computeSatelliteInertialOrbitPosition, generateFallbackPosition, getOrBuildSatrec } = satellitePropagator;
let satellitePoints = null;
let satelliteBackdropPoints = null;
let satelliteTrails = null;
@@ -40,10 +50,15 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
let satelliteIdleBreathingEnabled = true;
let satelliteRealAltitudeEnabled = true;
let satellitePositionWorker = null;
let satelliteWorkerReady = false;
let satelliteWorkerPending = false;
let satelliteWorkerDisabled = false;
let pendingPositionSnapshot = null;
let satelliteWorkerStartupTimer = null;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
@@ -120,14 +135,11 @@ const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [
"instanceColorStart",
"instanceColorEnd",
];
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
const FALLBACK_MIN_MEAN_MOTION = 12;
const FALLBACK_MEAN_MOTION_SPREAD = 4;
const FALLBACK_TRAIL_TIP_LENGTH = 0.004;
const FALLBACK_TRAIL_ALPHA_START = 0.2;
const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const POSITION_UPDATE_INTERVAL_MS = SATELLITE_POSITION_UPDATE_INTERVAL_MS;
const FOOTPRINT_DIRECTION_SAMPLE_MS = 30000;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
@@ -149,7 +161,6 @@ const LOCKED_RING_HOVER_SCALE = 1.32;
const LOCKED_RING_HOVER_LINE_WIDTH = 5;
const HOVER_RING_LINE_WIDTH = 3;
const LOCKED_RING_IDLE_OPACITY = 0.92;
const EARTH_RADIUS_KM = 6378.137;
const GROUND_FOOTPRINT_MIN_ELEVATION_DEG = 25;
const GROUND_FOOTPRINT_RADIUS_OFFSET = 0.72;
const GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM = 550;
@@ -213,6 +224,8 @@ function createSatellitePointMaterial({
size: { value: size * getPointPixelRatio() },
opacity: { value: opacity },
baseColor: { value: new THREE.Color(baseColor) },
breathingPhase: { value: 0 },
breathingEnabled: { value: 0 },
},
transparent: true,
depthTest: true,
@@ -220,13 +233,26 @@ function createSatellitePointMaterial({
vertexShader: `
uniform float size;
uniform vec3 baseColor;
uniform float breathingPhase;
uniform float breathingEnabled;
attribute float alpha;
attribute vec3 twinklePhase;
attribute vec2 twinkleShape;
${useVertexColor ? "attribute vec3 color;" : ""}
varying float vAlpha;
varying vec3 vColor;
void main() {
vAlpha = alpha;
if (breathingEnabled > 0.5) {
float phase = breathingPhase * twinklePhase.z;
float primary = 0.5 + 0.5 * sin(phase + twinklePhase.x);
float secondary = 0.5 + 0.5 * sin(phase * ${SATELLITE_TWINKLE_SECONDARY_SPEED.toFixed(8)} + twinklePhase.y);
float pulse = clamp(mix(primary, secondary, ${SATELLITE_TWINKLE_SECONDARY_WEIGHT.toFixed(8)}), 0.0, 1.0);
float shaped = twinkleShape.x + pow(pulse, 1.8) * twinkleShape.y;
vAlpha *= clamp(mix(${SATELLITE_CONFIG.dotOpacityMin.toFixed(8)}, ${SATELLITE_CONFIG.dotOpacityMax.toFixed(8)}, shaped),
${SATELLITE_CONFIG.dotOpacityMin.toFixed(8)}, ${SATELLITE_CONFIG.dotOpacityMax.toFixed(8)});
}
vColor = ${useVertexColor ? "color" : "baseColor"};
gl_PointSize = size;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
@@ -351,6 +377,7 @@ export function setSatelliteRealAltitudeEnabled(enabled) {
}
satelliteRealAltitudeEnabled = nextEnabled;
satellitePropagator.setRealAltitudeEnabled(nextEnabled);
clearSatelliteTrails();
updateSatellitePositions(0, true, { resetTrails: true });
if (
@@ -377,21 +404,10 @@ export function updateSatelliteIdleBreathingVisual(isIdle = true) {
satelliteBackdropPoints.material.opacity = 0.42;
}
const pointAlphaAttr = satellitePoints.geometry.attributes.alpha;
const backdropAlphaAttr = satelliteBackdropPoints.geometry.attributes.alpha;
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
const drawCount = satellitePoints.geometry.drawRange?.count ?? satelliteCapacity;
const count = Math.min(drawCount, satelliteCapacity, satellitePositions.length);
for (let i = 0; i < count; i += 1) {
const alpha = shouldHideSatellitePoint(i)
? 0
: getSatelliteTwinkleAlpha(i, isIdle);
pointAlphaAttr.array[i] = alpha;
backdropAlphaAttr.array[i] = alpha;
for (const points of [satellitePoints, satelliteBackdropPoints]) {
points.material.uniforms.breathingPhase.value = breathingPhase;
points.material.uniforms.breathingEnabled.value = satelliteIdleBreathingEnabled && isIdle ? 1 : 0;
}
pointAlphaAttr.needsUpdate = true;
backdropAlphaAttr.needsUpdate = true;
}
export function updateSatellitePointSize() {
@@ -722,32 +738,6 @@ function createSatellitePositionState(index = 0) {
};
}
function getSatelliteTwinkleAlpha(index, isIdle = true) {
if (!satelliteIdleBreathingEnabled || !isIdle) return 1;
const twinkle = satellitePositions[index]?.twinkle || createSatelliteTwinkleState(index);
const primaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed + twinkle.phase,
);
const secondaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed * SATELLITE_TWINKLE_SECONDARY_SPEED +
twinkle.secondaryPhase,
);
const mixedPulse = THREE.MathUtils.clamp(
primaryPulse * (1 - SATELLITE_TWINKLE_SECONDARY_WEIGHT) +
secondaryPulse * SATELLITE_TWINKLE_SECONDARY_WEIGHT,
0,
1,
);
const shapedPulse = twinkle.floor + Math.pow(mixedPulse, 1.8) * twinkle.intensity;
return THREE.MathUtils.clamp(
SATELLITE_CONFIG.dotOpacityMin +
shapedPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin),
SATELLITE_CONFIG.dotOpacityMin,
SATELLITE_CONFIG.dotOpacityMax,
);
}
function resetSatelliteTrailState() {
satellitePositions.forEach((position) => {
position.trail = [];
@@ -771,6 +761,61 @@ function clearSatelliteTrailGeometry() {
function clearSatelliteTrails() {
resetSatelliteTrailState();
clearSatelliteTrailGeometry();
if (satelliteData.length) startSatellitePositionWorker();
}
function stopSatellitePositionWorker() {
clearTimeout(satelliteWorkerStartupTimer);
satelliteWorkerStartupTimer = null;
satellitePositionWorker?.terminate();
satellitePositionWorker = null;
satelliteWorkerReady = false;
satelliteWorkerPending = false;
pendingPositionSnapshot = null;
}
function startSatellitePositionWorker() {
stopSatellitePositionWorker();
if (satelliteWorkerDisabled || !satelliteData.length || typeof Worker === "undefined") return;
const fail = (error) => {
console.warn("Satellite position worker unavailable; using synchronous propagation", error?.message || error);
satelliteWorkerDisabled = true;
stopSatellitePositionWorker();
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
};
try {
const worker = new Worker(new URL("./satellite-position-worker.js", import.meta.url), { type: "module" });
satellitePositionWorker = worker;
worker.onmessage = ({ data }) => {
if (worker !== satellitePositionWorker) return;
if (data.type === "ready") {
clearTimeout(satelliteWorkerStartupTimer);
satelliteWorkerStartupTimer = null;
satelliteWorkerReady = true;
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
} else if (data.type === "positions") {
satelliteWorkerPending = false;
pendingPositionSnapshot = data;
} else if (data.type === "error") {
fail(data);
}
};
worker.onerror = (error) => {
if (worker === satellitePositionWorker) fail(error);
};
satelliteWorkerStartupTimer = setTimeout(() => {
if (worker === satellitePositionWorker) fail(new Error("Worker startup timed out"));
}, SATELLITE_CONFIG.workerStartupTimeoutMs);
worker.postMessage({
type: "init",
satellites: satelliteData,
realAltitude: satelliteRealAltitudeEnabled,
threeUrl: import.meta.resolve("three"),
satelliteUrl: import.meta.resolve("satellite.js"),
});
} catch (error) {
fail(error);
}
}
function ensureSatelliteCapacity(count) {
@@ -917,6 +962,23 @@ function ensureSatelliteCapacity(count) {
twinkle: previousState.twinkle || createSatelliteTwinkleState(index),
};
});
const twinklePhases = new Float32Array(nextCapacity * 3);
const twinkleShapes = new Float32Array(nextCapacity * 2);
satellitePositions.forEach(({ twinkle }, index) => {
twinklePhases.set([twinkle.phase, twinkle.secondaryPhase, twinkle.speed], index * 3);
twinkleShapes.set([twinkle.floor, twinkle.intensity], index * 2);
});
const phaseAttribute = new THREE.BufferAttribute(twinklePhases, 3);
const shapeAttribute = new THREE.BufferAttribute(twinkleShapes, 2);
for (const points of [satellitePoints, satelliteBackdropPoints]) {
points.geometry.setAttribute("twinklePhase", phaseAttribute);
points.geometry.setAttribute("twinkleShape", shapeAttribute);
points.geometry.attributes.position.setUsage(THREE.DynamicDrawUsage);
points.geometry.attributes.alpha.setUsage(THREE.DynamicDrawUsage);
}
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
satelliteTrails.geometry.attributes[name].setUsage(THREE.DynamicDrawUsage);
}
satelliteCapacity = nextCapacity;
}
@@ -939,295 +1001,6 @@ function updateSatellitePointVisibilityAttributes(count = satelliteData.length)
backdropAlphaAttr.needsUpdate = true;
}
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
gstime(time),
);
} catch (error) {
return null;
}
}
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
const earthFixedPosition = convertEciPositionToSceneVector(
positionEci,
siderealTime,
);
const x = earthFixedPosition.x;
const y = earthFixedPosition.y;
const z = earthFixedPosition.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(
positionEci.x * positionEci.x +
positionEci.y * positionEci.y +
positionEci.z * positionEci.z,
);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
return null;
}
const scale = displayRadius / sceneRadius;
return new THREE.Vector3(x * scale, y * scale, z * scale);
}
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
siderealTime,
);
} catch (error) {
return null;
}
}
function convertEciPositionToSceneVector(positionEci, siderealTime) {
const positionEcf = eciToEcf(positionEci, siderealTime);
return new THREE.Vector3(
positionEcf.x,
positionEcf.z,
-positionEcf.y,
);
}
function getCompressedRealAltitudeOffset(radiusKm) {
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
const clampedAltitudeKm = Math.min(
altitudeKm,
SATELLITE_CONFIG.maxDisplayAltitudeKm,
);
const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm);
const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) /
Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm);
return THREE.MathUtils.lerp(
SATELLITE_CONFIG.minRealAltitudeOffset,
SATELLITE_CONFIG.maxRealAltitudeOffset,
THREE.MathUtils.clamp(normalizedAltitude, 0, 1),
);
}
function buildSatrecFromProperties(props, fallbackTime) {
if (props.tle_line1 && props.tle_line2) {
// Prefer source-provided TLE lines so the client does not need to rebuild them.
const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
if (!satrec.error) {
return satrec;
}
}
const tleLines = buildTleLinesFromElements(props, fallbackTime);
if (!tleLines) {
return null;
}
return twoline2satrec(tleLines.line1, tleLines.line2);
}
function getSatelliteSatrecCacheKey(props) {
if (!props?.norad_cat_id) {
return null;
}
if (props.tle_line1 && props.tle_line2) {
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
}
if (props.epoch) {
return [
"elements",
props.norad_cat_id,
props.epoch,
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
].join(":");
}
return null;
}
function getOrBuildSatrec(props, fallbackTime) {
const cacheKey = getSatelliteSatrecCacheKey(props);
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
return satelliteSatrecCache.get(cacheKey);
}
const satrec = buildSatrecFromProperties(props, fallbackTime);
if (cacheKey && satrec && !satrec.error) {
satelliteSatrecCache.set(cacheKey, satrec);
}
return satrec;
}
function computeTleChecksum(line) {
let sum = 0;
for (const char of line.slice(0, 68)) {
if (char >= "0" && char <= "9") {
sum += Number(char);
} else if (char === "-") {
sum += 1;
}
}
return String(sum % 10);
}
function buildTleLinesFromElements(props, fallbackTime) {
if (!props?.norad_cat_id) {
return null;
}
const requiredValues = [
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
];
if (requiredValues.some((value) => value === null || value === undefined)) {
return null;
}
const epochDate =
props.epoch && String(props.epoch).length >= 10
? new Date(props.epoch)
: fallbackTime;
if (Number.isNaN(epochDate.getTime())) {
return null;
}
const epochYear = epochDate.getUTCFullYear() % 100;
const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
const msOfDay =
epochDate.getUTCHours() * 3600000 +
epochDate.getUTCMinutes() * 60000 +
epochDate.getUTCSeconds() * 1000 +
epochDate.getUTCMilliseconds();
const dayFraction = msOfDay / 86400000;
const epochStr =
String(epochYear).padStart(2, "0") +
String(dayOfYear).padStart(3, "0") +
dayFraction.toFixed(8).slice(1);
const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
.toString()
.padStart(7, "0");
// Keep a local fallback for historical rows that do not have stored TLE lines yet.
const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
props.inclination,
)
.toFixed(4)
.padStart(
8,
)} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
props.arg_of_perigee,
)
.toFixed(4)
.padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
props.mean_motion,
)
.toFixed(8)
.padStart(11)}00000`;
return {
line1: line1Core + computeTleChecksum(line1Core),
line2: line2Core + computeTleChecksum(line2Core),
};
}
function generateFallbackPosition(satellite, index, total, time = new Date()) {
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
const hash = String(noradId)
.split("")
.reduce((a, b) => a + b.charCodeAt(0), 0);
const randomOffset = (hash % 1000) / 1000;
const rawMeanMotion = Number(satellite.properties?.mean_motion);
const meanMotion =
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
? rawMeanMotion
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
const normalizedIndex = index / total;
const elapsedDays = Number.isFinite(time?.getTime?.())
? time.getTime() / FALLBACK_ORBIT_DAY_MS
: Date.now() / FALLBACK_ORBIT_DAY_MS;
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
const theta =
normalizedIndex * Math.PI * 2 * 10 +
(raan * Math.PI) / 180 +
fallbackPhase;
const phi =
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
const adjustedPhi = Math.abs(phi % Math.PI);
const adjustedTheta = theta + randomOffset * Math.PI * 2;
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
const y = radius * Math.cos(adjustedPhi);
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
return new THREE.Vector3(x, y, z);
}
export async function loadSatellites(options = {}) {
const limit = getRequestedSatelliteLimit(options.limit);
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
@@ -1247,9 +1020,11 @@ export async function loadSatellites(options = {}) {
const data = await response.json();
satelliteData = data.features || [];
satelliteSatrecCache = new Map();
satellitePropagator.reset();
resetSatelliteTrailState();
ensureSatelliteCapacity(satelliteData.length);
satelliteWorkerDisabled = false;
startSatellitePositionWorker();
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return {
count: satelliteData.length,
@@ -1264,6 +1039,11 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
showSatellites ||
showTrails ||
lockedSatelliteIndex !== null;
if (pendingPositionSnapshot) {
const snapshot = pendingPositionSnapshot;
pendingPositionSnapshot = null;
applySatellitePositionSnapshot(snapshot, shouldUpdateTrails);
}
const shouldResetTrails =
options.resetTrails ||
(!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS);
@@ -1287,6 +1067,20 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
);
positionUpdateAccumulator = 0;
if (satellitePositionWorker) {
if (!satelliteWorkerReady || satelliteWorkerPending) return;
satelliteWorkerPending = true;
satellitePositionWorker.postMessage({
type: "update",
baseTime: Date.now() + elapsedMs,
seedTrails: shouldUpdateTrails && satellitePositions.some((position) => position.trailCount === 0),
});
return;
}
applySatellitePositionSnapshot({ baseTime: Date.now() + elapsedMs }, shouldUpdateTrails);
}
function applySatellitePositionSnapshot(snapshot, shouldUpdateTrails) {
const positions = satellitePoints.geometry.attributes.position.array;
const backdropPositions =
satelliteBackdropPoints.geometry.attributes.position.array;
@@ -1297,19 +1091,17 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array;
const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array;
const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array;
const baseTime = new Date(Date.now() + elapsedMs);
const baseTime = new Date(snapshot.baseTime);
const count = Math.min(satelliteData.length, satelliteCapacity);
let trailSegmentCount = 0;
for (let i = 0; i < count; i++) {
const satellite = satelliteData[i];
const props = satellite.properties;
const timeOffset = (i / count) * 2 * Math.PI * 0.1;
const adjustedTime = new Date(
baseTime.getTime() + timeOffset * 1000 * 60 * 10,
);
let pos = computeSatellitePosition(satellite, adjustedTime);
const adjustedTime = new Date(getSatelliteSampleTimeMs(baseTime.getTime(), i, count));
let pos = snapshot.positions
? satellitePositions[i].current.fromArray(snapshot.positions, i * 3)
: computeSatellitePosition(satellite, adjustedTime);
if (!pos) {
pos = generateFallbackPosition(satellite, i, count, adjustedTime);
}
@@ -1323,7 +1115,10 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
for (let k = 0; k < TRAIL_LENGTH; k++) {
const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS;
const pastTime = new Date(adjustedTime.getTime() - offsetMs);
let pastPos = computeSatellitePosition(satellite, pastTime);
let pastPos = snapshot.trailSeed
? (satPos.trail[satPos.trailIndex] || new THREE.Vector3())
.fromArray(snapshot.trailSeed, (i * TRAIL_LENGTH + k) * 3)
: computeSatellitePosition(satellite, pastTime);
if (!pastPos) {
pastPos = generateFallbackPosition(satellite, i, count, pastTime);
}
@@ -1332,7 +1127,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
}
satPos.trailCount = TRAIL_LENGTH;
} else {
satPos.trail[satPos.trailIndex] = pos.clone();
const trailPosition = satPos.trail[satPos.trailIndex] || new THREE.Vector3();
satPos.trail[satPos.trailIndex] = trailPosition.copy(pos);
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
}
@@ -2781,8 +2577,9 @@ export function hidePredictedOrbit() {
}
export function clearSatelliteData() {
stopSatellitePositionWorker();
satelliteData = [];
satelliteSatrecCache = new Map();
satellitePropagator.reset();
selectedSatellite = null;
lockedSatelliteIndex = null;
hoveredSatelliteIndex = null;
@@ -2857,7 +2654,7 @@ export function resetSatelliteState() {
satellitePositions = [];
satelliteCapacity = 0;
satelliteSatrecCache = new Map();
satellitePropagator.reset();
showSatellites = false;
showTrails = true;
}

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