Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655e2a7d2d | ||
|
|
3ea99a9529 | ||
|
|
f9c1334365 | ||
|
|
5f47ec1659 | ||
|
|
229be0bced | ||
|
|
50a417ca83 | ||
|
|
e9464a9833 | ||
|
|
86807f6af6 | ||
|
|
8b8f7138c0 | ||
|
|
d5f3784ffb | ||
|
|
195a8bf71c | ||
|
|
987c378f99 | ||
|
|
67f82dc41c | ||
|
|
abe04030fb | ||
|
|
6a5f9f7ad4 | ||
|
|
439a512148 | ||
|
|
f73fa1ea6d | ||
|
|
5b623a6385 | ||
|
|
0082cf3fbd | ||
|
|
3ae4acdff8 | ||
|
|
437efc848c | ||
|
|
003a46ac30 |
91
.claude/commands/goal-driven.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
|
||||
argument-hint: 建议填写任务目标;若同时给出成功标准更好
|
||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
||||
---
|
||||
|
||||
# /goal-driven — 目标驱动执行模式
|
||||
|
||||
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
|
||||
|
||||
适用场景:
|
||||
|
||||
- 长周期实现任务
|
||||
- 高复杂度工程任务
|
||||
- 可被明确验收的研究、实现、迁移、验证类工作
|
||||
|
||||
不适用场景:
|
||||
|
||||
- 纯脑暴
|
||||
- 无法定义成功标准的模糊任务
|
||||
- 很小的一次性修改
|
||||
|
||||
## 输入要求
|
||||
|
||||
若 `$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
|
||||
|
||||
启动时先输出:
|
||||
|
||||
```md
|
||||
Goal
|
||||
- ...
|
||||
|
||||
Criteria for success
|
||||
- ...
|
||||
|
||||
Plan
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Verification
|
||||
- ...
|
||||
```
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 先把任务固化为两个核心块:
|
||||
- `Goal`
|
||||
- `Criteria for success`
|
||||
|
||||
2. 成功标准必须尽量客观,可验证,可落地。
|
||||
优先写成:
|
||||
- 需要交付什么
|
||||
- 需要通过哪些测试或验证
|
||||
- 如何判断结果真的完成
|
||||
|
||||
3. 进入持续执行循环:
|
||||
- 完成一个阶段
|
||||
- 检查当前结果是否满足成功标准
|
||||
- 若未满足,明确剩余差距并继续推进
|
||||
|
||||
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
|
||||
|
||||
5. 如果验证失败:
|
||||
- 明确指出哪条成功标准没满足
|
||||
- 继续工作,不要把阶段性进展误判为完成
|
||||
|
||||
6. 只有在以下情况之一才能停止:
|
||||
- 成功标准已满足
|
||||
- 用户明确要求停止
|
||||
|
||||
## 执行风格
|
||||
|
||||
- 重证据,轻口头判断
|
||||
- 重验收,轻自我感觉
|
||||
- 优先用测试、日志、产物、对比结果来证明完成
|
||||
- 对长期任务保持“未达标就继续”的节奏
|
||||
|
||||
## 简版模板
|
||||
|
||||
```md
|
||||
Goal: [[[[[在此填写最终目标]]]]]
|
||||
|
||||
Criteria for success: [[[[[在此填写成功标准]]]]]
|
||||
|
||||
循环执行:
|
||||
1. 推进任务
|
||||
2. 检查是否满足成功标准
|
||||
3. 若未满足,继续工作
|
||||
4. 直到满足标准或用户明确停止
|
||||
```
|
||||
3
.codex/config.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
approval_policy = "never"
|
||||
|
||||
sandbox_mode = "danger-full-access"
|
||||
101
.codex/skills/goal-driven/SKILL.md
Executable file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: goal-driven
|
||||
description: Run a goal-driven execution loop for very large, long-horizon, rigorously verifiable tasks. Use when the user explicitly wants the lidangzzz/goal-driven method, a master-agent plus worker-agent style workflow, or a persistent loop that keeps working until concrete success criteria are satisfied.
|
||||
---
|
||||
|
||||
# Goal-Driven
|
||||
|
||||
Use this skill when the user wants a strict goal-driven workflow for a hard task with:
|
||||
|
||||
- one clear end goal
|
||||
- explicit success criteria
|
||||
- repeated verification against those criteria
|
||||
- continued execution until the criteria are actually met
|
||||
|
||||
This skill is adapted from `lidangzzz/goal-driven`, but trimmed for local skill use to avoid bloating context.
|
||||
|
||||
## When To Use
|
||||
|
||||
Use it for tasks like:
|
||||
|
||||
- compilers, interpreters, theorem-like proof work, deep refactors
|
||||
- long-running system design or implementation work
|
||||
- problems that are expensive and complex, but still objectively testable
|
||||
|
||||
Do not use it for:
|
||||
|
||||
- vague brainstorming without a success condition
|
||||
- short one-shot edits
|
||||
- tasks where "done" cannot be evaluated in a meaningful way
|
||||
|
||||
## Core Model
|
||||
|
||||
The workflow has two roles:
|
||||
|
||||
1. Master role
|
||||
Defines the goal, defines the success criteria, audits progress, and decides whether the work is actually complete.
|
||||
|
||||
2. Worker role
|
||||
Keeps advancing the task toward the goal. If a result is partial, stalled, or unverifiable, the worker continues.
|
||||
|
||||
In Codex, only use actual subagents when the user explicitly asks for delegation or subagent work and the platform supports it. Otherwise emulate the same loop locally: keep working, checkpointing, and re-verifying until the criteria are satisfied.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Normalize the task into two blocks:
|
||||
- `Goal`
|
||||
- `Criteria for success`
|
||||
|
||||
2. Make the criteria concrete and testable.
|
||||
Good criteria usually include:
|
||||
- required outputs
|
||||
- required validations or tests
|
||||
- edge cases or coverage thresholds
|
||||
- what evidence proves completion
|
||||
|
||||
3. Break the work into milestones that can each produce evidence.
|
||||
|
||||
4. Execute the next milestone.
|
||||
If subagents are explicitly allowed, the master may delegate bounded worker tasks.
|
||||
If not, do the work locally but keep the master/worker mindset.
|
||||
|
||||
5. Whenever work pauses, stalls, or appears complete, audit against the criteria directly.
|
||||
Check artifacts, tests, logs, diffs, metrics, or other real evidence.
|
||||
|
||||
6. If the criteria are not met, continue with a specific delta:
|
||||
- what is still missing
|
||||
- what evidence failed
|
||||
- what the next worker pass must improve
|
||||
|
||||
7. Stop only when the criteria are met, or when the user explicitly stops the process.
|
||||
|
||||
## Operating Rules
|
||||
|
||||
- Prefer objective checks over self-reported completion.
|
||||
- Do not confuse progress with completion.
|
||||
- If the worker says "done", verify it.
|
||||
- If verification fails, continue from the gap instead of restarting blindly.
|
||||
- Keep the goal stable unless the user changes it.
|
||||
- Tighten fuzzy criteria before sinking large amounts of effort.
|
||||
|
||||
## Recommended Response Shape
|
||||
|
||||
When starting a goal-driven task, structure the kickoff like this:
|
||||
|
||||
```md
|
||||
Goal
|
||||
- ...
|
||||
|
||||
Criteria for success
|
||||
- ...
|
||||
|
||||
Current plan
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Verification
|
||||
- What evidence will prove completion
|
||||
```
|
||||
|
||||
For a reusable prompt template, read [references/prompt-template.md](references/prompt-template.md).
|
||||
7
.codex/skills/goal-driven/agents/openai.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Goal-Driven"
|
||||
short_description: "Drive complex work until explicit success criteria are met."
|
||||
default_prompt: "Use $goal-driven to turn this task into a concrete goal, explicit success criteria, and a verification-driven execution loop."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
38
.codex/skills/goal-driven/references/prompt-template.md
Executable file
@@ -0,0 +1,38 @@
|
||||
# Goal-Driven Prompt Template
|
||||
|
||||
Use this when you want a reusable kickoff prompt for a master/worker execution loop.
|
||||
|
||||
```md
|
||||
# Goal-Driven System
|
||||
|
||||
Goal: [[[[[DEFINE THE FINAL GOAL HERE]]]]]
|
||||
|
||||
Criteria for success: [[[[[DEFINE THE SUCCESS CRITERIA HERE]]]]]
|
||||
|
||||
You are the master agent.
|
||||
|
||||
Your job is to:
|
||||
1. Keep the goal and criteria fixed.
|
||||
2. Start worker execution toward the goal.
|
||||
3. Audit any claimed progress against the criteria.
|
||||
4. If the criteria are not met, continue the work with a precise next delta.
|
||||
5. Stop only when the criteria are satisfied or the user explicitly stops the process.
|
||||
|
||||
Worker requirements:
|
||||
1. Break the task into subproblems.
|
||||
2. Keep producing concrete progress toward the goal.
|
||||
3. Report evidence, not just claims.
|
||||
4. Continue until the criteria are satisfied.
|
||||
|
||||
Master audit loop:
|
||||
1. Check whether the worker is still making progress.
|
||||
2. If the worker stalls or claims completion, verify against the criteria.
|
||||
3. If verification fails, resume work from the remaining gap.
|
||||
4. Repeat until the criteria are met.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Stronger criteria produce better results than stronger rhetoric.
|
||||
- Prefer measurable checks such as tests, parity checks, generated artifacts, benchmarks, or reviewable outputs.
|
||||
- If the environment does not support subagents, emulate the same loop locally.
|
||||
114
README.md
@@ -227,6 +227,120 @@ bun run build
|
||||
|
||||
启动服务后访问: `http://localhost:8000/docs`
|
||||
|
||||
## WSL / Windows 局域网访问
|
||||
|
||||
如果服务运行在 WSL 中,而你希望:
|
||||
|
||||
- Windows 本机浏览器访问开发服务
|
||||
- 同一局域网内的手机或其他电脑访问开发服务
|
||||
|
||||
推荐按下面顺序排查和配置。
|
||||
|
||||
### 1. 在 WSL 中启动服务
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
|
||||
|
||||
### 2. 先确认 WSL 内部服务正常
|
||||
|
||||
在 WSL 中执行:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `3000` 返回前端 HTML
|
||||
- `8000/health` 返回健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
|
||||
|
||||
如果这一步不通,先不要继续做 Windows 转发。
|
||||
|
||||
### 3. 在 Windows 本机验证 localhost 直通
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
||||
|
||||
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
|
||||
|
||||
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
|
||||
|
||||
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
```
|
||||
|
||||
再放行 Windows 防火墙:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
```
|
||||
|
||||
检查转发规则是否生效:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy show all
|
||||
```
|
||||
|
||||
预期能看到:
|
||||
|
||||
- `0.0.0.0:3000 -> 127.0.0.1:3000`
|
||||
- `0.0.0.0:8000 -> 127.0.0.1:8000`
|
||||
|
||||
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
ipconfig
|
||||
```
|
||||
|
||||
找到当前联网网卡的 IPv4 地址,例如 `192.168.8.228`。
|
||||
|
||||
局域网其他设备可访问:
|
||||
|
||||
- `http://<Windows局域网IP>:3000/earth`
|
||||
- `http://<Windows局域网IP>:3000/admin`
|
||||
|
||||
例如:
|
||||
|
||||
- `http://192.168.8.228:3000/earth`
|
||||
|
||||
### 6. 常见现象与判断
|
||||
|
||||
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
|
||||
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||
|
||||
### 7. 本项目一次性验证顺序
|
||||
|
||||
建议固定按这个顺序验证:
|
||||
|
||||
1. WSL 中执行 `curl http://localhost:3000`
|
||||
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||
3. Windows 中执行 `curl http://localhost:3000`
|
||||
4. Windows 中执行 `curl http://localhost:8000/health`
|
||||
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
||||
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
||||
|
||||
## 启动容错参数
|
||||
|
||||
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
||||
|
||||
15
TODO.md
@@ -20,3 +20,18 @@
|
||||
- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector
|
||||
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度
|
||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||
- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板
|
||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
||||
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
||||
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
||||
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
|
||||
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败
|
||||
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
||||
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
|
||||
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
|
||||
|
||||
@@ -420,6 +420,7 @@ async def list_datasources(
|
||||
collector_list.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
|
||||
@@ -4,12 +4,15 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import ROOT_DIR
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.persistent_logs import record_audit_log, record_system_log
|
||||
from app.services.system_control import (
|
||||
build_task_id,
|
||||
clear_active_task_id,
|
||||
@@ -23,6 +26,15 @@ from app.services.system_control import (
|
||||
set_active_task_id,
|
||||
upsert_task_state,
|
||||
)
|
||||
from app.services.system_logs import (
|
||||
DEFAULT_LOG_LINE_LIMIT,
|
||||
MAX_LOG_LINE_LIMIT,
|
||||
SUPPORTED_LOG_LEVELS,
|
||||
append_buffer_log,
|
||||
list_log_sources,
|
||||
normalize_log_level,
|
||||
read_log_snapshot,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -47,6 +59,59 @@ class RestartTaskLogsResponse(BaseModel):
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class SystemLogSourceSummary(BaseModel):
|
||||
source_id: str
|
||||
name: str
|
||||
kind: str
|
||||
location: str
|
||||
description: str
|
||||
category: str
|
||||
status: str
|
||||
|
||||
|
||||
class SystemLogSourcesResponse(BaseModel):
|
||||
items: list[SystemLogSourceSummary]
|
||||
|
||||
|
||||
class SystemLogDailyMarker(BaseModel):
|
||||
date_token: str
|
||||
total: int
|
||||
dominant_level: str
|
||||
|
||||
|
||||
class SystemLogSnapshotResponse(BaseModel):
|
||||
source_id: str
|
||||
name: str
|
||||
kind: str
|
||||
location: str
|
||||
description: str
|
||||
category: str
|
||||
status: str
|
||||
level: str
|
||||
selected_levels: list[str] = []
|
||||
search_query: str = ""
|
||||
available_levels: list[str]
|
||||
daily_markers: list[SystemLogDailyMarker] = []
|
||||
line_limit: int
|
||||
line_count: int
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class EarthClientLogEventCreate(BaseModel):
|
||||
level: str = "error"
|
||||
message: str
|
||||
category: str | None = None
|
||||
url: str | None = None
|
||||
module: str | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class EarthClientLogEventResponse(BaseModel):
|
||||
accepted: bool
|
||||
source_id: str
|
||||
level: str
|
||||
|
||||
|
||||
def ensure_super_admin(current_user: User) -> None:
|
||||
if not require_super_admin(current_user.role):
|
||||
raise HTTPException(
|
||||
@@ -55,9 +120,22 @@ def ensure_super_admin(current_user: User) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
||||
if raw_value in {None, ""}:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(raw_value, "%Y-%m-%d").date().isoformat()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{field_name} must be in YYYY-MM-DD format",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||
async def create_restart_task(
|
||||
payload: RestartTaskCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
@@ -133,11 +211,31 @@ async def create_restart_task(
|
||||
requested_by=requested_by,
|
||||
)
|
||||
clear_active_task_id(task_id)
|
||||
await record_audit_log(
|
||||
action="system.restart_task.requested",
|
||||
actor_id=current_user.id,
|
||||
actor_name=current_user.username,
|
||||
target_type="restart_task",
|
||||
target_id=task_id,
|
||||
result="failed",
|
||||
ip=request.client.host if request.client else None,
|
||||
details={"action": payload.action, "message": task_state["message"]},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=task_state["message"],
|
||||
) from exc
|
||||
|
||||
await record_audit_log(
|
||||
action="system.restart_task.requested",
|
||||
actor_id=current_user.id,
|
||||
actor_name=current_user.username,
|
||||
target_type="restart_task",
|
||||
target_id=task_id,
|
||||
result="accepted",
|
||||
ip=request.client.host if request.client else None,
|
||||
details={"action": payload.action},
|
||||
)
|
||||
return task_state
|
||||
|
||||
|
||||
@@ -165,3 +263,92 @@ async def get_restart_task_logs(
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found")
|
||||
return {"task_id": task_id, "lines": get_task_logs(task_id)}
|
||||
|
||||
|
||||
@router.get("/logs/sources", response_model=SystemLogSourcesResponse)
|
||||
async def get_system_log_sources(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
return {"items": list_log_sources()}
|
||||
|
||||
|
||||
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
||||
async def get_system_log_snapshot(
|
||||
source_id: str,
|
||||
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
||||
level: str = "all",
|
||||
levels: str | None = Query(None, description="Comma-separated log levels"),
|
||||
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
|
||||
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
||||
search: str | None = Query(None, description="Case-insensitive substring search"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ensure_super_admin(current_user)
|
||||
|
||||
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}",
|
||||
)
|
||||
if str(level).strip().lower() not in SUPPORTED_LOG_LEVELS and normalize_log_level(level) == "all" and str(level).strip().lower() not in {"", "all"}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
||||
if levels:
|
||||
for raw_level in str(levels).split(","):
|
||||
normalized_level = str(raw_level).strip().lower()
|
||||
if not normalized_level:
|
||||
continue
|
||||
if normalized_level not in SUPPORTED_LOG_LEVELS and normalize_log_level(normalized_level) == "all":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level")
|
||||
normalized_start_date = validate_log_date(start_date, "start_date")
|
||||
normalized_end_date = validate_log_date(end_date, "end_date")
|
||||
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
||||
|
||||
snapshot = read_log_snapshot(
|
||||
source_id,
|
||||
limit,
|
||||
level=level,
|
||||
levels=levels,
|
||||
start_date=normalized_start_date,
|
||||
end_date=normalized_end_date,
|
||||
search=search,
|
||||
)
|
||||
if snapshot is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.post("/logs/earth-client", response_model=EarthClientLogEventResponse)
|
||||
async def ingest_earth_client_log(
|
||||
payload: EarthClientLogEventCreate,
|
||||
request: Request,
|
||||
):
|
||||
normalized_level = normalize_log_level(payload.level)
|
||||
append_buffer_log(
|
||||
"earth-client",
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
context={
|
||||
"category": payload.category or "",
|
||||
"url": payload.url or "",
|
||||
"module": payload.module or "",
|
||||
"detail": payload.detail or "",
|
||||
},
|
||||
)
|
||||
await record_system_log(
|
||||
source="earth-client",
|
||||
service="earth",
|
||||
module=payload.module or "earth-client",
|
||||
event="earth.client.runtime_log",
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
category=payload.category or "client-runtime",
|
||||
context={
|
||||
"url": payload.url or "",
|
||||
"detail": payload.detail or "",
|
||||
"module": payload.module or "",
|
||||
"client_ip": request.client.host if request.client else "",
|
||||
},
|
||||
)
|
||||
return {"accepted": True, "source_id": "earth-client", "level": normalized_level}
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.countries import get_country_centroid
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
@@ -22,8 +23,11 @@ from app.models.collected_data import CollectedData
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
from app.services.persistent_logs import record_system_log
|
||||
from app.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__, service="api")
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
@@ -180,6 +184,12 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
mean_motion=metadata.get("mean_motion"),
|
||||
)
|
||||
|
||||
constellation_group = _normalize_satellite_constellation_group(
|
||||
metadata.get("constellation_group"),
|
||||
record.name,
|
||||
)
|
||||
footprint_policy = _get_satellite_footprint_policy(constellation_group)
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
@@ -189,6 +199,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
"id": record.id,
|
||||
"norad_cat_id": norad_id,
|
||||
"name": record.name,
|
||||
"constellation_group": constellation_group,
|
||||
"footprint_policy": footprint_policy,
|
||||
"international_designator": metadata.get("international_designator"),
|
||||
"epoch": metadata.get("epoch"),
|
||||
"inclination": metadata.get("inclination"),
|
||||
@@ -209,6 +221,31 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _normalize_satellite_constellation_group(
|
||||
raw_group: Any,
|
||||
name: Optional[str],
|
||||
) -> Optional[str]:
|
||||
normalized_group = str(raw_group or "").strip().lower()
|
||||
if normalized_group:
|
||||
return normalized_group
|
||||
|
||||
normalized_name = str(name or "").strip().upper()
|
||||
if normalized_name.startswith("STARLINK"):
|
||||
return "starlink"
|
||||
if normalized_name.startswith("IRIDIUM"):
|
||||
return "iridium-next"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_satellite_footprint_policy(constellation_group: Optional[str]) -> str:
|
||||
if constellation_group == "starlink":
|
||||
return "starlink_ground_footprint"
|
||||
if constellation_group == "iridium-next":
|
||||
return "iridium_coverage_ring"
|
||||
return "none"
|
||||
|
||||
|
||||
def _current_collected_data_stmt(source: str):
|
||||
return (
|
||||
select(CollectedData)
|
||||
@@ -363,6 +400,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _parse_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||
("el capitan", 37.6819, -121.7681),
|
||||
("livermore", 37.6819, -121.7681),
|
||||
("llnl", 37.6819, -121.7681),
|
||||
("lawrence livermore", 37.6819, -121.7681),
|
||||
("frontier", 35.9319, -84.3107),
|
||||
("oak ridge", 35.9319, -84.3107),
|
||||
("ornl", 35.9319, -84.3107),
|
||||
("aurora", 41.7130, -87.9820),
|
||||
("argonne", 41.7130, -87.9820),
|
||||
("anl", 41.7130, -87.9820),
|
||||
("fugaku", 34.6953, 135.1974),
|
||||
("kobe", 34.6953, 135.1974),
|
||||
("riken", 34.6953, 135.1974),
|
||||
("summit", 35.9319, -84.3107),
|
||||
("leonardo", 44.4949, 11.3426),
|
||||
("bologna", 44.4949, 11.3426),
|
||||
("alps", 46.0037, 8.9511),
|
||||
("lugano", 46.0037, 8.9511),
|
||||
("sunway taihulight", 31.4912, 120.3119),
|
||||
("wuxi", 31.4912, 120.3119),
|
||||
("tianhe-2", 23.1291, 113.2644),
|
||||
("tianhe-2a", 23.1291, 113.2644),
|
||||
("guangzhou", 23.1291, 113.2644),
|
||||
("colossus", 35.1495, -90.0490),
|
||||
("memphis", 35.1495, -90.0490),
|
||||
("xai", 35.1495, -90.0490),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hint_text(*parts: Any) -> str:
|
||||
return " ".join(
|
||||
str(part).strip().lower()
|
||||
for part in parts
|
||||
if part not in (None, "")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compute_center_coordinates(
|
||||
record: CollectedData,
|
||||
metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "precise",
|
||||
"geography_mode": "source_coordinates",
|
||||
"is_estimated": False,
|
||||
"estimated_reason": None,
|
||||
}
|
||||
|
||||
hint_text = _normalize_hint_text(
|
||||
record.name,
|
||||
get_record_field(record, "city"),
|
||||
get_record_field(record, "country"),
|
||||
metadata.get("site"),
|
||||
metadata.get("organization"),
|
||||
metadata.get("operator"),
|
||||
)
|
||||
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||
if needle in hint_text:
|
||||
return {
|
||||
"latitude": resolved_latitude,
|
||||
"longitude": resolved_longitude,
|
||||
"location_precision": "estimated_site",
|
||||
"geography_mode": "site_hint",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": f"Matched known site hint: {needle}",
|
||||
}
|
||||
|
||||
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||
if centroid:
|
||||
return {
|
||||
"latitude": centroid.get("latitude"),
|
||||
"longitude": centroid.get("longitude"),
|
||||
"location_precision": "estimated_country",
|
||||
"geography_mode": "country_centroid",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "Estimated from country centroid",
|
||||
}
|
||||
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "unknown",
|
||||
"geography_mode": "unknown",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "No resolvable location hints",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||
if capacity_value is None:
|
||||
return "unknown"
|
||||
|
||||
unit = str(capacity_unit or "").strip().lower()
|
||||
if unit in {"pflop/s", "pflops", "pflop"}:
|
||||
normalized_tflops = capacity_value * 1000
|
||||
elif unit in {"gflop/s", "gflops", "gflop"}:
|
||||
normalized_tflops = capacity_value / 1000
|
||||
else:
|
||||
normalized_tflops = capacity_value
|
||||
|
||||
if normalized_tflops >= 1_000_000:
|
||||
return "exascale"
|
||||
if normalized_tflops >= 100_000:
|
||||
return "ultra"
|
||||
if normalized_tflops >= 10_000:
|
||||
return "large"
|
||||
if normalized_tflops > 0:
|
||||
return "regional"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||
latitude = coordinate_info.get("latitude")
|
||||
longitude = coordinate_info.get("longitude")
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
|
||||
if site_type == "supercomputer":
|
||||
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||
capacity_unit = "GFlops"
|
||||
else:
|
||||
capacity_value = _parse_float(get_record_field(record, "value"))
|
||||
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
|
||||
|
||||
vendor = (
|
||||
metadata.get("manufacturer")
|
||||
or metadata.get("vendor")
|
||||
or metadata.get("gpu_type")
|
||||
)
|
||||
operator = (
|
||||
metadata.get("organization")
|
||||
or metadata.get("operator")
|
||||
or metadata.get("owner")
|
||||
)
|
||||
rank = metadata.get("rank")
|
||||
if rank in (None, "") and site_type == "supercomputer":
|
||||
rank = get_record_field(record, "rank")
|
||||
|
||||
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": record.id,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [longitude or 0, latitude or 0],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"site_type": site_type,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"operator": operator,
|
||||
"vendor": vendor,
|
||||
"capacity_value": capacity_value,
|
||||
"capacity_unit": capacity_unit,
|
||||
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
|
||||
"rank": rank,
|
||||
"gpu_count": metadata.get("gpu_count"),
|
||||
"gpu_type": metadata.get("gpu_type"),
|
||||
"cores": get_record_field(record, "cores"),
|
||||
"power": get_record_field(record, "power"),
|
||||
"source": record.source,
|
||||
"updated_at": updated_at,
|
||||
"status": "observed",
|
||||
"location_precision": coordinate_info.get("location_precision"),
|
||||
"geography_mode": coordinate_info.get("geography_mode"),
|
||||
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||
"data_type": "compute_center",
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_bgp_anomalies_to_geojson(
|
||||
records: List[BGPAnomaly],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
@@ -780,6 +1026,21 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception_event(
|
||||
"Failed to build cables GeoJSON response",
|
||||
event="visualization.cables.load_failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
await record_system_log(
|
||||
source="backend",
|
||||
service="api",
|
||||
module=__name__,
|
||||
event="visualization.cables.load_failed",
|
||||
level="error",
|
||||
message="Failed to build cables GeoJSON response",
|
||||
category="visualization",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@@ -816,6 +1077,21 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception_event(
|
||||
"Failed to build landing points GeoJSON response",
|
||||
event="visualization.landing_points.load_failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
await record_system_log(
|
||||
source="backend",
|
||||
service="api",
|
||||
module=__name__,
|
||||
event="visualization.landing_points.load_failed",
|
||||
level="error",
|
||||
message="Failed to build landing points GeoJSON response",
|
||||
category="visualization",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@@ -975,6 +1251,53 @@ async def get_gpu_clusters_geojson(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/compute-centers")
|
||||
async def get_compute_centers_geojson(
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取统一算力中心 GeoJSON 数据"""
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
["top500", "epoch_ai_gpu"],
|
||||
)
|
||||
records = _filter_known_records(
|
||||
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
|
||||
)
|
||||
if limit is not None:
|
||||
records = records[:limit]
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
},
|
||||
}
|
||||
|
||||
geojson = convert_compute_centers_to_geojson(records)
|
||||
features = geojson.get("features", [])
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(features),
|
||||
"stats": {
|
||||
"total": len(features),
|
||||
"supercomputers": sum(
|
||||
1 for feature in features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_clusters": sum(
|
||||
1 for feature in features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/bgp-anomalies")
|
||||
async def get_bgp_anomalies_geojson(
|
||||
severity: Optional[str] = Query(None),
|
||||
@@ -1030,6 +1353,71 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
|
||||
return {**geojson, "count": len(geojson.get("features", []))}
|
||||
|
||||
|
||||
@router.get("/geo/summary")
|
||||
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
||||
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
[
|
||||
"arcgis_cables",
|
||||
"arcgis_landing_points",
|
||||
"celestrak_tle",
|
||||
"top500",
|
||||
"epoch_ai_gpu",
|
||||
],
|
||||
)
|
||||
|
||||
cables = convert_cable_to_geojson(records_by_source.get("arcgis_cables", []))
|
||||
landing_points = convert_landing_point_to_geojson(
|
||||
records_by_source.get("arcgis_landing_points", []),
|
||||
)
|
||||
satellites = convert_satellite_to_geojson(
|
||||
_filter_known_records(records_by_source.get("celestrak_tle", [])),
|
||||
)
|
||||
compute_centers = convert_compute_centers_to_geojson(
|
||||
_filter_known_records(
|
||||
records_by_source.get("top500", [])
|
||||
+ records_by_source.get("epoch_ai_gpu", []),
|
||||
),
|
||||
)
|
||||
compute_features = compute_centers.get("features", [])
|
||||
|
||||
active_incident_result = await db.execute(
|
||||
select(func.count(BGPIncident.id)).where(BGPIncident.status == "active"),
|
||||
)
|
||||
active_anomaly_result = await db.execute(
|
||||
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active"),
|
||||
)
|
||||
active_incident_count = int(active_incident_result.scalar() or 0)
|
||||
active_anomaly_count = int(active_anomaly_result.scalar() or 0)
|
||||
bgp_collectors = await build_bgp_collector_coverage(
|
||||
db,
|
||||
source_filter=("ris_live_bgp", "bgpstream_bgp"),
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
||||
"stats": {
|
||||
"cable_count": len(cables.get("features", [])),
|
||||
"landing_point_count": len(landing_points.get("features", [])),
|
||||
"satellite_count": len(satellites.get("features", [])),
|
||||
"compute_center_count": len(compute_features),
|
||||
"supercomputer_count": sum(
|
||||
1 for feature in compute_features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_cluster_count": sum(
|
||||
1 for feature in compute_features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
"bgp_event_count": active_incident_count or active_anomaly_count,
|
||||
"bgp_incident_count": active_incident_count,
|
||||
"bgp_anomaly_count": active_anomaly_count,
|
||||
"bgp_collector_count": len([item for item in bgp_collectors if item.get("collector")]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有可视化数据的统一端点
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -10,10 +9,11 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__, service="api")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -22,11 +22,18 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
if payload.get("type") != "access":
|
||||
logger.warning(f"WebSocket auth failed: wrong token type")
|
||||
logger.warning_event(
|
||||
"WebSocket auth failed: wrong token type",
|
||||
event="auth.websocket.invalid_token_type",
|
||||
)
|
||||
return None
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.warning(f"WebSocket auth failed: {e}")
|
||||
logger.warning_event(
|
||||
"WebSocket auth failed",
|
||||
event="auth.websocket.decode_failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -36,10 +43,17 @@ async def websocket_endpoint(
|
||||
token: str = Query(...),
|
||||
):
|
||||
"""WebSocket endpoint for real-time data"""
|
||||
logger.info(f"WebSocket connection attempt with token: {token[:20]}...")
|
||||
logger.info_event(
|
||||
"WebSocket connection attempt",
|
||||
event="auth.websocket.connection_attempt",
|
||||
context={"token_preview": f"{token[:8]}..."},
|
||||
)
|
||||
payload = await authenticate_token(token)
|
||||
if payload is None:
|
||||
logger.warning("WebSocket authentication failed, closing connection")
|
||||
logger.warning_event(
|
||||
"WebSocket authentication failed, closing connection",
|
||||
event="auth.websocket.connection_rejected",
|
||||
)
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Redis caching service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Optional, Any
|
||||
|
||||
import redis
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Lazy Redis client initialization
|
||||
@@ -47,7 +47,7 @@ class CacheService:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache get error: {e}")
|
||||
logger.warning_event("Cache get error", event="cache.get.failed", context={"error": str(e)})
|
||||
return None
|
||||
|
||||
def set(
|
||||
@@ -61,7 +61,7 @@ class CacheService:
|
||||
serialized = json.dumps(value, default=str)
|
||||
return self.client.setex(key, expire_seconds, serialized)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache set error: {e}")
|
||||
logger.warning_event("Cache set error", event="cache.set.failed", context={"error": str(e)})
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
@@ -69,7 +69,7 @@ class CacheService:
|
||||
try:
|
||||
return self.client.delete(key) > 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete error: {e}")
|
||||
logger.warning_event("Cache delete error", event="cache.delete.failed", context={"error": str(e)})
|
||||
return False
|
||||
|
||||
def delete_pattern(self, pattern: str) -> int:
|
||||
@@ -80,7 +80,7 @@ class CacheService:
|
||||
return self.client.delete(*keys)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache delete_pattern error: {e}")
|
||||
logger.warning_event("Cache delete_pattern error", event="cache.delete_pattern.failed", context={"error": str(e)})
|
||||
return 0
|
||||
|
||||
def get_or_set(
|
||||
|
||||
@@ -30,6 +30,7 @@ COLLECTOR_URL_KEYS = {
|
||||
"iptoasn_prefix_geo": "iptoasn.combined_url",
|
||||
"opengeofeed_prefix_geo": "opengeofeed.public_csv_url",
|
||||
"nro_delegated_prefix_geo": "nro.delegated_stats_url",
|
||||
"news_live_streams": "news_live_streams.channels_url",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -86,3 +86,11 @@ opengeofeed:
|
||||
nro:
|
||||
# NRO delegated stats 下载地址
|
||||
delegated_stats_url: "https://ftp.ripe.net/pub/stats/ripencc/nro-stats/latest/nro-delegated-stats"
|
||||
|
||||
news_live_streams:
|
||||
# IPTV-org 频道元数据 JSON
|
||||
channels_url: "https://iptv-org.github.io/api/channels.json"
|
||||
# IPTV-org 频道播放流 JSON
|
||||
streams_url: "https://iptv-org.github.io/api/streams.json"
|
||||
# IPTV-org 台标 JSON
|
||||
logos_url: "https://iptv-org.github.io/api/logos.json"
|
||||
|
||||
161
backend/app/core/logging.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.core.request_context import get_request_id
|
||||
|
||||
DEFAULT_SERVICE = "backend"
|
||||
DEFAULT_EVENT = "app.log"
|
||||
DEFAULT_LOG_LEVEL = os.getenv("PLANET_LOG_LEVEL", "INFO").upper()
|
||||
REDACTED = "[REDACTED]"
|
||||
SENSITIVE_FIELD_NAMES = {
|
||||
"access_token",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
SENSITIVE_TEXT_PATTERNS = (
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(.+)"),
|
||||
re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._\-]+)"),
|
||||
re.compile(r"(?i)(token\s*[:=]\s*)(.+)"),
|
||||
re.compile(r"(?i)(password\s*[:=]\s*)(.+)"),
|
||||
re.compile(r"(?i)(cookie\s*[:=]\s*)(.+)"),
|
||||
)
|
||||
|
||||
|
||||
def sanitize_log_value(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): (REDACTED if str(key).lower() in SENSITIVE_FIELD_NAMES else sanitize_log_value(item))
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [sanitize_log_value(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
sanitized = value
|
||||
for pattern in SENSITIVE_TEXT_PATTERNS:
|
||||
sanitized = pattern.sub(lambda match: f"{match.group(1)}{REDACTED}", sanitized)
|
||||
return sanitized
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_context(context: Any) -> dict[str, Any]:
|
||||
if context is None:
|
||||
return {}
|
||||
if isinstance(context, Mapping):
|
||||
sanitized = sanitize_log_value(context)
|
||||
return {str(key): value for key, value in sanitized.items()}
|
||||
return {"value": sanitize_log_value(context)}
|
||||
|
||||
|
||||
class PlanetContextFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = getattr(record, "request_id", None) or get_request_id() or "-"
|
||||
record.service = getattr(record, "service", None) or DEFAULT_SERVICE
|
||||
record.event = getattr(record, "event", None) or DEFAULT_EVENT
|
||||
record.context = _normalize_context(getattr(record, "context", None))
|
||||
record.message = sanitize_log_value(record.getMessage())
|
||||
return True
|
||||
|
||||
|
||||
class PlanetFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
timestamp = self.formatTime(record, self.datefmt)
|
||||
level = record.levelname
|
||||
service = getattr(record, "service", DEFAULT_SERVICE)
|
||||
module_name = record.name
|
||||
event = getattr(record, "event", DEFAULT_EVENT)
|
||||
request_id = getattr(record, "request_id", "-")
|
||||
message = sanitize_log_value(record.getMessage())
|
||||
context = _normalize_context(getattr(record, "context", None))
|
||||
context_suffix = ""
|
||||
if context:
|
||||
context_suffix = f" context={json.dumps(context, ensure_ascii=False, sort_keys=True)}"
|
||||
rendered = (
|
||||
f"{timestamp} {level} service={service} module={module_name} "
|
||||
f"event={event} request_id={request_id} message={message}{context_suffix}"
|
||||
)
|
||||
if record.exc_info:
|
||||
rendered = f"{rendered}\n{self.formatException(record.exc_info)}"
|
||||
return rendered
|
||||
|
||||
|
||||
class PlanetLoggerAdapter(logging.LoggerAdapter):
|
||||
def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
|
||||
extra = dict(self.extra)
|
||||
extra.update(kwargs.get("extra", {}))
|
||||
if "context" in extra:
|
||||
extra["context"] = _normalize_context(extra.get("context"))
|
||||
kwargs["extra"] = extra
|
||||
return sanitize_log_value(msg), kwargs
|
||||
|
||||
def log_event(
|
||||
self,
|
||||
level: int,
|
||||
message: str,
|
||||
*,
|
||||
event: str,
|
||||
context: Mapping[str, Any] | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
self.log(level, message, extra={"event": event, "context": context or {}, **extra})
|
||||
|
||||
def debug_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.DEBUG, message, event=event, context=context, **extra)
|
||||
|
||||
def info_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.INFO, message, event=event, context=context, **extra)
|
||||
|
||||
def warning_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.WARNING, message, event=event, context=context, **extra)
|
||||
|
||||
def error_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
|
||||
self.log_event(logging.ERROR, message, event=event, context=context, **extra)
|
||||
|
||||
def exception_event(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
event: str,
|
||||
context: Mapping[str, Any] | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
self.error(message, exc_info=True, extra={"event": event, "context": context or {}, **extra})
|
||||
|
||||
|
||||
def get_logger(name: str, *, service: str = DEFAULT_SERVICE) -> PlanetLoggerAdapter:
|
||||
return PlanetLoggerAdapter(logging.getLogger(name), {"service": service})
|
||||
|
||||
|
||||
def configure_logging(level: str | None = None) -> None:
|
||||
root_logger = logging.getLogger()
|
||||
if getattr(configure_logging, "_configured", False):
|
||||
if level:
|
||||
root_logger.setLevel(level.upper())
|
||||
return
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
handler.addFilter(PlanetContextFilter())
|
||||
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel((level or DEFAULT_LOG_LEVEL).upper())
|
||||
|
||||
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
target_logger = logging.getLogger(logger_name)
|
||||
target_logger.handlers.clear()
|
||||
target_logger.propagate = True
|
||||
|
||||
logging.captureWarnings(True)
|
||||
configure_logging._configured = True
|
||||
14
backend/app/core/request_context.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
request_id_context: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||
|
||||
|
||||
def set_request_id(request_id: str | None) -> None:
|
||||
request_id_context.set(request_id)
|
||||
|
||||
|
||||
def get_request_id() -> str | None:
|
||||
return request_id_context.get()
|
||||
@@ -5,10 +5,22 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sess
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DB_POOL_CONFIG = {
|
||||
"pool_pre_ping": True,
|
||||
"pool_recycle": 1800,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_timeout": 30,
|
||||
}
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG if hasattr(settings, "DEBUG") else False,
|
||||
**DB_POOL_CONFIG,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
@@ -97,6 +109,19 @@ async def init_db():
|
||||
import app.models.system_setting # noqa: F401
|
||||
import app.models.playground_session # noqa: F401
|
||||
import app.models.playground_message # noqa: F401
|
||||
import app.models.system_log # noqa: F401
|
||||
|
||||
logger.warning_event(
|
||||
"Database pool settings active",
|
||||
event="database.pool.initialized",
|
||||
context={
|
||||
"pool_pre_ping": DB_POOL_CONFIG["pool_pre_ping"],
|
||||
"pool_recycle": DB_POOL_CONFIG["pool_recycle"],
|
||||
"pool_size": DB_POOL_CONFIG["pool_size"],
|
||||
"max_overflow": DB_POOL_CONFIG["max_overflow"],
|
||||
"pool_timeout": DB_POOL_CONFIG["pool_timeout"],
|
||||
},
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -7,6 +8,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.api.main import api_router
|
||||
from app.api.v1 import websocket
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging
|
||||
from app.core.request_context import set_request_id
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import init_db
|
||||
from app.services.scheduler import (
|
||||
@@ -17,6 +20,9 @@ from app.services.scheduler import (
|
||||
)
|
||||
|
||||
|
||||
configure_logging()
|
||||
|
||||
|
||||
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
if request.url.path.startswith("/ws") and request.method == "GET":
|
||||
@@ -28,6 +34,18 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid4().hex
|
||||
set_request_id(request_id)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
set_request_id(None)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
@@ -58,6 +76,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
app.add_middleware(WebSocketCORSMiddleware)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.bgp_observation import BGPObservation
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.playground_session import PlaygroundSession
|
||||
from app.models.playground_message import PlaygroundMessage
|
||||
from app.models.system_log import SystemLog, AuditLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -26,4 +27,6 @@ __all__ = [
|
||||
"BGPAnomaly",
|
||||
"BGPIncident",
|
||||
"BGPObservation",
|
||||
"SystemLog",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
40
backend/app/models/system_log.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import JSON, Column, DateTime, Integer, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class SystemLog(Base):
|
||||
__tablename__ = "system_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
source = Column(String(50), nullable=False, index=True)
|
||||
service = Column(String(50), nullable=True)
|
||||
module = Column(String(120), nullable=True)
|
||||
event = Column(String(160), nullable=True, index=True)
|
||||
level = Column(String(20), nullable=False, index=True)
|
||||
message = Column(Text, nullable=False)
|
||||
request_id = Column(String(64), nullable=True, index=True)
|
||||
trace_id = Column(String(64), nullable=True)
|
||||
user_id = Column(Integer, nullable=True, index=True)
|
||||
category = Column(String(80), nullable=True, index=True)
|
||||
context = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
actor_id = Column(Integer, nullable=True, index=True)
|
||||
actor_name = Column(String(255), nullable=True)
|
||||
action = Column(String(120), nullable=False, index=True)
|
||||
target_type = Column(String(80), nullable=True)
|
||||
target_id = Column(String(120), nullable=True)
|
||||
result = Column(String(40), nullable=True, index=True)
|
||||
request_id = Column(String(64), nullable=True, index=True)
|
||||
ip = Column(String(64), nullable=True)
|
||||
details = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -46,6 +46,9 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
item["_celestrak_group"] = group
|
||||
all_satellites.extend(data)
|
||||
print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
|
||||
except Exception as e:
|
||||
@@ -78,6 +81,7 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
"name": item.get("OBJECT_NAME", "Unknown"),
|
||||
"reference_date": item.get("EPOCH", ""),
|
||||
"metadata": {
|
||||
"constellation_group": item.get("_celestrak_group"),
|
||||
"norad_cat_id": item.get("NORAD_CAT_ID"),
|
||||
"international_designator": item.get("OBJECT_ID"),
|
||||
"epoch": item.get("EPOCH"),
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.services.collectors.base import BaseCollector
|
||||
|
||||
|
||||
@@ -18,52 +24,537 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
data_type = "news_live_stream"
|
||||
fail_on_empty = False
|
||||
|
||||
DEFAULT_TIMEOUT = 45.0
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
RESPONSE_CANDIDATE_KEYS = ("sources", "streams", "channels", "items", "results", "data")
|
||||
DEFAULT_ADAPTER = "iptv_org"
|
||||
DEFAULT_IPTV_ORG_STREAMS_URL = "https://iptv-org.github.io/api/streams.json"
|
||||
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
|
||||
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
|
||||
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
|
||||
DEFAULT_IPTV_ORG_MAX_SOURCES = 120
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
request_url = (self._resolved_url or "").strip()
|
||||
if not request_url:
|
||||
return []
|
||||
|
||||
async with httpx.AsyncClient(timeout=45.0, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
datasource_config = await self._load_datasource_config()
|
||||
effective_config = self._get_effective_config(datasource_config)
|
||||
adapter = str(effective_config.get("adapter") or "").strip().lower()
|
||||
if adapter == "iptv_org":
|
||||
return await self._fetch_iptv_org(request_url, effective_config)
|
||||
|
||||
request_headers = self._build_request_headers(datasource_config)
|
||||
request_config = self._get_request_config(datasource_config)
|
||||
request_params = self._build_request_params(datasource_config)
|
||||
request_json = self._build_request_json_body(datasource_config)
|
||||
request_data = self._build_request_form_body(datasource_config)
|
||||
timeout = self._get_timeout(datasource_config)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
response = await client.request(
|
||||
request_config["method"],
|
||||
request_url,
|
||||
headers={
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
headers=request_headers,
|
||||
params=request_params or None,
|
||||
json=request_json,
|
||||
data=request_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self.parse_response(response.json())
|
||||
return self.parse_response(
|
||||
response.json(),
|
||||
response_path=request_config["response_path"],
|
||||
)
|
||||
|
||||
def parse_response(self, response: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(response, dict):
|
||||
candidates = response.get("sources") or response.get("streams") or response.get("data") or []
|
||||
elif isinstance(response, list):
|
||||
candidates = response
|
||||
async def _load_datasource_config(self) -> DataSourceConfig | None:
|
||||
if not self._db_session:
|
||||
return None
|
||||
|
||||
result = await self._db_session.execute(
|
||||
select(DataSourceConfig)
|
||||
.where(DataSourceConfig.name == self.name)
|
||||
.where(DataSourceConfig.is_active.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _get_effective_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
|
||||
payload = dict(datasource_config.config or {}) if datasource_config else {}
|
||||
if payload:
|
||||
return payload
|
||||
|
||||
yaml_config = get_data_sources_config()
|
||||
return {
|
||||
"adapter": self.DEFAULT_ADAPTER,
|
||||
"streams_url": yaml_config.get_yaml_value("news_live_streams.streams_url")
|
||||
or self.DEFAULT_IPTV_ORG_STREAMS_URL,
|
||||
"logos_url": yaml_config.get_yaml_value("news_live_streams.logos_url")
|
||||
or self.DEFAULT_IPTV_ORG_LOGOS_URL,
|
||||
"news_categories": list(self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES),
|
||||
"exclude_categories": list(self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES),
|
||||
"max_sources": self.DEFAULT_IPTV_ORG_MAX_SOURCES,
|
||||
}
|
||||
|
||||
def _get_request_config(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
|
||||
payload = self._get_effective_config(datasource_config)
|
||||
raw_method = payload.get("method") or payload.get("request_method") or "GET"
|
||||
method = str(raw_method).strip().upper() or "GET"
|
||||
if method not in {"GET", "POST"}:
|
||||
method = "GET"
|
||||
|
||||
response_path = payload.get("response_path") or payload.get("payload_path") or payload.get("items_path")
|
||||
if isinstance(response_path, str):
|
||||
response_path = response_path.strip()
|
||||
else:
|
||||
candidates = []
|
||||
response_path = None
|
||||
|
||||
return {
|
||||
"method": method,
|
||||
"response_path": response_path or None,
|
||||
}
|
||||
|
||||
def _get_timeout(self, datasource_config: DataSourceConfig | None) -> float:
|
||||
payload = self._get_effective_config(datasource_config)
|
||||
try:
|
||||
return float(payload.get("timeout", self.DEFAULT_TIMEOUT))
|
||||
except (TypeError, ValueError):
|
||||
return self.DEFAULT_TIMEOUT
|
||||
|
||||
def _build_request_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
|
||||
headers = dict(self.DEFAULT_HEADERS)
|
||||
if datasource_config:
|
||||
headers.update(self._normalize_headers(datasource_config.headers))
|
||||
headers.update(self._build_auth_headers(datasource_config))
|
||||
return headers
|
||||
|
||||
def _build_request_params(self, datasource_config: DataSourceConfig | None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if not datasource_config:
|
||||
return params
|
||||
|
||||
payload = datasource_config.config or {}
|
||||
candidate = payload.get("params") or payload.get("query_params")
|
||||
if isinstance(candidate, dict):
|
||||
params.update(candidate)
|
||||
|
||||
if datasource_config.auth_type == "api_key":
|
||||
auth_config = datasource_config.auth_config or {}
|
||||
if str(auth_config.get("in") or auth_config.get("location") or "header").lower() == "query":
|
||||
api_key = auth_config.get("api_key")
|
||||
key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key"
|
||||
if api_key and key_name:
|
||||
params[str(key_name)] = api_key
|
||||
|
||||
return params
|
||||
|
||||
def _build_request_json_body(self, datasource_config: DataSourceConfig | None) -> Any:
|
||||
if not datasource_config:
|
||||
return None
|
||||
|
||||
payload = datasource_config.config or {}
|
||||
body = payload.get("json_body")
|
||||
if body is None and str(payload.get("body_type") or "").lower() in {"json", ""}:
|
||||
candidate = payload.get("body")
|
||||
if isinstance(candidate, (dict, list)):
|
||||
body = candidate
|
||||
return body
|
||||
|
||||
def _build_request_form_body(self, datasource_config: DataSourceConfig | None) -> Any:
|
||||
if not datasource_config:
|
||||
return None
|
||||
|
||||
payload = datasource_config.config or {}
|
||||
form_body = payload.get("form_body")
|
||||
if form_body is not None:
|
||||
return form_body
|
||||
|
||||
if str(payload.get("body_type") or "").lower() == "form":
|
||||
candidate = payload.get("body")
|
||||
if isinstance(candidate, dict):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _normalize_headers(self, headers: Any) -> dict[str, str]:
|
||||
if not isinstance(headers, dict):
|
||||
return {}
|
||||
normalized: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
header_name = str(key).strip()
|
||||
if not header_name or value is None:
|
||||
continue
|
||||
normalized[header_name] = str(value)
|
||||
return normalized
|
||||
|
||||
def _build_auth_headers(self, datasource_config: DataSourceConfig | None) -> dict[str, str]:
|
||||
if not datasource_config:
|
||||
return {}
|
||||
|
||||
auth_type = str(datasource_config.auth_type or "none").lower()
|
||||
auth_config = datasource_config.auth_config or {}
|
||||
if auth_type == "bearer" and auth_config.get("token"):
|
||||
return {"Authorization": f"Bearer {auth_config['token']}"}
|
||||
|
||||
if auth_type == "api_key" and auth_config.get("api_key"):
|
||||
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
|
||||
if location == "query":
|
||||
return {}
|
||||
key_name = auth_config.get("key_name") or "X-API-Key"
|
||||
return {str(key_name): str(auth_config["api_key"])}
|
||||
|
||||
if auth_type == "basic":
|
||||
username = str(auth_config.get("username") or "")
|
||||
password = str(auth_config.get("password") or "")
|
||||
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
return {}
|
||||
|
||||
def _extract_candidates(self, response: Any, response_path: str | None) -> list[Any]:
|
||||
if response_path:
|
||||
extracted = self._extract_from_path(response, response_path)
|
||||
if isinstance(extracted, list):
|
||||
return extracted
|
||||
if isinstance(extracted, dict):
|
||||
for key in self.RESPONSE_CANDIDATE_KEYS:
|
||||
nested = extracted.get(key)
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
return [extracted]
|
||||
|
||||
if isinstance(response, dict):
|
||||
for key in self.RESPONSE_CANDIDATE_KEYS:
|
||||
nested = response.get(key)
|
||||
if isinstance(nested, list):
|
||||
return nested
|
||||
return []
|
||||
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
return []
|
||||
|
||||
def _extract_from_path(self, payload: Any, path: str) -> Any:
|
||||
current = payload
|
||||
for segment in (part.strip() for part in path.split(".") if part.strip()):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(segment)
|
||||
continue
|
||||
if isinstance(current, list):
|
||||
try:
|
||||
current = current[int(segment)]
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
return current
|
||||
|
||||
def _infer_source_type(self, item: dict[str, Any]) -> str:
|
||||
explicit = str(item.get("source_type") or item.get("type") or "").strip().lower()
|
||||
if explicit in {"iframe", "hls", "video", "external", "youtube"}:
|
||||
return explicit
|
||||
|
||||
youtube_video_id = self._clean_text(
|
||||
item.get("youtube_video_id")
|
||||
or item.get("video_id")
|
||||
or item.get("youtubeVideoId")
|
||||
)
|
||||
youtube_channel = self._clean_text(item.get("youtube_channel") or item.get("channel_handle"))
|
||||
embed_url = self._clean_url(item.get("embed_url") or item.get("embed") or item.get("page_url"))
|
||||
stream_url = self._clean_url(item.get("stream_url") or item.get("stream") or item.get("playback_url") or item.get("hls_url"))
|
||||
homepage_url = self._clean_url(item.get("homepage_url") or item.get("source_url") or item.get("website"))
|
||||
|
||||
if youtube_video_id or youtube_channel:
|
||||
return "youtube"
|
||||
if stream_url.endswith(".m3u8"):
|
||||
return "hls"
|
||||
if stream_url:
|
||||
return "video"
|
||||
if embed_url:
|
||||
parsed = urlparse(embed_url)
|
||||
if "youtube.com" in (parsed.netloc or "") or "youtu.be" in (parsed.netloc or ""):
|
||||
return "youtube"
|
||||
return "iframe"
|
||||
if homepage_url:
|
||||
return "external"
|
||||
return "iframe"
|
||||
|
||||
def _parse_enabled(self, item: dict[str, Any]) -> bool:
|
||||
if "is_enabled" in item:
|
||||
return self._to_bool(item.get("is_enabled"), default=True)
|
||||
if "enabled" in item:
|
||||
return self._to_bool(item.get("enabled"), default=True)
|
||||
if "active" in item:
|
||||
return self._to_bool(item.get("active"), default=True)
|
||||
if "status" in item:
|
||||
status = str(item.get("status") or "").strip().lower()
|
||||
if status in {"disabled", "inactive", "offline"}:
|
||||
return False
|
||||
if status in {"enabled", "active", "online", "live"}:
|
||||
return True
|
||||
return True
|
||||
|
||||
def _to_bool(self, value: Any, *, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on", "enabled", "active", "online", "live"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off", "disabled", "inactive", "offline"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
def _clean_text(self, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
def _clean_url(self, value: Any) -> str:
|
||||
text = self._clean_text(value)
|
||||
if not text:
|
||||
return ""
|
||||
parsed = urlparse(text)
|
||||
if parsed.scheme and parsed.scheme not in {"http", "https"}:
|
||||
return ""
|
||||
if parsed.scheme and not parsed.netloc:
|
||||
return ""
|
||||
return text
|
||||
|
||||
async def _fetch_iptv_org(self, channels_url: str, collector_config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
streams_url = self._clean_url(collector_config.get("streams_url")) or self.DEFAULT_IPTV_ORG_STREAMS_URL
|
||||
logos_url = self._clean_url(collector_config.get("logos_url")) or self.DEFAULT_IPTV_ORG_LOGOS_URL
|
||||
news_categories = {
|
||||
self._clean_text(value).lower()
|
||||
for value in (collector_config.get("news_categories") or self.DEFAULT_IPTV_ORG_NEWS_CATEGORIES)
|
||||
if self._clean_text(value)
|
||||
}
|
||||
exclude_categories = {
|
||||
self._clean_text(value).lower()
|
||||
for value in (collector_config.get("exclude_categories") or self.DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES)
|
||||
if self._clean_text(value)
|
||||
}
|
||||
try:
|
||||
max_sources = int(collector_config.get("max_sources", self.DEFAULT_IPTV_ORG_MAX_SOURCES))
|
||||
except (TypeError, ValueError):
|
||||
max_sources = self.DEFAULT_IPTV_ORG_MAX_SOURCES
|
||||
|
||||
timeout = self.DEFAULT_TIMEOUT
|
||||
try:
|
||||
timeout = float(collector_config.get("timeout", self.DEFAULT_TIMEOUT))
|
||||
except (TypeError, ValueError):
|
||||
timeout = self.DEFAULT_TIMEOUT
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
channels_payload, streams_payload, logos_payload = await self._gather_iptv_org_payloads(
|
||||
client,
|
||||
channels_url,
|
||||
streams_url,
|
||||
logos_url,
|
||||
)
|
||||
|
||||
channels = channels_payload if isinstance(channels_payload, list) else []
|
||||
streams = streams_payload if isinstance(streams_payload, list) else []
|
||||
logos = logos_payload if isinstance(logos_payload, list) else []
|
||||
|
||||
logo_by_channel = {
|
||||
self._clean_text(item.get("channel")): self._clean_url(item.get("url"))
|
||||
for item in logos
|
||||
if isinstance(item, dict) and self._clean_text(item.get("channel")) and self._clean_url(item.get("url"))
|
||||
}
|
||||
|
||||
streams_by_channel: dict[str, list[dict[str, Any]]] = {}
|
||||
for stream in streams:
|
||||
if not isinstance(stream, dict):
|
||||
continue
|
||||
channel_id = self._clean_text(stream.get("channel"))
|
||||
if not channel_id:
|
||||
continue
|
||||
streams_by_channel.setdefault(channel_id, []).append(stream)
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for channel in channels:
|
||||
if not isinstance(channel, dict):
|
||||
continue
|
||||
|
||||
categories = [
|
||||
self._clean_text(value).lower()
|
||||
for value in (channel.get("categories") or [])
|
||||
if self._clean_text(value)
|
||||
]
|
||||
if news_categories and not any(category in news_categories for category in categories):
|
||||
continue
|
||||
if exclude_categories and any(category in exclude_categories for category in categories):
|
||||
continue
|
||||
if channel.get("is_nsfw") is True:
|
||||
continue
|
||||
if channel.get("closed"):
|
||||
continue
|
||||
|
||||
channel_id = self._clean_text(channel.get("id"))
|
||||
if not channel_id:
|
||||
continue
|
||||
|
||||
stream = self._pick_iptv_org_stream(streams_by_channel.get(channel_id) or [])
|
||||
if not stream:
|
||||
continue
|
||||
|
||||
stream_url = self._clean_url(stream.get("url"))
|
||||
if not stream_url:
|
||||
continue
|
||||
|
||||
name = self._clean_text(channel.get("name")) or channel_id
|
||||
notes_parts = [
|
||||
f"Imported from IPTV-org catalog ({channel_id})",
|
||||
f"Categories: {', '.join(categories)}" if categories else "",
|
||||
f"Quality: {self._clean_text(stream.get('quality'))}" if self._clean_text(stream.get("quality")) else "",
|
||||
]
|
||||
metadata = {
|
||||
"provider": self._clean_text(channel.get("network")) or "IPTV-org",
|
||||
"region": self._clean_text(channel.get("country")) or "Global",
|
||||
"language": "und",
|
||||
"source_type": "hls" if stream_url.endswith(".m3u8") else "video",
|
||||
"embed_url": "",
|
||||
"stream_url": stream_url,
|
||||
"homepage_url": self._clean_url(channel.get("website")),
|
||||
"poster_url": logo_by_channel.get(channel_id, ""),
|
||||
"youtube_video_id": "",
|
||||
"youtube_channel": "",
|
||||
"sort_order": 400 + len(normalized),
|
||||
"notes": "; ".join(part for part in notes_parts if part),
|
||||
"is_enabled": True,
|
||||
"collector_adapter": "iptv_org",
|
||||
"channel_id": channel_id,
|
||||
"categories": categories,
|
||||
"quality": self._clean_text(stream.get("quality")),
|
||||
"stream_label": self._clean_text(stream.get("label") or stream.get("title")),
|
||||
"stream_referrer": self._clean_text(stream.get("referrer")),
|
||||
"stream_user_agent": self._clean_text(stream.get("user_agent")),
|
||||
}
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"source_id": channel_id,
|
||||
"name": name,
|
||||
"description": metadata["notes"],
|
||||
"metadata": metadata,
|
||||
"reference_date": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
if len(normalized) >= max_sources:
|
||||
break
|
||||
|
||||
return normalized
|
||||
|
||||
async def _gather_iptv_org_payloads(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
channels_url: str,
|
||||
streams_url: str,
|
||||
logos_url: str,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
headers = dict(self.DEFAULT_HEADERS)
|
||||
channels_payload, streams_payload, logos_payload = await asyncio.gather(
|
||||
client.get(channels_url, headers=headers),
|
||||
client.get(streams_url, headers=headers),
|
||||
client.get(logos_url, headers=headers),
|
||||
)
|
||||
channels_payload.raise_for_status()
|
||||
streams_payload.raise_for_status()
|
||||
logos_payload.raise_for_status()
|
||||
return channels_payload.json(), streams_payload.json(), logos_payload.json()
|
||||
|
||||
def _pick_iptv_org_stream(self, streams: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
def score(stream: dict[str, Any]) -> tuple[int, int]:
|
||||
url = self._clean_url(stream.get("url"))
|
||||
quality = self._clean_text(stream.get("quality")).lower()
|
||||
quality_score = 0
|
||||
if quality.endswith("p"):
|
||||
try:
|
||||
quality_score = int(quality[:-1])
|
||||
except ValueError:
|
||||
quality_score = 0
|
||||
stream_score = 1000 if url.endswith(".m3u8") else 0
|
||||
return stream_score, quality_score
|
||||
|
||||
sorted_streams = sorted(streams, key=score, reverse=True)
|
||||
return sorted_streams[0]
|
||||
|
||||
def parse_response(self, response: Any, *, response_path: str | None = None) -> list[dict[str, Any]]:
|
||||
candidates = self._extract_candidates(response, response_path)
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(candidates):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
stream_id = item.get("id") or item.get("source_id") or item.get("slug") or f"news-live-{index + 1}"
|
||||
name = str(item.get("name") or item.get("title") or f"News Live {index + 1}").strip()
|
||||
stream_id = (
|
||||
item.get("id")
|
||||
or item.get("source_id")
|
||||
or item.get("slug")
|
||||
or item.get("channel_id")
|
||||
or item.get("code")
|
||||
or f"news-live-{index + 1}"
|
||||
)
|
||||
name = self._clean_text(
|
||||
item.get("name")
|
||||
or item.get("title")
|
||||
or item.get("channel")
|
||||
or item.get("display_name")
|
||||
or f"News Live {index + 1}"
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
source_type = self._infer_source_type(item)
|
||||
stream_url = self._clean_url(
|
||||
item.get("stream_url")
|
||||
or item.get("stream")
|
||||
or item.get("playback_url")
|
||||
or item.get("hls_url")
|
||||
or item.get("m3u8_url")
|
||||
)
|
||||
embed_url = self._clean_url(
|
||||
item.get("embed_url")
|
||||
or item.get("embed")
|
||||
or item.get("page_url")
|
||||
or (item.get("url") if source_type == "iframe" else "")
|
||||
)
|
||||
homepage_url = self._clean_url(
|
||||
item.get("homepage_url")
|
||||
or item.get("source_url")
|
||||
or item.get("website")
|
||||
or item.get("url")
|
||||
)
|
||||
metadata = {
|
||||
"provider": item.get("provider") or item.get("publisher") or "Collector",
|
||||
"region": item.get("region") or item.get("country") or "Global",
|
||||
"language": item.get("language") or "und",
|
||||
"source_type": item.get("source_type") or "iframe",
|
||||
"embed_url": item.get("embed_url") or item.get("url") or "",
|
||||
"stream_url": item.get("stream_url") or "",
|
||||
"homepage_url": item.get("homepage_url") or item.get("source_url") or "",
|
||||
"poster_url": item.get("poster_url") or "",
|
||||
"provider": self._clean_text(item.get("provider") or item.get("publisher") or item.get("network")) or "Collector",
|
||||
"region": self._clean_text(item.get("region") or item.get("country") or item.get("market")) or "Global",
|
||||
"language": self._clean_text(item.get("language") or item.get("lang") or item.get("locale")) or "und",
|
||||
"source_type": source_type,
|
||||
"embed_url": embed_url,
|
||||
"stream_url": stream_url,
|
||||
"homepage_url": homepage_url,
|
||||
"poster_url": self._clean_url(item.get("poster_url") or item.get("thumbnail_url") or item.get("logo_url")),
|
||||
"youtube_video_id": self._clean_text(
|
||||
item.get("youtube_video_id")
|
||||
or item.get("video_id")
|
||||
or item.get("youtubeVideoId")
|
||||
),
|
||||
"youtube_channel": self._clean_text(
|
||||
item.get("youtube_channel")
|
||||
or item.get("channel_handle")
|
||||
or item.get("youtubeChannel")
|
||||
),
|
||||
"sort_order": item.get("sort_order", 200 + index),
|
||||
"notes": item.get("notes") or item.get("description") or "",
|
||||
"is_enabled": item.get("is_enabled", True),
|
||||
"notes": self._clean_text(item.get("notes") or item.get("description") or item.get("summary")),
|
||||
"is_enabled": self._parse_enabled(item),
|
||||
}
|
||||
|
||||
normalized.append(
|
||||
@@ -72,7 +563,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
"name": name,
|
||||
"description": metadata["notes"],
|
||||
"metadata": metadata,
|
||||
"reference_date": item.get("reference_date", datetime.now(UTC).isoformat()),
|
||||
"reference_date": item.get("reference_date") or datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ class RegionProfile:
|
||||
accent: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionAnchor:
|
||||
region: str
|
||||
label: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsFeedSource:
|
||||
id: str
|
||||
@@ -95,6 +103,39 @@ REGION_PROFILES: dict[str, RegionProfile] = {
|
||||
),
|
||||
}
|
||||
|
||||
REGION_ANCHORS: dict[str, RegionAnchor] = {
|
||||
"americas": RegionAnchor(
|
||||
region="americas",
|
||||
label="美洲",
|
||||
latitude=37.0902,
|
||||
longitude=-95.7129,
|
||||
),
|
||||
"europe": RegionAnchor(
|
||||
region="europe",
|
||||
label="欧洲",
|
||||
latitude=50.1109,
|
||||
longitude=8.6821,
|
||||
),
|
||||
"middle-east-africa": RegionAnchor(
|
||||
region="middle-east-africa",
|
||||
label="中东与非洲",
|
||||
latitude=25.2048,
|
||||
longitude=55.2708,
|
||||
),
|
||||
"asia-pacific": RegionAnchor(
|
||||
region="asia-pacific",
|
||||
label="亚太",
|
||||
latitude=1.3521,
|
||||
longitude=103.8198,
|
||||
),
|
||||
"global": RegionAnchor(
|
||||
region="global",
|
||||
label="全球",
|
||||
latitude=20.0,
|
||||
longitude=0.0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||
return (
|
||||
@@ -213,6 +254,10 @@ def get_region_profile(region: str) -> RegionProfile:
|
||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||
|
||||
|
||||
def get_region_anchor(region: str) -> RegionAnchor:
|
||||
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
@@ -342,6 +387,7 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
anchor = get_region_anchor(item.feed_region)
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
@@ -352,6 +398,10 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An
|
||||
"region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
"location_label": anchor.label,
|
||||
"location_inferred": True,
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
|
||||
86
backend/app/services/persistent_logs.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.logging import get_logger, sanitize_log_value
|
||||
from app.core.request_context import get_request_id
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.system_log import AuditLog, SystemLog
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def record_system_log(
|
||||
*,
|
||||
source: str,
|
||||
level: str,
|
||||
message: str,
|
||||
service: str | None = None,
|
||||
module: str | None = None,
|
||||
event: str | None = None,
|
||||
request_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
user_id: int | None = None,
|
||||
category: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
session.add(
|
||||
SystemLog(
|
||||
source=source,
|
||||
service=service,
|
||||
module=module,
|
||||
event=event,
|
||||
level=level.lower(),
|
||||
message=str(sanitize_log_value(message)),
|
||||
request_id=request_id or get_request_id(),
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
context=sanitize_log_value(context or {}),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception_event(
|
||||
"Failed to persist system log",
|
||||
event="system_log.persist.failed",
|
||||
context={"event_name": event, "source": source},
|
||||
)
|
||||
|
||||
|
||||
async def record_audit_log(
|
||||
*,
|
||||
action: str,
|
||||
actor_id: int | None = None,
|
||||
actor_name: str | None = None,
|
||||
target_type: str | None = None,
|
||||
target_id: str | None = None,
|
||||
result: str | None = None,
|
||||
request_id: str | None = None,
|
||||
ip: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
session.add(
|
||||
AuditLog(
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
result=result,
|
||||
request_id=request_id or get_request_id(),
|
||||
ip=ip,
|
||||
details=sanitize_log_value(details or {}),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception_event(
|
||||
"Failed to persist audit log",
|
||||
event="audit_log.persist.failed",
|
||||
context={"action": action},
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Task Scheduler for running collection jobs."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -9,13 +8,14 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.session import async_session_factory
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.task import CollectionTask
|
||||
from app.services.collectors.registry import collector_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
||||
@@ -54,7 +54,11 @@ async def _update_next_run_at(datasource: DataSource, session) -> None:
|
||||
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
||||
collector = collector_registry.get(datasource.source)
|
||||
if not collector:
|
||||
logger.warning("Collector not found for datasource %s", datasource.source)
|
||||
logger.warning_event(
|
||||
"Collector not found for datasource",
|
||||
event="collector.schedule.collector_missing",
|
||||
context={"collector_name": datasource.source},
|
||||
)
|
||||
return
|
||||
|
||||
collector_registry.set_active(datasource.source, datasource.is_active)
|
||||
@@ -72,13 +76,17 @@ async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
||||
replace_existing=True,
|
||||
kwargs={"collector_name": datasource.source},
|
||||
)
|
||||
logger.info(
|
||||
"Scheduled collector: %s (every %sm)",
|
||||
datasource.source,
|
||||
datasource.frequency_minutes,
|
||||
logger.info_event(
|
||||
"Scheduled collector",
|
||||
event="collector.schedule.updated",
|
||||
context={"collector_name": datasource.source, "frequency_minutes": datasource.frequency_minutes},
|
||||
)
|
||||
else:
|
||||
logger.info("Collector disabled: %s", datasource.source)
|
||||
logger.info_event(
|
||||
"Collector disabled",
|
||||
event="collector.schedule.disabled",
|
||||
context={"collector_name": datasource.source},
|
||||
)
|
||||
|
||||
await _update_next_run_at(datasource, session)
|
||||
|
||||
@@ -87,18 +95,30 @@ async def run_collector_task(collector_name: str):
|
||||
"""Run a single collector task."""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
logger.error("Collector not found: %s", collector_name)
|
||||
logger.error_event(
|
||||
"Collector not found",
|
||||
event="collector.run.collector_missing",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
|
||||
datasource = result.scalar_one_or_none()
|
||||
if not datasource:
|
||||
logger.error("Datasource not found for collector: %s", collector_name)
|
||||
logger.error_event(
|
||||
"Datasource not found for collector",
|
||||
event="collector.run.datasource_missing",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return
|
||||
|
||||
if not datasource.is_active:
|
||||
logger.info("Skipping disabled collector: %s", collector_name)
|
||||
logger.info_event(
|
||||
"Skipping disabled collector",
|
||||
event="collector.run.skipped_disabled",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return
|
||||
|
||||
running_result = await db.execute(
|
||||
@@ -122,10 +142,10 @@ async def run_collector_task(collector_name: str):
|
||||
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
|
||||
)
|
||||
if not is_stale:
|
||||
logger.warning(
|
||||
"Skipping collector %s trigger because task %s is already running",
|
||||
collector_name,
|
||||
existing_running.id,
|
||||
logger.warning_event(
|
||||
"Skipping collector trigger because task is already running",
|
||||
event="collector.run.skipped_already_running",
|
||||
context={"collector_name": collector_name, "task_id": existing_running.id},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -143,31 +163,47 @@ async def run_collector_task(collector_name: str):
|
||||
else stale_reason
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"Marked stale running task %s as failed before rerun of %s",
|
||||
existing_running.id,
|
||||
collector_name,
|
||||
logger.warning_event(
|
||||
"Marked stale running task as failed before rerun",
|
||||
event="collector.run.stale_task_failed",
|
||||
context={"collector_name": collector_name, "task_id": existing_running.id},
|
||||
)
|
||||
|
||||
try:
|
||||
collector._datasource_id = datasource.id
|
||||
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
|
||||
logger.info_event(
|
||||
"Running collector",
|
||||
event="collector.run.started",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||
)
|
||||
task_result = await collector.run(db)
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = task_result.get("status")
|
||||
await _update_next_run_at(datasource, db)
|
||||
logger.info("Collector %s completed: %s", collector_name, task_result)
|
||||
logger.info_event(
|
||||
"Collector completed",
|
||||
event="collector.run.completed",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "cancelled"
|
||||
await db.commit()
|
||||
logger.warning("Collector %s cancelled by operator", collector_name)
|
||||
logger.warning_event(
|
||||
"Collector cancelled by operator",
|
||||
event="collector.run.cancelled",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "failed"
|
||||
await db.commit()
|
||||
logger.exception("Collector %s failed: %s", collector_name, exc)
|
||||
logger.exception_event(
|
||||
"Collector failed",
|
||||
event="collector.run.failed",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id, "error": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
||||
@@ -194,7 +230,11 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
|
||||
|
||||
if stale_tasks:
|
||||
await db.commit()
|
||||
logger.warning("Cleaned up %s stale running collection task(s)", len(stale_tasks))
|
||||
logger.warning_event(
|
||||
"Cleaned up stale running collection tasks",
|
||||
event="collector.cleanup.stale_tasks_cleaned",
|
||||
context={"count": len(stale_tasks)},
|
||||
)
|
||||
|
||||
return len(stale_tasks)
|
||||
|
||||
@@ -203,14 +243,14 @@ def start_scheduler() -> None:
|
||||
"""Start the scheduler."""
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
logger.info_event("Scheduler started", event="scheduler.started")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
"""Stop the scheduler."""
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler stopped")
|
||||
logger.info_event("Scheduler stopped", event="scheduler.stopped")
|
||||
|
||||
|
||||
async def sync_scheduler_with_datasources() -> None:
|
||||
@@ -271,12 +311,20 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
"""Run a collector immediately (not scheduled)."""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
logger.error("Collector not found: %s", collector_name)
|
||||
logger.error_event(
|
||||
"Collector not found",
|
||||
event="collector.trigger.collector_missing",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return False
|
||||
|
||||
existing_task = get_running_collector_task(collector_name)
|
||||
if existing_task is not None and not existing_task.done():
|
||||
logger.warning("Collector %s is already running in-memory; skipping duplicate trigger", collector_name)
|
||||
logger.warning_event(
|
||||
"Collector is already running in-memory; skipping duplicate trigger",
|
||||
event="collector.trigger.skipped_already_running",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -289,10 +337,18 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||
|
||||
task.add_done_callback(_cleanup_task)
|
||||
logger.info("Triggered collector: %s", collector_name)
|
||||
logger.info_event(
|
||||
"Triggered collector",
|
||||
event="collector.trigger.started",
|
||||
context={"collector_name": collector_name},
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
||||
logger.error_event(
|
||||
"Failed to trigger collector",
|
||||
event="collector.trigger.failed",
|
||||
context={"collector_name": collector_name, "error": str(exc)},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
532
backend/app/services/system_logs.py
Normal file
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.security import redis_client
|
||||
|
||||
DEFAULT_LOG_LINE_LIMIT = 200
|
||||
MAX_LOG_LINE_LIMIT = 1000
|
||||
BUFFER_LOG_LIMIT = 1000
|
||||
BUFFER_LOG_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
LOG_BUFFER_KEY_PREFIX = "planet:system_logs"
|
||||
|
||||
LOG_LEVEL_ERROR = "error"
|
||||
LOG_LEVEL_WARNING = "warning"
|
||||
LOG_LEVEL_INFO = "info"
|
||||
LOG_LEVEL_DEBUG = "debug"
|
||||
LOG_LEVEL_ALL = "all"
|
||||
|
||||
SUPPORTED_LOG_LEVELS = {
|
||||
LOG_LEVEL_ALL,
|
||||
LOG_LEVEL_ERROR,
|
||||
LOG_LEVEL_WARNING,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
}
|
||||
|
||||
LOG_LEVEL_ALIASES = {
|
||||
"warn": LOG_LEVEL_WARNING,
|
||||
"warning": LOG_LEVEL_WARNING,
|
||||
"err": LOG_LEVEL_ERROR,
|
||||
"error": LOG_LEVEL_ERROR,
|
||||
"info": LOG_LEVEL_INFO,
|
||||
"information": LOG_LEVEL_INFO,
|
||||
"debug": LOG_LEVEL_DEBUG,
|
||||
"trace": LOG_LEVEL_DEBUG,
|
||||
"critical": LOG_LEVEL_ERROR,
|
||||
"fatal": LOG_LEVEL_ERROR,
|
||||
}
|
||||
|
||||
TIMESTAMP_FORMATS = (
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
|
||||
LEVEL_PATTERNS = (
|
||||
("CRITICAL", LOG_LEVEL_ERROR),
|
||||
("FATAL", LOG_LEVEL_ERROR),
|
||||
("ERROR", LOG_LEVEL_ERROR),
|
||||
("WARNING", LOG_LEVEL_WARNING),
|
||||
("WARN", LOG_LEVEL_WARNING),
|
||||
("INFO", LOG_LEVEL_INFO),
|
||||
("DEBUG", LOG_LEVEL_DEBUG),
|
||||
("TRACE", LOG_LEVEL_DEBUG),
|
||||
)
|
||||
|
||||
LEADING_LEVEL_PATTERN = re.compile(
|
||||
r"^\s*(?:\[[^\]]+\]\s*)?(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b[:\s-]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
EMBEDDED_LEVEL_PATTERN = re.compile(
|
||||
r"\b(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogSource:
|
||||
source_id: str
|
||||
name: str
|
||||
kind: str
|
||||
location: str
|
||||
description: str
|
||||
category: str
|
||||
status: str = "ok"
|
||||
buffer_key: str | None = None
|
||||
container_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuredLogEntry:
|
||||
timestamp: datetime | None
|
||||
level: str | None
|
||||
display_line: str
|
||||
raw_line: str
|
||||
search_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyLogMarker:
|
||||
date_token: str
|
||||
total: int
|
||||
dominant_level: str
|
||||
|
||||
|
||||
LOG_SOURCES: dict[str, LogSource] = {
|
||||
"backend": LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location="/tmp/planet_backend.log",
|
||||
description="FastAPI 后端、调度器和采集任务共享日志。",
|
||||
category="service",
|
||||
),
|
||||
"frontend": LogSource(
|
||||
source_id="frontend",
|
||||
name="前端开发服务",
|
||||
kind="file",
|
||||
location="/tmp/planet_frontend.log",
|
||||
description="控制台与 Earth 前端开发服务输出。",
|
||||
category="service",
|
||||
),
|
||||
"ai-provider": LogSource(
|
||||
source_id="ai-provider",
|
||||
name="AI Provider",
|
||||
kind="docker",
|
||||
location="docker://planet_aiprovider",
|
||||
description="AI Provider 容器实时输出日志。",
|
||||
category="service",
|
||||
container_name="planet_aiprovider",
|
||||
),
|
||||
"earth-client": LogSource(
|
||||
source_id="earth-client",
|
||||
name="Earth 浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:earth-client",
|
||||
description="Earth 浏览器端上报的运行时错误与关键业务日志。",
|
||||
category="client",
|
||||
buffer_key=f"{LOG_BUFFER_KEY_PREFIX}:earth-client",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_log_level(level: str | None) -> str:
|
||||
if level is None:
|
||||
return LOG_LEVEL_ALL
|
||||
normalized = str(level).strip().lower()
|
||||
if normalized in {"", LOG_LEVEL_ALL}:
|
||||
return LOG_LEVEL_ALL
|
||||
return LOG_LEVEL_ALIASES.get(normalized, LOG_LEVEL_ALL)
|
||||
|
||||
|
||||
def normalize_log_levels(level: str | None = None, levels: str | None = None) -> tuple[str, ...]:
|
||||
normalized_levels: list[str] = []
|
||||
if levels:
|
||||
for item in str(levels).split(","):
|
||||
normalized = normalize_log_level(item)
|
||||
if normalized != LOG_LEVEL_ALL and normalized not in normalized_levels:
|
||||
normalized_levels.append(normalized)
|
||||
normalized_level = normalize_log_level(level)
|
||||
if normalized_level != LOG_LEVEL_ALL and normalized_level not in normalized_levels:
|
||||
normalized_levels.append(normalized_level)
|
||||
return tuple(normalized_levels)
|
||||
|
||||
|
||||
def get_source_status(source: LogSource) -> str:
|
||||
if source.kind == "file":
|
||||
path = Path(source.location)
|
||||
if not path.exists():
|
||||
return "missing"
|
||||
return "ok" if path.stat().st_size > 0 else "empty"
|
||||
if source.kind == "docker":
|
||||
return "ok" if shutil.which("docker") else "docker_unavailable"
|
||||
if source.kind == "buffer":
|
||||
if not source.buffer_key:
|
||||
return "source_unavailable"
|
||||
try:
|
||||
return "ok" if redis_client.llen(source.buffer_key) > 0 else "empty"
|
||||
except Exception:
|
||||
return "source_unavailable"
|
||||
return "source_unavailable"
|
||||
|
||||
|
||||
def list_log_sources() -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
for source in LOG_SOURCES.values():
|
||||
items.append(
|
||||
{
|
||||
"source_id": source.source_id,
|
||||
"name": source.name,
|
||||
"kind": source.kind,
|
||||
"location": source.location,
|
||||
"description": source.description,
|
||||
"category": source.category,
|
||||
"status": get_source_status(source),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def get_buffer_log_key(source_id: str) -> str:
|
||||
return f"{LOG_BUFFER_KEY_PREFIX}:{source_id}"
|
||||
|
||||
|
||||
def append_buffer_log(
|
||||
source_id: str,
|
||||
*,
|
||||
level: str,
|
||||
message: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"timestamp": datetime.now(tz=UTC).isoformat(),
|
||||
"level": normalize_log_level(level),
|
||||
"message": message,
|
||||
"context": context or {},
|
||||
}
|
||||
buffer_key = get_buffer_log_key(source_id)
|
||||
redis_client.rpush(buffer_key, json.dumps(payload, ensure_ascii=False))
|
||||
redis_client.ltrim(buffer_key, -BUFFER_LOG_LIMIT, -1)
|
||||
redis_client.expire(buffer_key, BUFFER_LOG_TTL_SECONDS)
|
||||
|
||||
|
||||
def parse_timestamp(raw_value: str | None) -> datetime | None:
|
||||
if not raw_value:
|
||||
return None
|
||||
candidate = str(raw_value).strip()
|
||||
if not candidate:
|
||||
return None
|
||||
candidate = candidate.replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
pass
|
||||
for fmt in TIMESTAMP_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(candidate, fmt).replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def parse_prefixed_timestamp(line: str) -> tuple[datetime | None, str]:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None, ""
|
||||
for prefix_length in (35, 32, 29, 26, 23, 19):
|
||||
if len(stripped) < prefix_length:
|
||||
continue
|
||||
prefix = stripped[:prefix_length]
|
||||
timestamp = parse_timestamp(prefix)
|
||||
if timestamp is not None:
|
||||
return timestamp, stripped[prefix_length:].lstrip()
|
||||
first_token = stripped.split(maxsplit=1)[0]
|
||||
timestamp = parse_timestamp(first_token)
|
||||
if timestamp is not None:
|
||||
remainder = stripped[len(first_token):].lstrip()
|
||||
return timestamp, remainder
|
||||
return None, stripped
|
||||
|
||||
|
||||
def infer_log_level_from_text(text: str, *, allow_embedded: bool = True) -> str | None:
|
||||
leading_match = LEADING_LEVEL_PATTERN.match(text)
|
||||
if leading_match:
|
||||
return normalize_log_level(leading_match.group(1))
|
||||
|
||||
if allow_embedded:
|
||||
embedded_match = EMBEDDED_LEVEL_PATTERN.search(text)
|
||||
if embedded_match:
|
||||
return normalize_log_level(embedded_match.group(1))
|
||||
upper_text = text.upper()
|
||||
for pattern, normalized in LEVEL_PATTERNS:
|
||||
if f"{pattern}:" in upper_text or f"{pattern} " in upper_text:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def build_display_line(timestamp: datetime | None, level: str | None, message: str) -> str:
|
||||
message_part = message.strip() if message else ""
|
||||
parts = []
|
||||
if timestamp is not None:
|
||||
parts.append(timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S"))
|
||||
if level:
|
||||
parts.append(level.upper())
|
||||
if message_part:
|
||||
parts.append(message_part)
|
||||
return " ".join(parts).strip()
|
||||
|
||||
|
||||
def sanitize_text_log_line(line: str) -> str:
|
||||
return CONTROL_CHAR_PATTERN.sub("", line)
|
||||
|
||||
|
||||
def parse_text_log_entry(line: str) -> StructuredLogEntry:
|
||||
sanitized_line = sanitize_text_log_line(line).rstrip("\n")
|
||||
timestamp, remainder = parse_prefixed_timestamp(sanitized_line)
|
||||
level = infer_log_level_from_text(remainder or sanitized_line, allow_embedded=False)
|
||||
display_line = sanitized_line
|
||||
return StructuredLogEntry(
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
display_line=display_line,
|
||||
raw_line=display_line,
|
||||
search_text=display_line.lower(),
|
||||
)
|
||||
|
||||
|
||||
def build_buffer_entry(payload: dict[str, Any]) -> StructuredLogEntry:
|
||||
timestamp = parse_timestamp(str(payload.get("timestamp", "")).strip())
|
||||
level = normalize_log_level(payload.get("level"))
|
||||
if level == LOG_LEVEL_ALL:
|
||||
level = None
|
||||
message = str(payload.get("message", "")).strip()
|
||||
context = payload.get("context")
|
||||
context_map = context if isinstance(context, dict) else {}
|
||||
context_fragments = []
|
||||
for key in ("category", "module", "url", "detail"):
|
||||
value = str(context_map.get(key, "")).strip()
|
||||
if value:
|
||||
context_fragments.append(f"{key}={value}")
|
||||
message_with_context = " | ".join([message, *context_fragments]) if context_fragments else message
|
||||
display_line = build_display_line(timestamp, level, message_with_context)
|
||||
search_text = " ".join(
|
||||
[
|
||||
message,
|
||||
json.dumps(context_map, ensure_ascii=False, sort_keys=True),
|
||||
display_line,
|
||||
]
|
||||
).lower()
|
||||
return StructuredLogEntry(
|
||||
timestamp=timestamp,
|
||||
level=level,
|
||||
display_line=display_line,
|
||||
raw_line=json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||||
search_text=search_text,
|
||||
)
|
||||
|
||||
|
||||
def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
path = Path(source.location)
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
recent_lines = deque(handle, maxlen=scan_limit)
|
||||
return [
|
||||
parse_text_log_entry(line)
|
||||
for line in recent_lines
|
||||
if sanitize_text_log_line(line).strip()
|
||||
]
|
||||
|
||||
|
||||
def read_docker_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
if not shutil.which("docker") or not source.container_name:
|
||||
return []
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"logs",
|
||||
"--timestamps",
|
||||
"--tail",
|
||||
str(scan_limit),
|
||||
source.container_name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
parse_text_log_entry(line)
|
||||
for line in completed.stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def read_buffer_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
if not source.buffer_key:
|
||||
return []
|
||||
try:
|
||||
raw_items = redis_client.lrange(source.buffer_key, -scan_limit, -1)
|
||||
except Exception:
|
||||
return []
|
||||
entries: list[StructuredLogEntry] = []
|
||||
for raw_item in raw_items:
|
||||
try:
|
||||
payload = json.loads(raw_item)
|
||||
except json.JSONDecodeError:
|
||||
entries.append(parse_text_log_entry(str(raw_item)))
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
entries.append(build_buffer_entry(payload))
|
||||
else:
|
||||
entries.append(parse_text_log_entry(str(raw_item)))
|
||||
return entries
|
||||
|
||||
|
||||
def read_source_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
if source.kind == "file":
|
||||
return read_file_entries(source, scan_limit)
|
||||
if source.kind == "docker":
|
||||
return read_docker_entries(source, scan_limit)
|
||||
if source.kind == "buffer":
|
||||
return read_buffer_entries(source, scan_limit)
|
||||
return []
|
||||
|
||||
|
||||
def matches_levels(entry: StructuredLogEntry, selected_levels: tuple[str, ...]) -> bool:
|
||||
if not selected_levels:
|
||||
return True
|
||||
return entry.level in selected_levels
|
||||
|
||||
|
||||
def matches_date_range(
|
||||
entry: StructuredLogEntry,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
) -> bool:
|
||||
if not start_date and not end_date:
|
||||
return True
|
||||
if entry.timestamp is None:
|
||||
return False
|
||||
date_token = entry.timestamp.astimezone(UTC).date().isoformat()
|
||||
if start_date and date_token < start_date:
|
||||
return False
|
||||
if end_date and date_token > end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def matches_search(entry: StructuredLogEntry, search: str | None) -> bool:
|
||||
if search is None:
|
||||
return True
|
||||
query = search.strip().lower()
|
||||
if not query:
|
||||
return True
|
||||
return query in entry.search_text
|
||||
|
||||
|
||||
def build_daily_log_markers(entries: list[StructuredLogEntry]) -> list[dict[str, Any]]:
|
||||
grouped: dict[str, list[StructuredLogEntry]] = {}
|
||||
for entry in entries:
|
||||
if entry.timestamp is None:
|
||||
continue
|
||||
date_token = entry.timestamp.astimezone(UTC).date().isoformat()
|
||||
grouped.setdefault(date_token, []).append(entry)
|
||||
|
||||
markers: list[DailyLogMarker] = []
|
||||
for date_token, group in sorted(grouped.items()):
|
||||
level_counts = Counter(
|
||||
entry.level
|
||||
for entry in group
|
||||
if entry.level in SUPPORTED_LOG_LEVELS and entry.level != LOG_LEVEL_ALL
|
||||
)
|
||||
dominant_level = LOG_LEVEL_INFO
|
||||
if level_counts:
|
||||
dominant_level = sorted(
|
||||
level_counts.items(),
|
||||
key=lambda item: (
|
||||
-item[1],
|
||||
("error", "warning", "info", "debug").index(item[0]),
|
||||
),
|
||||
)[0][0]
|
||||
markers.append(
|
||||
DailyLogMarker(
|
||||
date_token=date_token,
|
||||
total=len(group),
|
||||
dominant_level=dominant_level,
|
||||
)
|
||||
)
|
||||
return [marker.__dict__ for marker in markers]
|
||||
|
||||
|
||||
def read_log_snapshot(
|
||||
source_id: str,
|
||||
limit: int,
|
||||
*,
|
||||
level: str = LOG_LEVEL_ALL,
|
||||
levels: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
source = LOG_SOURCES.get(source_id)
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
selected_levels = normalize_log_levels(level, levels)
|
||||
search_query = (search or "").strip()
|
||||
scan_limit = max(min(MAX_LOG_LINE_LIMIT * 5, 5000), limit * 5, BUFFER_LOG_LIMIT if source.kind == "buffer" else 1000)
|
||||
all_entries = read_source_entries(source, scan_limit)
|
||||
marker_entries = [
|
||||
entry
|
||||
for entry in all_entries
|
||||
if matches_levels(entry, selected_levels) and matches_search(entry, search_query)
|
||||
]
|
||||
filtered_entries = [
|
||||
entry
|
||||
for entry in marker_entries
|
||||
if matches_date_range(entry, start_date, end_date)
|
||||
]
|
||||
visible_entries = filtered_entries[-limit:]
|
||||
|
||||
compatibility_level = selected_levels[0] if len(selected_levels) == 1 else LOG_LEVEL_ALL
|
||||
return {
|
||||
"source_id": source.source_id,
|
||||
"name": source.name,
|
||||
"kind": source.kind,
|
||||
"location": source.location,
|
||||
"description": source.description,
|
||||
"category": source.category,
|
||||
"status": get_source_status(source),
|
||||
"level": compatibility_level,
|
||||
"selected_levels": list(selected_levels),
|
||||
"search_query": search_query,
|
||||
"available_levels": [
|
||||
LOG_LEVEL_ALL,
|
||||
LOG_LEVEL_ERROR,
|
||||
LOG_LEVEL_WARNING,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
],
|
||||
"daily_markers": build_daily_log_markers(marker_entries),
|
||||
"line_limit": limit,
|
||||
"line_count": len(visible_entries),
|
||||
"lines": [entry.display_line for entry in visible_entries],
|
||||
}
|
||||
@@ -17,7 +17,7 @@ TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
|
||||
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
|
||||
|
||||
DEFAULT_TV_SETTINGS = {
|
||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||
"default_source_id": DEFAULT_TV_SOURCE_ID,
|
||||
"auto_fallback": True,
|
||||
"sources": [
|
||||
{
|
||||
@@ -362,7 +362,7 @@ def _build_collected_tv_source(record: CollectedData, index: int) -> dict[str, A
|
||||
"sort_order": metadata.get("sort_order", 200 + index),
|
||||
"collector_source": record.source,
|
||||
"notes": record.description or metadata.get("notes") or "",
|
||||
"updated_at": to_iso8601_utc(record.updated_at or record.reference_date or datetime.now(UTC)),
|
||||
"updated_at": to_iso8601_utc(record.collected_at or record.reference_date or datetime.now(UTC)),
|
||||
},
|
||||
index=index,
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ async def test_health_check():
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert "version" in data
|
||||
assert response.headers["x-request-id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -161,6 +162,345 @@ async def test_alerts_endpoint_with_auth(auth_headers):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_sources_requires_super_admin(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password_hash="hashed",
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_sources_with_super_admin(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.list_log_sources",
|
||||
return_value=[
|
||||
{
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
}
|
||||
],
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/sources", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"][0]["source_id"] == "backend"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_with_super_admin(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "all",
|
||||
"selected_levels": [],
|
||||
"search_query": "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 2,
|
||||
"lines": ["line 1", "line 2"],
|
||||
},
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/backend?limit=50", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["source_id"] == "backend"
|
||||
assert data["line_count"] == 2
|
||||
assert data["lines"] == ["line 1", "line 2"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_supports_level_filter(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "error",
|
||||
"selected_levels": ["error"],
|
||||
"search_query": "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 1,
|
||||
"lines": ["ERROR: failed"],
|
||||
},
|
||||
):
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/system/logs/backend?limit=50&level=error", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["level"] == "error"
|
||||
assert data["lines"] == ["ERROR: failed"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_supports_date_range_filter(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "all",
|
||||
"selected_levels": [],
|
||||
"search_query": "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 1,
|
||||
"lines": ["2026-04-23 INFO: service started"],
|
||||
},
|
||||
) as mock_read_log_snapshot:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/system/logs/backend?limit=50&start_date=2026-04-20&end_date=2026-04-23",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_read_log_snapshot.assert_called_once_with(
|
||||
"backend",
|
||||
50,
|
||||
level="all",
|
||||
levels=None,
|
||||
start_date="2026-04-20",
|
||||
end_date="2026-04-23",
|
||||
search=None,
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_supports_levels_and_search_filter(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.system_control.read_log_snapshot",
|
||||
return_value={
|
||||
"source_id": "backend",
|
||||
"name": "后端服务",
|
||||
"kind": "file",
|
||||
"location": "/tmp/planet_backend.log",
|
||||
"description": "FastAPI 后端、调度器和采集任务共享日志。",
|
||||
"category": "service",
|
||||
"status": "ok",
|
||||
"level": "all",
|
||||
"selected_levels": ["error", "warning"],
|
||||
"search_query": "timeout",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": 50,
|
||||
"line_count": 1,
|
||||
"lines": ["2026-04-23 10:00:00 ERROR timeout"],
|
||||
},
|
||||
) as mock_read_log_snapshot:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/system/logs/backend?limit=50&levels=error,warning&search=timeout",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_read_log_snapshot.assert_called_once_with(
|
||||
"backend",
|
||||
50,
|
||||
level="all",
|
||||
levels="error,warning",
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
search="timeout",
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_log_snapshot_rejects_invalid_date_range(auth_headers):
|
||||
def override_get_current_user():
|
||||
return User(
|
||||
id=1,
|
||||
username="root",
|
||||
email="root@example.com",
|
||||
password_hash="hashed",
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
app.dependency_overrides = {
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
}
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/system/logs/backend?start_date=2026-04-31",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "start_date must be in YYYY-MM-DD format" in response.json()["detail"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_earth_client_log_accepts_public_events():
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log:
|
||||
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/system/logs/earth-client",
|
||||
json={
|
||||
"level": "error",
|
||||
"message": "登陆点加载失败: 登陆点接口返回 HTTP 500",
|
||||
"category": "startup-load",
|
||||
"module": "layer-startup",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["accepted"] is True
|
||||
assert data["source_id"] == "earth-client"
|
||||
mock_append_buffer_log.assert_called_once()
|
||||
mock_record_system_log.assert_awaited_once()
|
||||
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
||||
assert persisted_kwargs["source"] == "earth-client"
|
||||
assert persisted_kwargs["event"] == "earth.client.runtime_log"
|
||||
assert persisted_kwargs["category"] == "startup-load"
|
||||
assert persisted_kwargs["level"] == "error"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_id_header_is_echoed_when_provided():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/health", headers={"X-Request-ID": "planet-test-request"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["x-request-id"] == "planet-test-request"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_token():
|
||||
"""Test that invalid token is rejected"""
|
||||
@@ -263,6 +603,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
|
||||
assert "content_blocks" in data
|
||||
assert "text_blocks" in data
|
||||
assert "thinking_blocks" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -382,8 +724,6 @@ async def test_save_playground_session_with_auth(auth_headers):
|
||||
assert data["state"]["objective"] == "测试目标"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
49
backend/tests/test_earth_news.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.services.earth_news import ParsedNewsItem, _serialize_item
|
||||
|
||||
|
||||
def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
item = ParsedNewsItem(
|
||||
id="google-apac:test",
|
||||
title="Example APAC story",
|
||||
summary="Example summary",
|
||||
url="https://example.com/story",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / APAC",
|
||||
feed_region="asia-pacific",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 4, 23, 2, 30, tzinfo=UTC),
|
||||
)
|
||||
|
||||
payload = _serialize_item(item, active_region="asia-pacific")
|
||||
|
||||
assert payload["latitude"] == 1.3521
|
||||
assert payload["longitude"] == 103.8198
|
||||
assert payload["location_label"] == "亚太"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["is_focus_match"] is True
|
||||
assert payload["published_at"] == "2026-04-23T02:30:00Z"
|
||||
|
||||
|
||||
def test_serialize_item_falls_back_to_global_anchor():
|
||||
item = ParsedNewsItem(
|
||||
id="custom:test",
|
||||
title="Fallback story",
|
||||
summary="Fallback summary",
|
||||
url="https://example.com/fallback",
|
||||
source="Fallback Source",
|
||||
feed_name="Fallback Feed",
|
||||
feed_region="unknown-region",
|
||||
homepage_url="https://example.com",
|
||||
published_at=None,
|
||||
)
|
||||
|
||||
payload = _serialize_item(item, active_region="americas")
|
||||
|
||||
assert payload["latitude"] == 20.0
|
||||
assert payload["longitude"] == 0.0
|
||||
assert payload["location_label"] == "全球"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["is_focus_match"] is False
|
||||
assert payload["published_at"] is None
|
||||
78
backend/tests/test_logging.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from app.core.logging import PlanetContextFilter, PlanetFormatter, get_logger
|
||||
from app.core.request_context import set_request_id
|
||||
|
||||
|
||||
def _capture_output(callback):
|
||||
stream = StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
handler.addFilter(PlanetContextFilter())
|
||||
|
||||
adapter = get_logger("tests.logging")
|
||||
target_logger = adapter.logger
|
||||
original_handlers = list(target_logger.handlers)
|
||||
original_level = target_logger.level
|
||||
original_propagate = target_logger.propagate
|
||||
|
||||
target_logger.handlers = [handler]
|
||||
target_logger.setLevel(logging.INFO)
|
||||
target_logger.propagate = False
|
||||
|
||||
try:
|
||||
callback(adapter)
|
||||
finally:
|
||||
handler.flush()
|
||||
target_logger.handlers = original_handlers
|
||||
target_logger.setLevel(original_level)
|
||||
target_logger.propagate = original_propagate
|
||||
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def test_structured_logger_injects_request_id_and_event():
|
||||
set_request_id("req-test-123")
|
||||
try:
|
||||
output = _capture_output(
|
||||
lambda logger: logger.info_event(
|
||||
"collector started",
|
||||
event="collector.run.started",
|
||||
context={"collector_name": "bgp_news"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
set_request_id(None)
|
||||
|
||||
assert "request_id=req-test-123" in output
|
||||
assert "event=collector.run.started" in output
|
||||
assert "service=backend" in output
|
||||
assert '"collector_name": "bgp_news"' in output
|
||||
|
||||
|
||||
def test_structured_logger_redacts_sensitive_text_and_context():
|
||||
set_request_id("req-test-redact")
|
||||
try:
|
||||
output = _capture_output(
|
||||
lambda logger: logger.error_event(
|
||||
"Authorization: Bearer super-secret-token",
|
||||
event="auth.token.failed",
|
||||
context={
|
||||
"token": "plain-secret",
|
||||
"nested": {"password": "hunter2"},
|
||||
"safe": "visible",
|
||||
},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
set_request_id(None)
|
||||
|
||||
assert "super-secret-token" not in output
|
||||
assert "plain-secret" not in output
|
||||
assert "hunter2" not in output
|
||||
assert "[REDACTED]" in output
|
||||
assert '"safe": "visible"' in output
|
||||
217
backend/tests/test_system_logs.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services import system_logs
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, list[str]] = {}
|
||||
|
||||
def rpush(self, key: str, value: str) -> None:
|
||||
self.store.setdefault(key, []).append(value)
|
||||
|
||||
def ltrim(self, key: str, start: int, end: int) -> None:
|
||||
items = self.store.get(key, [])
|
||||
normalized_end = None if end == -1 else end + 1
|
||||
self.store[key] = items[start:normalized_end]
|
||||
|
||||
def expire(self, key: str, seconds: int) -> None:
|
||||
return None
|
||||
|
||||
def lrange(self, key: str, start: int, end: int) -> list[str]:
|
||||
items = self.store.get(key, [])
|
||||
normalized_end = None if end == -1 else end + 1
|
||||
return items[start:normalized_end]
|
||||
|
||||
def llen(self, key: str) -> int:
|
||||
return len(self.store.get(key, []))
|
||||
|
||||
|
||||
def test_read_log_snapshot_uses_structured_buffer_timestamp_level_and_search(monkeypatch):
|
||||
fake_redis = FakeRedis()
|
||||
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"earth-client": system_logs.LogSource(
|
||||
source_id="earth-client",
|
||||
name="Earth 浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:earth-client",
|
||||
description="Earth 浏览器端上报日志",
|
||||
category="client",
|
||||
buffer_key=system_logs.get_buffer_log_key("earth-client"),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
fake_redis.rpush(
|
||||
system_logs.get_buffer_log_key("earth-client"),
|
||||
json.dumps(
|
||||
{
|
||||
"timestamp": "2026-04-22T10:15:30Z",
|
||||
"level": "warning",
|
||||
"message": "news feed degraded",
|
||||
"context": {"module": "news", "detail": "timeout"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
fake_redis.rpush(
|
||||
system_logs.get_buffer_log_key("earth-client"),
|
||||
json.dumps(
|
||||
{
|
||||
"timestamp": "2026-04-23T06:01:00Z",
|
||||
"level": "error",
|
||||
"message": "landing points failed",
|
||||
"context": {"module": "layer-startup", "detail": "http 500"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
snapshot = system_logs.read_log_snapshot(
|
||||
"earth-client",
|
||||
50,
|
||||
levels="error,warning",
|
||||
start_date="2026-04-23",
|
||||
end_date="2026-04-23",
|
||||
search="landing",
|
||||
)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["selected_levels"] == ["error", "warning"]
|
||||
assert snapshot["search_query"] == "landing"
|
||||
assert snapshot["line_count"] == 1
|
||||
assert snapshot["lines"][0].startswith("2026-04-23 06:01:00 ERROR landing points failed")
|
||||
assert snapshot["daily_markers"] == [
|
||||
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
|
||||
]
|
||||
|
||||
|
||||
def test_read_log_snapshot_parses_file_timestamp_and_builds_markers(tmp_path: Path, monkeypatch):
|
||||
log_path = tmp_path / "backend.log"
|
||||
log_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"2026-04-22 08:00:00 INFO service booted",
|
||||
"2026-04-23 09:15:00 WARNING disk pressure detected",
|
||||
"2026-04-23 09:16:00 ERROR sync failed",
|
||||
"2026-04-24 10:00:00 DEBUG collector trace",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"backend": system_logs.LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location=str(log_path),
|
||||
description="测试文件日志",
|
||||
category="service",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = system_logs.read_log_snapshot(
|
||||
"backend",
|
||||
50,
|
||||
levels="warning,error",
|
||||
search="failed",
|
||||
)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["line_count"] == 1
|
||||
assert snapshot["lines"] == ["2026-04-23 09:16:00 ERROR sync failed"]
|
||||
assert snapshot["daily_markers"] == [
|
||||
{"date_token": "2026-04-23", "total": 1, "dominant_level": "error"}
|
||||
]
|
||||
assert snapshot["status"] == "ok"
|
||||
|
||||
|
||||
def test_append_buffer_log_persists_normalized_level(monkeypatch):
|
||||
fake_redis = FakeRedis()
|
||||
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||
|
||||
system_logs.append_buffer_log(
|
||||
"earth-client",
|
||||
level="warn",
|
||||
message="feed delayed",
|
||||
context={"module": "news"},
|
||||
)
|
||||
|
||||
stored_items = fake_redis.lrange(system_logs.get_buffer_log_key("earth-client"), 0, -1)
|
||||
payload = json.loads(stored_items[0])
|
||||
assert payload["level"] == "warning"
|
||||
assert payload["message"] == "feed delayed"
|
||||
|
||||
|
||||
def test_infer_log_level_prefers_leading_prefix_over_query_string():
|
||||
line = 'INFO: 127.0.0.1 - "GET /api/v1/system/logs/backend?limit=200&level=error&levels=error HTTP/1.1" 200 OK'
|
||||
|
||||
entry = system_logs.parse_text_log_entry(line)
|
||||
|
||||
assert entry.level == "info"
|
||||
|
||||
|
||||
def test_parse_text_log_entry_does_not_promote_exception_context_to_error():
|
||||
line = "websockets.exceptions.ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout"
|
||||
|
||||
entry = system_logs.parse_text_log_entry(line)
|
||||
|
||||
assert entry.level is None
|
||||
|
||||
|
||||
def test_parse_text_log_entry_still_detects_explicit_error_prefix():
|
||||
line = "ERROR: [Errno 98] Address already in use"
|
||||
|
||||
entry = system_logs.parse_text_log_entry(line)
|
||||
|
||||
assert entry.level == "error"
|
||||
|
||||
|
||||
def test_read_log_snapshot_strips_nul_bytes_from_file_lines(tmp_path: Path, monkeypatch):
|
||||
log_path = tmp_path / "backend.log"
|
||||
log_path.write_bytes(
|
||||
(
|
||||
b"INFO: service booted\n"
|
||||
b"ERROR: bind failed\n"
|
||||
+ b"\x00" * 32
|
||||
+ b"2026-04-23 23:41:32 INFO service=backend message=request served\n"
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"backend": system_logs.LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location=str(log_path),
|
||||
description="测试文件日志",
|
||||
category="service",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
snapshot = system_logs.read_log_snapshot("backend", 50)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot["line_count"] == 3
|
||||
assert snapshot["lines"] == [
|
||||
"INFO: service booted",
|
||||
"ERROR: bind failed",
|
||||
"2026-04-23 23:41:32 INFO service=backend message=request served",
|
||||
]
|
||||
347
backend/tests/test_visualization_compute_centers.py
Normal file
@@ -0,0 +1,347 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
|
||||
def _build_record(
|
||||
*,
|
||||
record_id: int,
|
||||
source: str,
|
||||
data_type: str,
|
||||
name: str,
|
||||
country: str,
|
||||
city: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
metadata: dict,
|
||||
):
|
||||
return CollectedData(
|
||||
id=record_id,
|
||||
source=source,
|
||||
data_type=data_type,
|
||||
source_id=f"{source}-{record_id}",
|
||||
name=name,
|
||||
extra_data={
|
||||
"country": country,
|
||||
"city": city,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
**metadata,
|
||||
},
|
||||
collected_at=datetime(2026, 4, 22, tzinfo=timezone.utc),
|
||||
reference_date=datetime(2026, 4, 21, tzinfo=timezone.utc),
|
||||
is_current=True,
|
||||
)
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_unifies_sources():
|
||||
top500_record = _build_record(
|
||||
record_id=1,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={
|
||||
"rank": 1,
|
||||
"manufacturer": "HPE",
|
||||
"organization": "ORNL",
|
||||
"rmax": 1102000.0,
|
||||
"cores": 8730112,
|
||||
"power": 21510.0,
|
||||
},
|
||||
)
|
||||
gpu_record = _build_record(
|
||||
record_id=2,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Colossus",
|
||||
country="United States",
|
||||
city="Memphis",
|
||||
latitude=35.15,
|
||||
longitude=-90.05,
|
||||
metadata={
|
||||
"organization": "xAI",
|
||||
"gpu_type": "H100",
|
||||
"gpu_count": 100000,
|
||||
"value": "20000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([top500_record, gpu_record])
|
||||
|
||||
assert payload["type"] == "FeatureCollection"
|
||||
assert len(payload["features"]) == 2
|
||||
|
||||
supercomputer_feature = payload["features"][0]
|
||||
assert supercomputer_feature["properties"]["site_type"] == "supercomputer"
|
||||
assert supercomputer_feature["properties"]["capacity_unit"] == "GFlops"
|
||||
assert supercomputer_feature["properties"]["capacity_band"] == "exascale"
|
||||
assert supercomputer_feature["properties"]["operator"] == "ORNL"
|
||||
assert supercomputer_feature["properties"]["location_precision"] == "precise"
|
||||
assert supercomputer_feature["properties"]["is_estimated"] is False
|
||||
|
||||
gpu_feature = payload["features"][1]
|
||||
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
|
||||
assert gpu_feature["properties"]["vendor"] == "H100"
|
||||
assert gpu_feature["properties"]["gpu_count"] == 100000
|
||||
assert gpu_feature["properties"]["capacity_band"] == "large"
|
||||
assert gpu_feature["properties"]["location_precision"] == "precise"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
||||
hinted_record = _build_record(
|
||||
record_id=3,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Oak Ridge National Laboratory",
|
||||
"rmax": 1102000.0,
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([hinted_record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||
assert coords[0] == pytest.approx(-84.3107)
|
||||
assert coords[1] == pytest.approx(35.9319)
|
||||
assert payload["features"][0]["properties"]["is_estimated"] is True
|
||||
assert payload["features"][0]["properties"]["location_precision"] == "estimated_site"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
|
||||
centroid_record = _build_record(
|
||||
record_id=4,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Unknown Cluster",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Unknown Operator",
|
||||
"value": "10000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([centroid_record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
props = payload["features"][0]["properties"]
|
||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||
assert coords[0] == pytest.approx(-98.5795)
|
||||
assert coords[1] == pytest.approx(39.8283)
|
||||
assert props["is_estimated"] is True
|
||||
assert props["location_precision"] == "estimated_country"
|
||||
assert props["geography_mode"] == "country_centroid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_centers_geojson_endpoint_returns_stats():
|
||||
records = [
|
||||
_build_record(
|
||||
record_id=1,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={"rank": 1, "rmax": 1102000.0},
|
||||
),
|
||||
_build_record(
|
||||
record_id=2,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Colossus",
|
||||
country="United States",
|
||||
city="Memphis",
|
||||
latitude=35.15,
|
||||
longitude=-90.05,
|
||||
metadata={"value": "20000", "unit": "TFlop/s"},
|
||||
),
|
||||
]
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _ScalarResult(records)
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/compute-centers")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 2
|
||||
assert data["stats"]["supercomputers"] == 1
|
||||
assert data["stats"]["gpu_clusters"] == 1
|
||||
assert data["features"][0]["properties"]["data_type"] == "compute_center"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
records = [
|
||||
_build_record(
|
||||
record_id=1,
|
||||
source="arcgis_cables",
|
||||
data_type="submarine_cable",
|
||||
name="Test Cable",
|
||||
country="",
|
||||
city="",
|
||||
latitude=0,
|
||||
longitude=0,
|
||||
metadata={
|
||||
"route_coordinates": [[[0, 0], [1, 1]]],
|
||||
"status": "active",
|
||||
},
|
||||
),
|
||||
_build_record(
|
||||
record_id=2,
|
||||
source="arcgis_landing_points",
|
||||
data_type="landing_point",
|
||||
name="Test Landing",
|
||||
country="United States",
|
||||
city="New York",
|
||||
latitude=40.7,
|
||||
longitude=-74.0,
|
||||
metadata={"city_id": 10},
|
||||
),
|
||||
_build_record(
|
||||
record_id=3,
|
||||
source="celestrak_tle",
|
||||
data_type="satellite_tle",
|
||||
name="TESTSAT",
|
||||
country="",
|
||||
city="",
|
||||
latitude=0,
|
||||
longitude=0,
|
||||
metadata={
|
||||
"norad_cat_id": 12345,
|
||||
"tle_line1": "1 12345U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 9991",
|
||||
"tle_line2": "2 12345 51.6000 100.0000 0001000 10.0000 20.0000 15.50000000 01",
|
||||
},
|
||||
),
|
||||
_build_record(
|
||||
record_id=4,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={"rank": 1, "rmax": 1102000.0},
|
||||
),
|
||||
_build_record(
|
||||
record_id=5,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Colossus",
|
||||
country="United States",
|
||||
city="Memphis",
|
||||
latitude=35.15,
|
||||
longitude=-90.05,
|
||||
metadata={"value": "20000", "unit": "TFlop/s"},
|
||||
),
|
||||
]
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows=None, scalar_value=None):
|
||||
self._rows = rows or []
|
||||
self._scalar_value = scalar_value
|
||||
|
||||
def scalar(self):
|
||||
return self._scalar_value
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, query):
|
||||
query_text = str(query)
|
||||
if "bgp_incidents" in query_text:
|
||||
return _ScalarResult(scalar_value=2)
|
||||
if "bgp_anomalies" in query_text:
|
||||
return _ScalarResult(scalar_value=3)
|
||||
return _ScalarResult(rows=records)
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
async def _fake_build_bgp_collector_coverage(*_args, **_kwargs):
|
||||
return [
|
||||
{"collector": "rrc00"},
|
||||
{"collector": "rrc01"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.v1.visualization.build_bgp_collector_coverage",
|
||||
_fake_build_bgp_collector_coverage,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/summary")
|
||||
|
||||
assert response.status_code == 200
|
||||
stats = response.json()["stats"]
|
||||
assert stats["cable_count"] == 1
|
||||
assert stats["landing_point_count"] == 1
|
||||
assert stats["satellite_count"] == 1
|
||||
assert stats["compute_center_count"] == 2
|
||||
assert stats["supercomputer_count"] == 1
|
||||
assert stats["gpu_cluster_count"] == 1
|
||||
assert stats["bgp_event_count"] == 2
|
||||
assert stats["bgp_incident_count"] == 2
|
||||
assert stats["bgp_anomaly_count"] == 3
|
||||
assert stats["bgp_collector_count"] == 2
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -8,7 +8,322 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
## [0.41.1] — 2026-04-27
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复新闻直播面板设置项持久化失效:`closeTransientMobileOverlays` 通过旁路路径隐藏面板导致下次 persist 快照到错误状态,改为不重新从 DOM 读取面板可见性
|
||||
- 修复登陆点 pin 在地球侧面被半截遮挡:改为在接近地平线前(dot < 0.05)主动隐藏,避免深度测试切片
|
||||
|
||||
### 🔧 Improvements
|
||||
- 将所有画布绘制的图标抽取为 SVG,存入 `frontend/public/earth/assets/icons/`,新增图标规范到 `rules.md`
|
||||
|
||||
---
|
||||
|
||||
## [0.41.0] — 2026-04-27
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统完成地表到天空的注册顺序与关注优先的面板顺序拆分,支持基座海陆色块、国界、高清材质、云图、地形、算力、BGP、卫星、轨迹与海缆的稳定层级
|
||||
- 国界层新增真实行政区轮廓交互与中国/台湾联动高亮,修复高清材质、地形、footprint、卫星与经纬线之间的遮挡和 hover 竞争
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增无轮廓基座地图,所有图层关闭时仍保留 `#010609` 海洋与 `#080f1b` 陆地色块
|
||||
- 将大气云图抽象为独立图层并接入桌面/移动端图层开关、持久化状态与启动同步
|
||||
- 高清材质改为独立纹理覆盖层,地形显示在高清材质上方,并在高清材质关闭/恢复时保持原地形开关意图
|
||||
- 补充 Earth 渲染层级与图层样式文档,记录正式图层名、变量名、材质颜色、线宽与 renderOrder
|
||||
|
||||
---
|
||||
|
||||
## [0.40.5] — 2026-04-26
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星拖尾改用 Instanced screen-space ribbon,单 draw call 渲染所有轨迹段,支持像素级宽度控制
|
||||
- Iridium 地面覆盖重写为球面投影径向网格,修复填充光晕不可见问题;新增外圈 LineLoop
|
||||
- 搜索面板打开时改用双 rAF 延迟聚焦输入框,确保 CSS 过渡完成后焦点可靠触发
|
||||
- 代码清理:提取 `IRIDIUM_OVERLAY_COLOR`、`IRIDIUM_REFERENCE_ALTITUDE_KM` 常量,消除重复三角函数调用
|
||||
|
||||
---
|
||||
|
||||
## [0.39.0] — 2026-04-24
|
||||
|
||||
## [0.40.4] — 2026-04-26
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增页面可见性恢复处理,页面从后台切回前台时主动刷新卫星位置,避免累积后台时间在下一帧一次性回放
|
||||
- 抽出卫星轨迹状态与轨迹几何清理 helper,统一后台恢复与清空数据时的轨迹重置路径
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复页面在后台停留较久后恢复前台时,卫星轨迹因超大 `deltaTime` 突然跳变、拖尾异常拉长的问题
|
||||
- 修复后台恢复后首帧仍沿用旧轨迹缓存,导致轨迹与当前卫星位置短时错位的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.40.3] — 2026-04-25
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星点云升级为自定义 ShaderMaterial,支持 per-point alpha 控制,锁定/悬停卫星从点云中精确隐藏
|
||||
- 修复锁定环与自发光选中标记的 depthTest 错误(false → true),消除远端渲染穿透 artifact
|
||||
- 新增锁定环悬停态缩放与线宽(LOCKED_RING_HOVER_SCALE / LOCKED_RING_HOVER_LINE_WIDTH)
|
||||
- 修复 updateLockedDotWorldTransform / updateLockedHaloWorldTransform 未强制刷新 matrixWorld 导致的位置漂移
|
||||
|
||||
---
|
||||
|
||||
## [0.40.2] — 2026-04-24
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星点大小随镜头缩放动态调整,拉近变大、拉远变小,响应与相机距离线性对应
|
||||
- 调小卫星点默认基础尺寸(dotSize 2.8),缩放范围更合理
|
||||
|
||||
---
|
||||
|
||||
## [0.40.1] — 2026-04-24
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星选中标记(lockedring / lockeddot / 光晕)颜色统一跟随图例轨道倾角分类配色
|
||||
- 修复 Starlink footprint 在特定视角下遮蔽卫星点的渲染顺序问题(Group renderOrder 影响子 Mesh 排序)
|
||||
- footprint 材质改为 `depthTest: false` + 相机朝向 limbFade,替代 polygonOffset 深度竞争方案
|
||||
- 修复选中海缆时误触发附近卫星高亮(该行为属于 BGP 事件点逻辑,不应用于海缆)
|
||||
|
||||
---
|
||||
|
||||
## [0.40.0] — 2026-04-24
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 卫星 footprint 正式按星座能力分层:Starlink 保留专用地表覆盖,Iridium 改为独立外圈覆盖表达,其它非 Starlink 星座不再误用同一套 footprint
|
||||
- Earth 卫星详情卡补齐覆盖能力与当前显示说明,用户现在可以直接看见每颗卫星为什么显示 footprint、为何回退为自身发光
|
||||
|
||||
### 🔧 Improvements
|
||||
- 后端可视化接口新增并透传 `constellation_group` 与 `footprint_policy`,前端据此执行 capability-gated footprint renderer
|
||||
- 新增 Iridium 独立 coverage ring adapter,并继续保留 Starlink 专用 footprint 调校与昼夜可读性增强
|
||||
- 新增 Earth 卫星 footprint 策略技术文档,明确 GNSS、generic LEO、GEO 与 Iridium 的显示边界
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复前后端对 Iridium footprint policy 命名不一致,导致策略分发语义含混的问题
|
||||
- 清理 Starlink footprint 渲染中的未使用常量与过时命名,减少后续继续调校时的歧义
|
||||
|
||||
---
|
||||
|
||||
## [0.39.0] — 2026-04-24
|
||||
|
||||
### ✨ Highlights
|
||||
- 后端正式落下统一结构化日志地基:请求上下文、事件名、脱敏与持久化链路开始收口为可扩展的企业级日志体系
|
||||
- 系统日志页重构为真正的日志工作台:顶部筛选更紧凑,终端日志区成为主视觉,移动端 Earth 新闻/态势细节交互继续补稳
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 `backend/app/core/logging.py`,统一 `request_id`、`service`、`event` 注入与敏感字段脱敏,并接入后端主入口、调度器、缓存、数据库和可视化链路
|
||||
- 系统日志页筛选区重排为更紧凑的两层结构,信息摘要并入终端工具栏 tooltip,日志终端区留出更稳定的按钮避让空间
|
||||
- Earth 移动端态势抽屉补齐宽度约束与图例换行规则,新闻详情抽屉在巡航切换时可同步更新标题和摘要
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 `/tmp/planet_backend.log` 中混入空字节时,日志摘要条行数与实际可见日志不一致的问题
|
||||
- 修复移动端“态势”tab 在内容渲染后被图例文本撑宽、超出一屏的问题
|
||||
- 修复移动端新闻详情抽屉在巡航切换下一条新闻时标题更新但 summary 不同步的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.38.0] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新闻正式接入通用巡航层:新闻和 BGP 统一进入可配置巡航模块,桌面端与移动端都能在巡航聚焦时展示对应新闻卡片
|
||||
- 系统日志页升级为结构化过滤链路:按真实时间戳、结构化级别和字符串检索统一筛选,不再依赖前端或后端从日志文本里猜结果
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新闻巡航补齐业务适配层:按发生地与时间生成巡航目标,桌面端与移动端统一标题 + summary 卡片风格,并增加连线与打字机摘要展示
|
||||
- 日志页筛选体验重排,统一服务源、级别、行数、时间和检索布局,日历标记改为由后端返回的结构化每日聚合结果驱动
|
||||
- 后端补充 `system_logs` 结构化解析与多级别精确过滤能力,Earth 浏览器端日志缓冲与系统日志 API 现在走同一套筛选语义
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复新闻巡航模块开启后难以关闭、桌面/移动端设置状态互相污染的问题
|
||||
- 修复新闻巡航卡片缺少摘要、移动端详情样式不统一、新闻巡航缺少连线的问题
|
||||
- 修复日志级别筛选会被访问日志 query string 中的 `level=error` 等参数污染,从而把 `INFO` 行误判为 `ERROR` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.37.2] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
|
||||
|
||||
### 🔧 Improvements
|
||||
- 经纬线正式接入 Earth layer registry,复用现有图层切换、移动端图层卡片与设置持久化流
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.37.1] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
|
||||
|
||||
### 🔧 Improvements
|
||||
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.37.0] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 连线系统正式从巡航里解耦成通用 callout connector:桌面端和移动端统一支持对象级锚点、四边切换与临界区边缘滑动
|
||||
- BGP 巡航展示继续收口为稳定的“先定位卡片、再连真实锚点、再展示卡片”链路,移动端 popup 与桌面 info panel 的路线规则统一
|
||||
|
||||
### 🔧 Improvements
|
||||
- connector 配置从 `CRUISE_CONFIG` 拆到独立 `CONNECTOR_CONFIG`,默认类名、动画名和实例命名也全部去 cruise 语义
|
||||
- 移动端 popup 增加更稳定的 dock/obstacle 处理,拖动卡片时连线起终点会持续按几何关系自适应刷新
|
||||
- Earth 多个图层与控制逻辑继续收口,补充算力中心/BGP 风格对齐、layer panel 与相关交互细节调整
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复巡航模式下终点只像“视觉锚点”而不是真实绑定对象的问题,卡片拖动后终点现在会跟随
|
||||
- 修复移动端与桌面端多类连线路线异常:压线、反向、临界区折返、起点遮挡事件点等问题
|
||||
- 修复对象矩形临界区内连线仍强制中点到中点导致路线像“先钻进 source 内部”再出去的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.36.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新增统一“算力中心”图层:接入超算与 GPU 集群,支持搜索、统计、图例、详情卡与独立图层开关
|
||||
- 算力中心支持精确位置与估算位置两种状态,估算点会以问号角标区分,避免数据不全时整批节点在地图上消失
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth 详情卡拖拽与地球拖拽交互继续收口,减少拖动卡片和旋转地球时的选中文本与 pointer 竞争
|
||||
- `planet.sh` 改为通过独立脚本计算 AI Provider 依赖指纹,降低与根仓库依赖版本文件的无关耦合
|
||||
- README 补充 WSL / Windows 局域网访问排查与转发配置说明,便于开发环境联调
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Earth 算力中心图层在无原始坐标时无法显示的问题,支持站点提示和国家级估算回退
|
||||
- 修复信息卡拖拽事件可能被卡片级 stopPropagation 吞掉,导致拖拽流中断的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 统计展示改为统一 `data-earth-stat` 绑定机制,桌面 HUD 和移动端抽屉复用同一套状态更新入口
|
||||
|
||||
### 🔧 Improvements
|
||||
- 收口海缆、登陆点、卫星、BGP 事件与 BGP 状态的统计写入逻辑,减少后续继续补桌面/移动双写分支的成本
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复移动端态势抽屉中的海缆、登陆点与 BGP 统计在图层切换后可能停留旧值的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 移动端底部抽屉系统全面上线:响应式布局自动切换、Tab 导航、手势上拉/下滑开合、惯性速度判定
|
||||
- 移动端点击可交互物件(海缆、登陆点、卫星、BGP)后弹出智能定位悬浮卡片,可拖动,点击跳转详情
|
||||
|
||||
### 🔧 Improvements
|
||||
- 抽屉把手区域缩小至 36px(collapsed 时仅露出把手,不遮挡地球操作区)
|
||||
- 抽屉定期弹跳动画提示用户可上拉,5 秒间隔,打开后自动停止
|
||||
- 通知胶囊位置调整,不再覆盖品牌 logo
|
||||
- 移动端单指旋转、双指捏合缩放地球,触控事件冲突修复(pointer-events 级联)
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复移动端抽屉 shell 因 layout 高度(240px+)遮挡地球触控区域,pointer-events 改为按层级精确控制
|
||||
- 修复悬浮卡片因 setPointerCapture 在 iOS Safari 抑制合成 click 事件导致无法点击的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.34.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 搜索面板正式接入,支持搜索海缆、登陆点、卫星、BGP 事件与观测站,并可直接聚焦到对应对象
|
||||
- `planet.sh --allow-lan` 打通 Bun + Vite 的局域网开放链路,启动成功后自动打印推荐访问地址与后端健康检查地址
|
||||
|
||||
### 🔧 Improvements
|
||||
- 前端开发启动链统一改成 Bun 直接执行 Vite 入口,不再依赖 shell 中额外暴露的 Node 路径
|
||||
- Earth 搜索结果接入登陆点详情卡片与对象聚焦,搜索后可直接进入对应详情流
|
||||
- `planet.sh` 补充局域网 IPv4 自动识别与推荐地址输出,减少 WSL 局域网调试成本
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 `./planet.sh restart --allow-lan` 全量重启时未把 `--allow-lan` 继续传给 `start()`,导致前端退回本机监听的问题
|
||||
- 修复 WSL + Bun 环境下前端偶发因 Vite 启动链不稳定而无法正确监听 `0.0.0.0:3000` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.33.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- `news_live_streams` 采集器默认接入 `iptv-org` 频道目录,并将采集结果稳定并入 Earth TV 直播源列表
|
||||
- 数据源页支持直接编辑内置数据源 override,并为内置源提供一键恢复默认配置入口
|
||||
|
||||
### 🔧 Improvements
|
||||
- `News Live Streams` 现在作为可直接触发的内置默认数据源提供,无需先手工补 override 才能采集
|
||||
- TV 播放源菜单会直接区分 `[内置]` 和 `[采集]` 来源,频道来源信息也会同步展示
|
||||
- 新增 [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md),正式规划 Earth 态势新闻源配置化与后续采集器化路线
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 `news_live_streams` 采集完成后 `/api/v1/tv/streams` 因读取不存在的 `updated_at` 字段而导致默认频道全部消失的问题
|
||||
- 修复内置数据源操作列按钮显示不全,以及编辑抽屉中多个 `Collapse` 紧贴的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.32.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 设置新增“地球默认大小”持久化项,重置视角、缩放百分比重置和 BGP 巡航视图现在统一复用这一份默认 zoom
|
||||
- 卫星焦点层次继续收口:巡航进入 presentation 前不再过早 dim,非焦点卫星改成“降亮度/尾迹/背板”而不是去饱和度
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth 设置面板区块和左右留白进一步收紧,整体更贴近 HUD 面板的密度
|
||||
- toolbar 展开边界缓存改为按需刷新,减少 document 级 mousemove 期间的重复布局读取
|
||||
- Scrollbar 和 ScrollbarOverlay 收窄 observer 范围,减少大表格和动态菜单下的额外刷新成本
|
||||
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md),补充默认视图大小已进入 Earth 设置持久化真源
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复开启巡航后,尚未进入连线/presentation 时卫星已经整体变暗的问题
|
||||
- 修复默认大小重置链路分散在多个入口、实际 reset/cruise/缩放提示不一致的问题
|
||||
- 修复开启地形后卫星反馈层与地球背面可见性之间的一组表现问题,保留正面反馈同时恢复背面轨道遮挡
|
||||
|
||||
---
|
||||
|
||||
## [0.31.3] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层注册表和启动任务框架继续收口,启动顺序、启动模式、启动提示和任务注册现在都能从统一入口扩展
|
||||
- 修复 Earth 普通旋转模式与巡航模式切换时的一组交互回归,同时让卫星/地形/昼夜模式的表现更稳定
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) 启动任务注册表,支持 `registerLayerStartupTask(id, taskFactory)`,并拆成海缆 / 卫星 / BGP 独立注册函数
|
||||
- Earth 图层控制改成注册表驱动,统一承载 `startupPriority`、`startupMode`、`startupLabel`、`startupMessage` 与图层持久化元信息
|
||||
- Earth 设置支持持久化图层开关、旋转模式、HUD 面板显示状态、地形透明度与日夜模式,并提供一键重置
|
||||
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 记录图层注册表、启动任务、设置持久化与巡航适配边界
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复普通旋转模式下点击海缆 / 卫星 / BGP 后卡片和选中表现会被异常清空的问题
|
||||
- 修复巡航模式切回旋转再切回巡航后无法继续自动巡航的问题
|
||||
- 修复开启地形后卫星选中反馈层被高海拔区域吞掉的问题,并恢复轨道只在地球前半侧可见
|
||||
- 修复关闭日夜模式后地球照明仍沿真实昼夜切换、亮部过曝和偏色的问题,改成更中性的 inspection lighting
|
||||
- 修复 toolbar 收起态仍挡住地球交互,以及首帧短暂展开闪现的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.31.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机
|
||||
- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复
|
||||
- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画
|
||||
- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配
|
||||
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题
|
||||
- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题
|
||||
- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点
|
||||
|
||||
---
|
||||
|
||||
## [0.31.1] — 2026-04-21
|
||||
|
||||
@@ -27,6 +342,8 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
||||
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
||||
@@ -40,8 +357,6 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
## [0.30.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
|
||||
当前重点入口:
|
||||
|
||||
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||
- [earth-country-boundary-overlay-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-country-boundary-overlay-plan.md)
|
||||
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
|
||||
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
|
||||
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
|
||||
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
||||
|
||||
|
||||
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# Earth Compute Center BGP-Style Plan
|
||||
|
||||
## Goal
|
||||
|
||||
这份文档定义如何按照 BGP 模块的产品方式,把“算力中心”提升为 Earth 上的一级能力。
|
||||
|
||||
这里的“按 BGP 方式”指的是:
|
||||
|
||||
- 有独立的数据语义和接口入口
|
||||
- 有独立的 Earth 图层与图例
|
||||
- 有独立的 hover / click / 选中态 / 详情卡
|
||||
- 有独立的统计口径与后续专题页扩展空间
|
||||
|
||||
这里的“按 BGP 方式”不指:
|
||||
|
||||
- 机械复制 BGP 的 anomaly / incident / collector 三层事件模型
|
||||
- 为静态算力设施强行引入不必要的复杂告警语义
|
||||
|
||||
算力中心本质上更接近“长期基础设施分布层”,不是“高频动态异常层”。
|
||||
因此应该复用 BGP 的模块化方法,而不是照搬 BGP 的事件结构。
|
||||
|
||||
## Why
|
||||
|
||||
当前仓库里已经有算力相关基础:
|
||||
|
||||
- 后端已有 `top500` 和 `epoch_ai_gpu` 数据采集
|
||||
- 可视化接口已有 `/api/v1/visualization/geo/supercomputers` 和 `/api/v1/visualization/geo/gpu-clusters`
|
||||
- Earth 信息卡已对 `supercomputer` 和 `gpu_cluster` 做了基础类型兼容
|
||||
|
||||
但当前能力还停留在“数据可取到”的阶段,没有形成像 BGP 那样完整的可视化模块:
|
||||
|
||||
- Earth 缺少独立的算力图层加载模块
|
||||
- 缺少算力 marker 体系和视觉层级
|
||||
- 缺少算力图例、统计、开关和搜索接入
|
||||
- 缺少与海缆、BGP、卫星的关系表达
|
||||
- 缺少算力专题页和后续告警/研判扩展入口
|
||||
|
||||
所以当前真正的缺口不是“有没有数据”,而是“有没有产品级模块”。
|
||||
|
||||
## Core Principle
|
||||
|
||||
算力中心应当采用和 BGP 一致的模块化分层:
|
||||
|
||||
1. 数据层:稳定的数据契约和 GeoJSON 输出
|
||||
2. 渲染层:独立的 Earth 图层、marker 和视觉状态管理
|
||||
3. 交互层:hover、click、锁定态、详情卡、图例和统计
|
||||
4. 扩展层:后续专题页、关系分析、告警和 AI 研判
|
||||
|
||||
但语义上必须保持算力中心自身的特点:
|
||||
|
||||
- `site / center` 是主对象,不是事件
|
||||
- `capacity / rank / vendor / operator / status` 是主信息,不是异常严重度
|
||||
- `distribution / concentration / dependency` 是后续分析方向,不是第一阶段必须项
|
||||
|
||||
## Recommended Scope
|
||||
|
||||
第一版“算力中心”建议统一承载两类对象:
|
||||
|
||||
- `supercomputer`
|
||||
- `gpu_cluster`
|
||||
|
||||
并在 Earth 上收口为一个主题层:`compute_centers`
|
||||
|
||||
这样做有几个好处:
|
||||
|
||||
- 用户看到的是统一的“算力基础设施”语义,而不是零散数据源
|
||||
- 后端仍可保留 `top500` 和 `epoch_ai_gpu` 的来源差异
|
||||
- 前端可以在一个图层里再细分两种 marker 语言
|
||||
|
||||
## Current Gap
|
||||
|
||||
和 BGP 对比,当前差距主要在下面几层。
|
||||
|
||||
### 1. Data Contract Gap
|
||||
|
||||
现在的算力 GeoJSON 还是通用 `collected_data` 输出思路,字段较轻:
|
||||
|
||||
- `gpu_cluster` 只有基础名称和地点
|
||||
- `supercomputer` 只暴露一部分性能字段
|
||||
- 缺少统一的 `site_type / operator / capacity_band / source / updated_at / confidence`
|
||||
- 缺少统一的算力层聚合出口
|
||||
|
||||
### 2. Earth Rendering Gap
|
||||
|
||||
当前 Earth 里没有类似 `bgp.js` 的算力模块:
|
||||
|
||||
- `constants.js` 没有算力 API 路径和视觉配置
|
||||
- `main.js` 没有算力加载、拾取、状态同步和 HUD 更新
|
||||
- `controls.js` 没有算力图层开关和启动加载优先级
|
||||
- `layer-startup-tasks.js` 没有算力启动任务
|
||||
- `legend.js` / `ui.js` 没有算力统计与图例模式
|
||||
|
||||
### 3. Interaction Gap
|
||||
|
||||
虽然 `info-card.js` 支持基础字段,但还没有形成 BGP 那种完整交互链路:
|
||||
|
||||
- 没有 hover / selected / dimmed 的视觉状态
|
||||
- 没有算力对象专属 tooltip 与摘要文案
|
||||
- 没有锁定后与其他基础设施的联动高亮
|
||||
- 没有搜索、统计卡和详情组织方式
|
||||
|
||||
### 4. Product Expansion Gap
|
||||
|
||||
当前还没有“算力中心”专题页与分析语义:
|
||||
|
||||
- 没有全球分布/国家聚合/厂商聚合视图
|
||||
- 没有算力与海缆/BGP/区域的关系表达
|
||||
- 没有 AI brief / assessment 的后续落点
|
||||
|
||||
## Architecture Direction
|
||||
|
||||
推荐把算力中心做成“BGP 同级能力”,但采用更适合静态基础设施的结构。
|
||||
|
||||
### Backend
|
||||
|
||||
建议新增统一聚合接口,例如:
|
||||
|
||||
- `/api/v1/visualization/geo/compute-centers`
|
||||
|
||||
它的职责是把:
|
||||
|
||||
- `top500`
|
||||
- `epoch_ai_gpu`
|
||||
|
||||
统一转换成一个主题层输出,同时保留对象细分类型:
|
||||
|
||||
- `site_type: supercomputer | gpu_cluster`
|
||||
|
||||
建议统一字段至少包括:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `site_type`
|
||||
- `country`
|
||||
- `city`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `operator`
|
||||
- `vendor`
|
||||
- `capacity_value`
|
||||
- `capacity_unit`
|
||||
- `capacity_band`
|
||||
- `rank`
|
||||
- `source`
|
||||
- `updated_at`
|
||||
- `location_precision`
|
||||
- `geography_mode`
|
||||
- `is_estimated`
|
||||
- `estimated_reason`
|
||||
- `metadata`
|
||||
|
||||
这里建议优先做“统一聚合出口”,而不是一开始就新增独立数据库表。
|
||||
|
||||
原因:
|
||||
|
||||
- 当前源数据更新频率低,先复用 `collected_data` 成本更低
|
||||
- 可以先把 Earth 产品体验做完整
|
||||
- 如果后续要做历史趋势、关系推断、告警,再评估是否拆成独立模型
|
||||
|
||||
### Frontend Earth
|
||||
|
||||
建议新增独立模块,例如:
|
||||
|
||||
- `frontend/public/earth/js/compute-centers.js`
|
||||
|
||||
职责参照 `bgp.js`:
|
||||
|
||||
- 拉取算力中心 GeoJSON
|
||||
- 创建 marker
|
||||
- 管理 hover / selected / dimmed 状态
|
||||
- 输出图例项
|
||||
- 输出统计摘要
|
||||
- 提供 overlay 和详情格式化辅助函数
|
||||
|
||||
推荐视觉分层:
|
||||
|
||||
1. `supercomputer` 用更稳定、更规整的设施型符号
|
||||
2. `gpu_cluster` 用更活跃、更现代的密度型符号
|
||||
3. 选中态通过 halo / ring / related infrastructure highlight 表达
|
||||
|
||||
视觉上应避免把算力中心做成“BGP 事件点”那种高频脉冲风格。
|
||||
它应该更像长期存在的高价值设施。
|
||||
|
||||
## Phases
|
||||
|
||||
## Phase 1: Unified Earth Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 先把算力中心做成 Earth 上可用、可点、可解释的一级图层
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增统一算力 GeoJSON 接口
|
||||
- 新增 `compute-centers.js`
|
||||
- 在 `constants.js` 增加 API 路径和视觉配置
|
||||
- 在 `controls.js` 增加算力图层开关与启动元数据
|
||||
- 在 `layer-startup-tasks.js` 增加算力启动加载任务
|
||||
- 在 `main.js` 接入算力拾取、hover、click、锁定态和 HUD 统计
|
||||
- 在 `ui.js` / `legend.js` / `index.html` 增加算力统计与图例入口
|
||||
- 在 `info-card.js` 提升算力详情字段组织
|
||||
- 对无法精确定位、但可按国家或弱线索推测的大概位置,仍然生成地图点位
|
||||
- 这类对象必须带显式“估算位置”状态,例如图标问号角标与详情说明
|
||||
|
||||
完成标准:
|
||||
|
||||
- Earth 上能独立显示/隐藏算力中心
|
||||
- 两类对象有可区分的视觉表达
|
||||
- hover / click / 详情卡 / 图例 / 统计全部打通
|
||||
- 精确位置与估算位置在图标或文案上可区分,不会误导为同一精度
|
||||
- 不干扰现有海缆、卫星、BGP 的交互链路
|
||||
|
||||
## Phase 2: Relationship Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 让算力中心不只是“点”,而是和其他基础设施产生上下文关系
|
||||
|
||||
工作项:
|
||||
|
||||
- 建立算力中心与国家/区域聚合摘要
|
||||
- 增加与附近海缆登陆点的关系提示
|
||||
- 增加与 BGP 事件/观测范围的空间邻近提示
|
||||
- 增加与卫星覆盖或区域连通性的实验性提示
|
||||
|
||||
完成标准:
|
||||
|
||||
- 点击算力中心时,用户能看到“它和哪些基础设施相关”
|
||||
- 信息表达以辅助判断为主,不做夸张推断
|
||||
|
||||
## Phase 3: Compute Center Observatory
|
||||
|
||||
目标:
|
||||
|
||||
- 把算力中心从 Earth 图层扩展成独立专题观测能力
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增算力中心专题页
|
||||
- 提供国家/厂商/类型/容量分布统计
|
||||
- 支持列表、筛选、详情和历史快照
|
||||
- 预留 AI brief / assessment 入口
|
||||
|
||||
完成标准:
|
||||
|
||||
- 算力中心不再只是 Earth 上的视觉点位
|
||||
- 能作为独立业务上下文进入日常观察与研判
|
||||
|
||||
## Phase 4: Alerts And Assessment
|
||||
|
||||
目标:
|
||||
|
||||
- 在不滥造“假动态告警”的前提下,引入真正有价值的变化感知
|
||||
|
||||
候选方向:
|
||||
|
||||
- 新增大规模算力中心
|
||||
- 既有中心容量显著变化
|
||||
- 国家/区域集中度显著变化
|
||||
- 高价值中心与关键网络基础设施关系变化
|
||||
|
||||
完成标准:
|
||||
|
||||
- 告警来自可解释的结构变化
|
||||
- 不把静态数据硬做成噪声式实时事件流
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
建议按下面顺序推进:
|
||||
|
||||
1. 先统一 GeoJSON 契约
|
||||
2. 再做 Earth 独立模块和图层开关
|
||||
3. 再补详情卡、图例和统计
|
||||
4. 最后才做关系层和专题页
|
||||
|
||||
这样可以避免一开始把范围摊得过大。
|
||||
|
||||
## Unknown Location Strategy
|
||||
|
||||
由于部分算力数据源不会直接提供经纬度,未知位置补全不能只依赖“继续找 API 字段”。
|
||||
更稳妥的方式是做成一条分层富化链路,而不是单一猜测规则。
|
||||
|
||||
推荐按下面优先级推进:
|
||||
|
||||
1. 直接源信息
|
||||
|
||||
- 源记录显式给出 `latitude / longitude`
|
||||
- 源记录给出 `city / region / facility / campus / operator`
|
||||
- 源页面详情、内嵌 JSON、结构化元数据、新闻稿链接里能抽出地点线索
|
||||
|
||||
2. 名称与机构归一化
|
||||
|
||||
- 建立 `canonical_name / aliases / operator / facility` 归一化表
|
||||
- 把 `cluster name`、`operator`、`campus name` 归一到同一个实体
|
||||
- 优先解决同一对象多写法导致的命中失败,而不是先扩大猜测范围
|
||||
|
||||
3. 本地位置注册表
|
||||
|
||||
- 用仓库内可维护的 registry 保存高价值对象的位置知识
|
||||
- 每条记录至少包含:`canonical_name`、`aliases`、`operator`、`country`、`region`、`city`、`lat`、`lon`、`confidence`、`source_note`
|
||||
- 转换层优先读取 registry,避免地点知识长期散落在转换代码里
|
||||
|
||||
4. 分层回退定位
|
||||
|
||||
- `precise`
|
||||
- `estimated_site`
|
||||
- `estimated_city`
|
||||
- `estimated_region`
|
||||
- `estimated_national_hub`
|
||||
- `estimated_country`
|
||||
|
||||
这里建议把“国家内主要算力城市”作为国家质心之前的一层。
|
||||
例如没有美国精确位置时,优先考虑已知的主要算力/数据中心城市候选,而不是直接落在几何质心。
|
||||
|
||||
5. 候选证据富化
|
||||
|
||||
- 如果源 API 无地点信息,可以允许采集链路读取公开辅助证据
|
||||
- 例如机构官网、数据中心介绍页、新闻稿、百科型页面、公开 PDF
|
||||
- 但只提取“地点线索”,不把外部页面上的经纬度当真值直接写回
|
||||
|
||||
6. 人工校验闭环
|
||||
|
||||
- 对高价值且仍然未知的对象输出待核验清单
|
||||
- 把人工确认结果回写到位置注册表
|
||||
- 后续采集继续优先复用这层人工确认结果
|
||||
|
||||
### Additional Solution Paths
|
||||
|
||||
除了静态映射表,还可以考虑下面这些办法:
|
||||
|
||||
- 基于国家和运营方建立“主要园区候选集”,用稳定散列把同国未知节点分散到若干可信城市,而不是全部压到一个点
|
||||
- 基于数据中心/云厂商公开 region 列表建立 `operator -> city set` 候选映射,用于云 GPU 集群类对象
|
||||
- 把“估算依据”结构化,例如 `matched_alias`、`matched_operator`、`matched_city_text`、`fallback_country_hub`
|
||||
- 给位置补全增加 `last_verified_at`,便于后续按时间重新校验老旧映射
|
||||
- 单独维护“不可可靠定位”状态;这类对象仍可在国家级聚合统计中出现,但可以允许用户在地图上过滤掉
|
||||
- 后续如果你们愿意投入更多,可把这条链路做成小型 enrichment pipeline,而不是仅在 API 转换时临时判断
|
||||
|
||||
## Non-Goals
|
||||
|
||||
第一阶段不建议做这些内容:
|
||||
|
||||
- 不复制 BGP 巡航模式到算力中心
|
||||
- 不先做复杂实时 websocket 推送
|
||||
- 不先引入独立 `compute_center_incident` 一类模型
|
||||
- 不先做全量 AI 分析面板
|
||||
|
||||
原因是算力中心的第一需求是“被看清楚”,不是“被实时播报”。
|
||||
但“被看清楚”不等于“只显示精确坐标对象”。
|
||||
对于没有精确经纬度、但能推测到国家或区域级位置的算力中心,应优先以上图并标注估算状态的方式处理,而不是直接在地图上消失。
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- 后端存在统一的算力中心 GeoJSON 出口
|
||||
- Earth 有独立算力图层模块,而不是散落在 `main.js`
|
||||
- 页面上有清晰的算力开关、图例和统计
|
||||
- `supercomputer` 和 `gpu_cluster` 在视觉和详情上都可区分
|
||||
- 估算位置对象在地图和详情中都有明确状态提示
|
||||
- 现有 BGP / 海缆 / 卫星功能无回归
|
||||
- 代码结构上为后续专题页和关系分析留出了明确扩展点
|
||||
|
||||
## Summary
|
||||
|
||||
这项工作的本质不是“再多画几个点”。
|
||||
|
||||
它应该把算力中心从已有数据源,升级成与 BGP 同级的 Earth 观测主题:
|
||||
|
||||
- 有独立语义
|
||||
- 有独立图层
|
||||
- 有独立交互
|
||||
- 有后续分析扩展能力
|
||||
|
||||
推荐先完成 Phase 1,把算力中心做成真正可用的 Earth 一级模块,再继续推进关系层和专题页。
|
||||
400
docs/plans/earth-mobile-drawer-ui-plan.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Earth Mobile Drawer UI Plan
|
||||
|
||||
## 背景
|
||||
|
||||
当前 Earth 移动端已经补上了基础触控能力,例如:
|
||||
|
||||
- 单指拖拽旋转地球
|
||||
- 双指缩放
|
||||
- 点击阈值和基础事件隔离
|
||||
|
||||
但移动端 UI 仍然存在一个根本问题:
|
||||
|
||||
它还在沿用桌面 HUD 的内容切分方式,只是把原来的 panel、modal、toolbar 改位置、改层级、改容器。这样虽然能快速复用旧代码,但手机端体验仍然是生硬的,因为:
|
||||
|
||||
- 信息密度和结构是按桌面设计的
|
||||
- 面板标题、关闭、折叠、开关项是桌面心智,不是手机心智
|
||||
- 很多内容只是“被塞进抽屉”,而不是为抽屉重新设计
|
||||
- 设置里仍然带有“显示/隐藏某些 panel”的思路,但移动端本来就不应该存在那些独立 panel
|
||||
|
||||
因此本计划进一步收紧:
|
||||
|
||||
移动端不只是“底部抽屉化”,而是**重新设计一套 fit 抽屉体系的 mobile-first UI**。
|
||||
|
||||
## 新目标
|
||||
|
||||
1. 手机端不再使用现有 `toolbar` 作为主入口。
|
||||
2. 手机端不再使用现有独立 `panel / modal / sheet` 作为直接 UI 单元。
|
||||
3. 手机端统一采用“底部抽屉 + 顶部标题 + tab 切换 + 卡片内容”的单前景模式。
|
||||
4. 抽屉内部每个 tab 页面都按移动端重新设计内容结构,而不是直接复用旧 panel 结构。
|
||||
5. 设置页移除“显示/隐藏 panel”的桌面遗留配置。
|
||||
6. 媒体页拆成两个移动端页面:`新闻` 与 `TV`,都归入抽屉体系。
|
||||
7. 桌面端保持现有 HUD 体系,不回退。
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 1. 只复用数据和状态,不复用桌面 UI 结构
|
||||
|
||||
可复用:
|
||||
|
||||
- 图层注册表
|
||||
- 搜索结果数据
|
||||
- BGP / 海缆 / 卫星详情数据
|
||||
- 媒体数据
|
||||
- 旋转、缩放、选择、高亮等运行时状态
|
||||
|
||||
不直接复用:
|
||||
|
||||
- 桌面 panel DOM 结构
|
||||
- 桌面 panel header / close / collapse 交互
|
||||
- 桌面 settings 项里的“显示某 panel”逻辑
|
||||
- 桌面媒体面板布局
|
||||
|
||||
### 2. 抽屉是唯一主前景层
|
||||
|
||||
移动端同一时刻只有一个主前景层:底部抽屉。
|
||||
|
||||
抽屉内部切换内容页,而不是多个悬浮层互相覆盖。
|
||||
|
||||
### 3. 每个 tab 都是移动端页面,而不是 panel 容器
|
||||
|
||||
抽屉中的每一项都应视为一个移动端子页面:
|
||||
|
||||
- 有自己的标题
|
||||
- 有自己的内容层次
|
||||
- 有自己的滚动区域
|
||||
- 有自己的主操作
|
||||
|
||||
而不是简单挂一个旧面板进去。
|
||||
|
||||
### 4. 移动端状态提示不占据屏幕正中
|
||||
|
||||
桌面端当前很多通知、状态提示、胶囊消息更适合在屏幕上方居中出现,但移动端不应继续沿用这套布局。
|
||||
|
||||
移动端统一改为:
|
||||
|
||||
- 通知栏放在右上角安全区
|
||||
- 胶囊提示放在右上角堆叠
|
||||
- 不遮挡地球中心视野
|
||||
- 不与底部抽屉主交互区冲突
|
||||
|
||||
## 交互模型
|
||||
|
||||
### 默认态
|
||||
|
||||
移动端默认只显示:
|
||||
|
||||
- 地球主画布
|
||||
- 底部半露出的抽屉头部
|
||||
|
||||
不再单独显示上箭头按钮。
|
||||
|
||||
### 展开态
|
||||
|
||||
用户从底边直接上拉抽屉,或点击抽屉头部展开。
|
||||
|
||||
展开后显示:
|
||||
|
||||
- 当前页面标题
|
||||
- tab 导航
|
||||
- 当前页面内容
|
||||
|
||||
### 收起态
|
||||
|
||||
用户下拉抽屉头部收起,或点击背景收起。
|
||||
|
||||
## 信息架构
|
||||
|
||||
移动端抽屉内的一级页面重定为:
|
||||
|
||||
1. 图层
|
||||
2. 搜索
|
||||
3. 态势
|
||||
4. 新闻
|
||||
5. TV
|
||||
6. 设置
|
||||
7. 详情(按需出现,不固定常驻 tab)
|
||||
|
||||
其中 `新闻` 和 `TV` 不再共享同一个移动端媒体面板。
|
||||
|
||||
## 页面重设计要求
|
||||
|
||||
### 图层页
|
||||
|
||||
目标:
|
||||
|
||||
- 成为移动端最核心的控制页
|
||||
- 强调快速开关,不强调桌面 panel 感
|
||||
|
||||
内容建议:
|
||||
|
||||
- 顶部摘要:当前已启用图层数量
|
||||
- 图层列表卡片
|
||||
- 每个图层项只保留:
|
||||
- 图标
|
||||
- 中文名
|
||||
- 英文副标题
|
||||
- 开关
|
||||
- 去掉桌面式 header / collapse / close 结构
|
||||
|
||||
### 搜索页
|
||||
|
||||
目标:
|
||||
|
||||
- 成为抽屉中的完整搜索页
|
||||
- 避免看起来像桌面 modal 被塞进抽屉
|
||||
|
||||
内容建议:
|
||||
|
||||
- 顶部搜索输入框
|
||||
- 搜索提示文案
|
||||
- 结果列表
|
||||
- 结果项更适合手指点击
|
||||
- 结果点击后:
|
||||
- 聚焦地球对象
|
||||
- 自动切换到详情页
|
||||
|
||||
### 态势页
|
||||
|
||||
目标:
|
||||
|
||||
- 合并原来的 `stats + legend` 思路
|
||||
- 成为移动端全局态势页
|
||||
|
||||
内容建议:
|
||||
|
||||
- 顶部核心统计卡
|
||||
- 海缆数量
|
||||
- 登陆点数量
|
||||
- 卫星数量
|
||||
- BGP 事件数量
|
||||
- 当前关注层图例
|
||||
- BGP 状态摘要
|
||||
- 不再出现独立 legend 面板和独立 stats 面板
|
||||
|
||||
### 新闻页
|
||||
|
||||
目标:
|
||||
|
||||
- 从原媒体面板中拆出单独的移动端新闻页
|
||||
|
||||
内容建议:
|
||||
|
||||
- 当前区域焦点
|
||||
- 新闻源数量
|
||||
- 新闻卡片列表
|
||||
- 卡片内显示标题、来源、时间、区域
|
||||
- 外链操作更清晰
|
||||
|
||||
### TV 页
|
||||
|
||||
目标:
|
||||
|
||||
- 从原媒体面板中拆出单独的移动端 TV 页
|
||||
|
||||
内容建议:
|
||||
|
||||
- 顶部频道选择
|
||||
- 直播状态
|
||||
- 当前频道说明
|
||||
- 视频播放器区域
|
||||
- 刷新和外链按钮
|
||||
|
||||
不再保留桌面式“新闻/TV tab 共处一个 panel”的结构。
|
||||
|
||||
### 设置页
|
||||
|
||||
目标:
|
||||
|
||||
- 只保留对移动端仍有意义的系统配置
|
||||
|
||||
必须移除:
|
||||
|
||||
- 图层控制 panel 显示/隐藏
|
||||
- 图例 panel 显示/隐藏
|
||||
- 全球态势 panel 显示/隐藏
|
||||
- 媒体 panel 显示/隐藏
|
||||
|
||||
保留项建议:
|
||||
|
||||
- 旋转模式
|
||||
- 日夜模式
|
||||
- 地球默认大小
|
||||
- 地形透明度
|
||||
- 系统入口
|
||||
|
||||
原因:
|
||||
|
||||
移动端已经没有这些独立 panel 了,所以继续保留这些开关会制造错误心智。
|
||||
|
||||
### 详情页
|
||||
|
||||
目标:
|
||||
|
||||
- 成为海缆 / BGP / 卫星对象的统一移动端详情页
|
||||
|
||||
内容建议:
|
||||
|
||||
- 标题区
|
||||
- 类型标签
|
||||
- 关键属性列表
|
||||
- 相关对象摘要
|
||||
- 相关图层或态势提示
|
||||
|
||||
行为建议:
|
||||
|
||||
- 点击对象后自动切入详情页
|
||||
- 搜索结果点击后也切入详情页
|
||||
|
||||
## 阶段重定义
|
||||
|
||||
### 阶段 2:抽屉壳层
|
||||
|
||||
目标:
|
||||
|
||||
1. 实现底部抽屉基本壳层。
|
||||
2. 支持上拉展开、下拉收起、背景点击关闭。
|
||||
3. `mobile` 模式下隐藏旧 toolbar。
|
||||
4. `mobile` 模式下不再直接显示旧 panel。
|
||||
|
||||
完成标准:
|
||||
|
||||
1. 手机端只有地球主视图和抽屉。
|
||||
2. 抽屉开合稳定。
|
||||
|
||||
### 阶段 3:基础页面重做
|
||||
|
||||
目标:
|
||||
|
||||
1. 重新设计并实现图层页。
|
||||
2. 重新设计并实现搜索页。
|
||||
3. 重新设计并实现设置页。
|
||||
|
||||
完成标准:
|
||||
|
||||
1. 这三个页面不再是旧 panel 原样移植。
|
||||
2. 设置页已移除 panel 可见性开关。
|
||||
|
||||
### 阶段 4:态势与详情重做
|
||||
|
||||
目标:
|
||||
|
||||
1. 将 stats 和 legend 合并为新的态势页。
|
||||
2. 实现统一详情页。
|
||||
3. 对象点击与搜索结果点击都可切入详情页。
|
||||
|
||||
完成标准:
|
||||
|
||||
1. 不再存在移动端独立 legend / stats 面板。
|
||||
2. 详情页成为统一对象信息入口。
|
||||
|
||||
### 阶段 5:媒体拆分重做
|
||||
|
||||
目标:
|
||||
|
||||
1. 将原媒体面板拆成两个移动端页面:新闻页、TV 页。
|
||||
2. 分别重做这两个页面的布局。
|
||||
3. 保留各自必要操作,但不继续共享桌面 panel 结构。
|
||||
|
||||
完成标准:
|
||||
|
||||
1. 新闻与 TV 各自成为独立移动端页面。
|
||||
2. 不再使用桌面媒体 panel 的 tab 结构作为移动端主体。
|
||||
|
||||
### 阶段 6:手感与真机修正
|
||||
|
||||
目标:
|
||||
|
||||
1. 调整抽屉高度、节奏、手势阈值。
|
||||
2. 调整 tab 密度与文字层级。
|
||||
3. 优化 iPhone / Android 安全区。
|
||||
4. 优化抽屉滚动与地球拖拽边界。
|
||||
|
||||
完成标准:
|
||||
|
||||
1. 抽屉和地球不会抢手势。
|
||||
2. 手机端各页面信息层次清晰。
|
||||
3. 真机下无遮挡、无死层、无错误交互心智。
|
||||
|
||||
## 技术落点调整
|
||||
|
||||
### [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||
|
||||
职责:
|
||||
|
||||
- 只保留移动端抽屉壳层
|
||||
- 为各页面提供新的页面容器
|
||||
|
||||
不再把旧 panel 作为最终结构直接塞进抽屉。
|
||||
|
||||
### [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 管理抽屉开合
|
||||
- 管理 tab 切换
|
||||
- 管理详情页切入
|
||||
- 管理 mobile / desktop 分流
|
||||
|
||||
### [frontend/public/earth/js/search.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/search.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 保留搜索能力和结果逻辑
|
||||
- 输出给新的移动端搜索页
|
||||
|
||||
### [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 从桌面 info-card 逻辑中提取可复用的数据层
|
||||
- 服务新的移动端详情页
|
||||
|
||||
### [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 为新的 TV 页面提供数据和状态
|
||||
- 不再直接主导移动端媒体 panel 壳层
|
||||
|
||||
### [frontend/public/earth/js/news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 为新的新闻页面提供列表和区域焦点数据
|
||||
|
||||
### CSS
|
||||
|
||||
需要新增真正的移动端页面样式,而不是继续在旧 panel class 上堆条件分支:
|
||||
|
||||
- 图层页样式
|
||||
- 搜索页样式
|
||||
- 态势页样式
|
||||
- 新闻页样式
|
||||
- TV 页样式
|
||||
- 设置页样式
|
||||
- 详情页样式
|
||||
- 移动端右上角通知 / 胶囊提示样式
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `mobile` 模式下不再显示旧 toolbar。
|
||||
2. `mobile` 模式下不再把旧 panel 直接作为最终 UI。
|
||||
3. 图层、搜索、态势、新闻、TV、设置都是重新设计的移动端页面。
|
||||
4. 设置页不再包含移动端无意义的 panel 显示/隐藏项。
|
||||
5. 新闻与 TV 已拆分为两个移动端页面。
|
||||
6. legend / stats 已整合为态势页。
|
||||
7. 详情页成为统一对象详情入口。
|
||||
8. 移动端通知栏和胶囊提示已统一放到右上角安全区,而不是屏幕正中。
|
||||
|
||||
## 结论
|
||||
|
||||
本计划进一步明确:
|
||||
|
||||
移动端目标不是“把桌面 HUD 放进抽屉”,而是“以抽屉为载体,重做一套适合手机端的信息页面”。
|
||||
|
||||
后续开发必须以此为准:
|
||||
|
||||
- 复用数据
|
||||
- 重做界面
|
||||
- 清除桌面遗留心智
|
||||
156
docs/plans/earth-news-source-configuration-and-collector-plan.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Earth News Source Configuration And Collector Plan
|
||||
|
||||
## Why
|
||||
|
||||
当前 Earth 的“态势新闻”由 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 直接在请求时抓取 RSS / Google News feed,再按当前地球视角中心区域聚合返回。
|
||||
|
||||
这条链已经可用,但存在两个明显限制:
|
||||
|
||||
- 新闻源写死在代码里,不能像 TV 直播源一样从后台维护
|
||||
- 新闻并未进入统一采集体系,没有采集状态、失败监控、历史数据和后续 AI 复用能力
|
||||
|
||||
因此这块更合理的路线不是一步到位重写,而是分阶段推进:
|
||||
|
||||
1. 先做“新闻源配置化”
|
||||
2. 再做“新闻采集器化”
|
||||
|
||||
## Current State
|
||||
|
||||
当前实现分布在:
|
||||
|
||||
- 新闻接口
|
||||
- [news.py](/home/ray/dev/linkong/planet/backend/app/api/v1/news.py)
|
||||
- 实时聚合逻辑
|
||||
- [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py)
|
||||
- 前端消费
|
||||
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||
|
||||
当前新闻源包含:
|
||||
|
||||
- `BBC World` RSS
|
||||
- `DW Top Stories` RSS
|
||||
- 按区域关键词拼出来的 `Google News RSS`
|
||||
- `Global`
|
||||
- `Americas`
|
||||
- `Europe`
|
||||
- `Middle East / Africa`
|
||||
- `Asia Pacific`
|
||||
|
||||
当前不是采集器,也不落库,只做内存缓存。
|
||||
|
||||
## Phase 1: Source Configuration
|
||||
|
||||
### Goal
|
||||
|
||||
把 `NEWS_FEED_SOURCES` 从硬编码列表升级成可配置新闻源目录,但继续保留当前“实时聚合”的工作方式。
|
||||
|
||||
### Scope
|
||||
|
||||
- 为 Earth news 建立独立配置结构
|
||||
- 支持后台维护 feed 源
|
||||
- 支持启用/禁用、优先级、区域、源类型
|
||||
- 保持现有 `/api/v1/news/earth-feed` 输出协议不变
|
||||
|
||||
### Proposed Shape
|
||||
|
||||
建议配置字段至少包括:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `region`
|
||||
- `feed_url`
|
||||
- `homepage_url`
|
||||
- `source_type`
|
||||
- `priority`
|
||||
- `is_enabled`
|
||||
- 可选 `query_profile`
|
||||
- 可选 `language`
|
||||
- 可选 `notes`
|
||||
|
||||
### Suggested Storage
|
||||
|
||||
优先走系统设置或单独的 news source settings payload,而不是先建复杂新表。
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 改动小
|
||||
- 易上线
|
||||
- 和当前 TV settings 维护体验更接近
|
||||
- 先解决“写死在代码里”的问题
|
||||
|
||||
### Non-goals
|
||||
|
||||
这一阶段不做:
|
||||
|
||||
- 新闻入库
|
||||
- 新闻历史回看
|
||||
- 新闻采集任务监控
|
||||
- 新闻去重流水线
|
||||
|
||||
## Phase 2: News Collectorization
|
||||
|
||||
### Goal
|
||||
|
||||
把“态势新闻”升级为真正的采集器链路,使其进入采集系统和数据层。
|
||||
|
||||
### Scope
|
||||
|
||||
- 新增专用 news collector
|
||||
- 按配置源定时采集 RSS / feed
|
||||
- 做标题/链接级去重
|
||||
- 建立统一新闻记录模型
|
||||
- 为 Earth、控制台、AI 研判复用同一份新闻数据
|
||||
|
||||
### Benefits
|
||||
|
||||
- 有采集状态
|
||||
- 有失败监控
|
||||
- 有历史缓存
|
||||
- 可以做时间轴 / 区域新闻基线
|
||||
- 可以作为 AI 引用证据
|
||||
|
||||
### Required Design Work
|
||||
|
||||
需要提前明确:
|
||||
|
||||
- 新闻数据模型
|
||||
- 去重策略
|
||||
- 过期清理策略
|
||||
- 区域映射策略
|
||||
- 聚合排序策略
|
||||
- 新闻与 Earth 当前视角/区域的关联方式
|
||||
|
||||
### Candidate Output Model
|
||||
|
||||
至少应包含:
|
||||
|
||||
- `source_id`
|
||||
- `headline`
|
||||
- `summary`
|
||||
- `url`
|
||||
- `publisher`
|
||||
- `region`
|
||||
- `published_at`
|
||||
- `language`
|
||||
- `tags`
|
||||
- `raw_feed_source`
|
||||
- `reference_date`
|
||||
|
||||
## Recommended Order
|
||||
|
||||
推荐执行顺序:
|
||||
|
||||
1. 先完成 Phase 1 配置化
|
||||
2. 保持 Earth 继续实时聚合,但改为读取配置源
|
||||
3. 等新闻源稳定后,再设计 Phase 2 的 collector / storage / dedupe
|
||||
|
||||
## Decision
|
||||
|
||||
当前结论:
|
||||
|
||||
- TV 直播源:优先采集器化
|
||||
- 态势新闻:优先配置化,再采集器化
|
||||
|
||||
## Source Note
|
||||
|
||||
This plan is newly created for the Planet repo to separate the short-term "configurable source directory" work from the longer-term "collectorized news pipeline" work.
|
||||
793
docs/plans/enterprise-logging-system-plan.md
Normal file
@@ -0,0 +1,793 @@
|
||||
# Planet 企业级日志系统实施计划
|
||||
|
||||
## Goal
|
||||
|
||||
把 Planet 当前“能看一点运行输出”的日志能力,升级为一套真正可用、可定位、可纠错、可追责、可演进的企业级日志系统。
|
||||
|
||||
这里的“企业级”不是指一上来就接入很重的外部平台,而是指这套系统需要同时满足下面五件事:
|
||||
|
||||
1. 排障可用
|
||||
2. 历史可查
|
||||
3. 业务可解释
|
||||
4. 权限操作可追责
|
||||
5. 出错后能够反向定位到请求、任务、模块和操作者
|
||||
|
||||
最终目标不是“把更多 stdout 放到日志页里”,而是建立一套统一的日志契约与落地链路:
|
||||
|
||||
- 统一日志字段
|
||||
- 统一事件命名
|
||||
- 统一采集入口
|
||||
- 统一查询视图
|
||||
- 清晰的实时日志、持久化事件、审计日志分层
|
||||
|
||||
## Why
|
||||
|
||||
当前仓库已经有一些日志基础,但离真正可用的日志系统还有明显距离。
|
||||
|
||||
已有基础:
|
||||
|
||||
- 后端运行日志可通过 `/tmp/planet_backend.log` 查看
|
||||
- 前端开发服务日志可通过 `/tmp/planet_frontend.log` 查看
|
||||
- AI Provider 可从 Docker 容器读取日志
|
||||
- Earth 浏览器端关键日志可上报到后端并进入 Redis 缓冲
|
||||
- 已有 `system_logs` / `audit_logs` 持久化能力
|
||||
- 管理台已有“系统日志”页面,支持来源、级别、日期、搜索
|
||||
|
||||
当前缺口:
|
||||
|
||||
- 后端日志仍以 `uvicorn` / 文本输出为主,不是统一结构化事件流
|
||||
- 不同模块的日志格式不一致,很多地方只有 message,没有 event 语义
|
||||
- 还没有统一的后端 logger 封装与字段注入机制
|
||||
- 前端虽然能上报错误,但还没有统一 logger API 和统一事件词汇
|
||||
- Earth 与管理台之间的错误事件还没有形成可串联的事件链路
|
||||
- 历史持久化还偏点状,很多高价值失败并没有系统性落库
|
||||
- 系统日志页当前更像“运行输出查看器”,不是“多层日志查询台”
|
||||
- 审计日志与运行日志尚未形成明确的产品级联动
|
||||
|
||||
所以当前真正的问题不是“有没有日志页”,而是:
|
||||
|
||||
**当前系统能看见输出,但还不能稳定回答“发生了什么、影响了谁、在哪条链路上坏了、是否已修复、是谁触发的”。**
|
||||
|
||||
## Current State
|
||||
|
||||
截至 2026-04-23,当前代码中的日志相关能力大致如下。
|
||||
|
||||
### 1. 日志来源
|
||||
|
||||
当前系统日志页主要读取以下来源:
|
||||
|
||||
- `backend`
|
||||
读取 `/tmp/planet_backend.log`
|
||||
- `frontend`
|
||||
读取 `/tmp/planet_frontend.log`
|
||||
- `ai-provider`
|
||||
读取 Docker 容器日志
|
||||
- `earth-client`
|
||||
读取 Redis 缓冲的浏览器端日志
|
||||
|
||||
这些来源定义在:
|
||||
|
||||
- [backend/app/services/system_logs.py](/home/ray/dev/linkong/planet/backend/app/services/system_logs.py)
|
||||
|
||||
### 2. 当前日志读取模型
|
||||
|
||||
当前 `read_log_snapshot()` 的职责是:
|
||||
|
||||
- 读取某个来源的最近若干行
|
||||
- 解析基础级别与时间
|
||||
- 按级别、日期、搜索进行过滤
|
||||
- 返回用于日志页展示的快照
|
||||
|
||||
这个模型适合“运维查看器”,但不适合企业级日志系统,原因是:
|
||||
|
||||
- 读取基于文本尾部扫描,不是基于事件模型
|
||||
- 不同来源的结构粒度完全不同
|
||||
- 过滤依赖文本解析,准确率有限
|
||||
- 没有请求、任务、用户、资源、动作等核心关联字段
|
||||
|
||||
### 3. 已有持久化能力
|
||||
|
||||
当前已经存在两个持久化入口:
|
||||
|
||||
- `record_system_log(...)`
|
||||
- `record_audit_log(...)`
|
||||
|
||||
位置:
|
||||
|
||||
- [backend/app/services/persistent_logs.py](/home/ray/dev/linkong/planet/backend/app/services/persistent_logs.py)
|
||||
|
||||
这说明系统并不是从 0 开始,但也说明当前最大的问题是:
|
||||
|
||||
**持久化能力存在,但没有成为统一默认路径。**
|
||||
|
||||
### 4. 已有 request_id 基础
|
||||
|
||||
当前系统已具备 `request_id` 相关基础,部分持久化能力也会尝试写入 `request_id`。
|
||||
|
||||
这为后续做:
|
||||
|
||||
- 请求链路排障
|
||||
- 前后端关联查询
|
||||
- 任务执行追踪
|
||||
|
||||
提供了很好的基础。
|
||||
|
||||
### 5. 当前日志页定位
|
||||
|
||||
当前日志页已经具备:
|
||||
|
||||
- 来源切换
|
||||
- 级别筛选
|
||||
- 日期筛选
|
||||
- 搜索
|
||||
- 文本控制台视图
|
||||
|
||||
但它仍然是“单层视图”:
|
||||
|
||||
- 上面是筛选器
|
||||
- 下面是一块文本控制台
|
||||
|
||||
它还不是:
|
||||
|
||||
- 运行日志 + 事件日志 + 审计日志 的统一入口
|
||||
- 也没有事件详情、关联跳转、纠错建议、链路追踪能力
|
||||
|
||||
## Core Principles
|
||||
|
||||
这套日志系统后续必须遵循下面几个原则。
|
||||
|
||||
### 1. 分层,而不是混存
|
||||
|
||||
日志必须拆成三层:
|
||||
|
||||
1. 运行日志
|
||||
2. 持久化事件日志
|
||||
3. 审计日志
|
||||
|
||||
它们的用途不同,绝不能继续混成一个概念。
|
||||
|
||||
#### 运行日志
|
||||
|
||||
用于:
|
||||
|
||||
- 实时排障
|
||||
- 观察服务运行状态
|
||||
- 看 stdout / stderr / exception / collector 输出
|
||||
|
||||
特点:
|
||||
|
||||
- 数据量大
|
||||
- 时效性强
|
||||
- 保留周期短
|
||||
- 不要求每条都落库
|
||||
|
||||
#### 持久化事件日志
|
||||
|
||||
用于:
|
||||
|
||||
- 记录高价值错误
|
||||
- 记录关键业务失败
|
||||
- 支撑历史追溯
|
||||
- 支撑趋势分析
|
||||
|
||||
特点:
|
||||
|
||||
- 只持久化有价值事件
|
||||
- 必须结构化
|
||||
- 必须有统一 event 命名
|
||||
|
||||
#### 审计日志
|
||||
|
||||
用于:
|
||||
|
||||
- 留痕
|
||||
- 追责
|
||||
- 还原高权限操作
|
||||
|
||||
特点:
|
||||
|
||||
- 必须单独建模
|
||||
- 不与普通运行日志混用
|
||||
|
||||
### 2. 结构化优先
|
||||
|
||||
正式日志必须可拆字段,不能长期依赖自由文本。
|
||||
|
||||
最低要求至少能拿到:
|
||||
|
||||
- `timestamp`
|
||||
- `level`
|
||||
- `service`
|
||||
- `module`
|
||||
- `event`
|
||||
- `message`
|
||||
- `request_id`
|
||||
- `trace_id`
|
||||
- `user_id` / `actor`
|
||||
- `context`
|
||||
|
||||
### 3. 事件命名优先于 message 命名
|
||||
|
||||
人看的 message 可以变化,但机器查询和跨模块关联必须依赖稳定事件名。
|
||||
|
||||
例如:
|
||||
|
||||
- `collector.run.started`
|
||||
- `collector.run.completed`
|
||||
- `collector.run.failed`
|
||||
- `earth.layer.load_failed`
|
||||
- `earth.cruise.route_build_failed`
|
||||
- `system.restart_task.failed`
|
||||
- `auth.websocket.invalid_token`
|
||||
|
||||
### 4. 查询链路必须可串联
|
||||
|
||||
企业级日志系统的核心不是“有很多日志”,而是“能串起来”。
|
||||
|
||||
最终一条高价值事件,至少要能回链到下面任意几类对象:
|
||||
|
||||
- 某个请求
|
||||
- 某个任务
|
||||
- 某个用户
|
||||
- 某个数据源
|
||||
- 某个 Earth 模块
|
||||
- 某个管理动作
|
||||
|
||||
### 5. 默认脱敏
|
||||
|
||||
日志体系必须明确禁止记录:
|
||||
|
||||
- token
|
||||
- password
|
||||
- Authorization header
|
||||
- cookie
|
||||
- session
|
||||
- 明文敏感个人信息
|
||||
|
||||
并且需要有统一脱敏器,而不是靠调用者自觉。
|
||||
|
||||
### 6. “可纠错”不是一句口号
|
||||
|
||||
这里的“可纠错”至少包含三层:
|
||||
|
||||
1. 日志字段足够解释错误,方便人排查
|
||||
2. 系统能识别常见错误模式并给出纠偏建议
|
||||
3. 关键错误支持闭环动作,例如重试、重建索引、重新触发采集、跳转到对应对象
|
||||
|
||||
也就是说,这套日志系统最终不只是“告诉你出错了”,而要尽量接近“告诉你为什么出错、怎么修、去哪修”。
|
||||
|
||||
## Non-Goals
|
||||
|
||||
第一阶段不追求:
|
||||
|
||||
- 全量接入 ELK / Loki / Datadog / OpenTelemetry 全家桶
|
||||
- 做分布式 trace 全链路可视化大屏
|
||||
- 把所有历史日志都迁进数据库
|
||||
- 先做特别复杂的规则引擎
|
||||
|
||||
第一阶段追求的是:
|
||||
|
||||
- 在当前仓库和当前部署方式下,先把基础日志体系做正确
|
||||
- 再为后续平台化接入预留好接口
|
||||
|
||||
## Target Architecture
|
||||
|
||||
推荐目标架构如下。
|
||||
|
||||
### Layer 1: Runtime Logs
|
||||
|
||||
职责:
|
||||
|
||||
- 承载后端、前端开发服务、容器输出、浏览器端缓冲事件
|
||||
- 提供最近窗口内的实时查看能力
|
||||
|
||||
来源:
|
||||
|
||||
- 文件
|
||||
- Docker
|
||||
- Redis 缓冲
|
||||
- 后续可扩展到 stdout collector
|
||||
|
||||
接口:
|
||||
|
||||
- `GET /api/v1/system/logs/sources`
|
||||
- `GET /api/v1/system/logs/{source_id}`
|
||||
|
||||
这层继续保留,但需要做结构化增强和来源补强。
|
||||
|
||||
### Layer 2: Persistent System Events
|
||||
|
||||
职责:
|
||||
|
||||
- 只存高价值事件
|
||||
- 供历史追溯、事件列表、趋势和纠错使用
|
||||
|
||||
数据来源:
|
||||
|
||||
- 后端关键异常
|
||||
- 浏览器端关键失败
|
||||
- 采集器/调度器关键失败
|
||||
- 业务关键告警与降级事件
|
||||
|
||||
接口建议:
|
||||
|
||||
- `GET /api/v1/system/events`
|
||||
- `GET /api/v1/system/events/{id}`
|
||||
- `POST /api/v1/system/events/{id}/actions/...`(后续)
|
||||
|
||||
### Layer 3: Audit Logs
|
||||
|
||||
职责:
|
||||
|
||||
- 留痕高权限操作
|
||||
- 记录操作者、对象、结果、请求号
|
||||
|
||||
接口建议:
|
||||
|
||||
- `GET /api/v1/system/audit-logs`
|
||||
|
||||
### Layer 4: Error Intelligence / Triage
|
||||
|
||||
职责:
|
||||
|
||||
- 对高频错误做归类
|
||||
- 对已知错误给出解释与建议动作
|
||||
- 对相同错误进行 fingerprint 聚合
|
||||
|
||||
这是“可纠错”能力的关键层。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `fingerprint`
|
||||
- `root_cause_type`
|
||||
- `known_fix_hint`
|
||||
- `runbook_url`
|
||||
- `related_resource_type`
|
||||
- `related_resource_id`
|
||||
|
||||
## Canonical Event Model
|
||||
|
||||
推荐统一事件字段模型如下。
|
||||
|
||||
### Runtime Log Record
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-04-23T10:15:30Z",
|
||||
"level": "error",
|
||||
"service": "backend",
|
||||
"module": "app.services.scheduler",
|
||||
"event": "collector.run.failed",
|
||||
"message": "Collector bgp_news failed",
|
||||
"request_id": "req_xxx",
|
||||
"trace_id": "trace_xxx",
|
||||
"user_id": null,
|
||||
"actor": null,
|
||||
"resource_type": "collector",
|
||||
"resource_id": "bgp_news",
|
||||
"context": {
|
||||
"datasource_id": 12,
|
||||
"exception_type": "TimeoutError"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Persistent System Event
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1024,
|
||||
"event": "earth.layer.load_failed",
|
||||
"level": "error",
|
||||
"source": "earth-client",
|
||||
"service": "earth",
|
||||
"module": "cables",
|
||||
"message": "Failed to load cable layer",
|
||||
"fingerprint": "earth.layer.load_failed:cables:network_timeout",
|
||||
"request_id": "req_xxx",
|
||||
"trace_id": null,
|
||||
"user_id": 1,
|
||||
"resource_type": "earth_layer",
|
||||
"resource_id": "cables",
|
||||
"category": "visualization",
|
||||
"status": "open",
|
||||
"context": {
|
||||
"url": "/api/v1/visualization/geo/cables"
|
||||
},
|
||||
"created_at": "2026-04-23T10:15:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Audit Log
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 88,
|
||||
"action": "system.restart_task.requested",
|
||||
"actor_id": 1,
|
||||
"actor_name": "root",
|
||||
"target_type": "restart_task",
|
||||
"target_id": "restart_20260423_xxx",
|
||||
"result": "success",
|
||||
"request_id": "req_xxx",
|
||||
"ip": "127.0.0.1",
|
||||
"details": {
|
||||
"action": "restart_backend"
|
||||
},
|
||||
"created_at": "2026-04-23T10:15:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
## Phase 0: Logging Inventory And Naming Freeze
|
||||
|
||||
目标:
|
||||
|
||||
- 先统一“记录什么”和“怎么命名”,避免后面越做越乱
|
||||
|
||||
工作项:
|
||||
|
||||
- 盘点当前所有 `logging.getLogger` 使用点
|
||||
- 盘点裸 `print`
|
||||
- 盘点 `record_system_log` / `record_audit_log` 已落点位
|
||||
- 建立统一事件命名表
|
||||
- 定义 service / module / category / resource 字段枚举
|
||||
- 输出日志字段白名单和脱敏规范
|
||||
|
||||
完成标准:
|
||||
|
||||
- 有一份稳定的事件命名清单
|
||||
- 有一份字段规范清单
|
||||
- 后续新增日志不再“临时起名”
|
||||
|
||||
## Phase 1: Backend Structured Logging Foundation
|
||||
|
||||
目标:
|
||||
|
||||
- 把后端从“散落 logging + 文本输出”升级成“统一结构化 logger”
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增统一后端 logger helper,例如 `app/core/logging.py`
|
||||
- 自动注入:
|
||||
- `service`
|
||||
- `module`
|
||||
- `request_id`
|
||||
- `trace_id`
|
||||
- 增加统一脱敏 filter
|
||||
- 把关键模块先切到统一 logger:
|
||||
- API 层
|
||||
- scheduler
|
||||
- collectors
|
||||
- websocket
|
||||
- visualization
|
||||
- system control
|
||||
- 约束:
|
||||
- 正式路径禁止裸 `print`
|
||||
- 正式异常优先 `logger.exception(..., extra={...})`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 后端关键模块都有稳定 `event`
|
||||
- request 日志和异常日志能挂上 `request_id`
|
||||
- 不再依赖只看 `uvicorn` 原生文本输出来定位问题
|
||||
|
||||
## Phase 2: Persistent Event Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 把“值得长期保留的错误和关键事件”系统性落库
|
||||
|
||||
工作项:
|
||||
|
||||
- 重新定义 `record_system_log()` 的使用边界
|
||||
- 明确哪些事件必须持久化:
|
||||
- API 关键失败
|
||||
- 调度器失败
|
||||
- 采集器失败
|
||||
- Earth 客户端关键错误
|
||||
- 数据源不可用
|
||||
- 业务降级与恢复
|
||||
- 补齐字段:
|
||||
- `event`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `category`
|
||||
- `fingerprint`
|
||||
- `status`
|
||||
- 增加高频错误去重/聚合策略
|
||||
|
||||
完成标准:
|
||||
|
||||
- 高价值错误不再只存在于运行日志里
|
||||
- 能查询最近一周/一月的关键失败事件
|
||||
- 相同错误具备聚合基础
|
||||
|
||||
## Phase 3: Frontend And Earth Unified Logger
|
||||
|
||||
目标:
|
||||
|
||||
- 把前端从“点状 error 上报”升级成统一前端事件流
|
||||
|
||||
工作项:
|
||||
|
||||
- 在前端新增统一 logger API
|
||||
- 统一方法:
|
||||
- `debug`
|
||||
- `info`
|
||||
- `warn`
|
||||
- `error`
|
||||
- 统一字段:
|
||||
- `page`
|
||||
- `module`
|
||||
- `event`
|
||||
- `message`
|
||||
- `url`
|
||||
- `user_agent`
|
||||
- `context`
|
||||
- Earth 模块优先接入:
|
||||
- layer load failed
|
||||
- cruise build failed
|
||||
- popup render failed
|
||||
- connector render failed
|
||||
- websocket dropped
|
||||
- 管理台优先接入:
|
||||
- settings save failed
|
||||
- datasource toggle failed
|
||||
- restart task submit failed
|
||||
|
||||
完成标准:
|
||||
|
||||
- 前端日志事件名与后端可对齐
|
||||
- Earth 和管理台关键失败不再只停留在 console
|
||||
- 浏览器端关键问题能进入统一系统日志/事件层
|
||||
|
||||
## Phase 4: Audit Logging Completion
|
||||
|
||||
目标:
|
||||
|
||||
- 把管理员与高权限操作真正做成企业级审计
|
||||
|
||||
工作项:
|
||||
|
||||
- 扩大审计覆盖面:
|
||||
- 系统重启
|
||||
- 数据源启停
|
||||
- 调度规则变更
|
||||
- 配置变更
|
||||
- 人工触发采集
|
||||
- 删除/修改关键配置
|
||||
- 增加字段:
|
||||
- actor
|
||||
- target
|
||||
- before / after
|
||||
- request_id
|
||||
- IP
|
||||
- 审计页支持:
|
||||
- 动作筛选
|
||||
- 操作者筛选
|
||||
- 时间筛选
|
||||
- 目标对象筛选
|
||||
|
||||
完成标准:
|
||||
|
||||
- 所有高权限操作都能追到人、时间、对象、结果
|
||||
|
||||
## Phase 5: Log Console To Enterprise Observability UI
|
||||
|
||||
目标:
|
||||
|
||||
- 把当前“系统日志”页升级为真正的多层日志工作台
|
||||
|
||||
工作项:
|
||||
|
||||
- 将页面拆为三个主视图:
|
||||
1. 运行日志
|
||||
2. 关键事件
|
||||
3. 审计日志
|
||||
- 运行日志视图:
|
||||
- 保留大控制台
|
||||
- 支持来源、级别、日期、搜索
|
||||
- 关键事件视图:
|
||||
- 列表化展示高价值事件
|
||||
- 支持聚合、状态、指纹、对象筛选
|
||||
- 审计视图:
|
||||
- 列表化展示管理员动作
|
||||
- 增加详情抽屉:
|
||||
- 原始 message
|
||||
- context
|
||||
- request_id
|
||||
- related resource
|
||||
- recommended action
|
||||
|
||||
完成标准:
|
||||
|
||||
- 日志页不再只是“终端文本窗口”
|
||||
- 运维排障、历史追溯、审计留痕三者分层清晰
|
||||
|
||||
## Phase 6: Corrective Intelligence
|
||||
|
||||
目标:
|
||||
|
||||
- 让系统从“能看日志”进化到“能辅助修错”
|
||||
|
||||
工作项:
|
||||
|
||||
- 引入错误 fingerprint
|
||||
- 对已知错误配置:
|
||||
- 根因类型
|
||||
- 修复建议
|
||||
- runbook 链接
|
||||
- 推荐动作
|
||||
- 支持常见纠错动作:
|
||||
- 重试采集任务
|
||||
- 重载配置
|
||||
- 跳转到对应模块/资源
|
||||
- 打开相关日志过滤视图
|
||||
- 高频错误支持聚合与静默窗口
|
||||
|
||||
完成标准:
|
||||
|
||||
- 已知错误能给出明确建议
|
||||
- 运维不需要每次都从零猜
|
||||
|
||||
## Recommended Module Changes
|
||||
|
||||
### Backend
|
||||
|
||||
建议新增/增强的模块:
|
||||
|
||||
- `backend/app/core/logging.py`
|
||||
- 统一 logger 封装
|
||||
- formatter
|
||||
- filter
|
||||
- request/trace 注入
|
||||
- `backend/app/services/persistent_logs.py`
|
||||
- 扩展字段
|
||||
- 统一持久化策略
|
||||
- `backend/app/services/system_logs.py`
|
||||
- 逐步从“文本尾部查看器”升级为“运行日志聚合器”
|
||||
- `backend/app/services/log_classification.py`
|
||||
- 指纹
|
||||
- 根因分类
|
||||
- 纠错建议
|
||||
- `backend/app/api/v1/system_control.py`
|
||||
- 补充事件 / 审计 / 日志多视图接口
|
||||
|
||||
### Frontend
|
||||
|
||||
建议新增/增强:
|
||||
|
||||
- `frontend/src/lib/logger.ts`
|
||||
- 统一前端 logger API
|
||||
- `frontend/src/pages/Logs/Logs.tsx`
|
||||
- 升级为多层工作台
|
||||
- `frontend/public/earth/js/...`
|
||||
- 各 Earth 模块接入统一事件 logger
|
||||
|
||||
## Event Naming Convention
|
||||
|
||||
建议采用:
|
||||
|
||||
`<domain>.<resource>.<action>.<result>`
|
||||
|
||||
示例:
|
||||
|
||||
- `collector.datasource.run.started`
|
||||
- `collector.datasource.run.failed`
|
||||
- `earth.layer.cables.load.failed`
|
||||
- `earth.cruise.route.build.failed`
|
||||
- `system.restart_task.requested`
|
||||
- `system.restart_task.completed`
|
||||
- `auth.websocket.connect.failed`
|
||||
- `settings.datasource.priority.updated`
|
||||
|
||||
规则:
|
||||
|
||||
- 不用自然语言句子
|
||||
- 不把 ID 塞进 event 名里
|
||||
- 资源对象通过字段承载,不通过 event 名承载
|
||||
|
||||
## Query Model
|
||||
|
||||
最终推荐支持的查询维度:
|
||||
|
||||
- 时间范围
|
||||
- level
|
||||
- source
|
||||
- service
|
||||
- module
|
||||
- event
|
||||
- request_id
|
||||
- trace_id
|
||||
- user_id / actor
|
||||
- resource_type / resource_id
|
||||
- category
|
||||
- fingerprint
|
||||
- status
|
||||
- full-text search
|
||||
|
||||
## Retention Strategy
|
||||
|
||||
推荐保留策略:
|
||||
|
||||
- 运行日志:
|
||||
- 文件 / 容器 / Redis 缓冲保留短周期
|
||||
- 持久化事件:
|
||||
- 保留中长期
|
||||
- 审计日志:
|
||||
- 长期保留
|
||||
|
||||
初版可以先这样:
|
||||
|
||||
- 运行日志:7 到 14 天
|
||||
- 关键事件:90 到 180 天
|
||||
- 审计日志:180 天以上
|
||||
|
||||
后续再根据存储与合规要求调整。
|
||||
|
||||
## Security And Compliance
|
||||
|
||||
必须落实:
|
||||
|
||||
- 敏感字段脱敏
|
||||
- 前端上报白名单
|
||||
- 防止日志注入
|
||||
- 审计日志不可被普通管理员随意篡改
|
||||
- 高敏感纠错动作必须再次鉴权
|
||||
|
||||
## Success Criteria
|
||||
|
||||
当下面这些条件成立时,才算这套日志系统真的“成了”:
|
||||
|
||||
1. 一个后端请求失败时,能通过 `request_id` 在运行日志、持久化事件、审计日志之间串联查询
|
||||
2. 一个 Earth 前端错误能定位到页面、模块、事件名和上下文
|
||||
3. 一个采集器失败能同时看到运行日志、持久化事件和可执行纠错动作
|
||||
4. 一个管理员操作能查到操作者、目标对象、结果和 request_id
|
||||
5. 日志页不再只是文本控制台,而是完整的“运行日志 / 关键事件 / 审计日志”工作台
|
||||
6. 高频已知错误能聚合并给出修复建议
|
||||
|
||||
## Delivery Order
|
||||
|
||||
推荐严格按下面顺序做,不要乱跳:
|
||||
|
||||
1. Phase 0 命名与字段规范冻结
|
||||
2. Phase 1 后端结构化 logging 基础
|
||||
3. Phase 2 高价值事件持久化
|
||||
4. Phase 3 前端 / Earth 统一 logger
|
||||
5. Phase 4 审计覆盖补齐
|
||||
6. Phase 5 日志工作台 UI 重构
|
||||
7. Phase 6 指纹 / 纠错 / runbook
|
||||
|
||||
原因:
|
||||
|
||||
- 如果不先统一字段和命名,后面 UI 和持久化会越来越乱
|
||||
- 如果不先做后端结构化基础,前端上报再多也串不起来
|
||||
- 如果不先补持久化层,就只有“实时可看”,没有“历史可查”
|
||||
|
||||
## First Actionable Milestone
|
||||
|
||||
如果要从明天就开始做,最合理的第一个里程碑是:
|
||||
|
||||
### M1: 让后端关键路径全部拥有统一结构化事件
|
||||
|
||||
范围:
|
||||
|
||||
- API 请求入口/出口
|
||||
- scheduler
|
||||
- collectors
|
||||
- websocket
|
||||
- visualization
|
||||
- system control
|
||||
|
||||
交付物:
|
||||
|
||||
- 统一 logger helper
|
||||
- 统一 event naming 表
|
||||
- 统一 request_id 注入
|
||||
- 统一脱敏策略
|
||||
- 关键模块替换完成
|
||||
|
||||
完成这个里程碑后,Planet 才算真正拥有了“企业级日志系统的地基”。
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
- 前端上下文
|
||||
- Earth 前端结构
|
||||
- Earth 卫星 footprint 策略
|
||||
- Earth 渲染图层顺序
|
||||
- Earth 图层样式属性索引
|
||||
- 后端运行控制
|
||||
- collector 现状
|
||||
- 采集格式约定
|
||||
|
||||
@@ -67,6 +67,7 @@ React 路由入口:
|
||||
- 旋转/缩放/布局
|
||||
- HUD 面板拖拽
|
||||
- 图层开关状态机
|
||||
- Earth 设置读取、持久化与重置
|
||||
|
||||
这份文件是 Earth 前端当前最核心的 UI 控制入口。
|
||||
|
||||
@@ -96,8 +97,12 @@ React 路由入口:
|
||||
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
||||
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
||||
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
|
||||
- [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js)
|
||||
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||
|
||||
职责:
|
||||
|
||||
@@ -106,6 +111,27 @@ React 路由入口:
|
||||
- 面板内容
|
||||
- hover/lock/selection 语义
|
||||
|
||||
其中 Earth 启动加载链现在也拆成了两层:
|
||||
|
||||
- `controls.js`
|
||||
- 提供图层注册表与启动元信息
|
||||
- `layer-startup-tasks.js`
|
||||
- 提供图层启动任务注册表
|
||||
- 通过 `registerLayerStartupTask(id, taskFactory)` 扩展启动任务
|
||||
- `main.js`
|
||||
- 只负责读取排序后的启动图层,再按映射执行队列
|
||||
|
||||
其中巡航模式现在已经拆成两层:
|
||||
|
||||
- `cruise-sequencer.js`
|
||||
- 负责目标队列顺序、停留时长、切换节奏、打断与恢复
|
||||
- `callout-connector.js`
|
||||
- 负责卡片连线 SVG、路径计算与绘制动画
|
||||
- `bgp-cruise-adapter.js`
|
||||
- 负责 BGP 巡航展示适配:目标排序、卡片落点、连线路径、focus/overlay/info-card 时序
|
||||
|
||||
当前 BGP 巡航只是这套能力的一个调用方,不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。
|
||||
|
||||
## 当前样式分层
|
||||
|
||||
Earth 的 CSS 不是一份大样式表,而是分层管理:
|
||||
@@ -155,6 +181,57 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
|
||||
|
||||
因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。
|
||||
|
||||
另外,Earth 图层控制现在已经收成“注册表驱动”:
|
||||
|
||||
- 图层元数据
|
||||
- `id`
|
||||
- `icon`
|
||||
- `label`
|
||||
- `meta`
|
||||
- `buttonId`
|
||||
- `persist`
|
||||
- `startupPriority`
|
||||
- `startupMode`
|
||||
- `startupLabel`
|
||||
- `startupMessage`
|
||||
- 图层行为
|
||||
- `getVisible()`
|
||||
- `setVisible(next, options)`
|
||||
|
||||
当前入口仍在 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)。
|
||||
|
||||
这意味着后续新增图层时,优先应补一条图层注册定义,而不是同时去改:
|
||||
|
||||
- 图层面板 HTML
|
||||
- 持久化快照
|
||||
- 初始化恢复
|
||||
- click 绑定
|
||||
|
||||
这四处现在都应该由注册表派生。
|
||||
|
||||
其中:
|
||||
|
||||
- `startupPriority`
|
||||
- 描述图层参与启动加载时的顺序
|
||||
- `startupMode`
|
||||
- `visible`
|
||||
- 仅当前图层处于启用/可见状态时,才加入启动加载队列
|
||||
- `preload`
|
||||
- 即使当前图层未显示,也会参与启动预加载
|
||||
|
||||
当前 `main.js` 会通过注册表读取排序后的启动图层列表,再动态拼装启动加载队列,而不是手写一串固定步骤。像 BGP 这类需要尽早准备数据、但不一定默认显示的图层,应该优先走 `startupMode: "preload"`,而不是在启动流程里写隐式特判。
|
||||
|
||||
此外,启动阶段给用户看的提示文案也应尽量从注册表派生:
|
||||
|
||||
- `startupLabel`
|
||||
- 用于描述当前启动任务的业务名称
|
||||
- `startupMessage`
|
||||
- 用于描述启动中的提示文案
|
||||
- 可以是字符串
|
||||
- 也可以是对象,用于像海缆这种“准备阶段 / 主加载阶段”两段式文案
|
||||
|
||||
这样后续新增会参与启动加载的图层时,顺序、模式和提示文案都在同一处定义,不需要再去 `main.js` 里补第二套常量。
|
||||
|
||||
### `data-status-target`
|
||||
|
||||
图层按钮可以通过:
|
||||
@@ -168,6 +245,26 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
|
||||
|
||||
以后别的异步图层也可以沿用这套约定。
|
||||
|
||||
## 当前设置持久化
|
||||
|
||||
Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一负责:
|
||||
|
||||
- 捕获默认值
|
||||
- 从 `localStorage` 读取上次设置
|
||||
- 初始化应用当前设置
|
||||
- 用户变更后即时持久化
|
||||
- 一键重置回默认值
|
||||
|
||||
当前持久化的范围是:
|
||||
|
||||
- 旋转模式
|
||||
- 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源)
|
||||
- HUD 面板显示/隐藏
|
||||
- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP`
|
||||
- 地形透明度
|
||||
|
||||
也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。
|
||||
|
||||
## 当前地形链路
|
||||
|
||||
真实地形首次启用会慢,原因不只是一个:
|
||||
@@ -233,6 +330,27 @@ Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易
|
||||
|
||||
每次大功能完成后,都要做一次 cleanup pass。
|
||||
|
||||
### 4. 巡航与业务事件不要再深度耦合
|
||||
|
||||
当前正确边界应该是:
|
||||
|
||||
- 通用巡航层只知道:
|
||||
- 当前目标
|
||||
- 队列顺序
|
||||
- 相机 focus
|
||||
- 停留 / 隐藏 / 切换
|
||||
- 业务模块只负责:
|
||||
- 提供目标队列
|
||||
- 提供 focus 坐标
|
||||
- 提供卡片内容
|
||||
- 提供高亮/图层副作用
|
||||
|
||||
如果以后再给海缆、卫星或新闻做巡航,不应复制一套新的 `main.js` 状态变量,而应复用:
|
||||
|
||||
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 这种业务适配层模式
|
||||
|
||||
## 当前推荐改动方式
|
||||
|
||||
如果后续继续改 Earth,建议按这个顺序:
|
||||
|
||||
235
docs/technical/earth-layer-style-reference.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Earth 图层样式属性索引
|
||||
|
||||
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
|
||||
`renderOrder` 等样式属性。层级关系请配合
|
||||
[earth-render-layer-order.md](/home/ray/dev/linkong/planet/docs/technical/earth-render-layer-order.md)
|
||||
查看。
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 类别 | 约定 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 全局配置对象 | `*_CONFIG` | `COUNTRY_BOUNDARY_CONFIG` |
|
||||
| 图层半径偏移 | `*AltitudeOffset` / `radiusOffset` | `lineAltitudeOffset`, `GRID_CONFIG.radiusOffset` |
|
||||
| 透明度 | `*Opacity` | `hoverLineOpacity` |
|
||||
| 渲染顺序 | `*RenderOrder` | `textureOverlayRenderOrder` |
|
||||
| 颜色 | `*Color`,十六进制数字或 CSS 色值 | `lineColor`, `colors.supercomputer` |
|
||||
| 线宽 | `lineWidth` / `*LineWidth` | `GRID_CONFIG.lineWidth` |
|
||||
|
||||
## Earth 基座与高清材质
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Earth 基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| Earth 基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| Earth 基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | 独立高清材质球半径 |
|
||||
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
|
||||
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| 高清材质颜色乘色 | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
|
||||
|
||||
## Earth 遮挡与昼夜
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 遮挡球半径系数 | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | 深度遮挡球半径 |
|
||||
| 遮挡球分段 | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | 遮挡球几何分段 |
|
||||
| 遮挡球 renderOrder | inline | `-1` | `occluder.renderOrder` |
|
||||
| 昼夜太阳方向 | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | 自定义 day/night shader |
|
||||
| 夜侧最低亮度 | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.32` | shader uniform |
|
||||
| 日侧增强 | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `0.94` | shader uniform |
|
||||
| 暮光宽度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightWidth` | `0.24` | shader uniform |
|
||||
| 暮光强度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity` | `0.14` | shader uniform |
|
||||
| 暮光颜色 | `EARTH_MATERIAL_CONFIG.dayNight.twilightColor` | `0x4ea0ff` | shader uniform |
|
||||
| 夜侧 tint 颜色 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintColor` | `0x0b1830` | shader uniform |
|
||||
| 夜侧 tint 强度 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity` | `0.05` | shader uniform |
|
||||
|
||||
## 大气辉光与云图
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 内层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
|
||||
| 内层大气分段 | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
|
||||
| 内层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | shader RGB |
|
||||
| 内层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | shader rim |
|
||||
| 内层大气强度 | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | shader alpha multiplier |
|
||||
| 外层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.016` | `atmosOuterGeo` |
|
||||
| 外层大气分段 | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
|
||||
| 外层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | shader RGB |
|
||||
| 外层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `5.0` | shader rim |
|
||||
| 外层大气强度 | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.02` | shader alpha multiplier |
|
||||
| 大气辉光 blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
|
||||
| 大气辉光 renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
|
||||
| 云图半径偏移 | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | 云层球半径 |
|
||||
| 云图分段 | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | 云层球几何分段 |
|
||||
| 云图透明度 | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
|
||||
| 云图贴图 | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | 云层贴图 |
|
||||
| 云图 blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
|
||||
|
||||
## 海陆基座与国界
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
|
||||
| 海洋填充色 | local `OCEAN_HEX` | `0x010609` | 海陆基座 canvas 背景 |
|
||||
| 陆地填充色 | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | 海陆基座 canvas 陆地 |
|
||||
| 海陆基座透明度 | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
|
||||
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` 半径 |
|
||||
| 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 |
|
||||
| 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint |
|
||||
| 国界 tint 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` 半径 |
|
||||
| 国界 tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
|
||||
| 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 |
|
||||
| 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity |
|
||||
| 国界线 hover 时压暗透明度 | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | hover 时普通国界线 opacity |
|
||||
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | 普通国界线半径 |
|
||||
| 国界线 renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | 普通国界线层级 |
|
||||
| 国界 hover 颜色 | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | 霓虹红橘 |
|
||||
| 国界 hover 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | hover 实线 opacity |
|
||||
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | hover 实线半径 |
|
||||
| 国界 hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | hover 实线层级 |
|
||||
| 国界 hover glow 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | glow 线 opacity |
|
||||
| 国界 hover glow 线宽 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | glow `LineBasicMaterial.linewidth` |
|
||||
| 国界 hover glow 层级偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | glow renderOrder = `2.29` |
|
||||
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | glow 半径 = hover 半径 + 0.04 |
|
||||
|
||||
## 真实地形
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 地形 tile size | `TERRAIN_CONFIG.tileSize` | `256` | Terrarium tile 读取 |
|
||||
| 地形 base zoom | `TERRAIN_CONFIG.baseZoom` | `4` | 地形采样 zoom |
|
||||
| 地形几何分段 | `geometryWidthSegments / geometryHeightSegments` | `320 / 320` | 地形球几何 |
|
||||
| 地形基准半径偏移 | `TERRAIN_CONFIG.baseRadiusOffset` | `0.16` | 地形压过高清材质 |
|
||||
| 地形夸张系数 | `TERRAIN_CONFIG.exaggeration` | `34` | 海拔转世界单位 |
|
||||
| 地形陆地淡入高度 | `TERRAIN_CONFIG.landRevealFadeMeters` | `220` | 顶点 alpha |
|
||||
| 地形透明度 | `TERRAIN_CONFIG.opacity` | `0.62` | `MeshPhongMaterial.opacity` |
|
||||
| 地形颜色 | `TERRAIN_CONFIG.color` | `0x7f9d7f` | `MeshPhongMaterial.color` |
|
||||
| 地形 emissive | `TERRAIN_CONFIG.emissive` | `0x061008` | `MeshPhongMaterial.emissive` |
|
||||
| 地形 specular | `TERRAIN_CONFIG.specular` | `0x233126` | `MeshPhongMaterial.specular` |
|
||||
| 地形 shininess | `TERRAIN_CONFIG.shininess` | `10` | `MeshPhongMaterial.shininess` |
|
||||
| 地形 renderOrder | inline | `1.2` | `terrain.renderOrder` |
|
||||
| 地形 polygonOffset | inline | `factor -1`, `units -1` | 降低贴近球面时的闪烁 |
|
||||
|
||||
## 经纬线
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 经纬线半径偏移 | `GRID_CONFIG.radiusOffset` | `0.14` | 经纬线球面半径 |
|
||||
| 经纬线颜色 | `GRID_CONFIG.color` | `0xc0e0ff` | `LineBasicMaterial.color` |
|
||||
| 经纬线透明度 | `GRID_CONFIG.opacity` | `0.08` | `LineBasicMaterial.opacity` |
|
||||
| 经纬线线宽 | `GRID_CONFIG.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 经纬线 renderOrder | `GRID_CONFIG.renderOrder` | `2.05` | 经纬线层级 |
|
||||
| 纬线间隔 | `GRID_CONFIG.latitudeStep` | `15` | 纬线生成步长 |
|
||||
| 经线间隔 | `GRID_CONFIG.longitudeStep` | `30` | 经线生成步长 |
|
||||
| 线段采样步长 | `GRID_CONFIG.segmentStep` | `5` | 经纬线采样步长 |
|
||||
|
||||
## 海缆与登陆点
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |
|
||||
| 海缆半径偏移 | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | 海缆线半径 |
|
||||
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
|
||||
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
|
||||
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | 对齐算力中心贴地表 marker 高度 |
|
||||
| 登陆点 icon 贴图尺寸 | `CABLE_CONFIG.landingPoint.textureSize` | `256` | canvas 渲染 EPS 参考图的实心 map-pin,中间圆孔透明镂空 |
|
||||
| 登陆点 icon 宽高比 | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` |
|
||||
| 登陆点 icon 锚点 | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`,将 pin 下端点对齐登陆点经纬度 |
|
||||
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `12` | 对齐算力中心等地表 icon 的 sprite 高度 |
|
||||
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
|
||||
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | 兼容旧球体材质;sprite 不使用 |
|
||||
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | 兼容旧球体材质;sprite 不使用 |
|
||||
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
|
||||
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | 对齐算力中心地表设施层级 |
|
||||
| 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 |
|
||||
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
|
||||
| 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 |
|
||||
| 非相关登陆点 emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | dim 状态弱琥珀自发光 |
|
||||
| 非相关登陆点 emissive 强度 | `landingPointVisual.dimmed.emissiveIntensity` | `0.18` | dim 状态弱发光强度 |
|
||||
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 |
|
||||
|
||||
## 卫星、轨迹和 footprint
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 卫星显示半径偏移 | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | 卫星点位置 |
|
||||
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size |
|
||||
| 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 |
|
||||
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | 呼吸动画 |
|
||||
| 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 |
|
||||
| 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor |
|
||||
| 卫星背景点透明度 | inline | `0.42` | backdrop point opacity |
|
||||
| 卫星点透明度 | inline | `0.9` | point material opacity |
|
||||
| 卫星背景点 renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
|
||||
| 卫星点 renderOrder | inline | `6` | `satellitePoints.renderOrder` |
|
||||
| 卫星轨迹长度 | `SATELLITE_CONFIG.trailLength` | `10` | trail buffer |
|
||||
| 卫星轨迹线宽 | `SATELLITE_CONFIG.trailLineWidth` | `3` | ribbon shader uniform |
|
||||
| 选中 ring 大小 | `SATELLITE_CONFIG.ringSize` | `0.07` | hover / locked ring sprite |
|
||||
| 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit |
|
||||
| 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` |
|
||||
| 自发光选中点透明度 | inline | `0.96` | locked dot material |
|
||||
| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | footprint fill |
|
||||
| footprint group renderOrder | inline | `0` | 避免 Group 排序盖过卫星点 |
|
||||
|
||||
## 算力中心
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 |
|
||||
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
|
||||
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker |
|
||||
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
|
||||
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 |
|
||||
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 |
|
||||
| dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 |
|
||||
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture |
|
||||
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture |
|
||||
| 关联颜色 | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | 关联态 |
|
||||
| 算力中心 renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | 地表设施低于卫星点 |
|
||||
|
||||
## BGP 观测
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `2.1` | anomaly marker |
|
||||
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker |
|
||||
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite |
|
||||
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane |
|
||||
| hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 |
|
||||
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite |
|
||||
| hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 |
|
||||
| dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 |
|
||||
| collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 |
|
||||
| critical 颜色 | `BGP_CONFIG.severityColors.critical` | `0xff4d4f` | 严重事件 |
|
||||
| high 颜色 | `BGP_CONFIG.severityColors.high` | `0xff9f43` | 高危事件 |
|
||||
| medium 颜色 | `BGP_CONFIG.severityColors.medium` | `0xffd166` | 中危事件 |
|
||||
| low 颜色 | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | 低危事件 |
|
||||
| collector 基础色 | `BGP_CONFIG.collectorColor` | `0x6db7ff` | collector 默认色 |
|
||||
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
|
||||
| BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring |
|
||||
| BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring |
|
||||
| collector marker renderOrder | inline | `3` | `marker.renderOrder` |
|
||||
| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` |
|
||||
|
||||
## 天体与星空
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 天球半径 | `CELESTIAL_CONFIG.skyRadius` | `2600` | 天体背景 |
|
||||
| 天球透明度 | `CELESTIAL_CONFIG.skyOpacity` | `1` | 背景材质 |
|
||||
| 太阳距离 / 缩放 | `sunDistance / sunScale` | `2150 / 78` | 太阳 sprite |
|
||||
| 月亮距离 / 缩放 | `moonDistance / moonScale` | `2050 / 38` | 月亮 sprite |
|
||||
| 太阳 halo 缩放 | `CELESTIAL_CONFIG.sunHaloScale` | `136` | 太阳 halo |
|
||||
| 月亮 halo 缩放 | `CELESTIAL_CONFIG.moonHaloScale` | `62` | 月亮 halo |
|
||||
| 太阳光颜色 / 强度 | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | scene light |
|
||||
| 背光颜色 / 强度 | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | scene light |
|
||||
| 星空点数量 | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
|
||||
| 星空半径范围 | `minRadius + radiusJitter` | `800 + 200` | 随机分布 |
|
||||
| 星空点颜色 | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
|
||||
| 星空点大小 | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |
|
||||
@@ -95,3 +95,93 @@
|
||||
- 手工配置源
|
||||
- `news_live_streams` 采集器采集源
|
||||
- 当前默认兜底源为 `CCTV-4 中文国际`
|
||||
- `news_live_streams` 在未配置 override 时,默认使用 `iptv-org`:
|
||||
- `channels.json`
|
||||
- `streams.json`
|
||||
- `logos.json`
|
||||
并自动筛出新闻类频道目录
|
||||
|
||||
## 采集器配置方式
|
||||
|
||||
`news_live_streams` 不需要单独新页面,直接复用现有数据源配置:
|
||||
|
||||
- `endpoint`
|
||||
- 频道目录 JSON API 地址
|
||||
- `auth_type`
|
||||
- `none` / `bearer` / `api_key` / `basic`
|
||||
- `headers`
|
||||
- 额外请求头
|
||||
- `config`
|
||||
- 采集器请求与解析行为
|
||||
|
||||
### 支持的 `config` 字段
|
||||
|
||||
```json
|
||||
{
|
||||
"timeout": 30,
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"region": "global"
|
||||
},
|
||||
"body_type": "json",
|
||||
"body": {
|
||||
"include_disabled": false
|
||||
},
|
||||
"response_path": "payload.channels"
|
||||
}
|
||||
```
|
||||
|
||||
- `timeout`
|
||||
- 请求超时秒数
|
||||
- `method`
|
||||
- `GET` 或 `POST`
|
||||
- `params`
|
||||
- 查询参数对象
|
||||
- `body_type`
|
||||
- `json` 或 `form`
|
||||
- `body`
|
||||
- 配合 `POST` 使用的请求体
|
||||
- `json_body`
|
||||
- 显式 JSON 请求体,优先级高于 `body`
|
||||
- `form_body`
|
||||
- 显式表单请求体,优先级高于 `body`
|
||||
- `response_path`
|
||||
- 返回 JSON 中频道数组所在路径,支持点路径,例如:
|
||||
- `payload.channels`
|
||||
- `data.items`
|
||||
- `result.streams`
|
||||
|
||||
### 认证补充
|
||||
|
||||
- `bearer`
|
||||
- 使用 `Authorization: Bearer <token>`
|
||||
- `api_key`
|
||||
- 默认作为请求头发送
|
||||
- 如果 `auth_config.in = "query"`,则作为 query param 发送
|
||||
- `basic`
|
||||
- 使用 HTTP Basic Authorization
|
||||
|
||||
## 兼容的响应结构
|
||||
|
||||
采集器会优先读取:
|
||||
|
||||
- 顶层数组
|
||||
- 或这些常见字段下的数组:
|
||||
- `sources`
|
||||
- `streams`
|
||||
- `channels`
|
||||
- `items`
|
||||
- `results`
|
||||
- `data`
|
||||
|
||||
同时会兼容这些字段别名:
|
||||
|
||||
- `id` / `source_id` / `slug` / `channel_id` / `code`
|
||||
- `name` / `title` / `channel` / `display_name`
|
||||
- `provider` / `publisher` / `network`
|
||||
- `stream_url` / `stream` / `playback_url` / `hls_url` / `m3u8_url`
|
||||
- `embed_url` / `embed` / `page_url`
|
||||
- `homepage_url` / `source_url` / `website`
|
||||
- `language` / `lang` / `locale`
|
||||
- `youtube_video_id` / `video_id`
|
||||
- `youtube_channel` / `channel_handle`
|
||||
|
||||
57
docs/technical/earth-render-layer-order.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Earth 渲染图层顺序
|
||||
|
||||
本文记录当前 Earth 渲染器的图层顺序和每层意图。后续调整
|
||||
`renderOrder`、半径偏移、深度策略或指针交互时,需要同步更新这里。
|
||||
|
||||
注意:图层控制面板顺序和注册 / 启动加载顺序是两套语义。
|
||||
|
||||
| 顺序类型 | 当前顺序 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → BGP → 地形 → 高清材质 → 大气云图 → 国界 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
|
||||
| 注册 / 启动加载顺序 | 经纬线 → 国界 → 高清材质 → 大气云图 → 海缆 → 算力中心 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
|
||||
|
||||
## 地表图层栈
|
||||
|
||||
| 顺序 | 图层 | 来源 | 渲染 / 半径策略 | 深度 / 交互策略 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有 Earth 内容之后。 |
|
||||
| -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 | `cables.js` | `CABLE_CONFIG.line.renderOrder` | 海缆拾取路径 | 保持现有海缆层级。 |
|
||||
| 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` | 禁用 raycast | 只保证压过高清材质。 |
|
||||
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
|
||||
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested,Group renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
|
||||
| 3-5 | BGP 标记和覆盖层 | `bgp.js` | 各 marker 自身 renderOrder | BGP 拾取路径 | 保持现有 BGP 视觉层级。 |
|
||||
| 4.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 |
|
||||
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
|
||||
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
|
||||
|
||||
## 开关联动
|
||||
|
||||
| 开关 | 行为 |
|
||||
| --- | --- |
|
||||
| 高清材质 off | 隐藏高清材质,启用国界 tint / 基座表面,禁用地形和昼夜开关交互,并记住地形和昼夜之前状态。 |
|
||||
| 高清材质 on | 恢复高清材质,并恢复记住的地形 / 昼夜状态。 |
|
||||
| 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 |
|
||||
| 大气云图 | 只控制云图 mesh 显隐。 |
|
||||
| 国界 | 控制国界线和 hover 线显隐;海陆基座填充独立存在,作为 Earth 基座地图使用。 |
|
||||
|
||||
## 交互规则
|
||||
|
||||
| 交互 | 当前规则 |
|
||||
| --- | --- |
|
||||
| Earth 坐标 hover | 高清材质可见时使用高清材质 overlay 作为地表拾取目标,否则使用 Earth 基座球。 |
|
||||
| 国界 hover | 先把地表拾取坐标转成经纬度,再用 GeoJSON 点面判断;国界 hover 线本身不接收 raycast。 |
|
||||
| 国界 hover 视觉 | hover 时压暗普通国界线,并绘制无深度测试的光晕和实线。 |
|
||||
| 中国 / 台湾 hover | `CHN` 和 `TWN` 被归到同一个 hover 高亮组;tooltip 仍显示鼠标实际命中的 feature。 |
|
||||
| 地形 | 只作为视觉层参与,`terrain.raycast` 已禁用。 |
|
||||
| 卫星 | 使用屏幕空间卫星拾取,避免 footprint 或地表层挡住卫星点击。 |
|
||||
198
docs/technical/earth-satellite-footprint-policy.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Earth Satellite Footprint Policy
|
||||
|
||||
本文件记录 Earth 卫星图层当前关于 `footprint` 的产品边界、资料依据和已落地实现,目标是避免把 Starlink 这套专用地表覆盖模型误用到其它星座上。
|
||||
|
||||
相关上下文:
|
||||
|
||||
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
|
||||
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/backend-collectors.md)
|
||||
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
|
||||
## 当前目标
|
||||
|
||||
- 明确哪些非 Starlink 卫星不该显示贴地 footprint
|
||||
- 明确哪些星座未来可以有独立 footprint,但不能复用 Starlink bowtie / GSO-gap 模型
|
||||
- 把这条策略沉淀成可执行实现边界,而不是继续散落在视觉参数里
|
||||
|
||||
## 本地实际类别
|
||||
|
||||
当前 CelesTrak 卫星分组在 [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py) 中包括:
|
||||
|
||||
- `starlink`
|
||||
- `gps-ops`
|
||||
- `galileo`
|
||||
- `glonass`
|
||||
- `beidou`
|
||||
- `leo`
|
||||
- `geo`
|
||||
- `iridium-next`
|
||||
|
||||
其中非 Starlink 类别是:
|
||||
|
||||
- `gps-ops`
|
||||
- `galileo`
|
||||
- `glonass`
|
||||
- `beidou`
|
||||
- `leo`
|
||||
- `geo`
|
||||
- `iridium-next`
|
||||
|
||||
## 资料结论
|
||||
|
||||
### 1. GNSS / RNSS: `gps-ops`, `galileo`, `glonass`, `beidou`
|
||||
|
||||
默认不要画局部地表 footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- 公开资料强调的是 `Earth-pointing`、`Earth coverage`、`continuous global coverage`
|
||||
- 这类系统的公开语义是全球导航 / 授时覆盖,不是 Starlink 那种面向终端业务的局部 spot footprint
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认只显示卫星本体和轨道
|
||||
- 如果后续要强调“服务可达性”,只能做很弱的 global coverage 语义,不应画贴地局部光斑
|
||||
|
||||
资料:
|
||||
|
||||
- [GPS III EC Antenna Patterns](https://www.navcen.uscg.gov/sites/default/files/pdf/gps/GPS_ZIP/GPS_III_EC_Antenna_Patterns_SVN_74_75_76_77_78.pdf)
|
||||
- [ESA Galileo satellites](https://www.esa.int/Applications/Satellite_navigation/Galileo/Galileo_satellites)
|
||||
- [Navipedia Galileo General Introduction](https://gssc.esa.int/navipedia/index.php/Galileo_General_Introduction)
|
||||
- [BeiDou official overview](https://www.beidou.gov.cn/xt/gfxz/201812/P020190117356387956569.pdf)
|
||||
- [GPS.gov GNSS overview](https://www.gps.gov/systems/gnss/)
|
||||
|
||||
### 2. `iridium-next`
|
||||
|
||||
可以有 footprint,但不能复用 Starlink 的单一 bowtie footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- Iridium NEXT 公开资料强调的是固定多 spot beam 体系
|
||||
- 公开示例里常见的是 `48 fixed spot beams in 4 tiers`
|
||||
- 这和 Starlink 当前这套“单星、单主 footprint、带 GSO 缺口”的业务可视化不是同一个问题
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认:仍然不画 Starlink 式地表 footprint
|
||||
- 后续如果要做:单独接入 Iridium 多波束适配层
|
||||
- 在视觉上更接近多束 cluster / 蜂窝 / 分层束,而不是单个 bowtie 光斑
|
||||
|
||||
资料:
|
||||
|
||||
- [Iridium Satellite Spot Beam Coverage on the US](https://www.mathworks.com/help/phased/ug/iridium-satellite-spot-beam-coverage-on-the-us-1.html)
|
||||
|
||||
### 3. `geo`
|
||||
|
||||
默认不要画统一 footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- GEO 通信星公开上可能是 global beam、zone beam、spot beam、steerable spot beam
|
||||
- 没有 operator / payload / beam contour 元数据时,统一画一个 footprint 很容易错
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认只显示 GEO belt 和卫星驻点语义
|
||||
- 只有拿到 beam contour / operator metadata 时才允许画 footprint
|
||||
|
||||
资料:
|
||||
|
||||
- [ITU Handbook on Satellite](https://www.itu.int/dms_pub/itu-r/opb/hdb/R-HDB-42-2002-PDF-E.pdf)
|
||||
|
||||
### 4. `leo`(generic)
|
||||
|
||||
默认不要画 footprint。
|
||||
|
||||
原因:
|
||||
|
||||
- `leo` 组过于混杂,可能同时包含通信、遥感、试验、观测等不同任务
|
||||
- 没有 mission / payload / antenna pattern 元数据时,无法判断是否存在可视化意义上的服务覆盖面
|
||||
|
||||
更合适的表示:
|
||||
|
||||
- 默认只显示卫星和轨道
|
||||
- 后续如果按 operator / mission subtype 细分,再决定是否引入独立 coverage mode
|
||||
|
||||
## 产品策略
|
||||
|
||||
当前统一策略如下:
|
||||
|
||||
- `Starlink`
|
||||
- 保留当前专用 `ground_footprint` 逻辑
|
||||
- `Iridium NEXT`
|
||||
- 预留独立适配层
|
||||
- 当前不复用 Starlink footprint
|
||||
- `GPS / Galileo / GLONASS / BeiDou`
|
||||
- 不显示贴地 footprint
|
||||
- `GEO`
|
||||
- 无 beam metadata 不显示 footprint
|
||||
- `generic LEO`
|
||||
- 无 mission metadata 不显示 footprint
|
||||
|
||||
## 已落地实现
|
||||
|
||||
本次实现只做最小可执行版本,不改现有 Starlink 视觉参数:
|
||||
|
||||
1. 后端把星座分组和 footprint 策略提示透给前端
|
||||
|
||||
- CelesTrak collector 会把 `GROUP` 记入 `metadata.constellation_group`
|
||||
- Visualization API 会输出:
|
||||
- `properties.constellation_group`
|
||||
- `properties.footprint_policy`
|
||||
|
||||
当前策略值:
|
||||
|
||||
- `starlink_ground_footprint`
|
||||
- `iridium_coverage_ring`
|
||||
- `none`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
|
||||
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
|
||||
|
||||
2. 前端把 footprint 变成 capability-gated renderer
|
||||
|
||||
- `ground_footprint` 只有在 `footprint_policy === starlink_ground_footprint` 时才真正启用
|
||||
- `iridium-next` 不再回退成占位分支,而是走独立的 Iridium coverage ring adapter
|
||||
- 其它非 Starlink 即使用户全局选择了 `ground_footprint`,也会自动回退到 `self_glow`
|
||||
|
||||
对应代码:
|
||||
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/iridium-footprint-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/iridium-footprint-adapter.js)
|
||||
|
||||
3. 卫星信息卡显示 capability,而不是只显示轨道参数
|
||||
|
||||
- 卫星详情现在会明确显示:
|
||||
- `星座/分组`
|
||||
- `覆盖能力`
|
||||
- `当前显示`
|
||||
- `覆盖模型`
|
||||
- 这样用户能直接看到:
|
||||
- 当前卫星是否支持 footprint
|
||||
- 当前显示是不是因为 capability gating 被回退
|
||||
- Iridium 和 Starlink 使用的不是同一种模型
|
||||
|
||||
对应代码:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
|
||||
|
||||
## 当前实现边界
|
||||
|
||||
这条边界需要继续保持:
|
||||
|
||||
- `Starlink` 的 footprint 参数和 shader 逻辑只服务于 Starlink
|
||||
- 非 Starlink 的能力判断属于“策略层 / 适配层”
|
||||
- 不要把不同星座的覆盖模型再混写进同一套参数里
|
||||
- `iridium-next` 已经切成独立 adapter,应继续沿这条边界演进,而不是给现有 Starlink bowtie 增加更多 if/else
|
||||
|
||||
## 后续建议
|
||||
|
||||
如果继续往前做,推荐顺序是:
|
||||
|
||||
1. 为 `iridium-next` 新建独立 footprint adapter
|
||||
2. 在 UI 上补一个只读提示,让用户知道当前卫星是否支持 footprint
|
||||
3. 如果未来拿到 GEO beam contour / operator metadata,再为 GEO 开 operator-specific footprint
|
||||
@@ -16,12 +16,33 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.31.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.41.1`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.41.1` | improvement | `dev` | `pending` | 修复新闻直播持久化失效、pin 边缘遮挡;图标抽取为 SVG 并建立规范 |
|
||||
| `0.41.0` | feature | `dev` | `pending` | Earth 图层顺序拆分、基座海陆色块、国界交互、高清材质/云图/地形层级与样式文档落地 |
|
||||
| `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 |
|
||||
| `0.40.4` | bugfix | `dev` | `pending` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 |
|
||||
| `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial,修复锁定环 depthTest 与位置漂移,新增悬停态缩放 |
|
||||
| `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 |
|
||||
| `0.40.1` | improvement | `dev` | `pending` | 卫星选中标记配色跟随图例,修复 footprint 遮蔽卫星渲染问题,修复选中海缆误触发卫星高亮 |
|
||||
| `0.40.0` | feature | `dev` | `pending` | Earth 卫星 footprint 按星座能力分层,Iridium 独立 coverage ring 落地,卫星详情卡补齐覆盖能力与当前显示说明 |
|
||||
| `0.39.0` | feature | `dev` | `pending` | 后端统一结构化日志地基落地,系统日志页重构为紧凑日志工作台,并修复 Earth 移动端态势抽屉与新闻详情同步问题 |
|
||||
| `0.38.0` | feature | `dev` | `pending` | Earth 新闻接入通用巡航与专用卡片链路,系统日志页升级为结构化时间/级别过滤与真正字符串检索 |
|
||||
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
|
||||
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh` 在 `uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
|
||||
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector,统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
|
||||
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
|
||||
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
|
||||
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
|
||||
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
|
||||
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override,并修复 TV 合并采集源后默认频道消失的问题 |
|
||||
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源,并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
|
||||
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
|
||||
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
|
||||
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
|
||||
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
|
||||
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.31.1",
|
||||
"version": "0.41.1",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -25,8 +25,8 @@
|
||||
"vite": "^5.0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "bun ./node_modules/vite/bin/vite.js",
|
||||
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "bun ./node_modules/vite/bin/vite.js preview"
|
||||
}
|
||||
}
|
||||
|
||||
19
frontend/public/earth/assets/icons/bgp-collector.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP route collector marker. Outer ring + Material access_point icon. -->
|
||||
<!-- States: idle opacity=0.74, hover/locked use brighter blend (color controlled externally) -->
|
||||
|
||||
<!-- Outer ring -->
|
||||
<circle cx="64" cy="64" r="22" fill="none" stroke="rgba(111,160,197,0.34)" stroke-width="1.2"/>
|
||||
|
||||
<!-- access_point icon: 24x24 path scaled 4x and offset to (16,16) in 128x128 canvas space -->
|
||||
<g transform="translate(16 16) scale(4 4)"
|
||||
fill="rgba(214,224,233,0.88)"
|
||||
stroke="rgba(64,106,136,0.74)"
|
||||
stroke-width="0.9"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round">
|
||||
<path d="M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"/>
|
||||
<!-- Center dot override -->
|
||||
<circle cx="12" cy="12" r="0.85" fill="rgba(222,231,239,0.72)" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
13
frontend/public/earth/assets/icons/bgp-event-burst.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP specific/burst anomaly symbol: 6 radial spokes (r 26→48) + center dot (r=16). -->
|
||||
<!-- Spoke endpoints calculated as: inner=64+cos(angle)*26, outer=64+cos(angle)*48 for 6 angles -->
|
||||
<g stroke="currentColor" stroke-width="10" stroke-linecap="round">
|
||||
<line x1="90" y1="64" x2="112" y2="64"/>
|
||||
<line x1="77" y1="86.5" x2="88" y2="105.6"/>
|
||||
<line x1="51" y1="86.5" x2="40" y2="105.6"/>
|
||||
<line x1="38" y1="64" x2="16" y2="64"/>
|
||||
<line x1="51" y1="41.5" x2="40" y2="22.4"/>
|
||||
<line x1="77" y1="41.5" x2="88" y2="22.4"/>
|
||||
</g>
|
||||
<circle cx="64" cy="64" r="16" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 714 B |
4
frontend/public/earth/assets/icons/bgp-event-dot.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP generic event symbol: filled circle. -->
|
||||
<circle cx="64" cy="64" r="28" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP withdraw event symbol: exclamation mark (rounded bar + dot). -->
|
||||
<rect x="52" y="22" width="24" height="62" rx="12" fill="currentColor"/>
|
||||
<circle cx="64" cy="102" r="10" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 277 B |
7
frontend/public/earth/assets/icons/bgp-event-leak.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP route leak symbol: two nested open triangular outlines (outer + inner chevron). -->
|
||||
<g stroke="currentColor" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<polyline points="28,96 64,28 100,96"/>
|
||||
<polyline points="40,82 64,54 88,82"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
4
frontend/public/earth/assets/icons/bgp-event-ring.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- Ring overlay for BGP event markers. Stroked circle, no fill. -->
|
||||
<circle cx="64" cy="64" r="44" fill="none" stroke="rgba(255,255,255,0.98)" stroke-width="6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP origin anomaly symbol: upward triangle. -->
|
||||
<polygon points="64,18 110,106 18,106" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 188 B |
4
frontend/public/earth/assets/icons/bgp-event-wave.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP flap event symbol: zigzag/wave (filled W shape, closed). -->
|
||||
<polygon points="14,100 38,26 64,100 90,26 114,100" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 218 B |
12
frontend/public/earth/assets/icons/bgp-glow-dot.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- BGP collector base glow: radial gradient dot. Inner r=8 fully opaque, fades to transparent at r=56. -->
|
||||
<defs>
|
||||
<radialGradient id="bgp-glow" cx="64" cy="64" r="56" fx="64" fy="64" fr="8" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="white" stop-opacity="1"/>
|
||||
<stop offset="24%" stop-color="white" stop-opacity="0.92"/>
|
||||
<stop offset="58%" stop-color="white" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="64" cy="64" r="56" fill="url(#bgp-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 653 B |
16
frontend/public/earth/assets/icons/compute-gpu-cluster.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- GPU cluster marker: database/cylinder stack icon. -->
|
||||
<!-- Color: #2dd4bf (teal) per COMPUTE_CENTER_CONFIG.colors.gpu_cluster -->
|
||||
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
|
||||
<!-- Outer cylinder: top ellipse cap + side rect + bottom half-ellipse -->
|
||||
<!-- Inner groove ring: smaller cylinder shape overlaid at same color (subtle shape layering) -->
|
||||
<g fill="#2dd4bf">
|
||||
<rect x="46" y="46" width="36" height="28"/>
|
||||
<ellipse cx="64" cy="46" rx="18" ry="8"/>
|
||||
<path d="M 82,74 A 18,8 0 0,1 46,74 Z"/>
|
||||
|
||||
<rect x="52" y="58" width="24" height="6"/>
|
||||
<ellipse cx="64" cy="58" rx="12" ry="4.5"/>
|
||||
<path d="M 76,64 A 12,4.5 0 0,1 52,64 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
10
frontend/public/earth/assets/icons/compute-supercomputer.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<!-- Supercomputer marker: flat-screen monitor with neck and base stand. -->
|
||||
<!-- Color: #38bdf8 (sky-blue) per COMPUTE_CENTER_CONFIG.colors.supercomputer -->
|
||||
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
|
||||
<g fill="#38bdf8">
|
||||
<rect x="40" y="42" width="48" height="30" rx="7"/>
|
||||
<rect x="58" y="74" width="12" height="8" rx="3"/>
|
||||
<rect x="50" y="84" width="28" height="5" rx="2.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
10
frontend/public/earth/assets/icons/marker-landing-point.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 50 600 680">
|
||||
<!-- Location pin with circular cutout. Coordinates in 1000-unit space (canvas scale: size/1000). -->
|
||||
<!-- States: color via fill attribute — default white for 3D canvas, orange (#ffaa00) for normal, white for hover/locked -->
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M400 704 C386 704 375 697 367 684 L173 378 C117 290 144 173 229 111 C278 75 337 57 400 57 C463 57 522 75 571 111 C656 173 683 290 627 378 L433 684 C425 697 414 704 400 704 Z
|
||||
M400 320 m-86 0 a86 86 0 1 0 172 0 a86 86 0 1 0 -172 0"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 611 B |
@@ -8,6 +8,10 @@
|
||||
|
||||
:root {
|
||||
--hud-scale: 1;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--hud-offset: calc(20px * var(--hud-scale));
|
||||
--hud-radius: calc(22px * var(--hud-scale));
|
||||
--hud-panel-padding: calc(18px * var(--hud-scale));
|
||||
@@ -66,12 +70,23 @@ body.earth-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.is-globe-dragging,
|
||||
body.earth-page.is-globe-dragging,
|
||||
body.earth-page.is-globe-dragging * {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
.earth-app {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.earth-app canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.earth-app.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -133,3 +133,20 @@
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-stats {
|
||||
position: fixed;
|
||||
top: calc(8px + var(--safe-top));
|
||||
right: 8px;
|
||||
width: min(180px, calc(100vw - 16px));
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
.layout-mode-mobile.earth-search-open .hud-panel-stats,
|
||||
.layout-mode-mobile.earth-settings-open .hud-panel-stats,
|
||||
.layout-mode-mobile.earth-media-open .hud-panel-stats,
|
||||
.layout-mode-mobile.earth-info-open .hud-panel-stats {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
@@ -153,15 +153,28 @@
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
visibility: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.hud-panel-info.is-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.info-card-cruise-link {
|
||||
.hud-panel-info.hud-panel-info--anchor-stable {
|
||||
transform: none;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel-info.hud-panel-info--anchor-stable.is-visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.callout-connector {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -173,7 +186,7 @@
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
.info-card-cruise-link polyline {
|
||||
.callout-connector polyline {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.98);
|
||||
stroke-width: 2.15;
|
||||
@@ -185,7 +198,7 @@
|
||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||
}
|
||||
|
||||
.info-card-cruise-link circle {
|
||||
.callout-connector circle {
|
||||
fill: rgba(255, 255, 255, 0.98);
|
||||
stroke: rgba(7, 16, 32, 0.72);
|
||||
stroke-width: 1.0;
|
||||
@@ -197,21 +210,29 @@
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-visible {
|
||||
.callout-connector.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating polyline {
|
||||
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
.callout-connector.is-animating polyline {
|
||||
animation: calloutConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle {
|
||||
animation: cruiseConnectorNodeIn 0.22s ease forwards;
|
||||
animation-delay: 0.22s;
|
||||
.callout-connector.is-animating circle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorDraw {
|
||||
.callout-connector.is-animating circle:first-of-type {
|
||||
animation: calloutConnectorNodeIn 0.14s ease forwards;
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
|
||||
.callout-connector.is-animating circle:last-of-type {
|
||||
animation: calloutConnectorNodeIn 0.16s ease forwards;
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
@keyframes calloutConnectorDraw {
|
||||
from {
|
||||
stroke-dashoffset: var(--connector-length, 0px);
|
||||
}
|
||||
@@ -220,7 +241,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorNodeIn {
|
||||
@keyframes calloutConnectorNodeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
@@ -282,6 +303,8 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar {
|
||||
@@ -319,6 +342,8 @@
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.18s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-label:hover {
|
||||
@@ -333,6 +358,8 @@
|
||||
text-align: right;
|
||||
max-width: calc(180px * var(--hud-scale));
|
||||
word-break: break-word;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Type-specific header accent colors */
|
||||
@@ -354,8 +381,151 @@
|
||||
}
|
||||
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
|
||||
|
||||
.info-card.news .info-card-header {
|
||||
background: rgba(255, 196, 92, 0.12);
|
||||
border-bottom-color: rgba(255, 196, 92, 0.16);
|
||||
}
|
||||
.info-card.news .info-card-header h3 { color: #ffd77a; }
|
||||
|
||||
.info-card.news .info-card-content {
|
||||
padding-top: calc(10px * var(--hud-scale));
|
||||
padding-bottom: calc(12px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-layout {
|
||||
display: grid;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-kicker {
|
||||
color: rgba(255, 215, 122, 0.82);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-card-news-title {
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.96rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
font-weight: 700;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.info-card-news-summary-shell {
|
||||
position: relative;
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border: 1px solid rgba(255, 215, 122, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
|
||||
rgba(7, 15, 29, 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 10px 28px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-card-news-summary-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
|
||||
opacity: 0.42;
|
||||
transform: translateX(-100%);
|
||||
animation: infoCardNewsScan 3.2s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-card-news-summary-label {
|
||||
color: rgba(188, 212, 238, 0.72);
|
||||
font-size: calc(0.6rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: calc(6px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-news-summary {
|
||||
position: relative;
|
||||
color: #d7e6f7;
|
||||
font-size: calc(0.8rem * var(--hud-scale));
|
||||
line-height: 1.65;
|
||||
min-height: calc(4.8em * var(--hud-scale));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info-card-news-summary.is-typing::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.58em;
|
||||
height: 1.05em;
|
||||
margin-left: 0.16em;
|
||||
vertical-align: -0.14em;
|
||||
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
|
||||
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
|
||||
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes infoCardNewsScan {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes infoCardNewsCaret {
|
||||
0%, 49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout-expanded: slide left column off-screen ────────────── */
|
||||
|
||||
.earth-app.layout-expanded .earth-left-column {
|
||||
transform: translate(calc(-100% + var(--hud-offset)), 0);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-left-column {
|
||||
top: calc(8px + var(--safe-top));
|
||||
left: 8px;
|
||||
max-width: min(300px, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-info {
|
||||
position: fixed;
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
top: auto !important;
|
||||
bottom: calc(84px + var(--safe-bottom)) !important;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: min(58vh, 520px);
|
||||
z-index: 240;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-content {
|
||||
max-height: min(46vh, 420px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-property {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .info-card-value {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,23 @@
|
||||
.layer-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(5 * (56px * var(--hud-scale)));
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
@@ -169,6 +186,7 @@
|
||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
transition: background 0.14s ease;
|
||||
min-height: calc(56px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.layer-row:last-child {
|
||||
@@ -253,6 +271,16 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-label,
|
||||
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.layer-row-toggle-track::after {
|
||||
content: "";
|
||||
@@ -317,3 +345,34 @@
|
||||
|
||||
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
||||
individual rule needed since the whole column translates together. */
|
||||
|
||||
.layout-mode-mobile .hud-panel-layers {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(88px + var(--safe-bottom));
|
||||
width: auto;
|
||||
max-height: min(60vh, 520px);
|
||||
margin-top: 0;
|
||||
z-index: 220;
|
||||
transform: translateY(calc(100% + 28px));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.24s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-layers.is-mobile-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .layer-panel-body {
|
||||
max-height: min(52vh, 460px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .layer-panel-list {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
@@ -138,3 +138,24 @@
|
||||
bottom: var(--hud-offset);
|
||||
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-legend {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
bottom: calc(84px + var(--safe-bottom));
|
||||
width: min(172px, calc(100vw - 16px));
|
||||
z-index: 205;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .legend-list {
|
||||
max-height: min(20vh, 180px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile.earth-search-open .hud-panel-legend,
|
||||
.layout-mode-mobile.earth-settings-open .hud-panel-legend,
|
||||
.layout-mode-mobile.earth-media-open .hud-panel-legend,
|
||||
.layout-mode-mobile.earth-info-open .hud-panel-legend {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,15 @@
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-card--cruise {
|
||||
border-color: rgba(255, 213, 128, 0.42);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
|
||||
0 0 18px rgba(255, 184, 77, 0.12);
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-group,
|
||||
@@ -40,6 +41,7 @@
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster {
|
||||
@@ -103,6 +105,22 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb:has(#layer-action) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-toolbar-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation: floatDock 4.6s ease-in-out infinite;
|
||||
animation-delay: var(--orb-delay, 0s);
|
||||
@@ -120,7 +138,7 @@
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 14px;
|
||||
font-size: calc(14px * var(--toolbar-scale));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -361,7 +379,7 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px);
|
||||
transform: translate(-50%, calc(8px * var(--toolbar-scale)));
|
||||
}
|
||||
|
||||
.earth-toolbar-popover::before {
|
||||
@@ -381,7 +399,7 @@
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: calc(100% + (12px * var(--toolbar-scale)));
|
||||
transform: translate(-50%, 10px);
|
||||
transform: translate(-50%, calc(10px * var(--toolbar-scale)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -398,16 +416,16 @@
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn,
|
||||
.earth-zoom-toolbar .earth-zoom-value {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
width: calc(42px * var(--toolbar-scale));
|
||||
min-width: calc(42px * var(--toolbar-scale));
|
||||
border-radius: 50%;
|
||||
color: var(--hud-text-soft);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn {
|
||||
height: 42px;
|
||||
font-size: 20px;
|
||||
height: calc(42px * var(--toolbar-scale));
|
||||
font-size: calc(20px * var(--toolbar-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -416,9 +434,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
height: calc(42px * var(--toolbar-scale));
|
||||
padding: 0;
|
||||
font-size: 0.68rem;
|
||||
font-size: calc(11px * var(--toolbar-scale));
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
@@ -429,15 +447,15 @@
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
bottom: calc(56px * var(--toolbar-scale));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
||||
color: var(--hud-text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
|
||||
border-radius: calc(6px * var(--toolbar-scale));
|
||||
font-size: calc(12px * var(--toolbar-scale));
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -453,7 +471,7 @@
|
||||
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
bottom: 58px;
|
||||
bottom: calc(58px * var(--toolbar-scale));
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
||||
@@ -462,6 +480,6 @@
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border: calc(6px * var(--toolbar-scale)) solid transparent;
|
||||
border-top-color: rgba(18, 31, 52, 0.96);
|
||||
}
|
||||
|
||||
@@ -285,6 +285,27 @@
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .hud-panel-media {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: calc(8px + var(--safe-top));
|
||||
bottom: calc(84px + var(--safe-bottom));
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
min-width: 0;
|
||||
z-index: 230;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .tv-panel-player {
|
||||
min-height: min(42vh, 360px);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .tv-panel-edge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 右下角视觉标记 */
|
||||
.tv-panel-edge[data-edge="br"]::before {
|
||||
content: "";
|
||||
|
||||
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
@@ -16,6 +16,22 @@
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function applyInitialEarthViewportMode() {
|
||||
var width = window.innerWidth;
|
||||
var height = window.innerHeight;
|
||||
var mode = "desktop";
|
||||
|
||||
if (width <= 820) {
|
||||
mode = "mobile";
|
||||
} else if (width <= 1080 || height <= 760) {
|
||||
mode = "compact";
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle("layout-mode-mobile", mode === "mobile");
|
||||
document.documentElement.classList.toggle("layout-mode-compact", mode === "compact");
|
||||
document.documentElement.dataset.earthLayoutMode = mode;
|
||||
})();
|
||||
|
||||
(function applyInitialHudScale() {
|
||||
var referenceWidth = 1920;
|
||||
var referenceHeight = 1080;
|
||||
@@ -92,23 +108,13 @@
|
||||
|
||||
<!-- Layer rows -->
|
||||
<div class="layer-panel-list" id="layer-panel-list">
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -122,13 +128,23 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="算力中心 compute centers">
|
||||
<span class="material-symbols-rounded layer-row-icon">memory</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">算力中心</span>
|
||||
<span class="layer-row-meta">Compute Centers</span>
|
||||
</div>
|
||||
<button id="toggle-compute-centers" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换算力中心显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,6 +158,56 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="高清材质 纹理 texture hd earth">
|
||||
<span class="material-symbols-rounded layer-row-icon">globe</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">高清材质</span>
|
||||
<span class="layer-row-meta">High-Res Texture</span>
|
||||
</div>
|
||||
<button id="toggle-earth-high-res-texture" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换高清材质显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="大气 云图 云层 clouds atmosphere">
|
||||
<span class="material-symbols-rounded layer-row-icon">cloud</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">大气云图</span>
|
||||
<span class="layer-row-meta">Cloud Layer</span>
|
||||
</div>
|
||||
<button id="toggle-atmosphere-clouds" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换大气云图显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="国界 国家 borders countries boundary">
|
||||
<span class="material-symbols-rounded layer-row-icon">public</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">国界</span>
|
||||
<span class="layer-row-meta">Country Borders</span>
|
||||
</div>
|
||||
<button id="toggle-country-boundaries" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换国界显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">经纬线</span>
|
||||
<span class="layer-row-meta">Graticule</span>
|
||||
</div>
|
||||
<button id="toggle-grid-lines" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换经纬线显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty search state -->
|
||||
@@ -154,16 +220,24 @@
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">layers</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">图层</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
@@ -174,7 +248,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
@@ -182,7 +256,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
@@ -190,7 +264,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
|
||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">zoom_in</span>
|
||||
@@ -203,7 +277,7 @@
|
||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
@@ -211,7 +285,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
@@ -219,7 +293,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
@@ -274,23 +348,27 @@
|
||||
<!-- 2-col KPI grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="cable-count">—</span>
|
||||
<span class="stat-num" id="cable-count" data-earth-stat="cable-count">—</span>
|
||||
<span class="stat-label">海缆系统</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="landing-point-count">—</span>
|
||||
<span class="stat-num" id="landing-point-count" data-earth-stat="landing-point-count">—</span>
|
||||
<span class="stat-label">登陆点</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="satellite-count">—</span>
|
||||
<span class="stat-num" id="satellite-count" data-earth-stat="satellite-count">—</span>
|
||||
<span class="stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-anomaly-count">—</span>
|
||||
<span class="stat-num" id="compute-center-count" data-earth-stat="compute-center-count">—</span>
|
||||
<span class="stat-label">算力中心</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-anomaly-count" data-earth-stat="bgp-anomaly-count">—</span>
|
||||
<span class="stat-label">BGP 事件</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-collector-count">—</span>
|
||||
<span class="stat-num" id="bgp-collector-count" data-earth-stat="bgp-collector-count">—</span>
|
||||
<span class="stat-label">BGP 观测站</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
@@ -302,12 +380,12 @@
|
||||
<!-- BGP status footer -->
|
||||
<div class="stats-footer">
|
||||
<span class="stats-footer-dot"></span>
|
||||
<span id="bgp-status-summary" class="stats-footer-text">暂无观测数据</span>
|
||||
<span id="bgp-status-summary" class="stats-footer-text" data-earth-stat="bgp-status-summary">暂无观测数据</span>
|
||||
</div>
|
||||
|
||||
<!-- hidden elements kept for JS compatibility -->
|
||||
<span id="terrain-status" hidden></span>
|
||||
<span id="texture-quality" hidden></span>
|
||||
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
|
||||
<span id="texture-quality" data-earth-stat="texture-quality" hidden></span>
|
||||
<span id="camera-distance" hidden></span>
|
||||
</div>
|
||||
|
||||
@@ -412,6 +490,367 @@
|
||||
|
||||
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
|
||||
<span id="earth-mobile-popup-dock" class="earth-mobile-popup-dock" aria-hidden="true"></span>
|
||||
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
|
||||
<div class="earth-mobile-popup-body">
|
||||
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>
|
||||
<div class="earth-mobile-popup-sub" id="earth-mobile-popup-sub"></div>
|
||||
</div>
|
||||
<span class="material-symbols-rounded earth-mobile-popup-chevron">chevron_right</span>
|
||||
</div>
|
||||
<div id="mobile-drawer-overlay" class="earth-mobile-drawer-overlay" hidden></div>
|
||||
<div id="mobile-drawer-shell" class="earth-mobile-drawer-shell" aria-hidden="true">
|
||||
<div class="earth-mobile-drawer-sheet">
|
||||
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
|
||||
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-nav">
|
||||
<div class="earth-mobile-drawer-nav-copy">
|
||||
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
|
||||
<span class="earth-mobile-drawer-nav-title">模块切换</span>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs-shell">
|
||||
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--left" aria-hidden="true"></div>
|
||||
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
|
||||
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
|
||||
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
|
||||
<span class="earth-mobile-drawer-tab-label">图层</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
|
||||
<span class="earth-mobile-drawer-tab-label">搜索</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
|
||||
<span class="earth-mobile-drawer-tab-label">态势</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
|
||||
<span class="earth-mobile-drawer-tab-label">新闻</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
|
||||
<span class="earth-mobile-drawer-tab-label">TV</span>
|
||||
</button>
|
||||
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
|
||||
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
|
||||
<span class="earth-mobile-drawer-tab-label">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-content">
|
||||
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
|
||||
<div class="earth-mobile-page earth-mobile-page--layers">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Layer Control</span>
|
||||
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
|
||||
</div>
|
||||
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
|
||||
<div class="earth-mobile-page earth-mobile-page--search">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Object Search</span>
|
||||
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星、算力中心和 BGP 事件</span>
|
||||
</div>
|
||||
<div class="earth-mobile-search-shell">
|
||||
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
|
||||
<input
|
||||
id="mobile-earth-search-input"
|
||||
class="earth-mobile-search-input"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="输入名称、地点、NORAD、ASN..."
|
||||
>
|
||||
<button id="mobile-earth-search-clear" class="earth-mobile-search-clear" type="button" aria-label="清除搜索" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
|
||||
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
|
||||
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
|
||||
<div class="earth-mobile-page earth-mobile-page--situation">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Situation</span>
|
||||
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stats-grid">
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-cable-count" class="earth-mobile-stat-num" data-earth-stat="cable-count">—</span>
|
||||
<span class="earth-mobile-stat-label">海缆系统</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-landing-point-count" class="earth-mobile-stat-num" data-earth-stat="landing-point-count">—</span>
|
||||
<span class="earth-mobile-stat-label">登陆点</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count">—</span>
|
||||
<span class="earth-mobile-stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count">—</span>
|
||||
<span class="earth-mobile-stat-label">算力中心</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num" data-earth-stat="bgp-anomaly-count">—</span>
|
||||
<span class="earth-mobile-stat-label">BGP 事件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-situation-card">
|
||||
<div class="earth-mobile-situation-card-title">图例</div>
|
||||
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
|
||||
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-situation-card">
|
||||
<div class="earth-mobile-situation-card-title">BGP 状态</div>
|
||||
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
|
||||
<div class="earth-mobile-page earth-mobile-page--news">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">News</span>
|
||||
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
|
||||
</div>
|
||||
<div class="earth-mobile-news-focus">
|
||||
<div>
|
||||
<div class="earth-mobile-news-focus-kicker">当前关注区域</div>
|
||||
<div id="mobile-news-focus-label" class="earth-mobile-news-focus-label">全球焦点</div>
|
||||
<div id="mobile-news-focus-coords" class="earth-mobile-news-focus-coords">跟随当前视角自动聚焦</div>
|
||||
</div>
|
||||
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
|
||||
</div>
|
||||
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
|
||||
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
|
||||
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||
<div class="earth-mobile-news-actions">
|
||||
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
|
||||
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
|
||||
</div>
|
||||
<a id="mobile-news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="tv">
|
||||
<div class="earth-mobile-page earth-mobile-page--tv">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">TV</span>
|
||||
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
|
||||
</div>
|
||||
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
|
||||
<div class="earth-mobile-tv-player">
|
||||
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="mobile-tv-iframe"
|
||||
class="earth-mobile-tv-iframe"
|
||||
hidden
|
||||
title="移动端新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
<div id="mobile-tv-overview" class="earth-mobile-tv-overview">
|
||||
<div id="mobile-tv-overview-bar" class="earth-mobile-tv-overview-bar" role="button" tabindex="0" aria-expanded="false" aria-controls="mobile-tv-meta-wrap">
|
||||
<div class="earth-mobile-tv-overview-copy">
|
||||
<span class="earth-mobile-tv-overview-kicker">频道信息</span>
|
||||
<span id="mobile-tv-overview-headline" class="earth-mobile-tv-overview-headline">暂无可用频道</span>
|
||||
<span id="mobile-tv-overview-summary" class="earth-mobile-tv-overview-summary">展开查看当前频道来源、目录和补充说明</span>
|
||||
<div id="mobile-tv-overview-tags" class="earth-mobile-tv-overview-tags" aria-label="频道摘要标签"></div>
|
||||
</div>
|
||||
<div class="earth-mobile-tv-overview-actions">
|
||||
<button id="mobile-tv-refresh" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="刷新频道列表" title="刷新频道列表">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">refresh</span>
|
||||
</button>
|
||||
<button id="mobile-tv-open-external" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="访问频道官网" title="访问频道官网">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-tv-meta-wrap" class="earth-mobile-tv-meta-wrap">
|
||||
<div class="earth-mobile-tv-meta">
|
||||
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
|
||||
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
|
||||
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
|
||||
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
|
||||
<div class="earth-mobile-page earth-mobile-page--settings">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Settings</span>
|
||||
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">旋转</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">旋转模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">巡航模块</span>
|
||||
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻可按需加入。</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
|
||||
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
|
||||
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">卫星</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">卫星显示风格</span>
|
||||
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="self_glow" aria-pressed="true">自身发光</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="ground_footprint" aria-pressed="false">真实地表覆盖</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">视图</div>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">日夜模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-daynight-toggle checked>
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地球默认大小</span>
|
||||
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
data-default-earth-size-slider
|
||||
aria-label="移动端调整地球默认大小"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">地形</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地形透明度</span>
|
||||
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
data-terrain-opacity-slider
|
||||
aria-label="移动端调整地形透明度"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">系统</div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
|
||||
<div class="earth-mobile-page earth-mobile-page--details">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Details</span>
|
||||
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
|
||||
</div>
|
||||
<div class="earth-mobile-detail-card">
|
||||
<div class="earth-mobile-detail-header">
|
||||
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
|
||||
<div class="earth-mobile-detail-heading">
|
||||
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
|
||||
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
|
||||
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
|
||||
<div id="search-backdrop" class="earth-search-backdrop"></div>
|
||||
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
|
||||
<div class="earth-search-header hud-panel__header">
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-search-kicker">搜索</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-search-content hud-panel__body">
|
||||
<div class="earth-search-input-shell">
|
||||
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
|
||||
<input
|
||||
id="earth-search-input"
|
||||
class="earth-search-input"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="搜索海缆、登陆点、卫星、算力中心、BGP 事件..."
|
||||
>
|
||||
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
|
||||
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
|
||||
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||
@@ -419,6 +858,10 @@
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-settings-kicker">设置</div>
|
||||
</div>
|
||||
<button id="settings-reset" class="earth-settings-reset hud-panel__action" type="button" aria-label="重置设置">
|
||||
<span class="material-symbols-rounded">restart_alt</span>
|
||||
<span>重置</span>
|
||||
</button>
|
||||
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
@@ -430,7 +873,7 @@
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">旋转模式</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||
<button
|
||||
@@ -451,11 +894,69 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">巡航模块</span>
|
||||
<span class="earth-settings-item-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
|
||||
</div>
|
||||
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-chip is-active"
|
||||
data-cruise-module-toggle="bgp"
|
||||
aria-pressed="true"
|
||||
>
|
||||
BGP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-chip"
|
||||
data-cruise-module-toggle="news"
|
||||
aria-pressed="false"
|
||||
>
|
||||
新闻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">卫星显示风格</span>
|
||||
<span class="earth-settings-item-subtitle">选择卫星锁定态使用自身发光,还是强调真实地表覆盖范围。</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn is-active"
|
||||
data-satellite-display-style="self_glow"
|
||||
aria-pressed="true"
|
||||
>
|
||||
自身发光
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn"
|
||||
data-satellite-display-style="ground_footprint"
|
||||
aria-pressed="false"
|
||||
>
|
||||
真实地表覆盖
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
<label class="earth-settings-item" for="toggle-daynight">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">日夜模式</span>
|
||||
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-daynight" type="checkbox" checked>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-layers">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">图层控制</span>
|
||||
@@ -498,6 +999,30 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地球默认大小</span>
|
||||
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="default-earth-size-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
aria-label="调整地球默认大小"
|
||||
>
|
||||
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<div class="earth-settings-list">
|
||||
|
||||
589
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
@@ -0,0 +1,589 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||
import {
|
||||
computeNearestPerimeterAnchor,
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
} from "./callout-connector.js";
|
||||
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
||||
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
||||
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
|
||||
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
||||
const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
|
||||
const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
|
||||
const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17;
|
||||
const CRUISE_MOBILE_POPUP_MARGIN_PX = 14;
|
||||
const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52;
|
||||
const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16;
|
||||
const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12;
|
||||
const CRUISE_MARKER_SCREEN_PADDING_PX = 4;
|
||||
|
||||
function getDockAxisOffsets(dockSide, gapPx) {
|
||||
return {
|
||||
offsetX:
|
||||
dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0,
|
||||
offsetY:
|
||||
dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getObstaclePaddingBySide(side, paddingPx) {
|
||||
if (side === "left") {
|
||||
return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx };
|
||||
}
|
||||
if (side === "right") {
|
||||
return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx };
|
||||
}
|
||||
if (side === "top") {
|
||||
return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx };
|
||||
}
|
||||
return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 };
|
||||
}
|
||||
|
||||
const scratchMarkerWorldScale = new THREE.Vector3();
|
||||
const scratchCameraQuaternion = new THREE.Quaternion();
|
||||
const scratchCameraRight = new THREE.Vector3();
|
||||
const scratchCameraUp = new THREE.Vector3();
|
||||
const scratchMarkerRightPoint = new THREE.Vector3();
|
||||
const scratchMarkerLeftPoint = new THREE.Vector3();
|
||||
const scratchMarkerTopPoint = new THREE.Vector3();
|
||||
const scratchMarkerBottomPoint = new THREE.Vector3();
|
||||
|
||||
function projectWorldToScreen(point, camera) {
|
||||
if (!point || !camera) return null;
|
||||
const projected = point.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getMarkerTimestamp(marker) {
|
||||
const rawValue = marker?.userData?.created_at_raw;
|
||||
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
|
||||
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||||
}
|
||||
|
||||
export function createBGPCruiseAdapter({
|
||||
camera,
|
||||
getMarkers,
|
||||
connector,
|
||||
focusView,
|
||||
setMarkerLocked,
|
||||
clearMarkerState,
|
||||
showMarkerOverlay,
|
||||
applySatelliteHighlights,
|
||||
showMarkerInfo,
|
||||
hideInfo,
|
||||
isInfoVisible,
|
||||
getLockedObject,
|
||||
refreshMarkers,
|
||||
}) {
|
||||
let currentMarkerId = null;
|
||||
let cardPlacement = null;
|
||||
let knownEventIds = new Set();
|
||||
|
||||
function getCurrentMarker() {
|
||||
if (!currentMarkerId) return null;
|
||||
return getMarkers().find((marker) => marker?.userData?.id === currentMarkerId) || null;
|
||||
}
|
||||
|
||||
function getSortedMarkers() {
|
||||
return getMarkers()
|
||||
.slice()
|
||||
.sort((a, b) => getMarkerTimestamp(b) - getMarkerTimestamp(a));
|
||||
}
|
||||
|
||||
function getMarkerScreenCoords(marker) {
|
||||
if (!marker || !camera) return null;
|
||||
scratchBGPWorldPosition.copy(marker.position);
|
||||
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
||||
return projectWorldToScreen(scratchBGPWorldPosition, camera);
|
||||
}
|
||||
|
||||
function getVisibleMobilePopup() {
|
||||
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||
? mobilePopup
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVisibleInfoPanel() {
|
||||
const infoPanel = document.getElementById("info-panel");
|
||||
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||
? infoPanel
|
||||
: null;
|
||||
}
|
||||
|
||||
function getMarkerScreenRect(marker) {
|
||||
const center = getMarkerScreenCoords(marker);
|
||||
if (!center || !camera || !marker) return null;
|
||||
|
||||
marker.getWorldScale(scratchMarkerWorldScale);
|
||||
const worldWidth = Math.max(
|
||||
0.0001,
|
||||
Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x,
|
||||
);
|
||||
const worldHeight = Math.max(
|
||||
0.0001,
|
||||
Number(scratchMarkerWorldScale.y || worldWidth),
|
||||
);
|
||||
|
||||
camera.getWorldQuaternion(scratchCameraQuaternion);
|
||||
scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||
scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||
|
||||
scratchMarkerRightPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraRight, worldWidth * 0.5);
|
||||
scratchMarkerLeftPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraRight, -worldWidth * 0.5);
|
||||
scratchMarkerTopPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraUp, worldHeight * 0.5);
|
||||
scratchMarkerBottomPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraUp, -worldHeight * 0.5);
|
||||
|
||||
const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera);
|
||||
const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera);
|
||||
const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera);
|
||||
const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera);
|
||||
if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const halfWidth = Math.max(
|
||||
Math.abs(rightPoint.x - center.x),
|
||||
Math.abs(leftPoint.x - center.x),
|
||||
1,
|
||||
);
|
||||
const halfHeight = Math.max(
|
||||
Math.abs(topPoint.y - center.y),
|
||||
Math.abs(bottomPoint.y - center.y),
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||
y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||
width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||
height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardScreenCoords(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const safeBottom =
|
||||
parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||
) || 0;
|
||||
const estimatedCardWidth = Math.min(
|
||||
CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
|
||||
window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2,
|
||||
);
|
||||
const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX;
|
||||
const topBound = Math.max(
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO,
|
||||
window.innerHeight -
|
||||
CRUISE_MOBILE_DRAWER_CLEARANCE_PX -
|
||||
safeBottom -
|
||||
estimatedCardHeight -
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
),
|
||||
);
|
||||
const rightSlotLeft = Math.max(
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
);
|
||||
const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX;
|
||||
const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5;
|
||||
const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5;
|
||||
const rightClearance = rightSlotLeft - markerCoords.x;
|
||||
const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth);
|
||||
const rightCost =
|
||||
Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||
Math.abs(rightSlotCenterX - markerCoords.x);
|
||||
const leftCost =
|
||||
Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||
Math.abs(markerCoords.x - leftSlotCenterX);
|
||||
const placeOnRight = rightCost <= leftCost;
|
||||
const left = placeOnRight ? rightSlotLeft : leftSlotLeft;
|
||||
return {
|
||||
x: left,
|
||||
y: topBound,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
dockSide: placeOnRight ? "left" : "right",
|
||||
};
|
||||
}
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
) || 1;
|
||||
const estimatedCardHeight = Math.min(
|
||||
CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale,
|
||||
window.innerHeight * 0.7,
|
||||
);
|
||||
const estimatedCardWidth = Math.min(
|
||||
CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale,
|
||||
window.innerWidth - CRUISE_CARD_VIEWPORT_PADDING_PX,
|
||||
);
|
||||
|
||||
const x =
|
||||
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
|
||||
const y =
|
||||
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
|
||||
const margin = CRUISE_CARD_SCREEN_MARGIN_PX;
|
||||
const clampedX = Math.min(
|
||||
Math.max(margin, x),
|
||||
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
|
||||
);
|
||||
const clampedY = Math.min(
|
||||
Math.max(margin, y),
|
||||
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
|
||||
);
|
||||
return {
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardAnchorTarget() {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (
|
||||
document.body.classList.contains("layout-mode-mobile") &&
|
||||
mobilePopup
|
||||
) {
|
||||
const dockSide = mobilePopup.dataset.dockSide || "left";
|
||||
const { offsetX, offsetY } = getDockAxisOffsets(
|
||||
dockSide,
|
||||
CONNECTOR_CONFIG.panelGapPx,
|
||||
);
|
||||
return {
|
||||
element: mobilePopup,
|
||||
side: dockSide,
|
||||
alignRatio: 0.5,
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (
|
||||
document.body.classList.contains("layout-mode-mobile") &&
|
||||
mobilePopup
|
||||
) {
|
||||
const side = mobilePopup.dataset.dockSide || "left";
|
||||
return {
|
||||
element: mobilePopup,
|
||||
padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX),
|
||||
};
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
if (infoPanel) {
|
||||
return {
|
||||
element: infoPanel,
|
||||
padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX),
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackPlacement) {
|
||||
return {
|
||||
x: fallbackPlacement.x,
|
||||
y: fallbackPlacement.y,
|
||||
width: fallbackPlacement.width ?? 0,
|
||||
height: fallbackPlacement.height ?? 0,
|
||||
padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
const popupRect =
|
||||
mobilePopup
|
||||
? mobilePopup.getBoundingClientRect()
|
||||
: fallbackPlacement
|
||||
? {
|
||||
left: fallbackPlacement.x,
|
||||
top: fallbackPlacement.y,
|
||||
right: fallbackPlacement.x + (fallbackPlacement.width ?? 0),
|
||||
bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0),
|
||||
}
|
||||
: null;
|
||||
if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left";
|
||||
|
||||
return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left";
|
||||
}
|
||||
|
||||
function syncMobileDockSide(markerCoords, fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement);
|
||||
if (mobilePopup) {
|
||||
mobilePopup.dataset.dockSide = dockSide;
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectorPath(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
const markerRect = getMarkerScreenRect(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
syncMobileDockSide(markerCoords, targetCardCoords);
|
||||
const cardAnchorTarget = getCardAnchorTarget();
|
||||
const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget);
|
||||
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||
if (!cardAnchorCoords) return null;
|
||||
|
||||
return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, {
|
||||
routingMode: "adaptive",
|
||||
sourceRect: markerRect,
|
||||
targetAnchor: cardAnchorTarget ?? cardAnchorCoords,
|
||||
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||
startFrom: "source",
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||
if (!targetCardCoords) return null;
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
const desktopTarget =
|
||||
infoPanel
|
||||
? infoPanel
|
||||
: {
|
||||
x: targetCardCoords.x,
|
||||
y: targetCardCoords.y,
|
||||
width: targetCardCoords.width,
|
||||
height: targetCardCoords.height,
|
||||
};
|
||||
|
||||
return createConnectorPath(
|
||||
markerCoords,
|
||||
desktopTarget,
|
||||
{
|
||||
routingMode: "adaptive",
|
||||
sourceRect: markerRect,
|
||||
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
startFrom: "source",
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function renderConnector(marker, { animate = false } = {}) {
|
||||
const path = getConnectorPath(marker);
|
||||
if (!path) return false;
|
||||
return connector.render(path, { animate });
|
||||
}
|
||||
|
||||
function extractFeatureIds(features = []) {
|
||||
return features
|
||||
.map((feature) => {
|
||||
const properties = feature?.properties || {};
|
||||
const coords = feature?.geometry?.coordinates || [];
|
||||
return (
|
||||
properties.id ||
|
||||
properties.incident_key ||
|
||||
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return {
|
||||
getSortedMarkers,
|
||||
getCurrentMarker,
|
||||
isPresentationVisible() {
|
||||
return cardPlacement != null;
|
||||
},
|
||||
clearCurrentHighlight() {
|
||||
const marker = getCurrentMarker();
|
||||
if (marker && getLockedObject() !== marker) {
|
||||
clearMarkerState(marker);
|
||||
}
|
||||
currentMarkerId = null;
|
||||
},
|
||||
async focusMarker(marker, { interrupt = false } = {}) {
|
||||
if (!marker) return;
|
||||
currentMarkerId = marker.userData?.id || null;
|
||||
setMarkerLocked(marker);
|
||||
showMarkerOverlay(marker);
|
||||
|
||||
await focusView({
|
||||
lat: marker.userData?.latitude ?? 0,
|
||||
lon: marker.userData?.longitude ?? 0,
|
||||
rotLon: (marker.userData?.longitude ?? 0) - 270,
|
||||
duration: interrupt
|
||||
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||
: CRUISE_CONFIG.focusDurationMs,
|
||||
suppressStatus: true,
|
||||
});
|
||||
|
||||
cardPlacement = getCardScreenCoords(marker);
|
||||
},
|
||||
async presentMarker(marker, { context }) {
|
||||
if (!marker) return false;
|
||||
|
||||
const showCruiseMarkerInfo = ({ reveal = true } = {}) =>
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
reveal,
|
||||
anchorStable: true,
|
||||
dockSide: cardPlacement?.dockSide,
|
||||
});
|
||||
|
||||
showCruiseMarkerInfo({ reveal: false });
|
||||
await context.nextFrame();
|
||||
if (!context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
connectorReady = renderConnector(marker, { animate: !connectorReady });
|
||||
if (connectorReady) break;
|
||||
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||
break;
|
||||
}
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!connectorReady || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
applySatelliteHighlights(marker);
|
||||
|
||||
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
showCruiseMarkerInfo();
|
||||
await context.nextFrame();
|
||||
if (!isInfoVisible()) {
|
||||
showCruiseMarkerInfo();
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!isInfoVisible() || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
async hidePresentation({ context }) {
|
||||
if (!getLockedObject()) {
|
||||
hideInfo();
|
||||
}
|
||||
connector.hide();
|
||||
const hideDelayCompleted = await context.wait(CRUISE_PRESENTATION_HIDE_MS, {
|
||||
secondary: true,
|
||||
});
|
||||
if (!hideDelayCompleted) return;
|
||||
cardPlacement = null;
|
||||
},
|
||||
repositionConnector(marker) {
|
||||
if (!cardPlacement || !marker || !connector.isVisible() || connector.isAnimating()) {
|
||||
return;
|
||||
}
|
||||
renderConnector(marker, { animate: false });
|
||||
},
|
||||
resetPresentation() {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
},
|
||||
syncKnownEventIds() {
|
||||
knownEventIds = new Set(
|
||||
getMarkers()
|
||||
.map((marker) => marker?.userData?.id)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return knownEventIds;
|
||||
},
|
||||
async pollForNewMarkerIds() {
|
||||
const [incidentResponse, anomalyResponse] = await Promise.all([
|
||||
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
||||
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
||||
]);
|
||||
|
||||
if (!incidentResponse.ok || !anomalyResponse.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [incidentPayload, anomalyPayload] = await Promise.all([
|
||||
incidentResponse.json(),
|
||||
anomalyResponse.json(),
|
||||
]);
|
||||
|
||||
const incidentFeatures = Array.isArray(incidentPayload?.features)
|
||||
? incidentPayload.features
|
||||
: [];
|
||||
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
|
||||
? anomalyPayload.features
|
||||
: [];
|
||||
const selectedFeatures =
|
||||
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
|
||||
|
||||
const nextIds = extractFeatureIds(selectedFeatures);
|
||||
const newIds = nextIds.filter((id) => !knownEventIds.has(id));
|
||||
if (newIds.length === 0) return [];
|
||||
|
||||
await refreshMarkers();
|
||||
this.syncKnownEventIds();
|
||||
return newIds;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const bgpGroup = new THREE.Group();
|
||||
const bgpOverlayGroup = new THREE.Group();
|
||||
@@ -280,37 +280,23 @@ function blendHexColors(fromHex, toHex, ratio) {
|
||||
function getCollectorDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
marker.getWorldPosition(collectorWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset;
|
||||
const referenceFovRad = (75 * Math.PI) / 180;
|
||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
||||
const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6);
|
||||
const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9);
|
||||
const worldPerPixel =
|
||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
|
||||
});
|
||||
}
|
||||
|
||||
function getEventDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
marker.getWorldPosition(collectorWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset;
|
||||
const referenceFovRad = (75 * Math.PI) / 180;
|
||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
||||
const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7);
|
||||
const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9);
|
||||
const worldPerPixel =
|
||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
|
||||
});
|
||||
}
|
||||
|
||||
function orientCollectorMarkerToSurface(marker, position) {
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
CABLE_STATE,
|
||||
CABLE_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { showInfoCard } from "./info-card.js";
|
||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||
|
||||
@@ -20,8 +20,56 @@ export let lockedCable = null;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
let landingPointGeometry = null;
|
||||
const landingPointWorldPosition = new THREE.Vector3();
|
||||
let landingPointTexture = null;
|
||||
const _lpWorldPos = new THREE.Vector3();
|
||||
|
||||
function createLandingPointTexture() {
|
||||
const size = CABLE_CONFIG.landingPoint.textureSize;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const iconPath = new Path2D(
|
||||
[
|
||||
"M400 704",
|
||||
"C386 704 375 697 367 684",
|
||||
"L173 378",
|
||||
"C117 290 144 173 229 111",
|
||||
"C278 75 337 57 400 57",
|
||||
"C463 57 522 75 571 111",
|
||||
"C656 173 683 290 627 378",
|
||||
"L433 684",
|
||||
"C425 697 414 704 400 704",
|
||||
"Z",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.translate(size * 0.12, size * 0.02);
|
||||
ctx.scale(size / 1000, size / 1000);
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill(iconPath);
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.beginPath();
|
||||
ctx.arc(400, 320, 86, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function getLandingPointTexture() {
|
||||
if (!landingPointTexture) {
|
||||
landingPointTexture = createLandingPointTexture();
|
||||
}
|
||||
return landingPointTexture;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -33,24 +81,13 @@ function getLandingPointDistanceScale(point, camera) {
|
||||
!camera ||
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
|
||||
) return 1;
|
||||
point.getWorldPosition(landingPointWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition);
|
||||
const referenceDistance =
|
||||
CONFIG.defaultCameraZ -
|
||||
CONFIG.earthRadius +
|
||||
CABLE_CONFIG.landingPoint.altitudeOffset;
|
||||
const referenceFovDeg =
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75;
|
||||
const referenceFovRad = (referenceFovDeg * Math.PI) / 180;
|
||||
const cameraFovRad =
|
||||
(((camera.fov || referenceFovDeg)) * Math.PI) / 180;
|
||||
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
return clamp(
|
||||
worldPerPixel / referenceWorldPerPixel,
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
);
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
|
||||
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
@@ -61,7 +98,7 @@ function disposeMaterial(material) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (material.map) {
|
||||
if (material.map && !material.userData?.sharedMap) {
|
||||
material.map.dispose();
|
||||
}
|
||||
material.dispose();
|
||||
@@ -81,6 +118,22 @@ function disposeObject(object, parent) {
|
||||
}
|
||||
}
|
||||
|
||||
function setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
|
||||
point.material.color.set(color);
|
||||
point.material.opacity = opacity;
|
||||
if (point.material.emissive && emissive !== undefined) {
|
||||
point.material.emissive.setHex(emissive);
|
||||
}
|
||||
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
|
||||
point.material.emissiveIntensity = emissiveIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
function setLandingPointScale(point, heightScale) {
|
||||
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
|
||||
point.scale.set(heightScale * aspect, heightScale, 1);
|
||||
}
|
||||
|
||||
function getCableColor(properties) {
|
||||
if (properties.color) {
|
||||
if (
|
||||
@@ -336,9 +389,8 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
feature.properties.status === "In Service"),
|
||||
).length;
|
||||
|
||||
const cableCountEl = document.getElementById("cable-count");
|
||||
const statusEl = document.getElementById("cable-status-summary");
|
||||
if (cableCountEl) cableCountEl.textContent = cableCount + "个";
|
||||
setEarthStatValue("cable-count", `${cableCount}个`);
|
||||
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
|
||||
|
||||
updateEarthStats({
|
||||
@@ -370,13 +422,6 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
|
||||
if (!landingPointGeometry) {
|
||||
landingPointGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
}
|
||||
let validCount = 0;
|
||||
|
||||
for (const feature of data.features) {
|
||||
@@ -409,36 +454,39 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
landingPointGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
const marker = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: getLandingPointTexture(),
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
marker.material.userData.sharedMap = true;
|
||||
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
|
||||
marker.center.set(
|
||||
CABLE_CONFIG.landingPoint.anchorX,
|
||||
CABLE_CONFIG.landingPoint.anchorY,
|
||||
);
|
||||
marker.position.copy(position);
|
||||
marker.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
sharedGeometry: true,
|
||||
};
|
||||
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale);
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
earthObj.add(marker);
|
||||
landingPoints.push(marker);
|
||||
validCount++;
|
||||
}
|
||||
|
||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
||||
if (landingPointCountEl) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
}
|
||||
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
@@ -549,6 +597,18 @@ export function getAllLandingPoints() {
|
||||
return landingPoints;
|
||||
}
|
||||
|
||||
// Hide pins within ~3° of the limb (normalised dot < 0.05) to prevent the
|
||||
// "half-clipped-into-globe" look caused by depthTest clipping the billboard
|
||||
// against closer earth-surface geometry near the horizon.
|
||||
const _FACING_DOT_MIN_SQ = 0.05 * 0.05;
|
||||
|
||||
function isFacingCamera(lp, camera) {
|
||||
lp.getWorldPosition(_lpWorldPos);
|
||||
const d = _lpWorldPos.dot(camera.position);
|
||||
if (d <= 0) return false;
|
||||
return d * d > _FACING_DOT_MIN_SQ * _lpWorldPos.lengthSq() * camera.position.lengthSq();
|
||||
}
|
||||
|
||||
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
|
||||
const pulse =
|
||||
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
|
||||
@@ -560,58 +620,65 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
|
||||
: [];
|
||||
|
||||
landingPoints.forEach((lp) => {
|
||||
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isRelated =
|
||||
!dimAll &&
|
||||
Array.isArray(lp.userData.cableNames) &&
|
||||
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||
|
||||
if (isRelated) {
|
||||
lp.material.color.setHex(0xffd27a);
|
||||
lp.material.emissive.setHex(0x7a4a00);
|
||||
lp.material.emissiveIntensity =
|
||||
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
|
||||
0.2 +
|
||||
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
|
||||
lp.material.opacity =
|
||||
Math.max(
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: 0xffd27a,
|
||||
emissive: 0x7a4a00,
|
||||
emissiveIntensity:
|
||||
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
|
||||
0.2 +
|
||||
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
|
||||
opacity: Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
|
||||
);
|
||||
),
|
||||
});
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
lp.scale.setScalar(
|
||||
setLandingPointScale(
|
||||
lp,
|
||||
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
|
||||
baseScale *
|
||||
distanceScale,
|
||||
distanceScale,
|
||||
);
|
||||
} else {
|
||||
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
|
||||
const r = dimColor.r * brightness;
|
||||
const g = dimColor.g * brightness;
|
||||
const b = dimColor.b * brightness;
|
||||
lp.material.color.setRGB(r / 255, g / 255, b / 255);
|
||||
lp.material.emissive.setHex(CABLE_CONFIG.landingPointVisual.dimmed.emissive);
|
||||
lp.material.emissiveIntensity =
|
||||
CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity;
|
||||
lp.material.opacity = CABLE_CONFIG.landingPointVisual.dimmed.opacity;
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: new THREE.Color(r / 255, g / 255, b / 255),
|
||||
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
|
||||
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
|
||||
});
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
lp.scale.setScalar(baseScale * distanceScale);
|
||||
setLandingPointScale(lp, baseScale * distanceScale);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resetLandingPointVisualState(camera = null) {
|
||||
landingPoints.forEach((lp) => {
|
||||
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
|
||||
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
|
||||
lp.material.emissiveIntensity = CABLE_CONFIG.landingPoint.emissiveIntensity;
|
||||
lp.material.opacity = CABLE_CONFIG.landingPoint.opacity;
|
||||
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
});
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
lp.scale.setScalar(baseScale * distanceScale);
|
||||
setLandingPointScale(lp, baseScale * distanceScale);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
645
frontend/public/earth/js/callout-connector.js
Normal file
@@ -0,0 +1,645 @@
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DEFAULT_CLASS_NAME = "callout-connector";
|
||||
const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
|
||||
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
|
||||
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
|
||||
|
||||
function createSvgElement(tagName) {
|
||||
return document.createElementNS(SVG_NS, tagName);
|
||||
}
|
||||
|
||||
function resolveElementAnchorSide(side) {
|
||||
switch (side) {
|
||||
case "right":
|
||||
case "top":
|
||||
case "bottom":
|
||||
case "left":
|
||||
return side;
|
||||
default:
|
||||
return "left";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveElementAnchorAlignRatio(ratio) {
|
||||
if (!Number.isFinite(ratio)) return 0.5;
|
||||
return Math.min(Math.max(ratio, 0), 1);
|
||||
}
|
||||
|
||||
function resolveAnchorElement(target) {
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (target?.element instanceof HTMLElement) return target.element;
|
||||
if (typeof target?.selector === "string") {
|
||||
const matched = document.querySelector(target.selector);
|
||||
return matched instanceof HTMLElement ? matched : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRectElement(target) {
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (target?.element instanceof HTMLElement) return target.element;
|
||||
if (typeof target?.selector === "string") {
|
||||
const matched = document.querySelector(target.selector);
|
||||
return matched instanceof HTMLElement ? matched : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFiniteRect(rect) {
|
||||
return (
|
||||
rect &&
|
||||
Number.isFinite(rect.left) &&
|
||||
Number.isFinite(rect.top) &&
|
||||
Number.isFinite(rect.right) &&
|
||||
Number.isFinite(rect.bottom)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRect(rect) {
|
||||
if (!rect) return null;
|
||||
const left = Number(rect.left);
|
||||
const top = Number(rect.top);
|
||||
const right = Number(rect.right);
|
||||
const bottom = Number(rect.bottom);
|
||||
if (
|
||||
!Number.isFinite(left) ||
|
||||
!Number.isFinite(top) ||
|
||||
!Number.isFinite(right) ||
|
||||
!Number.isFinite(bottom)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: Math.min(left, right),
|
||||
top: Math.min(top, bottom),
|
||||
right: Math.max(left, right),
|
||||
bottom: Math.max(top, bottom),
|
||||
};
|
||||
}
|
||||
|
||||
function expandRect(rect, padding = 0) {
|
||||
const normalized = normalizeRect(rect);
|
||||
if (!normalized) return null;
|
||||
if (typeof padding === "object" && padding !== null) {
|
||||
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
|
||||
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
|
||||
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
|
||||
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
|
||||
return {
|
||||
left: normalized.left - leftPadding,
|
||||
top: normalized.top - topPadding,
|
||||
right: normalized.right + rightPadding,
|
||||
bottom: normalized.bottom + bottomPadding,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: normalized.left - padding,
|
||||
top: normalized.top - padding,
|
||||
right: normalized.right + padding,
|
||||
bottom: normalized.bottom + padding,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeSequentialPoints(points) {
|
||||
const nextPoints = [];
|
||||
for (const point of points) {
|
||||
const previous = nextPoints[nextPoints.length - 1];
|
||||
if (
|
||||
previous &&
|
||||
Math.abs(previous.x - point.x) < 0.5 &&
|
||||
Math.abs(previous.y - point.y) < 0.5
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
nextPoints.push(point);
|
||||
}
|
||||
return nextPoints;
|
||||
}
|
||||
|
||||
// Compute the anchor point on the nearest perimeter edge of rect to source.
|
||||
// gap is applied outward from the edge, so the anchor is outside the rect.
|
||||
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
|
||||
const { left, top, right, bottom } = rect;
|
||||
const midX = (left + right) * 0.5;
|
||||
const midY = (top + bottom) * 0.5;
|
||||
|
||||
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
|
||||
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
|
||||
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
|
||||
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
|
||||
|
||||
// Source inside rect: snap to nearest edge midpoint
|
||||
const dLeft = source.x - left;
|
||||
const dRight = right - source.x;
|
||||
const dTop = source.y - top;
|
||||
const dBottom = bottom - source.y;
|
||||
const minD = Math.min(dLeft, dRight, dTop, dBottom);
|
||||
|
||||
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
|
||||
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
|
||||
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
|
||||
return { x: midX, y: bottom + gap, side: "bottom" };
|
||||
}
|
||||
|
||||
export function resolveConnectorObstacleRect(target, options = {}) {
|
||||
if (!target) return null;
|
||||
|
||||
if (typeof target === "function") {
|
||||
return resolveConnectorObstacleRect(target(), options);
|
||||
}
|
||||
|
||||
const resolvedPadding =
|
||||
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
|
||||
? target.padding
|
||||
: options.padding;
|
||||
const padding =
|
||||
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
|
||||
? resolvedPadding
|
||||
: 0;
|
||||
|
||||
if (isFiniteRect(target)) {
|
||||
return expandRect(target, padding);
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isFinite(target.x) &&
|
||||
Number.isFinite(target.y) &&
|
||||
Number.isFinite(target.width) &&
|
||||
Number.isFinite(target.height)
|
||||
) {
|
||||
return expandRect(
|
||||
{
|
||||
left: Number(target.x),
|
||||
top: Number(target.y),
|
||||
right: Number(target.x) + Number(target.width),
|
||||
bottom: Number(target.y) + Number(target.height),
|
||||
},
|
||||
padding,
|
||||
);
|
||||
}
|
||||
|
||||
const element = resolveRectElement(target);
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
return expandRect(element.getBoundingClientRect(), padding);
|
||||
}
|
||||
|
||||
export function resolveConnectorAnchor(target) {
|
||||
if (!target) return null;
|
||||
|
||||
if (typeof target === "function") {
|
||||
return resolveConnectorAnchor(target());
|
||||
}
|
||||
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
|
||||
return { x: Number(target.x), y: Number(target.y) };
|
||||
}
|
||||
|
||||
const element = resolveAnchorElement(target);
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const side = resolveElementAnchorSide(target.side);
|
||||
const alignRatio = resolveElementAnchorAlignRatio(
|
||||
target.alignRatio ?? target.anchorRatio ?? target.ratio,
|
||||
);
|
||||
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
|
||||
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
|
||||
|
||||
let x = rect.left + rect.width * 0.5;
|
||||
let y = rect.top + rect.height * 0.5;
|
||||
|
||||
if (side === "left") {
|
||||
x = rect.left;
|
||||
y = rect.top + rect.height * alignRatio;
|
||||
} else if (side === "right") {
|
||||
x = rect.right;
|
||||
y = rect.top + rect.height * alignRatio;
|
||||
} else if (side === "top") {
|
||||
x = rect.left + rect.width * alignRatio;
|
||||
y = rect.top;
|
||||
} else if (side === "bottom") {
|
||||
x = rect.left + rect.width * alignRatio;
|
||||
y = rect.bottom;
|
||||
}
|
||||
|
||||
return {
|
||||
x: x + offsetX,
|
||||
y: y + offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConnectorRect(target) {
|
||||
return resolveConnectorObstacleRect(target, { padding: 0 });
|
||||
}
|
||||
|
||||
function createRectSideMidpoint(rect, side, gap = 0) {
|
||||
const normalizedRect = normalizeRect(rect);
|
||||
if (!normalizedRect) return null;
|
||||
|
||||
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
|
||||
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
|
||||
|
||||
if (side === "left") {
|
||||
return { x: normalizedRect.left - gap, y: midpointY, side };
|
||||
}
|
||||
if (side === "right") {
|
||||
return { x: normalizedRect.right + gap, y: midpointY, side };
|
||||
}
|
||||
if (side === "top") {
|
||||
return { x: midpointX, y: normalizedRect.top - gap, side };
|
||||
}
|
||||
if (side === "bottom") {
|
||||
return { x: midpointX, y: normalizedRect.bottom + gap, side };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function createRectEdgeAnchor(rect, side, position, gap = 0) {
|
||||
const normalizedRect = normalizeRect(rect);
|
||||
if (!normalizedRect) return null;
|
||||
|
||||
const x =
|
||||
side === "left"
|
||||
? normalizedRect.left - gap
|
||||
: side === "right"
|
||||
? normalizedRect.right + gap
|
||||
: clamp(
|
||||
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
|
||||
normalizedRect.left,
|
||||
normalizedRect.right,
|
||||
);
|
||||
const y =
|
||||
side === "top"
|
||||
? normalizedRect.top - gap
|
||||
: side === "bottom"
|
||||
? normalizedRect.bottom + gap
|
||||
: clamp(
|
||||
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
|
||||
normalizedRect.top,
|
||||
normalizedRect.bottom,
|
||||
);
|
||||
|
||||
return { x, y, side };
|
||||
}
|
||||
|
||||
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
|
||||
if (!startPoint || !endPoint) return null;
|
||||
const normalizedDirections = directions.filter(Boolean);
|
||||
if (!normalizedDirections.length) {
|
||||
return dedupeSequentialPoints([startPoint, endPoint]);
|
||||
}
|
||||
|
||||
const firstDirection = normalizedDirections[0];
|
||||
const corner =
|
||||
firstDirection === "left" || firstDirection === "right"
|
||||
? { x: endPoint.x, y: startPoint.y }
|
||||
: { x: startPoint.x, y: endPoint.y };
|
||||
|
||||
return dedupeSequentialPoints([startPoint, corner, endPoint]);
|
||||
}
|
||||
|
||||
function resolveSourceAnchorGapPx(sourceGapPx) {
|
||||
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
|
||||
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
|
||||
}
|
||||
|
||||
export function createElbowConnectorPoints(source, target, options = {}) {
|
||||
const resolvedSource = resolveConnectorAnchor(source);
|
||||
const resolvedTarget = resolveConnectorAnchor(target);
|
||||
if (!resolvedSource || !resolvedTarget) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
sourceGapPx = 12,
|
||||
targetGapPx = 8,
|
||||
elbowOffsetPx = 18,
|
||||
elbowDropPx = 14,
|
||||
} = options;
|
||||
|
||||
const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||
const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x) ||
|
||||
!Number.isFinite(sourcePoint.y) ||
|
||||
!Number.isFinite(targetPoint.x) ||
|
||||
!Number.isFinite(targetPoint.y)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const horizontalDirection = sourcePoint.x <= targetPoint.x ? 1 : -1;
|
||||
const startX = sourcePoint.x + horizontalDirection * sourceGapPx;
|
||||
const startY = sourcePoint.y;
|
||||
const endX = targetPoint.x - horizontalDirection * targetGapPx;
|
||||
const endY = targetPoint.y;
|
||||
const elbowX = endX - horizontalDirection * elbowOffsetPx;
|
||||
const elbowY = Math.min(startY, endY) + elbowDropPx;
|
||||
|
||||
if (Math.abs(endX - startX) < 8 && Math.abs(endY - startY) < 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderedPoints = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: elbowX, y: elbowY },
|
||||
{ x: endX, y: endY },
|
||||
];
|
||||
|
||||
return {
|
||||
points: startFrom === "target" ? orderedPoints.slice().reverse() : orderedPoints,
|
||||
start: startFrom === "target" ? orderedPoints[2] : orderedPoints[0],
|
||||
end: startFrom === "target" ? orderedPoints[0] : orderedPoints[2],
|
||||
};
|
||||
}
|
||||
|
||||
function createAdaptiveConnectorPoints(source, target, options = {}) {
|
||||
const resolvedSource = resolveConnectorAnchor(source);
|
||||
if (!resolvedSource) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
sourceGapPx = 12,
|
||||
targetGapPx = 8,
|
||||
obstacleClearancePx = 8,
|
||||
obstacles = [],
|
||||
targetAnchor = null,
|
||||
sourceRect = null,
|
||||
} = options;
|
||||
|
||||
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
|
||||
|
||||
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
|
||||
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
|
||||
.filter(Boolean);
|
||||
|
||||
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
|
||||
const resolvedTarget =
|
||||
resolveConnectorAnchor(targetAnchor ?? target) ||
|
||||
(fallbackTargetRect
|
||||
? computeNearestPerimeterAnchor(
|
||||
sp,
|
||||
fallbackTargetRect,
|
||||
Math.max(targetGapPx, obstacleClearancePx + 1),
|
||||
)
|
||||
: null);
|
||||
if (!resolvedTarget) return null;
|
||||
|
||||
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
|
||||
|
||||
const primaryObstacle = normalizedObstacles[0] || null;
|
||||
const relationRect = fallbackTargetRect || primaryObstacle;
|
||||
if (!relationRect) return null;
|
||||
|
||||
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
|
||||
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
|
||||
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
|
||||
|
||||
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
|
||||
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
|
||||
const isTargetAbove = relationRect.bottom < sp.y;
|
||||
const isTargetBelow = relationRect.top > sp.y;
|
||||
const isSourceWithinAnchorHorizontalRange =
|
||||
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
|
||||
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
|
||||
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
|
||||
|
||||
let directions = [];
|
||||
let targetSide = null;
|
||||
const isTargetCenterWithinSourceVerticalRange =
|
||||
sourceRelationRect &&
|
||||
targetCenterY >= sourceRelationRect.top &&
|
||||
targetCenterY <= sourceRelationRect.bottom;
|
||||
const isTargetCenterWithinSourceHorizontalRange =
|
||||
sourceRelationRect &&
|
||||
targetCenterX >= sourceRelationRect.left &&
|
||||
targetCenterX <= sourceRelationRect.right;
|
||||
|
||||
if (isRightMidpointLeftOfSource) {
|
||||
targetSide = "right";
|
||||
if (isTargetCenterWithinSourceVerticalRange) {
|
||||
directions = ["left"];
|
||||
} else if (rightMidpoint.y < sp.y) {
|
||||
directions = ["top", "left"];
|
||||
} else if (rightMidpoint.y > sp.y) {
|
||||
directions = ["bottom", "left"];
|
||||
} else {
|
||||
directions = ["left"];
|
||||
}
|
||||
} else if (isLeftMidpointRightOfSource) {
|
||||
targetSide = "left";
|
||||
if (isTargetCenterWithinSourceVerticalRange) {
|
||||
directions = ["right"];
|
||||
} else if (leftMidpoint.y < sp.y) {
|
||||
directions = ["top", "right"];
|
||||
} else if (leftMidpoint.y > sp.y) {
|
||||
directions = ["bottom", "right"];
|
||||
} else {
|
||||
directions = ["right"];
|
||||
}
|
||||
} else if (isSourceWithinAnchorHorizontalRange) {
|
||||
if (isTargetAbove) {
|
||||
targetSide = "bottom";
|
||||
directions = isTargetCenterWithinSourceHorizontalRange
|
||||
? ["top"]
|
||||
: targetCenterX >= sp.x
|
||||
? ["right", "top"]
|
||||
: ["left", "top"];
|
||||
} else if (isTargetBelow) {
|
||||
targetSide = "top";
|
||||
directions = isTargetCenterWithinSourceHorizontalRange
|
||||
? ["bottom"]
|
||||
: targetCenterX >= sp.x
|
||||
? ["right", "bottom"]
|
||||
: ["left", "bottom"];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetSide || !directions.length) {
|
||||
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||
}
|
||||
|
||||
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
|
||||
const targetPoint =
|
||||
resolvedTarget && targetAnchor
|
||||
? end
|
||||
: derivedTargetAnchor || end;
|
||||
|
||||
const sourceSide = directions[0] || null;
|
||||
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
|
||||
const shouldSlideSourceAnchorAlongEdge =
|
||||
(sourceSide === "left" || sourceSide === "right") &&
|
||||
isTargetCenterWithinSourceVerticalRange ||
|
||||
(sourceSide === "top" || sourceSide === "bottom") &&
|
||||
isTargetCenterWithinSourceHorizontalRange;
|
||||
const derivedSourceAnchor =
|
||||
sourceRelationRect && sourceSide
|
||||
? shouldSlideSourceAnchorAlongEdge
|
||||
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
|
||||
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
|
||||
: null;
|
||||
const startPoint = derivedSourceAnchor || sp;
|
||||
|
||||
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
|
||||
if (!pts) {
|
||||
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||
}
|
||||
pts = dedupeSequentialPoints(pts);
|
||||
return {
|
||||
points: startFrom === "target" ? pts.slice().reverse() : pts,
|
||||
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
|
||||
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
export function createConnectorPath(source, target, options = {}) {
|
||||
const {
|
||||
routingMode = "simple",
|
||||
} = options;
|
||||
|
||||
if (routingMode === "adaptive") {
|
||||
return createAdaptiveConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
if (routingMode === "simple") {
|
||||
return createElbowConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
return createElbowConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
export class CalloutConnector {
|
||||
constructor({
|
||||
container = null,
|
||||
containerId = "container",
|
||||
className = DEFAULT_CLASS_NAME,
|
||||
drawAnimationName = DEFAULT_DRAW_ANIMATION_NAME,
|
||||
} = {}) {
|
||||
this.container = container;
|
||||
this.containerId = containerId;
|
||||
this.className = className;
|
||||
this.drawAnimationName = drawAnimationName;
|
||||
this.connectorEl = null;
|
||||
this.polylineEl = null;
|
||||
this.startpointEl = null;
|
||||
this.endpointEl = null;
|
||||
}
|
||||
|
||||
resolveContainer() {
|
||||
if (this.container instanceof HTMLElement) return this.container;
|
||||
this.container = document.getElementById(this.containerId);
|
||||
return this.container instanceof HTMLElement ? this.container : null;
|
||||
}
|
||||
|
||||
ensure() {
|
||||
if (this.connectorEl instanceof SVGSVGElement) {
|
||||
return this.connectorEl;
|
||||
}
|
||||
|
||||
const container = this.resolveContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const connector = createSvgElement("svg");
|
||||
connector.setAttribute("class", this.className);
|
||||
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
||||
connector.setAttribute("preserveAspectRatio", "none");
|
||||
|
||||
const polyline = createSvgElement("polyline");
|
||||
const startpoint = createSvgElement("circle");
|
||||
const endpoint = createSvgElement("circle");
|
||||
startpoint.setAttribute("r", "4");
|
||||
endpoint.setAttribute("r", "4");
|
||||
|
||||
connector.append(startpoint, polyline, endpoint);
|
||||
container.appendChild(connector);
|
||||
|
||||
connector.addEventListener("animationend", (event) => {
|
||||
if (
|
||||
event.animationName === this.drawAnimationName &&
|
||||
this.connectorEl?.classList.contains("is-visible")
|
||||
) {
|
||||
if (this.polylineEl) {
|
||||
this.polylineEl.style.strokeDashoffset = "0";
|
||||
}
|
||||
this.connectorEl?.classList.remove("is-animating");
|
||||
}
|
||||
});
|
||||
|
||||
this.connectorEl = connector;
|
||||
this.polylineEl = polyline;
|
||||
this.startpointEl = startpoint;
|
||||
this.endpointEl = endpoint;
|
||||
return connector;
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return this.connectorEl?.classList.contains("is-visible") === true;
|
||||
}
|
||||
|
||||
isAnimating() {
|
||||
return this.connectorEl?.classList.contains("is-animating") === true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
const connector = this.ensure();
|
||||
if (!connector) return;
|
||||
connector.classList.remove("is-visible", "is-animating");
|
||||
}
|
||||
|
||||
render(path, { animate = false } = {}) {
|
||||
const connector = this.ensure();
|
||||
if (
|
||||
!connector ||
|
||||
!this.polylineEl ||
|
||||
!this.startpointEl ||
|
||||
!this.endpointEl ||
|
||||
!Array.isArray(path?.points) ||
|
||||
path.points.length < 2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewWidth = window.innerWidth;
|
||||
const viewHeight = window.innerHeight;
|
||||
connector.setAttribute("viewBox", `0 0 ${viewWidth} ${viewHeight}`);
|
||||
|
||||
const pointsText = path.points
|
||||
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
this.polylineEl.setAttribute("points", pointsText);
|
||||
this.startpointEl.setAttribute("cx", path.start.x.toFixed(2));
|
||||
this.startpointEl.setAttribute("cy", path.start.y.toFixed(2));
|
||||
this.endpointEl.setAttribute("cx", path.end.x.toFixed(2));
|
||||
this.endpointEl.setAttribute("cy", path.end.y.toFixed(2));
|
||||
|
||||
const totalLength =
|
||||
typeof this.polylineEl.getTotalLength === "function"
|
||||
? this.polylineEl.getTotalLength()
|
||||
: 0;
|
||||
|
||||
this.polylineEl.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
|
||||
this.polylineEl.style.strokeDashoffset =
|
||||
totalLength > 0 ? `${animate ? totalLength : 0}` : "";
|
||||
connector.style.setProperty(
|
||||
"--connector-length",
|
||||
totalLength > 0 ? `${totalLength}` : "0px",
|
||||
);
|
||||
connector.classList.add("is-visible");
|
||||
|
||||
if (animate && totalLength > 0) {
|
||||
connector.classList.remove("is-animating");
|
||||
void connector.getBoundingClientRect();
|
||||
this.polylineEl.style.strokeDashoffset = `${totalLength}`;
|
||||
connector.classList.add("is-animating");
|
||||
} else {
|
||||
connector.classList.remove("is-animating");
|
||||
}
|
||||
|
||||
return totalLength > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as THREE from "three";
|
||||
import * as Astronomy from "astronomy-engine";
|
||||
|
||||
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||
import {
|
||||
CELESTIAL_CONFIG,
|
||||
EARTH_CONFIG,
|
||||
SCENE_LIGHT_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
@@ -21,7 +25,11 @@ let moonDirection = defaultMoonDirection.clone();
|
||||
let lastUpdatedAt = 0;
|
||||
let linkedSunLight = null;
|
||||
let linkedBackLight = null;
|
||||
let linkedAmbientLight = null;
|
||||
let linkedPointLight = null;
|
||||
let linkedCamera = null;
|
||||
let linkedEarth = null;
|
||||
let dayNightLightingEnabled = true;
|
||||
let brightStarSprites = [];
|
||||
let celestialRotationQuaternion = new THREE.Quaternion();
|
||||
let celestialViewQuaternion = new THREE.Quaternion();
|
||||
@@ -333,10 +341,71 @@ function updateSpritePositions() {
|
||||
}
|
||||
|
||||
function updateLighting() {
|
||||
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
|
||||
const inspection = CELESTIAL_CONFIG.inspectionLighting;
|
||||
const cameraDirection = linkedCamera
|
||||
? linkedCamera.position.clone().normalize()
|
||||
: defaultSunDirection.clone();
|
||||
const worldUp = new THREE.Vector3(0, 1, 0);
|
||||
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
|
||||
if (right.lengthSq() < 1e-6) {
|
||||
right.set(1, 0, 0);
|
||||
} else {
|
||||
right.normalize();
|
||||
}
|
||||
const adjustedUp = new THREE.Vector3()
|
||||
.crossVectors(cameraDirection, right)
|
||||
.normalize();
|
||||
|
||||
const resolveInspectionDirection = (offset) =>
|
||||
cameraDirection
|
||||
.clone()
|
||||
.multiplyScalar(offset.z)
|
||||
.add(right.clone().multiplyScalar(offset.x))
|
||||
.add(adjustedUp.clone().multiplyScalar(offset.y))
|
||||
.normalize();
|
||||
|
||||
if (linkedAmbientLight) {
|
||||
linkedAmbientLight.color.setHex(inspection.ambientColor);
|
||||
linkedAmbientLight.intensity = inspection.ambientIntensity;
|
||||
}
|
||||
|
||||
if (linkedSunLight) {
|
||||
linkedSunLight.color.setHex(inspection.keyLightColor);
|
||||
linkedSunLight.intensity = inspection.keyLightIntensity;
|
||||
linkedSunLight.position
|
||||
.copy(resolveInspectionDirection(inspection.keyLightOffset))
|
||||
.multiplyScalar(inspection.keyLightDistance);
|
||||
}
|
||||
|
||||
if (linkedBackLight) {
|
||||
linkedBackLight.color.setHex(inspection.backLightColor);
|
||||
linkedBackLight.intensity = inspection.backLightIntensity;
|
||||
linkedBackLight.position
|
||||
.copy(resolveInspectionDirection(inspection.backLightOffset))
|
||||
.multiplyScalar(inspection.backLightDistance);
|
||||
}
|
||||
|
||||
if (linkedPointLight) {
|
||||
linkedPointLight.color.setHex(inspection.pointLightColor);
|
||||
linkedPointLight.intensity = inspection.pointLightIntensity;
|
||||
linkedPointLight.position
|
||||
.copy(resolveInspectionDirection(inspection.pointLightOffset))
|
||||
.multiplyScalar(inspection.pointLightDistance);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const physicalSunDirection = getPhysicalSunDirection(
|
||||
new Date(lastUpdatedAt || Date.now()),
|
||||
);
|
||||
|
||||
if (linkedAmbientLight) {
|
||||
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
|
||||
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
|
||||
}
|
||||
|
||||
if (linkedSunLight) {
|
||||
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
|
||||
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
|
||||
@@ -352,6 +421,16 @@ function updateLighting() {
|
||||
.copy(physicalSunDirection)
|
||||
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
|
||||
}
|
||||
|
||||
if (linkedPointLight) {
|
||||
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
|
||||
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
|
||||
linkedPointLight.position.set(
|
||||
SCENE_LIGHT_CONFIG.point.position.x,
|
||||
SCENE_LIGHT_CONFIG.point.position.y,
|
||||
SCENE_LIGHT_CONFIG.point.position.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function computeCelestialState(date = new Date()) {
|
||||
@@ -364,14 +443,24 @@ function computeCelestialState(date = new Date()) {
|
||||
|
||||
export function initCelestialLayer(
|
||||
scene,
|
||||
{ camera = null, sunLight = null, backLight = null, earth = null } = {},
|
||||
{
|
||||
camera = null,
|
||||
sunLight = null,
|
||||
backLight = null,
|
||||
ambientLight = null,
|
||||
pointLight = null,
|
||||
earth = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
|
||||
|
||||
disposeCelestialLayer();
|
||||
|
||||
linkedCamera = camera;
|
||||
linkedSunLight = sunLight;
|
||||
linkedBackLight = backLight;
|
||||
linkedAmbientLight = ambientLight;
|
||||
linkedPointLight = pointLight;
|
||||
linkedEarth = earth;
|
||||
|
||||
celestialRoot = new THREE.Group();
|
||||
@@ -457,6 +546,10 @@ export function initCelestialLayer(
|
||||
export function updateCelestialLayer(date = new Date(), camera = null) {
|
||||
if (!celestialRoot) return;
|
||||
|
||||
if (camera) {
|
||||
linkedCamera = camera;
|
||||
}
|
||||
|
||||
refreshCelestialView();
|
||||
|
||||
const now = date.getTime();
|
||||
@@ -503,6 +596,11 @@ export function setCelestialFollow(nextFollow = {}) {
|
||||
return getCelestialDebugState();
|
||||
}
|
||||
|
||||
export function setCelestialDayNightEnabled(enabled) {
|
||||
dayNightLightingEnabled = enabled;
|
||||
updateLighting();
|
||||
}
|
||||
|
||||
export function disposeCelestialLayer() {
|
||||
if (celestialRoot?.parent) {
|
||||
celestialRoot.parent.remove(celestialRoot);
|
||||
@@ -531,7 +629,11 @@ export function disposeCelestialLayer() {
|
||||
lastUpdatedAt = 0;
|
||||
linkedSunLight = null;
|
||||
linkedBackLight = null;
|
||||
linkedAmbientLight = null;
|
||||
linkedPointLight = null;
|
||||
linkedCamera = null;
|
||||
linkedEarth = null;
|
||||
dayNightLightingEnabled = true;
|
||||
sunDirection.copy(defaultSunDirection);
|
||||
moonDirection.copy(defaultMoonDirection);
|
||||
runtimeOrientationEuler = {
|
||||
|
||||
96
frontend/public/earth/js/client-logs.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { PATHS } from "./constants.js";
|
||||
|
||||
const RECENT_EVENT_TTL_MS = 15_000;
|
||||
const recentEventMap = new Map();
|
||||
|
||||
function normalizeErrorDetail(detail) {
|
||||
if (!detail) return "";
|
||||
if (detail instanceof Error) {
|
||||
return detail.stack || detail.message || String(detail);
|
||||
}
|
||||
if (typeof detail === "string") {
|
||||
return detail;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return String(detail);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeKey(level, message, detail, category) {
|
||||
return `${level}::${category || ""}::${message}::${detail}`;
|
||||
}
|
||||
|
||||
function shouldSkip(level, message, detail, category) {
|
||||
const key = dedupeKey(level, message, detail, category);
|
||||
const now = Date.now();
|
||||
const lastSeenAt = recentEventMap.get(key);
|
||||
recentEventMap.set(key, now);
|
||||
|
||||
for (const [entryKey, entryTime] of recentEventMap.entries()) {
|
||||
if (now - entryTime > RECENT_EVENT_TTL_MS) {
|
||||
recentEventMap.delete(entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS;
|
||||
}
|
||||
|
||||
export async function reportEarthClientLog({
|
||||
level = "error",
|
||||
message,
|
||||
category = "runtime",
|
||||
module = "earth",
|
||||
detail = "",
|
||||
}) {
|
||||
if (!message) return;
|
||||
const normalizedDetail = normalizeErrorDetail(detail);
|
||||
if (shouldSkip(level, message, normalizedDetail, category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(PATHS.earthClientLogsApi, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
category,
|
||||
module,
|
||||
url: window.location.href,
|
||||
detail: normalizedDetail.slice(0, 4000),
|
||||
}),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Swallow reporting failures to avoid recursive log noise.
|
||||
}
|
||||
}
|
||||
|
||||
export function registerEarthClientErrorHandlers() {
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "window-error",
|
||||
module: "main",
|
||||
message: event.message || "Earth 页面发生未捕获错误",
|
||||
detail: event.error || `${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("未处理的 Promise 错误:", event.reason);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "unhandledrejection",
|
||||
module: "main",
|
||||
message: "Earth 页面发生未处理 Promise 错误",
|
||||
detail: event.reason,
|
||||
});
|
||||
});
|
||||
}
|
||||
376
frontend/public/earth/js/compute-centers.js
Normal file
@@ -0,0 +1,376 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const computeCenterGroup = new THREE.Group();
|
||||
const computeCenterMarkers = [];
|
||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||
const textureCache = new Map();
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
let gpuClusterCount = 0;
|
||||
|
||||
function buildComputeCenterMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
const longitude = Number(coordinates[0]);
|
||||
const latitude = Number(coordinates[1]);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...props,
|
||||
latitude,
|
||||
longitude,
|
||||
displayLatitude: latitude,
|
||||
displayLongitude: longitude,
|
||||
site_type: normalizeSiteType(props.site_type),
|
||||
};
|
||||
}
|
||||
|
||||
function spreadComputeCenterPositions(markers) {
|
||||
const groups = new Map();
|
||||
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key).push(marker);
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
if (group.length <= 1) return;
|
||||
|
||||
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
|
||||
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
|
||||
group.forEach((marker, index) => {
|
||||
const angle = (Math.PI * 2 * index) / group.length;
|
||||
marker.displayLatitude =
|
||||
marker.latitude + Math.sin(angle) * radius * offsetStep;
|
||||
marker.displayLongitude =
|
||||
marker.longitude + Math.cos(angle) * radius * offsetStep;
|
||||
marker.isSpread = true;
|
||||
marker.groupSize = group.length;
|
||||
});
|
||||
});
|
||||
|
||||
markers.forEach((marker) => {
|
||||
if (marker.isSpread) return;
|
||||
marker.displayLatitude = marker.latitude;
|
||||
marker.displayLongitude = marker.longitude;
|
||||
marker.isSpread = false;
|
||||
marker.groupSize = 1;
|
||||
});
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
function createMarkerTexture(siteType, isEstimated = false) {
|
||||
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
|
||||
if (textureCache.has(textureKey)) {
|
||||
return textureCache.get(textureKey);
|
||||
}
|
||||
|
||||
const color =
|
||||
COMPUTE_CENTER_CONFIG.colors[siteType] ||
|
||||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
const centerX = 64;
|
||||
const centerY = 64;
|
||||
const baseFill = color;
|
||||
|
||||
function fillPath(draw, options = {}) {
|
||||
const { fillStyle = color } = options;
|
||||
context.save();
|
||||
context.fillStyle = fillStyle;
|
||||
context.beginPath();
|
||||
draw();
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 128, 128);
|
||||
|
||||
if (siteType === "supercomputer") {
|
||||
fillPath(() => {
|
||||
context.roundRect(40, 42, 48, 30, 7);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.roundRect(58, 74, 12, 8, 3);
|
||||
context.roundRect(50, 84, 28, 5, 2.5);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
} else {
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
|
||||
context.rect(46, 46, 36, 28);
|
||||
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
|
||||
context.rect(52, 58, 24, 6);
|
||||
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEstimated) {
|
||||
fillPath(() => {
|
||||
context.arc(94, 36, 12, 0, Math.PI * 2);
|
||||
}, {
|
||||
fillStyle: "rgba(15,23,42,0.92)",
|
||||
});
|
||||
context.save();
|
||||
context.fillStyle = "rgba(255,255,255,0.98)";
|
||||
context.font = "bold 18px sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("?", 94, 36);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function normalizeSiteType(siteType) {
|
||||
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
|
||||
}
|
||||
|
||||
function getBaseScale(siteType) {
|
||||
return siteType === "supercomputer"
|
||||
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
|
||||
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
|
||||
}
|
||||
|
||||
function getDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
|
||||
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
|
||||
});
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function createComputeCenterMarker(markerData) {
|
||||
const siteType = markerData.site_type;
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
|
||||
});
|
||||
const marker = new THREE.Sprite(material);
|
||||
const baseScale = getBaseScale(siteType);
|
||||
marker.position.copy(
|
||||
latLonToVector3(
|
||||
markerData.displayLatitude,
|
||||
markerData.displayLongitude,
|
||||
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(baseScale);
|
||||
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
|
||||
marker.visible = showComputeCenters;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
site_type: siteType,
|
||||
type: "compute_center",
|
||||
baseScale,
|
||||
state: "normal",
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
};
|
||||
computeCenterGroup.add(marker);
|
||||
computeCenterMarkers.push(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function formatComputeCenterTypeLabel(siteType) {
|
||||
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
|
||||
}
|
||||
|
||||
export function formatComputeCenterCapacity(markerData) {
|
||||
const value = markerData?.capacity_value;
|
||||
const unit = markerData?.capacity_unit;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return `${value}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatComputeCenterUpdatedAt(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatComputeCenterLocationPrecision(markerData) {
|
||||
const precision = markerData?.location_precision;
|
||||
if (precision === "precise") return "精确坐标";
|
||||
if (precision === "estimated_site") return "估算位置(站点级)";
|
||||
if (precision === "estimated_country") return "估算位置(国家级)";
|
||||
return "位置未知";
|
||||
}
|
||||
|
||||
export function getComputeCenterLegendItems() {
|
||||
return [
|
||||
{
|
||||
label: "超算中心",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
|
||||
},
|
||||
{
|
||||
label: "GPU 集群",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getComputeCenterMarkers() {
|
||||
return computeCenterMarkers;
|
||||
}
|
||||
|
||||
export function getComputeCenterCount() {
|
||||
return computeCenterMarkers.length;
|
||||
}
|
||||
|
||||
export function getComputeCenterSupercomputerCount() {
|
||||
return supercomputerCount;
|
||||
}
|
||||
|
||||
export function getComputeCenterGPUClusterCount() {
|
||||
return gpuClusterCount;
|
||||
}
|
||||
|
||||
export function getComputeCenterStatusSummary() {
|
||||
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
|
||||
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
|
||||
}
|
||||
|
||||
export function setComputeCenterMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== "compute_center") return;
|
||||
marker.userData.state = state;
|
||||
}
|
||||
|
||||
export function clearComputeCenterSelection() {
|
||||
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
|
||||
}
|
||||
|
||||
export function clearComputeCenterData(earth) {
|
||||
computeCenterMarkers.length = 0;
|
||||
supercomputerCount = 0;
|
||||
gpuClusterCount = 0;
|
||||
clearGroup(computeCenterGroup);
|
||||
if (earth && computeCenterGroup.parent === earth) {
|
||||
earth.remove(computeCenterGroup);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleComputeCenters(show) {
|
||||
showComputeCenters = Boolean(show);
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowComputeCenters() {
|
||||
return showComputeCenters;
|
||||
}
|
||||
|
||||
export async function loadComputeCenters(_scene, earth) {
|
||||
const response = await fetch(PATHS.computeCentersApi);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Compute centers HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearComputeCenterData(earth);
|
||||
|
||||
spreadComputeCenterPositions(
|
||||
features
|
||||
.map((feature) => buildComputeCenterMarkerData(feature))
|
||||
.filter(Boolean),
|
||||
)
|
||||
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
|
||||
.forEach((markerData) => {
|
||||
const marker = createComputeCenterMarker(markerData);
|
||||
if (!marker) return;
|
||||
if (marker.userData.site_type === "supercomputer") {
|
||||
supercomputerCount += 1;
|
||||
} else {
|
||||
gpuClusterCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (earth && !computeCenterGroup.parent) {
|
||||
earth.add(computeCenterGroup);
|
||||
}
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
|
||||
return {
|
||||
totalCount: computeCenterMarkers.length,
|
||||
supercomputerCount,
|
||||
gpuClusterCount,
|
||||
summary: getComputeCenterStatusSummary(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
|
||||
const now = Date.now();
|
||||
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
|
||||
const state = marker.userData?.state || "normal";
|
||||
const pulse =
|
||||
1 +
|
||||
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
|
||||
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
|
||||
|
||||
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
|
||||
let scaleMultiplier = 1;
|
||||
|
||||
if (isLocked) {
|
||||
opacity = 1;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
|
||||
} else if (state === "hover") {
|
||||
opacity = 0.98;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
|
||||
} else if (hasFocus) {
|
||||
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
|
||||
}
|
||||
|
||||
const distanceScale = getDistanceScale(marker, camera);
|
||||
marker.material.opacity = showComputeCenters ? opacity : 0;
|
||||
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scene configuration
|
||||
export const CONFIG = {
|
||||
defaultCameraZ: 300,
|
||||
defaultViewZoom: 1.0,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 5.0,
|
||||
earthRadius: 100,
|
||||
@@ -17,6 +18,21 @@ export const ROTATION_MODE = {
|
||||
CRUISE: "cruise",
|
||||
};
|
||||
|
||||
export const CRUISE_MODULES = {
|
||||
BGP: "bgp",
|
||||
NEWS: "news",
|
||||
};
|
||||
|
||||
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
|
||||
|
||||
export const SATELLITE_DISPLAY_STYLES = {
|
||||
SELF_GLOW: "self_glow",
|
||||
GROUND_FOOTPRINT: "ground_footprint",
|
||||
};
|
||||
|
||||
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
|
||||
SATELLITE_DISPLAY_STYLES.SELF_GLOW;
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
@@ -24,12 +40,12 @@ export const CRUISE_CONFIG = {
|
||||
maxPolledEvents: 200,
|
||||
cardAnchorXRatio: 0.68,
|
||||
cardAnchorYRatio: 0.24,
|
||||
linkMarkerGapPx: 18,
|
||||
linkPanelGapPx: 12,
|
||||
linkElbowOffsetPx: 72,
|
||||
linkAnchorHeightRatio: 0.26,
|
||||
linkForcedBendPx: 34,
|
||||
linkElbowDropPx: 24,
|
||||
};
|
||||
|
||||
export const CONNECTOR_CONFIG = {
|
||||
markerGapPx: 18,
|
||||
panelGapPx: 12,
|
||||
obstacleClearancePx: 8,
|
||||
};
|
||||
|
||||
export const HUD_CONFIG = {
|
||||
@@ -92,6 +108,45 @@ export const CELESTIAL_CONFIG = {
|
||||
sunLightColor: 0xfff4df,
|
||||
backLightIntensity: 0.3,
|
||||
backLightColor: 0x2b4c78,
|
||||
inspectionLighting: {
|
||||
enabled: true,
|
||||
ambientIntensity: 0.64,
|
||||
ambientColor: 0x707070,
|
||||
keyLightIntensity: 0.92,
|
||||
keyLightColor: 0xfcfcfb,
|
||||
keyLightDistance: 380,
|
||||
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
|
||||
backLightIntensity: 0.26,
|
||||
backLightColor: 0x8f96a0,
|
||||
backLightDistance: 260,
|
||||
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
|
||||
pointLightIntensity: 0.36,
|
||||
pointLightColor: 0xfafcff,
|
||||
pointLightDistance: 320,
|
||||
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
|
||||
},
|
||||
};
|
||||
|
||||
export const SCENE_LIGHT_CONFIG = {
|
||||
ambient: {
|
||||
color: 0x404060,
|
||||
intensity: 1,
|
||||
},
|
||||
sun: {
|
||||
color: 0xffffff,
|
||||
intensity: 1.2,
|
||||
position: { x: 5, y: 3, z: 5 },
|
||||
},
|
||||
back: {
|
||||
color: 0x446688,
|
||||
intensity: 0.3,
|
||||
position: { x: -5, y: 0, z: -5 },
|
||||
},
|
||||
point: {
|
||||
color: 0xffffff,
|
||||
intensity: 0.4,
|
||||
position: { x: 10, y: 10, z: 10 },
|
||||
},
|
||||
};
|
||||
|
||||
export const TERRAIN_CONFIG = {
|
||||
@@ -100,7 +155,7 @@ export const TERRAIN_CONFIG = {
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
baseRadiusOffset: 0.16,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
@@ -113,12 +168,72 @@ export const TERRAIN_CONFIG = {
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
dataPath: "/earth/data/countries-admin0.min.geojson",
|
||||
lineAltitudeOffset: 0.24,
|
||||
hoverAltitudeOffset: 0.32,
|
||||
lineColor: 0x7fc7ff,
|
||||
lineOpacity: 0.58,
|
||||
lineRenderOrder: 2.2,
|
||||
dimmedLineOpacity: 0.18,
|
||||
hoverLineColor: 0xff3b1f,
|
||||
hoverLineOpacity: 1.0,
|
||||
hoverLineRenderOrder: 2.3,
|
||||
hoverGlowOpacity: 0.38,
|
||||
hoverGlowLineWidth: 3,
|
||||
hoverGlowRenderOrderOffset: 0.01,
|
||||
hoverGlowRadiusOffset: 0.04,
|
||||
tintAltitudeOffset: 0.04,
|
||||
tintColor: 0x0b1830,
|
||||
tintRenderOrder: 0.2,
|
||||
landColor: 0x080f1b,
|
||||
landOpacity: 1.0,
|
||||
landAltitudeOffset: 0.08,
|
||||
landRenderOrder: 0.86,
|
||||
landMaskWidth: 2048,
|
||||
landMaskHeight: 1024,
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||
earthSummaryApi: '/api/v1/visualization/geo/summary',
|
||||
earthClientLogsApi: '/api/v1/system/logs/earth-client',
|
||||
};
|
||||
|
||||
export const COMPUTE_CENTER_CONFIG = {
|
||||
altitudeOffset: 0.48,
|
||||
maxRenderedMarkers: 300,
|
||||
overlapSpread: {
|
||||
groupPrecision: 4,
|
||||
radius: 1.4,
|
||||
offsetStep: 0.28,
|
||||
},
|
||||
marker: {
|
||||
baseOpacity: 0.88,
|
||||
supercomputerScale: 12,
|
||||
gpuClusterScale: 12,
|
||||
hoverScale: 1.16,
|
||||
lockedScale: 1.22,
|
||||
dimmedScale: 0.82,
|
||||
dimmedOpacity: 0.34,
|
||||
pulseSpeed: 0.0038,
|
||||
pulseAmplitude: 0.03,
|
||||
},
|
||||
colors: {
|
||||
supercomputer: "#38bdf8",
|
||||
gpu_cluster: "#2dd4bf",
|
||||
linked: "#f8fafc",
|
||||
},
|
||||
sizeStabilization: {
|
||||
enabled: true,
|
||||
min: 0.12,
|
||||
max: 3.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Cable colors mapping
|
||||
@@ -145,15 +260,17 @@ export const CABLE_CONFIG = {
|
||||
renderOrder: 1,
|
||||
},
|
||||
landingPoint: {
|
||||
altitudeOffset: 0.1,
|
||||
radius: 0.4,
|
||||
widthSegments: 16,
|
||||
heightSegments: 16,
|
||||
baseScale: 2.5,
|
||||
altitudeOffset: 0.48,
|
||||
textureSize: 256,
|
||||
iconAspectRatio: 0.82,
|
||||
anchorX: 0.52,
|
||||
anchorY: 0.276,
|
||||
baseScale: 12,
|
||||
color: 0xffaa00,
|
||||
emissive: 0x442200,
|
||||
emissiveIntensity: 0.5,
|
||||
opacity: 1.0,
|
||||
renderOrder: 4.5,
|
||||
},
|
||||
landingPointSizeStabilization: {
|
||||
enabled: true,
|
||||
@@ -163,7 +280,7 @@ export const CABLE_CONFIG = {
|
||||
},
|
||||
landingPointVisual: {
|
||||
pulseSpeed: 0.003,
|
||||
dimBrightness: 0.3,
|
||||
dimBrightness: 0.62,
|
||||
related: {
|
||||
emissiveIntensityBase: 0.5,
|
||||
emissiveIntensityPulse: 0.5,
|
||||
@@ -173,10 +290,10 @@ export const CABLE_CONFIG = {
|
||||
scalePulse: 0.3,
|
||||
},
|
||||
dimmed: {
|
||||
colorRGB: { r: 255, g: 170, b: 0 },
|
||||
emissive: 0x000000,
|
||||
emissiveIntensity: 0,
|
||||
opacity: 0.3,
|
||||
colorRGB: { r: 180, g: 116, b: 28 },
|
||||
emissive: 0x3a2200,
|
||||
emissiveIntensity: 0.18,
|
||||
opacity: 0.78,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -189,10 +306,16 @@ export const CABLE_STATE = {
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
initialLoadCount: 2400,
|
||||
hydrateFullAfterInitialLoad: true,
|
||||
initialLoadCount: null,
|
||||
hydrateFullAfterInitialLoad: false,
|
||||
trailLength: 10,
|
||||
dotSize: 4,
|
||||
trailLineWidth: 3,
|
||||
displayAltitudeOffset: 8,
|
||||
frontFacingDotThreshold: 0.015,
|
||||
overlayRenderOrder: 12,
|
||||
dotBaseSize: 2.8,
|
||||
dotBackdropScale: 1.28,
|
||||
dotZoomScalePower: 1,
|
||||
ringSize: 0.07,
|
||||
apiPath: '/api/v1/visualization/geo/satellites',
|
||||
breathingSpeed: 0.08,
|
||||
@@ -299,19 +422,43 @@ export const PREDICTED_ORBIT_CONFIG = {
|
||||
};
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
latitudeStep: 10,
|
||||
radiusOffset: 0.14,
|
||||
color: 0xc0e0ff,
|
||||
opacity: 0.08,
|
||||
lineWidth: 1,
|
||||
renderOrder: 2.05,
|
||||
latitudeStep: 15,
|
||||
longitudeStep: 30,
|
||||
gridStep: 5
|
||||
segmentStep: 5,
|
||||
};
|
||||
|
||||
export const CLOUD_LAYER_CONFIG = {
|
||||
radiusOffset: 3,
|
||||
widthSegments: 64,
|
||||
heightSegments: 64,
|
||||
opacity: 0.15,
|
||||
textureUrl: "./assets/earth_clouds_1024.png",
|
||||
};
|
||||
|
||||
export const STARFIELD_CONFIG = {
|
||||
count: 8000,
|
||||
minRadius: 800,
|
||||
radiusJitter: 200,
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
};
|
||||
|
||||
export const EARTH_MATERIAL_CONFIG = {
|
||||
// Diffuse color multiplies with texture — pure white = full saturation,
|
||||
// slightly grey-blue pulls perceived saturation down without a custom shader.
|
||||
color: 0xcdd8e6,
|
||||
// Base sphere sits below the country fill and high-res texture overlays.
|
||||
// Keep it dark so a delayed overlay never flashes or reads as a white layer.
|
||||
color: 0x010609,
|
||||
specular: 0x1a2d45,
|
||||
shininess: 12,
|
||||
emissive: 0x050a12,
|
||||
opacity: 0.96,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: 0.1,
|
||||
textureOverlayOpacity: 0.88,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
|
||||
// Depth-mask occluder keeps far-side objects hidden behind the earth
|
||||
occluderRadiusFactor: 0.999,
|
||||
|
||||
2303
frontend/public/earth/js/controls.js
vendored
500
frontend/public/earth/js/country-boundaries.js
Normal file
@@ -0,0 +1,500 @@
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||||
let _earthObj = null;
|
||||
let _features = [];
|
||||
let _landMesh = null;
|
||||
let _tintMesh = null;
|
||||
let _boundaryLines = null;
|
||||
let _hoverGlowLines = null;
|
||||
let _hoverLines = null;
|
||||
let _hoveredFeature = null;
|
||||
let _hoveredGroupKey = null;
|
||||
let _visible = false;
|
||||
let _landFillEnabled = true;
|
||||
let _landFillSuppressed = false;
|
||||
let _tintEnabled = false;
|
||||
let _loaded = false;
|
||||
let _loadPromise = null;
|
||||
|
||||
const OCEAN_HEX = 0x010609;
|
||||
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
|
||||
|
||||
function hexToStyle(hex) {
|
||||
return `#${hex.toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
return [
|
||||
(hex >> 16) & 255,
|
||||
(hex >> 8) & 255,
|
||||
hex & 255,
|
||||
];
|
||||
}
|
||||
|
||||
function buildLandTexture(features) {
|
||||
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
||||
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const oceanRgb = hexToRgb(OCEAN_HEX);
|
||||
|
||||
if (!ctx) {
|
||||
const oceanData = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < oceanData.length; i += 4) {
|
||||
oceanData[i] = oceanRgb[0];
|
||||
oceanData[i + 1] = oceanRgb[1];
|
||||
oceanData[i + 2] = oceanRgb[2];
|
||||
oceanData[i + 3] = 255;
|
||||
}
|
||||
const fallbackTexture = new THREE.DataTexture(
|
||||
oceanData,
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
fallbackTexture.needsUpdate = true;
|
||||
return fallbackTexture;
|
||||
}
|
||||
|
||||
// Ocean background
|
||||
ctx.fillStyle = hexToStyle(OCEAN_HEX);
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Land polygons using evenodd fill rule so holes (lakes, islands) work correctly
|
||||
ctx.fillStyle = hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor);
|
||||
|
||||
for (const feat of features) {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) continue;
|
||||
const polys =
|
||||
geom.type === "Polygon" ? [geom.coordinates] :
|
||||
geom.type === "MultiPolygon" ? geom.coordinates : null;
|
||||
if (!polys) continue;
|
||||
|
||||
for (const rings of polys) {
|
||||
ctx.beginPath();
|
||||
for (const ring of rings) {
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
// equirectangular: x = (lon+180)/360*width, y = (90-lat)/180*height
|
||||
const px = ((ring[i][0] + 180) / 360) * width;
|
||||
const py = ((90 - ring[i][1]) / 180) * height;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.fill("evenodd");
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const tex = new THREE.DataTexture(
|
||||
new Uint8Array(imageData.data),
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
tex.flipY = true;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function makeLandMesh(tex) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 128, 64);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
map: tex,
|
||||
transparent: COUNTRY_BOUNDARY_CONFIG.landOpacity < 1,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.landOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-land-ocean";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
|
||||
mesh.visible = false;
|
||||
mesh.raycast = () => {};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function makeTintMesh() {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 64, 32);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-tint";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.tintRenderOrder;
|
||||
mesh.visible = false;
|
||||
mesh.raycast = () => {};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// ─── Boundary line geometry ────────────────────────────────────────────────────
|
||||
|
||||
function ringToSegments(ring, radius, out) {
|
||||
const n = ring.length;
|
||||
if (n < 2) return;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
out.push(latLonToVector3(ring[i][1], ring[i][0], radius));
|
||||
out.push(latLonToVector3(ring[i+1][1], ring[i+1][0], radius));
|
||||
}
|
||||
}
|
||||
|
||||
function featureToSegments(geom, radius) {
|
||||
const pts = [];
|
||||
if (!geom) return pts;
|
||||
if (geom.type === "Polygon") {
|
||||
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
|
||||
} else if (geom.type === "MultiPolygon") {
|
||||
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function buildBoundaryLines(features) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const all = [];
|
||||
for (const feat of features) {
|
||||
const pts = featureToSegments(feat.geometry, r);
|
||||
all.push(...pts);
|
||||
}
|
||||
|
||||
const geo = all.length > 0
|
||||
? new THREE.BufferGeometry().setFromPoints(all)
|
||||
: new THREE.BufferGeometry();
|
||||
const lines = new THREE.LineSegments(geo, mat);
|
||||
lines.name = "country-boundary-all";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildHoverLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity < 1,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-hover";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildHoverGlowLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
linewidth: COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-hover-glow";
|
||||
lines.renderOrder =
|
||||
COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder -
|
||||
COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function setBoundaryLinesDimmed(dimmed) {
|
||||
if (!_boundaryLines?.material) return;
|
||||
_boundaryLines.material.opacity = dimmed
|
||||
? COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity
|
||||
: COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
||||
_boundaryLines.material.needsUpdate = true;
|
||||
}
|
||||
|
||||
function clearHoverLineGeometries() {
|
||||
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
|
||||
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
|
||||
}
|
||||
|
||||
function featureListToSegments(features, radius) {
|
||||
return features.flatMap(f => featureToSegments(f.geometry, radius));
|
||||
}
|
||||
|
||||
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
|
||||
|
||||
function pointInRing(lat, lon, ring) {
|
||||
let inside = false;
|
||||
const n = ring.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
if ((yi > lat) !== (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function featureContains(lat, lon, feat) {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) return false;
|
||||
if (geom.type === "Polygon") {
|
||||
if (!pointInRing(lat, lon, geom.coordinates[0])) return false;
|
||||
return geom.coordinates.slice(1).every(h => !pointInRing(lat, lon, h));
|
||||
}
|
||||
if (geom.type === "MultiPolygon") {
|
||||
return geom.coordinates.some(poly =>
|
||||
pointInRing(lat, lon, poly[0]) &&
|
||||
poly.slice(1).every(h => !pointInRing(lat, lon, h))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function makeCountryInfo(feat) {
|
||||
if (!feat) return null;
|
||||
const p = feat.properties || {};
|
||||
return {
|
||||
name: p.NAME_EN || p.NAME || p.ADMIN || "",
|
||||
nameZh: p.NAME_ZH || null,
|
||||
isoA3: p.ISO_A3 || p.ADM0_A3 || null,
|
||||
isoA2: p.ISO_A2 || null,
|
||||
continent: p.CONTINENT || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCountryHighlightGroupKey(feat) {
|
||||
const p = feat?.properties || {};
|
||||
const isoA3 = p.ISO_A3 || p.ADM0_A3 || "";
|
||||
if (isoA3 === "CHN" || isoA3 === "TWN") {
|
||||
return "CHN_TWN";
|
||||
}
|
||||
return isoA3 || p.ISO_A2 || p.NAME_EN || p.NAME || p.ADMIN || null;
|
||||
}
|
||||
|
||||
function getHighlightFeatures(feat) {
|
||||
const groupKey = getCountryHighlightGroupKey(feat);
|
||||
if (!groupKey) return feat ? [feat] : [];
|
||||
return _features.filter(f => getCountryHighlightGroupKey(f) === groupKey);
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Called during init (before data load). Creates the placeholder tint sphere. */
|
||||
export function createCountryBoundaryLayer(earthObj) {
|
||||
_earthObj = earthObj;
|
||||
_tintMesh = makeTintMesh();
|
||||
_earthObj.add(_tintMesh);
|
||||
}
|
||||
|
||||
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
|
||||
export async function loadCountryBoundaries() {
|
||||
if (_loaded) return _features.length;
|
||||
if (_loadPromise) return _loadPromise;
|
||||
|
||||
_loadPromise = (async () => {
|
||||
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
|
||||
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
|
||||
const geojson = await resp.json();
|
||||
_features = (geojson.features || []).filter(f => f.geometry);
|
||||
|
||||
const tex = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(tex);
|
||||
_earthObj.add(_landMesh);
|
||||
|
||||
_boundaryLines = buildBoundaryLines(_features);
|
||||
_earthObj.add(_boundaryLines);
|
||||
|
||||
_hoverGlowLines = buildHoverGlowLines();
|
||||
_earthObj.add(_hoverGlowLines);
|
||||
|
||||
_hoverLines = buildHoverLines();
|
||||
_earthObj.add(_hoverLines);
|
||||
|
||||
_loaded = true;
|
||||
return _features.length;
|
||||
})();
|
||||
|
||||
return _loadPromise;
|
||||
}
|
||||
|
||||
/** Load if not yet loaded, then return feature count. */
|
||||
export async function ensureCountryBoundariesReady() {
|
||||
if (!_loaded) await loadCountryBoundaries();
|
||||
return _features.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the country boundary lines.
|
||||
* The land/ocean fill is the base earth map and stays independent from this
|
||||
* line visibility switch.
|
||||
* @param {boolean} visible
|
||||
* @param {{ showTint?: boolean, showLandFill?: boolean, suppressLandFill?: boolean }} [opts]
|
||||
* showLandFill – whether to show the base land/ocean fill.
|
||||
* Defaults to the current stored value so callers that only
|
||||
* care about visibility don't need to repeat it.
|
||||
* suppressLandFill – temporarily keep the fill below the high-res texture
|
||||
* without changing the layer's own fill state.
|
||||
*/
|
||||
export function toggleCountryBoundaries(
|
||||
visible,
|
||||
{ showTint = false, showLandFill = null, suppressLandFill = null } = {},
|
||||
) {
|
||||
_visible = Boolean(visible);
|
||||
|
||||
if (showLandFill !== null) _landFillEnabled = Boolean(showLandFill);
|
||||
if (suppressLandFill !== null) _landFillSuppressed = Boolean(suppressLandFill);
|
||||
|
||||
if (_landMesh) {
|
||||
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
if (_boundaryLines) _boundaryLines.visible = _visible;
|
||||
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
|
||||
if (_hoverLines) _hoverLines.visible = _visible;
|
||||
|
||||
if (!_visible) {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
}
|
||||
|
||||
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the land/ocean canvas fill independently of boundary lines.
|
||||
*/
|
||||
export function setLandFillEnabled(enabled) {
|
||||
_landFillEnabled = Boolean(enabled);
|
||||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
|
||||
export function setLandFillSuppressed(enabled) {
|
||||
_landFillSuppressed = Boolean(enabled);
|
||||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable / disable the solid dark tint overlay (used when high-res texture is off).
|
||||
*/
|
||||
export function setSurfaceTintEnabled(enabled) {
|
||||
_tintEnabled = Boolean(enabled);
|
||||
if (_tintMesh) _tintMesh.visible = _visible && _tintEnabled;
|
||||
}
|
||||
|
||||
export function getShowCountryBoundaries() {
|
||||
return _visible;
|
||||
}
|
||||
|
||||
/** Clear the hover highlight without hiding the full layer. */
|
||||
export function clearCountryBoundaryHover() {
|
||||
if (!_hoveredFeature) return;
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hover highlight for the given lat/lon coords.
|
||||
* Returns a country-info object when hovering over land, or null over ocean.
|
||||
*/
|
||||
export function updateCountryBoundaryHover(coords) {
|
||||
if (!_loaded || !_visible) return null;
|
||||
const { lat, lon } = coords;
|
||||
|
||||
const found = _features.find(f => featureContains(lat, lon, f)) || null;
|
||||
const groupKey = getCountryHighlightGroupKey(found);
|
||||
|
||||
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
|
||||
_hoveredFeature = found;
|
||||
_hoveredGroupKey = groupKey;
|
||||
if (_hoverLines) {
|
||||
if (!found) {
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
} else {
|
||||
setBoundaryLinesDimmed(true);
|
||||
const highlightFeatures = getHighlightFeatures(found);
|
||||
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
|
||||
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
||||
if (_hoverGlowLines) {
|
||||
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
|
||||
_hoverGlowLines.geometry.setFromPoints(glowPts);
|
||||
}
|
||||
const corePts = featureListToSegments(highlightFeatures, coreRadius);
|
||||
_hoverLines.geometry.setFromPoints(corePts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found ? makeCountryInfo(found) : null;
|
||||
}
|
||||
|
||||
/** Dispose all Three.js objects and reset state. */
|
||||
export function clearCountryBoundaryData() {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
|
||||
function disposeObj(obj) {
|
||||
if (!obj) return;
|
||||
if (_earthObj) _earthObj.remove(obj);
|
||||
obj.geometry?.dispose();
|
||||
if (obj.material) {
|
||||
if (obj.material.map) obj.material.map.dispose();
|
||||
obj.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
disposeObj(_hoverLines);
|
||||
disposeObj(_hoverGlowLines);
|
||||
disposeObj(_boundaryLines);
|
||||
disposeObj(_landMesh);
|
||||
disposeObj(_tintMesh);
|
||||
|
||||
_hoverLines = null;
|
||||
_hoverGlowLines = null;
|
||||
_boundaryLines = null;
|
||||
_landMesh = null;
|
||||
_tintMesh = null;
|
||||
_features = [];
|
||||
_loaded = false;
|
||||
_loadPromise = null;
|
||||
_visible = false;
|
||||
_landFillEnabled = true;
|
||||
_landFillSuppressed = false;
|
||||
_tintEnabled = false;
|
||||
}
|
||||
|
||||
export function getCountryBoundaryLegendItems() {
|
||||
return [
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
|
||||
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
|
||||
];
|
||||
}
|
||||
229
frontend/public/earth/js/cruise-sequencer.js
Normal file
@@ -0,0 +1,229 @@
|
||||
function nextAnimationFrame() {
|
||||
return new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
export class CruiseSequencer {
|
||||
constructor({
|
||||
isActive,
|
||||
getItems,
|
||||
getItemId,
|
||||
focusItem,
|
||||
presentItem,
|
||||
hideItem,
|
||||
clearCurrent,
|
||||
onStop,
|
||||
dwellMs = 2400,
|
||||
transitionGapMs = 24,
|
||||
}) {
|
||||
this.isActive = isActive;
|
||||
this.getItems = getItems;
|
||||
this.getItemId = getItemId;
|
||||
this.focusItem = focusItem;
|
||||
this.presentItem = presentItem;
|
||||
this.hideItem = hideItem;
|
||||
this.clearCurrent = clearCurrent;
|
||||
this.onStop = onStop;
|
||||
this.dwellMs = dwellMs;
|
||||
this.transitionGapMs = transitionGapMs;
|
||||
|
||||
this.currentItemId = null;
|
||||
this.currentIndex = -1;
|
||||
this.queuedItemIds = [];
|
||||
this.sequenceToken = 0;
|
||||
this.advanceQueued = false;
|
||||
this.advanceInterrupt = false;
|
||||
this.advanceInFlight = false;
|
||||
this.advanceLoopToken = 0;
|
||||
this.primaryTimerId = null;
|
||||
this.secondaryTimerId = null;
|
||||
this.presentationVisible = false;
|
||||
}
|
||||
|
||||
getCurrentItem() {
|
||||
if (!this.currentItemId) return null;
|
||||
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
|
||||
}
|
||||
|
||||
getCurrentItemId() {
|
||||
return this.currentItemId;
|
||||
}
|
||||
|
||||
isPresentationPinned() {
|
||||
return this.presentationVisible;
|
||||
}
|
||||
|
||||
isBusy() {
|
||||
return this.advanceInFlight || this.presentationVisible;
|
||||
}
|
||||
|
||||
enqueue(itemIds = []) {
|
||||
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
|
||||
this.queuedItemIds = Array.from(
|
||||
new Set([...itemIds.filter(Boolean), ...this.queuedItemIds]),
|
||||
);
|
||||
}
|
||||
|
||||
setPresentationVisible(visible) {
|
||||
this.presentationVisible = Boolean(visible);
|
||||
}
|
||||
|
||||
clearTimers() {
|
||||
if (this.primaryTimerId) {
|
||||
clearTimeout(this.primaryTimerId);
|
||||
this.primaryTimerId = null;
|
||||
}
|
||||
if (this.secondaryTimerId) {
|
||||
clearTimeout(this.secondaryTimerId);
|
||||
this.secondaryTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
interruptPresentation({ preservePresentation = false, resetLoop = false } = {}) {
|
||||
this.sequenceToken += 1;
|
||||
this.clearTimers();
|
||||
this.advanceQueued = false;
|
||||
this.advanceInterrupt = false;
|
||||
if (resetLoop) {
|
||||
this.advanceLoopToken += 1;
|
||||
this.advanceInFlight = false;
|
||||
}
|
||||
if (!preservePresentation) {
|
||||
this.presentationVisible = false;
|
||||
this.clearCurrent?.();
|
||||
}
|
||||
}
|
||||
|
||||
stop({ preservePresentation = false } = {}) {
|
||||
this.interruptPresentation({ preservePresentation });
|
||||
this.currentItemId = preservePresentation ? this.currentItemId : null;
|
||||
this.currentIndex = preservePresentation ? this.currentIndex : -1;
|
||||
this.queuedItemIds = [];
|
||||
this.onStop?.({ preservePresentation });
|
||||
}
|
||||
|
||||
createContext(token) {
|
||||
return {
|
||||
token,
|
||||
isCurrent: () => token === this.sequenceToken && this.isActive(),
|
||||
wait: (durationMs, { secondary = false } = {}) =>
|
||||
new Promise((resolve) => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (secondary) {
|
||||
if (this.secondaryTimerId === timerId) this.secondaryTimerId = null;
|
||||
} else if (this.primaryTimerId === timerId) {
|
||||
this.primaryTimerId = null;
|
||||
}
|
||||
resolve(token === this.sequenceToken && this.isActive());
|
||||
}, durationMs);
|
||||
|
||||
if (secondary) {
|
||||
this.secondaryTimerId = timerId;
|
||||
} else {
|
||||
this.primaryTimerId = timerId;
|
||||
}
|
||||
}),
|
||||
nextFrame: nextAnimationFrame,
|
||||
setPresentationVisible: (visible) => {
|
||||
if (token !== this.sequenceToken) return;
|
||||
this.presentationVisible = Boolean(visible);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
resolveNextItem(items) {
|
||||
let targetItem = null;
|
||||
while (this.queuedItemIds.length > 0 && !targetItem) {
|
||||
const queuedId = this.queuedItemIds.shift();
|
||||
targetItem = items.find((item) => this.getItemId(item) === queuedId) || null;
|
||||
}
|
||||
|
||||
if (targetItem) return targetItem;
|
||||
|
||||
const nextIndex = this.currentIndex >= 0 ? (this.currentIndex + 1) % items.length : 0;
|
||||
return items[nextIndex] || items[0] || null;
|
||||
}
|
||||
|
||||
async performAdvance({ interrupt = false } = {}) {
|
||||
if (!this.isActive()) return;
|
||||
|
||||
const items = this.getItems();
|
||||
if (!Array.isArray(items) || items.length === 0) return;
|
||||
|
||||
const targetItem = this.resolveNextItem(items);
|
||||
if (!targetItem) return;
|
||||
|
||||
const token = ++this.sequenceToken;
|
||||
const context = this.createContext(token);
|
||||
|
||||
this.clearTimers();
|
||||
this.presentationVisible = false;
|
||||
this.clearCurrent?.();
|
||||
|
||||
this.currentItemId = this.getItemId(targetItem);
|
||||
this.currentIndex = items.findIndex(
|
||||
(item) => this.getItemId(item) === this.currentItemId,
|
||||
);
|
||||
|
||||
await this.focusItem?.(targetItem, { interrupt, context });
|
||||
if (!context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const presented = await this.presentItem?.(targetItem, { interrupt, context });
|
||||
if (!presented || !context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.presentationVisible = true;
|
||||
const dwellCompleted = await context.wait(this.dwellMs);
|
||||
if (!dwellCompleted || !context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.hideItem?.(targetItem, { context });
|
||||
if (!context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.presentationVisible = false;
|
||||
const gapCompleted = await context.wait(this.transitionGapMs, { secondary: true });
|
||||
if (!gapCompleted || !context.isCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.advance();
|
||||
}
|
||||
|
||||
async advance({ interrupt = false } = {}) {
|
||||
if (!this.isActive()) return;
|
||||
|
||||
this.advanceQueued = true;
|
||||
this.advanceInterrupt = this.advanceInterrupt || interrupt;
|
||||
if (this.advanceInFlight) return;
|
||||
|
||||
const activeLoopToken = ++this.advanceLoopToken;
|
||||
this.advanceInFlight = true;
|
||||
try {
|
||||
while (
|
||||
this.advanceQueued &&
|
||||
this.isActive() &&
|
||||
this.advanceLoopToken === activeLoopToken
|
||||
) {
|
||||
const nextInterrupt = this.advanceInterrupt;
|
||||
this.advanceQueued = false;
|
||||
this.advanceInterrupt = false;
|
||||
await this.performAdvance({ interrupt: nextInterrupt });
|
||||
}
|
||||
} finally {
|
||||
if (this.advanceLoopToken === activeLoopToken) {
|
||||
this.advanceInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import {
|
||||
CLOUD_LAYER_CONFIG,
|
||||
CONFIG,
|
||||
EARTH_CONFIG,
|
||||
EARTH_MATERIAL_CONFIG,
|
||||
GRID_CONFIG,
|
||||
STARFIELD_CONFIG,
|
||||
TERRAIN_CONFIG,
|
||||
} from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
export let clouds = null;
|
||||
export let terrain = null;
|
||||
let showGridLines = false;
|
||||
let showClouds = true;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
let _earthTextureOverlay = null;
|
||||
let _earthTextureOverlayMaterial = null;
|
||||
let _earthShader = null;
|
||||
let _dayNightEnabled = true;
|
||||
let _loadedTexture = null;
|
||||
let _textureVisible = true;
|
||||
const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
||||
@@ -33,6 +48,7 @@ function applyEarthDayNightShader(material) {
|
||||
shader.uniforms.uTwilightColor = { value: twilightColor };
|
||||
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
||||
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
|
||||
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
|
||||
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <common>",
|
||||
@@ -55,7 +71,8 @@ uniform float uTwilightWidth;
|
||||
uniform float uTwilightIntensity;
|
||||
uniform vec3 uTwilightColor;
|
||||
uniform vec3 uNightTintColor;
|
||||
uniform float uNightTintIntensity;`,
|
||||
uniform float uNightTintIntensity;
|
||||
uniform float uDayNightEnabled;`,
|
||||
).replace(
|
||||
"#include <output_fragment>",
|
||||
`
|
||||
@@ -65,16 +82,27 @@ uniform float uNightTintIntensity;`,
|
||||
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
||||
|
||||
outgoingLight *= mix(uNightFloor, uDayBoost, daylight);
|
||||
outgoingLight += uTwilightColor * twilight * uTwilightIntensity;
|
||||
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
|
||||
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
|
||||
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
|
||||
float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
|
||||
float cameraBoost = mix(0.62, 1.08, nDotV);
|
||||
|
||||
float dn = uDayNightEnabled;
|
||||
vec3 dnLight = outgoingLight;
|
||||
dnLight *= mix(uNightFloor, uDayBoost, daylight);
|
||||
dnLight += uTwilightColor * twilight * uTwilightIntensity;
|
||||
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
|
||||
|
||||
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
|
||||
// dn=1: full day/night solar lighting
|
||||
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
|
||||
|
||||
#include <output_fragment>
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
material.customProgramCacheKey = () => "earth-day-night-v1";
|
||||
material.customProgramCacheKey = () => "earth-day-night-v5";
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
@@ -88,7 +116,7 @@ export function createEarth(scene) {
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
emissive: C.emissive,
|
||||
transparent: true,
|
||||
transparent: C.opacity < 1,
|
||||
opacity: C.opacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: true,
|
||||
@@ -102,6 +130,30 @@ export function createEarth(scene) {
|
||||
earth.rotation.x = EARTH_CONFIG.tiltRad;
|
||||
scene.add(earth);
|
||||
|
||||
const textureOverlayGeometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + C.textureOverlayAltitudeOffset,
|
||||
128,
|
||||
128,
|
||||
);
|
||||
_earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({
|
||||
color: 0xffffff,
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
transparent: true,
|
||||
opacity: C.textureOverlayOpacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
});
|
||||
_earthTextureOverlay = new THREE.Mesh(
|
||||
textureOverlayGeometry,
|
||||
_earthTextureOverlayMaterial,
|
||||
);
|
||||
_earthTextureOverlay.name = "earth-high-res-texture-overlay";
|
||||
_earthTextureOverlay.renderOrder = C.textureOverlayRenderOrder;
|
||||
_earthTextureOverlay.visible = false;
|
||||
earth.add(_earthTextureOverlay);
|
||||
|
||||
// Depth-mask occluder — invisible sphere slightly inside the earth,
|
||||
// writes to the depth buffer so far-side cables/satellites are occluded.
|
||||
const occluderGeometry = new THREE.SphereGeometry(
|
||||
@@ -183,11 +235,14 @@ export function createEarth(scene) {
|
||||
}
|
||||
|
||||
export function createClouds(scene, earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + CLOUD_LAYER_CONFIG.radiusOffset,
|
||||
CLOUD_LAYER_CONFIG.widthSegments,
|
||||
CLOUD_LAYER_CONFIG.heightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
transparent: true,
|
||||
linewidth: 2,
|
||||
opacity: 0.15,
|
||||
opacity: CLOUD_LAYER_CONFIG.opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
@@ -195,10 +250,12 @@ export function createClouds(scene, earthObj) {
|
||||
});
|
||||
|
||||
clouds = new THREE.Mesh(geometry, material);
|
||||
clouds.name = "earth-atmosphere-clouds";
|
||||
clouds.visible = showClouds;
|
||||
earthObj.add(clouds);
|
||||
|
||||
textureLoader.load(
|
||||
'./assets/earth_clouds_1024.png',
|
||||
CLOUD_LAYER_CONFIG.textureUrl,
|
||||
function(texture) {
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
@@ -212,6 +269,17 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function toggleClouds(visible) {
|
||||
showClouds = Boolean(visible);
|
||||
if (clouds) {
|
||||
clouds.visible = showClouds;
|
||||
}
|
||||
}
|
||||
|
||||
export function getShowClouds() {
|
||||
return showClouds;
|
||||
}
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
@@ -224,7 +292,6 @@ export function createTerrain(earthObj) {
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
vertexAlphas: true,
|
||||
transparent: true,
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
@@ -238,7 +305,8 @@ export function createTerrain(earthObj) {
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
terrain.renderOrder = 1.2;
|
||||
terrain.raycast = () => {};
|
||||
earthObj.add(terrain);
|
||||
|
||||
return terrain;
|
||||
@@ -252,11 +320,11 @@ export function toggleTerrain(visible) {
|
||||
|
||||
export function createStars(scene) {
|
||||
const starGeometry = new THREE.BufferGeometry();
|
||||
const starCount = 8000;
|
||||
const starCount = STARFIELD_CONFIG.count;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
|
||||
for (let i = 0; i < starCount * 3; i += 3) {
|
||||
const r = 800 + Math.random() * 200;
|
||||
const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
|
||||
@@ -268,8 +336,8 @@ export function createStars(scene) {
|
||||
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
|
||||
const starMaterial = new THREE.PointsMaterial({
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
color: STARFIELD_CONFIG.color,
|
||||
size: STARFIELD_CONFIG.size,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
@@ -289,17 +357,19 @@ export function createGridLines(scene, earthObj) {
|
||||
latitudeLines = [];
|
||||
longitudeLines = [];
|
||||
|
||||
const earthRadius = 100.1;
|
||||
const earthRadius = CONFIG.earthRadius + GRID_CONFIG.radiusOffset;
|
||||
const gridMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x44aaff,
|
||||
color: GRID_CONFIG.color,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
linewidth: 1
|
||||
opacity: GRID_CONFIG.opacity,
|
||||
linewidth: GRID_CONFIG.lineWidth,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
for (let lat = -75; lat <= 75; lat += 15) {
|
||||
for (let lat = -75; lat <= 75; lat += GRID_CONFIG.latitudeStep) {
|
||||
const points = [];
|
||||
for (let lon = -180; lon <= 180; lon += 5) {
|
||||
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.segmentStep) {
|
||||
const point = latLonToVector3(lat, lon, earthRadius);
|
||||
points.push(point);
|
||||
}
|
||||
@@ -307,13 +377,15 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'latitude', value: lat };
|
||||
line.renderOrder = GRID_CONFIG.renderOrder;
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
latitudeLines.push(line);
|
||||
}
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += 30) {
|
||||
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.longitudeStep) {
|
||||
const points = [];
|
||||
for (let lat = -90; lat <= 90; lat += 5) {
|
||||
for (let lat = -90; lat <= 90; lat += GRID_CONFIG.segmentStep) {
|
||||
const point = latLonToVector3(lat, lon, earthRadius);
|
||||
points.push(point);
|
||||
}
|
||||
@@ -321,23 +393,48 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'longitude', value: lon };
|
||||
line.renderOrder = GRID_CONFIG.renderOrder;
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
longitudeLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleGridLines(visible) {
|
||||
showGridLines = visible;
|
||||
latitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
longitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowGridLines() {
|
||||
return showGridLines;
|
||||
}
|
||||
|
||||
export function getEarth() {
|
||||
return earth;
|
||||
}
|
||||
|
||||
export function getEarthSurfacePickTarget() {
|
||||
return _earthTextureOverlay?.visible ? _earthTextureOverlay : earth;
|
||||
}
|
||||
|
||||
export function getClouds() {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function clearEarthTexture() {
|
||||
if (!_earthMaterial) return;
|
||||
_earthMaterial.map = null;
|
||||
_earthMaterial.needsUpdate = true;
|
||||
_loadedTexture = null;
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = null;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setEarthSunDirection(direction) {
|
||||
@@ -348,9 +445,30 @@ export function setEarthSunDirection(direction) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setDayNightEnabled(enabled) {
|
||||
_dayNightEnabled = enabled;
|
||||
if (_earthShader?.uniforms?.uDayNightEnabled) {
|
||||
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
|
||||
}
|
||||
if (_earthMaterial) {
|
||||
if (enabled) {
|
||||
// Restore normal Phong lighting + custom day/night shader
|
||||
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
|
||||
_earthMaterial.emissiveMap = null;
|
||||
} else {
|
||||
// Full bright: zero diffuse so directional light has no effect;
|
||||
_earthMaterial.color.setRGB(0, 0, 0);
|
||||
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||
_earthMaterial.emissiveMap = null;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEarthTexture() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
if (!_earthTextureOverlayMaterial) { resolve(); return; }
|
||||
|
||||
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
|
||||
const tryLoad = (index) => {
|
||||
@@ -367,8 +485,12 @@ export function loadEarthTexture() {
|
||||
texture.anisotropy = 16;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
_earthMaterial.map = texture;
|
||||
_earthMaterial.needsUpdate = true;
|
||||
_loadedTexture = texture;
|
||||
_earthTextureOverlayMaterial.map = texture;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = _textureVisible;
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
null,
|
||||
@@ -378,3 +500,18 @@ export function loadEarthTexture() {
|
||||
tryLoad(0);
|
||||
});
|
||||
}
|
||||
|
||||
export function setEarthTextureVisible(visible) {
|
||||
_textureVisible = Boolean(visible);
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = _textureVisible && Boolean(_loadedTexture);
|
||||
}
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = _loadedTexture || null;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getEarthTextureVisible() {
|
||||
return _textureVisible;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,406 @@ import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
let typewriterTimerId = null;
|
||||
let typewriterToken = 0;
|
||||
let pendingMobileDetailState = null;
|
||||
let mobileDetailsListenerBound = false;
|
||||
let renderedMobileDetailKey = null;
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
}
|
||||
|
||||
function getNewsSummaryPreview(data, maxLength = 34) {
|
||||
const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||
}
|
||||
|
||||
function stopTypewriterAnimation() {
|
||||
typewriterToken += 1;
|
||||
if (typewriterTimerId) {
|
||||
window.clearTimeout(typewriterTimerId);
|
||||
typewriterTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startTypewriterAnimation(target, text, options = {}) {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
stopTypewriterAnimation();
|
||||
|
||||
const content = typeof text === 'string' ? text : '';
|
||||
const token = typewriterToken;
|
||||
const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22;
|
||||
const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90;
|
||||
|
||||
target.textContent = '';
|
||||
target.classList.add('is-typing');
|
||||
|
||||
let index = 0;
|
||||
const tick = () => {
|
||||
if (token !== typewriterToken) return;
|
||||
index += 1;
|
||||
target.textContent = content.slice(0, index);
|
||||
if (index < content.length) {
|
||||
typewriterTimerId = window.setTimeout(tick, stepMs);
|
||||
return;
|
||||
}
|
||||
target.classList.remove('is-typing');
|
||||
typewriterTimerId = null;
|
||||
};
|
||||
|
||||
typewriterTimerId = window.setTimeout(() => {
|
||||
if (token !== typewriterToken) return;
|
||||
if (!content) {
|
||||
target.classList.remove('is-typing');
|
||||
typewriterTimerId = null;
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
}, startDelayMs);
|
||||
}
|
||||
|
||||
function renderNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
content.innerHTML = `
|
||||
<div class="info-card-news-layout">
|
||||
<div class="info-card-news-kicker">NEWS SIGNAL</div>
|
||||
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="info-card-news-summary-shell">
|
||||
<div class="info-card-news-summary-label">SUMMARY</div>
|
||||
<div class="info-card-news-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const summaryEl = content.querySelector('[data-news-summary]');
|
||||
startTypewriterAnimation(summaryEl, summary);
|
||||
}
|
||||
|
||||
function renderMobileNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
content.innerHTML = `
|
||||
<div class="earth-mobile-news-detail">
|
||||
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
|
||||
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="earth-mobile-news-detail-summary-shell">
|
||||
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
|
||||
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const summaryEl = content.querySelector('[data-news-summary]');
|
||||
startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 });
|
||||
}
|
||||
|
||||
function renderMobileDetailContent(type, config, data) {
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
|
||||
stopTypewriterAnimation();
|
||||
if (type === 'news') {
|
||||
renderMobileNewsCardContent(content, data);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
<span class="earth-mobile-detail-row-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function getMobileDetailRenderKey(type, data) {
|
||||
if (type !== 'news') return null;
|
||||
return [
|
||||
type,
|
||||
data?.id ?? '',
|
||||
data?.url ?? '',
|
||||
data?.published_at ?? '',
|
||||
data?.title ?? '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function isMobileDetailsDrawerActive() {
|
||||
const detailsSlot = document.querySelector('[data-drawer-slot="details"]');
|
||||
return detailsSlot instanceof HTMLElement && detailsSlot.classList.contains('is-active');
|
||||
}
|
||||
|
||||
function ensureMobileDetailsListener() {
|
||||
if (mobileDetailsListenerBound) return;
|
||||
mobileDetailsListenerBound = true;
|
||||
|
||||
window.addEventListener('earth:open-details-tab', () => {
|
||||
if (!document.body.classList.contains('layout-mode-mobile')) return;
|
||||
if (!pendingMobileDetailState) return;
|
||||
const nextKey = getMobileDetailRenderKey(
|
||||
pendingMobileDetailState.type,
|
||||
pendingMobileDetailState.data,
|
||||
);
|
||||
if (nextKey && nextKey === renderedMobileDetailKey) return;
|
||||
renderMobileDetailContent(
|
||||
pendingMobileDetailState.type,
|
||||
pendingMobileDetailState.config,
|
||||
pendingMobileDetailState.data,
|
||||
);
|
||||
renderedMobileDetailKey = nextKey;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDefaultCardContent(content, config, data) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── 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 'news': return data.title || '新闻事件';
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
case 'gpu_cluster': return data.name || 'GPU集群';
|
||||
default: return '详情';
|
||||
}
|
||||
}
|
||||
|
||||
function getMobilePopupSubtitle(type, data) {
|
||||
switch (type) {
|
||||
case 'cable': return data.owner || data.status || '海缆';
|
||||
case 'landing_point': return data.country || '登陆点';
|
||||
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
|
||||
case 'bgp': return data.severity || 'BGP路由异常';
|
||||
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
|
||||
case 'bgp_collector': return data.location || 'BGP观测站';
|
||||
case 'supercomputer': return data.country || '超级计算机';
|
||||
case 'gpu_cluster': return data.country || 'GPU集群';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function positionMobilePopup(popup, touchX, touchY, options = {}) {
|
||||
const margin = 14;
|
||||
const drawerClearance = 52;
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
const safeBottom = parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
|
||||
) || 0;
|
||||
const bottomBound = vpH - drawerClearance - safeBottom;
|
||||
|
||||
// Measure actual popup size (it's rendered but invisible via opacity)
|
||||
const popW = popup.offsetWidth || 200;
|
||||
const popH = popup.offsetHeight || 68;
|
||||
|
||||
if (options.absolute === true) {
|
||||
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
|
||||
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 22;
|
||||
const spaceRight = vpW - touchX;
|
||||
const spaceLeft = touchX;
|
||||
const spaceBottom = bottomBound - touchY;
|
||||
const spaceTop = touchY;
|
||||
|
||||
let left, top;
|
||||
|
||||
// Horizontal: side with more room
|
||||
if (spaceRight >= popW + gap + margin) {
|
||||
left = touchX + gap;
|
||||
} else if (spaceLeft >= popW + gap + margin) {
|
||||
left = touchX - gap - popW;
|
||||
} else {
|
||||
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
|
||||
}
|
||||
|
||||
// Vertical: prefer above touch, then below
|
||||
if (spaceTop >= popH + gap + margin) {
|
||||
top = touchY - gap - popH;
|
||||
} else if (spaceBottom >= popH + gap + margin) {
|
||||
top = touchY + gap;
|
||||
} else {
|
||||
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
|
||||
}
|
||||
|
||||
left = Math.max(margin, Math.min(left, vpW - popW - margin));
|
||||
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
|
||||
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
let popupShowToken = 0;
|
||||
|
||||
function showMobilePopup(type, data, x, y, options = {}) {
|
||||
// Require coordinates — skip if called without position (e.g. from handleCableClick)
|
||||
if (x == null || y == null) return;
|
||||
|
||||
const popup = document.getElementById('earth-mobile-popup');
|
||||
const iconEl = document.getElementById('earth-mobile-popup-icon');
|
||||
const titleEl = document.getElementById('earth-mobile-popup-title');
|
||||
const subEl = document.getElementById('earth-mobile-popup-sub');
|
||||
if (!popup || !iconEl || !titleEl || !subEl) return;
|
||||
|
||||
const config = CARD_CONFIG[type];
|
||||
if (!config) return;
|
||||
|
||||
iconEl.textContent = config.icon;
|
||||
titleEl.textContent = getMobilePopupTitle(type, data);
|
||||
subEl.textContent = getMobilePopupSubtitle(type, data);
|
||||
|
||||
// Invalidate any in-flight hide listener
|
||||
popupShowToken += 1;
|
||||
const token = popupShowToken;
|
||||
|
||||
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
|
||||
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
|
||||
popup.removeAttribute('hidden');
|
||||
popup.classList.remove('is-visible');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
positionMobilePopup(popup, x, y, options);
|
||||
if (options.reveal === false) {
|
||||
return;
|
||||
}
|
||||
if (token !== popupShowToken) return; // superseded
|
||||
void popup.getBoundingClientRect();
|
||||
popup.classList.add('is-visible');
|
||||
});
|
||||
}
|
||||
|
||||
function hideMobilePopup() {
|
||||
const popup = document.getElementById('earth-mobile-popup');
|
||||
if (!popup) return;
|
||||
popupShowToken += 1; // invalidate any pending show
|
||||
popup.classList.remove('is-visible');
|
||||
popup.classList.remove('earth-mobile-popup--anchor-stable');
|
||||
delete popup.dataset.dockSide;
|
||||
popup.addEventListener('transitionend', () => {
|
||||
if (!popup.classList.contains('is-visible')) {
|
||||
popup.setAttribute('hidden', '');
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
let popupClickBound = false;
|
||||
function ensurePopupClickHandler() {
|
||||
if (popupClickBound) return;
|
||||
popupClickBound = true;
|
||||
const popup = document.getElementById('earth-mobile-popup');
|
||||
if (!popup) return;
|
||||
|
||||
let dragPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
let startLeft = 0, startTop = 0;
|
||||
let dragged = false;
|
||||
const DRAG_THRESHOLD = 10;
|
||||
|
||||
const emitDragEvent = (dragging) => {
|
||||
const rect = popup.getBoundingClientRect();
|
||||
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
|
||||
detail: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
popup.addEventListener('pointerdown', (e) => {
|
||||
if (e.button > 0) return;
|
||||
e.stopPropagation();
|
||||
dragPointerId = e.pointerId;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
const rect = popup.getBoundingClientRect();
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
dragged = false;
|
||||
});
|
||||
|
||||
// Track drag at document level so pointer can leave popup bounds
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerId !== dragPointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
dragged = true;
|
||||
e.stopPropagation();
|
||||
const margin = 8;
|
||||
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
|
||||
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
emitDragEvent(true);
|
||||
});
|
||||
|
||||
document.addEventListener('pointerup', (e) => {
|
||||
if (e.pointerId !== dragPointerId) return;
|
||||
const wasDragged = dragged;
|
||||
dragPointerId = null;
|
||||
dragged = false;
|
||||
emitDragEvent(false);
|
||||
if (!wasDragged) {
|
||||
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('pointercancel', (e) => {
|
||||
if (e.pointerId === dragPointerId) {
|
||||
dragPointerId = null;
|
||||
dragged = false;
|
||||
emitDragEvent(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Block click from bubbling to document (which would close the drawer)
|
||||
popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
const CARD_CONFIG = {
|
||||
cable: {
|
||||
@@ -18,6 +418,18 @@ const CARD_CONFIG = {
|
||||
{ key: 'rfs', label: '投入使用' }
|
||||
]
|
||||
},
|
||||
landing_point: {
|
||||
icon: '📍',
|
||||
title: '登陆点详情',
|
||||
className: 'cable',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'cable_count', label: '关联海缆数' },
|
||||
{ key: 'cables', label: '关联海缆' }
|
||||
]
|
||||
},
|
||||
satellite: {
|
||||
icon: '🛰️',
|
||||
title: '卫星详情',
|
||||
@@ -25,6 +437,10 @@ const CARD_CONFIG = {
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'norad_id', label: 'NORAD ID' },
|
||||
{ key: 'constellation', label: '星座/分组' },
|
||||
{ key: 'footprint_capability', label: '覆盖能力' },
|
||||
{ key: 'current_display', label: '当前显示' },
|
||||
{ key: 'footprint_model', label: '覆盖模型' },
|
||||
{ key: 'inclination', label: '倾角', unit: '°' },
|
||||
{ key: 'period', label: '周期', unit: '分钟' },
|
||||
{ key: 'perigee', label: '近地点', unit: 'km' },
|
||||
@@ -55,6 +471,20 @@ const CARD_CONFIG = {
|
||||
{ key: 'summary', label: '摘要' }
|
||||
]
|
||||
},
|
||||
news: {
|
||||
icon: '📰',
|
||||
title: '新闻事件详情',
|
||||
className: 'news',
|
||||
fields: [
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'published_at_display', label: '发布时间' },
|
||||
{ key: 'location_label', label: '发生地' },
|
||||
{ key: 'region_label', label: '区域' },
|
||||
{ key: 'feed_name', label: '聚合源' },
|
||||
{ key: 'summary', label: '摘要' },
|
||||
{ key: 'url', label: '原文链接' }
|
||||
]
|
||||
},
|
||||
bgp_collector: {
|
||||
icon: '📍',
|
||||
title: 'BGP观测站详情',
|
||||
@@ -79,15 +509,22 @@ const CARD_CONFIG = {
|
||||
},
|
||||
supercomputer: {
|
||||
icon: '🖥️',
|
||||
title: '超算详情',
|
||||
title: '超算中心详情',
|
||||
className: 'supercomputer',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'site_type_label', label: '类型' },
|
||||
{ key: 'rank', label: '排名' },
|
||||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
||||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
||||
{ key: 'capacity', label: '实测算力' },
|
||||
{ key: 'vendor', label: '厂商' },
|
||||
{ key: 'operator', label: '运营方' },
|
||||
{ key: 'cores', label: '核心数' },
|
||||
{ key: 'power', label: '功耗', unit: 'kW' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'location_precision_label', label: '位置精度' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
},
|
||||
gpu_cluster: {
|
||||
@@ -96,8 +533,17 @@ const CARD_CONFIG = {
|
||||
className: 'gpu_cluster',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'site_type_label', label: '类型' },
|
||||
{ key: 'capacity', label: '估算算力' },
|
||||
{ key: 'gpu_count', label: 'GPU 数量' },
|
||||
{ key: 'gpu_type', label: 'GPU 型号' },
|
||||
{ key: 'vendor', label: '芯片/平台' },
|
||||
{ key: 'operator', label: '运营方' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'location_precision_label', label: '位置精度' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -114,19 +560,47 @@ function setupInfoCardDrag(panel) {
|
||||
if (!handle) return;
|
||||
|
||||
let isDragging = false;
|
||||
let activePointerId = null;
|
||||
let startPointerX = 0;
|
||||
let startPointerY = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
const stopDragging = () => {
|
||||
const emitDragEvent = () => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-drag', {
|
||||
detail: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging: isDragging,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const stopDragging = (event) => {
|
||||
if (
|
||||
event &&
|
||||
activePointerId !== null &&
|
||||
"pointerId" in event &&
|
||||
event.pointerId !== activePointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
isDragging = false;
|
||||
activePointerId = null;
|
||||
panel.classList.remove('is-dragging');
|
||||
document.body.style.userSelect = '';
|
||||
emitDragEvent();
|
||||
};
|
||||
|
||||
const onMove = (event) => {
|
||||
if (!isDragging) return;
|
||||
if (activePointerId !== null && event.pointerId !== activePointerId) return;
|
||||
event.preventDefault();
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nextLeft = Math.min(
|
||||
@@ -139,11 +613,14 @@ function setupInfoCardDrag(panel) {
|
||||
);
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
emitDragEvent();
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
activePointerId = event.pointerId;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
@@ -157,11 +634,14 @@ function setupInfoCardDrag(panel) {
|
||||
panel.classList.add('is-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
emitDragEvent();
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', stopDragging);
|
||||
handle.addEventListener('pointercancel', stopDragging);
|
||||
// Listen in capture phase so card-level stopPropagation used to shield the
|
||||
// globe canvas does not swallow the drag stream before we can reposition.
|
||||
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
|
||||
window.addEventListener('pointerup', stopDragging, { capture: true });
|
||||
window.addEventListener('pointercancel', stopDragging, { capture: true });
|
||||
handle.addEventListener('lostpointercapture', stopDragging);
|
||||
}
|
||||
|
||||
@@ -175,6 +655,8 @@ function mountCard() {
|
||||
panel.id = 'info-panel';
|
||||
panel.className = 'hud-panel hud-panel-info';
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
panel.setAttribute('hidden', '');
|
||||
panel.innerHTML = `
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
@@ -240,6 +722,13 @@ function mountCard() {
|
||||
|
||||
function positionPanel(panel, x, y, options = {}) {
|
||||
if (!panel) return;
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
panel.style.left = '8px';
|
||||
panel.style.right = '8px';
|
||||
panel.style.top = 'auto';
|
||||
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
|
||||
return;
|
||||
}
|
||||
const margin = 12;
|
||||
const offset = 14;
|
||||
const vpW = window.innerWidth;
|
||||
@@ -282,13 +771,35 @@ function positionPanel(panel, x, y, options = {}) {
|
||||
function showPanel(x, y, options = {}) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
|
||||
panel.removeAttribute('hidden');
|
||||
panel.setAttribute('aria-hidden', 'false');
|
||||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||
panel.classList.add('is-visible');
|
||||
if (options.reveal === false) {
|
||||
panel.classList.remove('is-visible');
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
panel.classList.add('is-visible');
|
||||
});
|
||||
document.body.classList.add('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||
);
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = getPanel();
|
||||
if (panel) panel.classList.remove('is-visible');
|
||||
if (panel) {
|
||||
panel.classList.remove('is-visible');
|
||||
panel.classList.remove('hud-panel-info--anchor-stable');
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
panel.setAttribute('hidden', '');
|
||||
}
|
||||
document.body.classList.remove('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
);
|
||||
}
|
||||
|
||||
// No-op: event binding now happens lazily in mountCard()
|
||||
@@ -308,6 +819,49 @@ export function showInfoCard(type, data, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
currentType = type;
|
||||
pendingMobileDetailState = { type, config, data };
|
||||
ensureMobileDetailsListener();
|
||||
|
||||
// Fill drawer details slot (accessible when user taps popup → opens details tab)
|
||||
const icon = document.getElementById('mobile-info-card-icon');
|
||||
const title = document.getElementById('mobile-info-card-title');
|
||||
const typeLabel = document.getElementById('mobile-info-card-type');
|
||||
const content = document.getElementById('mobile-info-card-content');
|
||||
|
||||
if (icon) icon.textContent = config.icon;
|
||||
if (title) {
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
: config.title;
|
||||
}
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent = type === 'news'
|
||||
? 'news signal'
|
||||
: type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
if (content && type !== 'news') {
|
||||
renderMobileDetailContent(type, config, data);
|
||||
renderedMobileDetailKey = null;
|
||||
} else if (content && type === 'news' && isMobileDetailsDrawerActive()) {
|
||||
renderMobileNewsCardContent(content, data);
|
||||
renderedMobileDetailKey = getMobileDetailRenderKey(type, data);
|
||||
}
|
||||
|
||||
// Show the floating mini popup near the touch point (requires coordinates)
|
||||
if (options.x != null && options.y != null) {
|
||||
ensurePopupClickHandler();
|
||||
showMobilePopup(type, data, options.x, options.y, options);
|
||||
document.body.classList.add('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
mountCard();
|
||||
|
||||
currentType = type;
|
||||
@@ -316,37 +870,34 @@ export function showInfoCard(type, data, options = {}) {
|
||||
const title = document.getElementById('info-card-title');
|
||||
const content = document.getElementById('info-card-content');
|
||||
|
||||
stopTypewriterAnimation();
|
||||
card.className = 'info-card ' + config.className;
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = config.title;
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
: config.title;
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
if (type === 'news') {
|
||||
renderNewsCardContent(content, data);
|
||||
} else {
|
||||
renderDefaultCardContent(content, config, data);
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showPanel(options.x, options.y, options);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
stopTypewriterAnimation();
|
||||
if (document.body.classList.contains('layout-mode-mobile')) {
|
||||
hideMobilePopup();
|
||||
document.body.classList.remove('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
);
|
||||
currentType = null;
|
||||
pendingMobileDetailState = null;
|
||||
renderedMobileDetailKey = null;
|
||||
return;
|
||||
}
|
||||
hidePanel();
|
||||
currentType = null;
|
||||
}
|
||||
|
||||
252
frontend/public/earth/js/iridium-footprint-adapter.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
const EARTH_RADIUS_KM = 6378.137;
|
||||
const SURFACE_SCALE = 1.003;
|
||||
const SURFACE_OFFSET = 0.72;
|
||||
const CLUSTER_DIAMETER_KM_APPROX = 4500;
|
||||
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
|
||||
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
|
||||
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
|
||||
const FILL_RINGS = 12;
|
||||
const FILL_SEGMENTS = 48;
|
||||
const RING_SEGMENTS = 72;
|
||||
|
||||
function disposeMaterial(material) {
|
||||
if (!material) return;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach(disposeMaterial);
|
||||
return;
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObjectTree(object) {
|
||||
if (!object) return;
|
||||
object.traverse((child) => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) disposeMaterial(child.material);
|
||||
});
|
||||
}
|
||||
|
||||
function createIridiumFillMaterial() {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) },
|
||||
uOpacity: { value: 0.55 },
|
||||
},
|
||||
vertexShader: `
|
||||
attribute vec2 aUv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = aUv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float r2 = dot(vUv, vUv);
|
||||
float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2));
|
||||
float alpha = glow * uOpacity;
|
||||
if (alpha <= 0.001) discard;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
function createIridiumRingMaterial() {
|
||||
return new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(IRIDIUM_OVERLAY_COLOR),
|
||||
transparent: true,
|
||||
opacity: 0.75,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
function projectOffsetToSurface(
|
||||
centerNormal,
|
||||
alongTrack,
|
||||
crossTrack,
|
||||
alongKm,
|
||||
crossKm,
|
||||
earthRadiusWorld,
|
||||
) {
|
||||
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
|
||||
const surfaceRadius = earthRadiusWorld * SURFACE_SCALE + SURFACE_OFFSET;
|
||||
return centerNormal
|
||||
.clone()
|
||||
.multiplyScalar(earthRadiusWorld)
|
||||
.addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
|
||||
.addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
|
||||
.normalize()
|
||||
.multiplyScalar(surfaceRadius);
|
||||
}
|
||||
|
||||
function computeClusterRadiusKm(altitudeKm) {
|
||||
const altitudeScale = THREE.MathUtils.clamp(
|
||||
(Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM,
|
||||
0.88,
|
||||
1.18,
|
||||
);
|
||||
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
|
||||
}
|
||||
|
||||
function buildFillGeometry() {
|
||||
// Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each.
|
||||
// Positions are updated in world space each frame; indices are static.
|
||||
const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS;
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const uvs = new Float32Array(vertexCount * 2);
|
||||
|
||||
// Center vertex: uv = (0,0)
|
||||
// Edge vertices: uv on unit circle, r = ring/FILL_RINGS
|
||||
|
||||
const indices = [];
|
||||
// Center to first ring: triangle fan
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const a = 1 + s;
|
||||
const b = 1 + (s + 1) % FILL_SEGMENTS;
|
||||
indices.push(0, a, b);
|
||||
}
|
||||
// Ring to ring
|
||||
for (let r = 0; r < FILL_RINGS - 1; r++) {
|
||||
const ringBase = 1 + r * FILL_SEGMENTS;
|
||||
const nextBase = ringBase + FILL_SEGMENTS;
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const s1 = (s + 1) % FILL_SEGMENTS;
|
||||
indices.push(ringBase + s, nextBase + s, ringBase + s1);
|
||||
indices.push(nextBase + s, nextBase + s1, ringBase + s1);
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function buildRingGeometry() {
|
||||
const positions = new Float32Array(RING_SEGMENTS * 3);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function createIridiumFootprintAdapter({
|
||||
earthObj,
|
||||
earthRadiusWorld,
|
||||
renderOrder,
|
||||
}) {
|
||||
if (!earthObj) return null;
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = "iridium-footprint-overlay";
|
||||
group.renderOrder = renderOrder;
|
||||
group.userData = { earthRadiusWorld, fill: null, outerRing: null };
|
||||
|
||||
const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial());
|
||||
fill.name = "iridium-cluster-fill";
|
||||
fill.renderOrder = renderOrder;
|
||||
fill.frustumCulled = false;
|
||||
group.add(fill);
|
||||
group.userData.fill = fill;
|
||||
|
||||
const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial());
|
||||
outerRing.name = "iridium-outer-ring";
|
||||
outerRing.renderOrder = renderOrder;
|
||||
outerRing.frustumCulled = false;
|
||||
group.add(outerRing);
|
||||
group.userData.outerRing = outerRing;
|
||||
|
||||
earthObj.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function updateIridiumFootprintAdapter(
|
||||
group,
|
||||
{ position, alongTrack, crossTrack, altitudeKm },
|
||||
) {
|
||||
if (!group || !position || !alongTrack || !crossTrack) return;
|
||||
|
||||
const earthRadiusWorld = group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
|
||||
const centerNormal = position.clone().normalize();
|
||||
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
|
||||
const alongRadiusKm = clusterRadiusKm * 1.18;
|
||||
const crossRadiusKm = clusterRadiusKm * 0.96;
|
||||
|
||||
const fill = group.userData?.fill;
|
||||
if (fill) {
|
||||
const posAttr = fill.geometry.attributes.position;
|
||||
const uvAttr = fill.geometry.attributes.aUv;
|
||||
|
||||
// Center vertex
|
||||
const center = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld,
|
||||
);
|
||||
posAttr.setXYZ(0, center.x, center.y, center.z);
|
||||
uvAttr.setXY(0, 0, 0);
|
||||
|
||||
// Ring vertices
|
||||
for (let r = 1; r <= FILL_RINGS; r++) {
|
||||
const t = r / FILL_RINGS;
|
||||
const aKm = alongRadiusKm * t;
|
||||
const cKm = crossRadiusKm * t;
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const angle = (s / FILL_SEGMENTS) * Math.PI * 2;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
const pt = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack,
|
||||
aKm * cosA,
|
||||
cKm * sinA,
|
||||
earthRadiusWorld,
|
||||
);
|
||||
const vi = 1 + (r - 1) * FILL_SEGMENTS + s;
|
||||
posAttr.setXYZ(vi, pt.x, pt.y, pt.z);
|
||||
uvAttr.setXY(vi, t * cosA, t * sinA);
|
||||
}
|
||||
}
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
uvAttr.needsUpdate = true;
|
||||
fill.geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
const outerRing = group.userData?.outerRing;
|
||||
if (outerRing) {
|
||||
const posAttr = outerRing.geometry.attributes.position;
|
||||
for (let k = 0; k < RING_SEGMENTS; k++) {
|
||||
const angle = (k / RING_SEGMENTS) * Math.PI * 2;
|
||||
const pt = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack,
|
||||
alongRadiusKm * Math.cos(angle),
|
||||
crossRadiusKm * Math.sin(angle),
|
||||
earthRadiusWorld,
|
||||
);
|
||||
posAttr.setXYZ(k, pt.x, pt.y, pt.z);
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
outerRing.geometry.computeBoundingSphere();
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeIridiumFootprintAdapter(group, earthObj) {
|
||||
if (!group) return;
|
||||
if (earthObj) {
|
||||
earthObj.remove(group);
|
||||
} else if (group.parent) {
|
||||
group.parent.remove(group);
|
||||
}
|
||||
disposeObjectTree(group);
|
||||
}
|
||||
@@ -23,12 +23,14 @@ export function setLayerButtonState(button, options = {}) {
|
||||
const {
|
||||
active = null,
|
||||
loading = false,
|
||||
disabled = null,
|
||||
tooltip = null,
|
||||
statusText = null,
|
||||
} = options;
|
||||
button.classList.toggle("is-loading", loading);
|
||||
button.toggleAttribute("aria-busy", loading);
|
||||
button.disabled = loading;
|
||||
button.disabled = loading || (disabled === true);
|
||||
button.classList.toggle("is-disabled", disabled === true);
|
||||
if (typeof active === "boolean") {
|
||||
updateLayerButtonState(button, active);
|
||||
}
|
||||
|
||||
233
frontend/public/earth/js/layer-startup-tasks.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
loadLandingPoints,
|
||||
getCableLegendItems,
|
||||
toggleCables,
|
||||
} from "./cables.js";
|
||||
import {
|
||||
clearSatelliteData,
|
||||
getSatelliteLegendItems,
|
||||
loadSatellites,
|
||||
toggleSatellites,
|
||||
} from "./satellites.js";
|
||||
import {
|
||||
loadBGPAnomalies,
|
||||
toggleBGP,
|
||||
} from "./bgp.js";
|
||||
import {
|
||||
loadComputeCenters,
|
||||
toggleComputeCenters,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
getCountryBoundaryLegendItems,
|
||||
loadCountryBoundaries,
|
||||
toggleCountryBoundaries,
|
||||
} from "./country-boundaries.js";
|
||||
|
||||
/**
|
||||
* Layer startup task registry.
|
||||
*
|
||||
* This module is the startup-task counterpart to the layer registry in controls.js:
|
||||
* - controls.js owns layer metadata such as startupPriority/startupMode/startupMessage
|
||||
* - this file owns the executable startup task factory for each layer id
|
||||
*
|
||||
* A startup task is registered via registerLayerStartupTask(id, taskFactory).
|
||||
* The taskFactory receives a startup context from main.js and must return an async
|
||||
* function with the signature async (layerDefinition) => void.
|
||||
*
|
||||
* Put a task here only when a layer needs dedicated startup loading work:
|
||||
* - preloading data at boot
|
||||
* - staged loading with progress/loading messages
|
||||
* - post-load UI refresh or warmup
|
||||
*
|
||||
* Do not put plain visibility toggles or persistent UI state here; those still belong
|
||||
* to the layer registry/state flow in controls.js.
|
||||
*/
|
||||
const startupTaskRegistry = new Map();
|
||||
|
||||
export function resolveStartupMessage(layer, key, fallback) {
|
||||
const message = layer?.startupMessage;
|
||||
if (message && typeof message === "object" && key in message) {
|
||||
return message[key];
|
||||
}
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createLayerStartupTaskMap(context) {
|
||||
return Object.fromEntries(
|
||||
Array.from(startupTaskRegistry.entries()).map(([id, taskFactory]) => [
|
||||
id,
|
||||
taskFactory(context),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerLayerStartupTask(id, taskFactory) {
|
||||
if (typeof id !== "string" || !id.trim()) {
|
||||
throw new Error("registerLayerStartupTask 需要有效的图层 id");
|
||||
}
|
||||
if (typeof taskFactory !== "function") {
|
||||
throw new Error("registerLayerStartupTask 需要可调用的任务工厂");
|
||||
}
|
||||
startupTaskRegistry.set(id, taskFactory);
|
||||
}
|
||||
|
||||
function registerBuiltinLayerStartupTasks() {
|
||||
startupTaskRegistry.clear();
|
||||
registerCountryBoundaryStartupTask();
|
||||
registerCableStartupTask();
|
||||
registerComputeCenterStartupTask();
|
||||
registerBGPStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
}
|
||||
|
||||
function registerCableStartupTask() {
|
||||
registerLayerStartupTask("cables", (context) => async (layer) => {
|
||||
if (!context.isCablesEnabled()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
await loadLandingPoints(context.scene, context.earth, { silent: true });
|
||||
} catch (error) {
|
||||
context.reportError("登陆点", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载海缆..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
await loadGeoJSONFromPath(context.scene, context.earth, { silent: true });
|
||||
if (!context.isCancelled() && context.isCablesEnabled()) {
|
||||
toggleCables(true);
|
||||
context.updateCableToggleUi(true);
|
||||
context.setLegendItems("cables", getCableLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "海缆", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerSatelliteStartupTask() {
|
||||
registerLayerStartupTask("satellites", (context) => async (layer) => {
|
||||
if (!context.isSatellitesEnabled()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载卫星..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
clearSatelliteData();
|
||||
const loadResult = await loadSatellites({
|
||||
limit: context.getInitialSatelliteLoadLimit(),
|
||||
});
|
||||
if (!context.isCancelled() && context.isSatellitesEnabled()) {
|
||||
context.updateSatelliteToggleUi(true, loadResult.count);
|
||||
context.setLegendItems("satellites", getSatelliteLegendItems());
|
||||
context.refreshLegend();
|
||||
context.scheduleSatellitePositionWarmup(() => {
|
||||
if (!context.isCancelled() && context.isSatellitesEnabled()) {
|
||||
toggleSatellites(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (context.shouldHydrateFullSatelliteSet(loadResult)) {
|
||||
const hydrationToken = context.nextSatelliteHydrationToken();
|
||||
context.hydrateAllSatellitesInBackground(
|
||||
() =>
|
||||
hydrationToken === context.getSatelliteHydrationToken() &&
|
||||
!context.isCancelled() &&
|
||||
context.isSatellitesEnabled(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "卫星", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerBGPStartupTask() {
|
||||
registerLayerStartupTask("bgp", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
const bgpResult = await loadBGPAnomalies(context.scene, context.earth);
|
||||
if (!context.isCancelled()) {
|
||||
toggleBGP(context.getShowBGP());
|
||||
context.updateBGPHud(bgpResult);
|
||||
context.syncBGPKnownEventIds();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "BGP态势", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerCountryBoundaryStartupTask() {
|
||||
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
await loadCountryBoundaries();
|
||||
if (!context.isCancelled()) {
|
||||
const textureOn = context.isEarthTextureVisible();
|
||||
toggleCountryBoundaries(context.getShowCountryBoundaries(), {
|
||||
showTint: !textureOn,
|
||||
showLandFill: true,
|
||||
suppressLandFill: false,
|
||||
});
|
||||
context.setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "国界", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerComputeCenterStartupTask() {
|
||||
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
const computeCenterResult = await loadComputeCenters(context.scene, context.earth);
|
||||
if (!context.isCancelled()) {
|
||||
toggleComputeCenters(context.getShowComputeCenters());
|
||||
context.updateComputeCenterHud(computeCenterResult);
|
||||
context.setLegendItems("computeCenters", context.getComputeCenterLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
registerBuiltinLayerStartupTasks();
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
bgp: { title: "BGP" },
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
countryBoundaries: { title: "国界" },
|
||||
computeCenters: { title: "算力" },
|
||||
bgp: { title: "BGP" },
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
@@ -11,6 +13,8 @@ let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
countryBoundaries: [],
|
||||
computeCenters: [],
|
||||
bgp: [],
|
||||
};
|
||||
|
||||
@@ -62,17 +66,18 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
|
||||
function syncCurrentLabel(mode) {
|
||||
const labelEl = document.getElementById("legend-current-label");
|
||||
if (!labelEl) return;
|
||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
|
||||
.forEach((labelEl) => {
|
||||
if (labelEl) {
|
||||
labelEl.textContent = nextLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const listEl = document.querySelector("#legend .legend-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const items = legendItemsByMode[mode] || [];
|
||||
listEl.innerHTML = items
|
||||
const html = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="legend-item">
|
||||
@@ -81,4 +86,14 @@ function renderLegend(mode) {
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const desktopList = document.querySelector("#legend .legend-list");
|
||||
if (desktopList) {
|
||||
desktopList.innerHTML = html;
|
||||
}
|
||||
|
||||
const mobileList = document.getElementById("mobile-situation-legend-list");
|
||||
if (mobileList) {
|
||||
mobileList.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
357
frontend/public/earth/js/news-cruise-adapter.js
Normal file
@@ -0,0 +1,357 @@
|
||||
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 {
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
} from "./callout-connector.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
getNewsPayload,
|
||||
selectNewsItem,
|
||||
clearSelectedNewsItem,
|
||||
} from "./news.js";
|
||||
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const MOBILE_CARD_MARGIN_PX = 14;
|
||||
const MOBILE_CARD_TOP_RATIO = 0.16;
|
||||
const MOBILE_CARD_WIDTH_PX = 220;
|
||||
const scratchNewsWorldPosition = new THREE.Vector3();
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
function getItemTimestamp(item) {
|
||||
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatPublishedAt(rawValue) {
|
||||
if (!rawValue) return "刚刚同步";
|
||||
const parsed = new Date(rawValue);
|
||||
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
|
||||
return parsed.toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getCardPlacement() {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const safeBottom =
|
||||
parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||
) || 0;
|
||||
const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2);
|
||||
return {
|
||||
x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX),
|
||||
y: Math.max(
|
||||
MOBILE_CARD_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerHeight * MOBILE_CARD_TOP_RATIO,
|
||||
window.innerHeight - safeBottom - 120,
|
||||
),
|
||||
),
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
) || 1;
|
||||
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
|
||||
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
|
||||
|
||||
return {
|
||||
x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5,
|
||||
y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNewsItemToCruiseEvent(item) {
|
||||
const latitude = Number(item?.latitude);
|
||||
const longitude = Number(item?.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `news:${item.id}`,
|
||||
sourceId: item.id,
|
||||
type: "news",
|
||||
title: item.title || "新闻事件",
|
||||
summary: item.summary || "",
|
||||
source: item.source || "",
|
||||
feedName: item.feed_name || "",
|
||||
region: item.region || "global",
|
||||
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
|
||||
url: item.url || "",
|
||||
publishedAt: item.published_at || null,
|
||||
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
sortTimestamp: getItemTimestamp(item),
|
||||
};
|
||||
}
|
||||
|
||||
export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) {
|
||||
let currentItemId = null;
|
||||
let knownEventIds = new Set();
|
||||
let cardPlacement = null;
|
||||
|
||||
function getVisibleMobilePopup() {
|
||||
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||
? mobilePopup
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVisibleInfoPanel() {
|
||||
const infoPanel = document.getElementById("info-panel");
|
||||
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||
? infoPanel
|
||||
: null;
|
||||
}
|
||||
|
||||
function getItemWorldPosition(item) {
|
||||
const earthObj = earth?.();
|
||||
if (!earthObj || !item) return null;
|
||||
scratchNewsWorldPosition.copy(
|
||||
latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3),
|
||||
);
|
||||
earthObj.localToWorld(scratchNewsWorldPosition);
|
||||
return scratchNewsWorldPosition;
|
||||
}
|
||||
|
||||
function getItemScreenCoords(item) {
|
||||
if (!camera || !item) return null;
|
||||
const worldPosition = getItemWorldPosition(item);
|
||||
if (!worldPosition) return null;
|
||||
const projected = worldPosition.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardAnchorTarget() {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||
return {
|
||||
element: mobilePopup,
|
||||
side: mobilePopup.dataset.dockSide || "left",
|
||||
alignRatio: 0.5,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
|
||||
return { element: mobilePopup };
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
if (infoPanel) {
|
||||
return { element: infoPanel };
|
||||
}
|
||||
|
||||
if (fallbackPlacement) {
|
||||
return {
|
||||
x: fallbackPlacement.x,
|
||||
y: fallbackPlacement.y,
|
||||
width: fallbackPlacement.width ?? 0,
|
||||
height: fallbackPlacement.height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectorPath(item) {
|
||||
const itemCoords = getItemScreenCoords(item);
|
||||
if (!itemCoords) return null;
|
||||
|
||||
const targetPlacement = cardPlacement || getCardPlacement();
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const anchorTarget = getCardAnchorTarget();
|
||||
const anchorCoords = resolveConnectorAnchor(anchorTarget);
|
||||
if (!anchorCoords) return null;
|
||||
return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, {
|
||||
routingMode: "adaptive",
|
||||
obstacles: getCardObstacleTarget(targetPlacement),
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
const target =
|
||||
infoPanel ||
|
||||
{
|
||||
x: targetPlacement.x,
|
||||
y: targetPlacement.y,
|
||||
width: targetPlacement.width,
|
||||
height: targetPlacement.height,
|
||||
};
|
||||
|
||||
return createConnectorPath(itemCoords, target, {
|
||||
routingMode: "adaptive",
|
||||
obstacles: getCardObstacleTarget(targetPlacement),
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
function renderConnector(item, { animate = false } = {}) {
|
||||
const path = getConnectorPath(item);
|
||||
if (!path) return false;
|
||||
return connector?.render(path, { animate }) === true;
|
||||
}
|
||||
|
||||
function getSortedItems() {
|
||||
const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : [];
|
||||
return items
|
||||
.map(mapNewsItemToCruiseEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureItemsLoaded() {
|
||||
await ensureNewsPanelReady();
|
||||
},
|
||||
getSortedItems,
|
||||
clearCurrentHighlight() {
|
||||
currentItemId = null;
|
||||
clearSelectedNewsItem();
|
||||
},
|
||||
async focusItem(item, { interrupt = false } = {}) {
|
||||
if (!item) return;
|
||||
currentItemId = item.id;
|
||||
await ensureNewsPanelReady();
|
||||
await focusView({
|
||||
lat: item.latitude,
|
||||
lon: item.longitude,
|
||||
rotLon: item.longitude - 270,
|
||||
duration: interrupt
|
||||
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||
: CRUISE_CONFIG.focusDurationMs,
|
||||
suppressStatus: true,
|
||||
});
|
||||
cardPlacement = getCardPlacement();
|
||||
},
|
||||
async presentItem(item, { context }) {
|
||||
if (!item) return false;
|
||||
|
||||
selectNewsItem(item.sourceId);
|
||||
const placement = cardPlacement || getCardPlacement();
|
||||
cardPlacement = placement;
|
||||
showInfoCard("news", {
|
||||
title: item.title,
|
||||
summary: item.summary || item.title || "",
|
||||
}, {
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
reveal: false,
|
||||
});
|
||||
|
||||
await context.nextFrame();
|
||||
if (!context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
connectorReady = renderConnector(item, { animate: !connectorReady });
|
||||
if (connectorReady) break;
|
||||
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||
break;
|
||||
}
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!connectorReady || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
return false;
|
||||
}
|
||||
|
||||
showInfoCard("news", {
|
||||
title: item.title,
|
||||
summary: item.summary || item.title || "",
|
||||
}, {
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
absolute: true,
|
||||
anchorStable: true,
|
||||
});
|
||||
await context.nextFrame();
|
||||
return context.isCurrent();
|
||||
},
|
||||
async hidePresentation({ context }) {
|
||||
clearSelectedNewsItem();
|
||||
connector?.hide();
|
||||
hideInfoCard();
|
||||
await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true });
|
||||
cardPlacement = null;
|
||||
},
|
||||
repositionConnector(item) {
|
||||
if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) {
|
||||
return;
|
||||
}
|
||||
renderConnector(item, { animate: false });
|
||||
},
|
||||
resetPresentation() {
|
||||
clearSelectedNewsItem();
|
||||
cardPlacement = null;
|
||||
connector?.hide();
|
||||
},
|
||||
syncKnownEventIds() {
|
||||
knownEventIds = new Set(getSortedItems().map((item) => item.id));
|
||||
return knownEventIds;
|
||||
},
|
||||
diffNewEventIds(itemIds = []) {
|
||||
return itemIds
|
||||
.map((itemId) => `news:${itemId}`)
|
||||
.filter((itemId) => !knownEventIds.has(itemId));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { isTVPanelVisible } from "./tv.js";
|
||||
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
@@ -17,18 +17,20 @@ let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
let selectedCruiseStoryId = null;
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
status: document.getElementById("news-board-status"),
|
||||
focusLabel: document.getElementById("news-focus-label"),
|
||||
focusCoords: document.getElementById("news-focus-coords"),
|
||||
sourceCount: document.getElementById("news-source-count"),
|
||||
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
|
||||
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
|
||||
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
|
||||
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
|
||||
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
|
||||
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
|
||||
regionChip: document.getElementById("news-region-chip"),
|
||||
board: document.getElementById("news-board-list"),
|
||||
empty: document.getElementById("news-board-empty"),
|
||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
||||
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
|
||||
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
|
||||
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,18 +83,33 @@ function renderPayload(nextPayload) {
|
||||
openBtn,
|
||||
feedAnchor,
|
||||
} = getElements();
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (regionChip) {
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
}
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
// Mobile page does not show the compact chip row.
|
||||
} else if (!regionChip) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusLabel.textContent = focus.label || "全球焦点";
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
|
||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||
@@ -138,7 +155,7 @@ function renderPayload(nextPayload) {
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
@@ -153,6 +170,14 @@ function renderPayload(nextPayload) {
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
applyCruiseStorySelection();
|
||||
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
|
||||
detail: {
|
||||
payload: nextPayload,
|
||||
itemIds: items.map((item) => item.id),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
@@ -271,32 +296,80 @@ export async function ensureNewsPanelReady() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function getNewsPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
|
||||
if (!(board instanceof HTMLElement)) return;
|
||||
const cards = board.querySelectorAll("[data-news-id]");
|
||||
cards.forEach((card) => {
|
||||
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
|
||||
card.classList.toggle("news-story-card--cruise", matches);
|
||||
if (matches && scrollIntoView) {
|
||||
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyCruiseStorySelection(options = {}) {
|
||||
const desktopBoard = document.getElementById("news-board-list");
|
||||
const mobileBoard = document.getElementById("mobile-news-board-list");
|
||||
updateBoardSelection(desktopBoard, options);
|
||||
updateBoardSelection(mobileBoard, options);
|
||||
}
|
||||
|
||||
function revealSelectedCruiseStory() {
|
||||
if (!selectedCruiseStoryId) return;
|
||||
applyCruiseStorySelection({ scrollIntoView: true });
|
||||
}
|
||||
|
||||
export function selectNewsItem(itemId, options = {}) {
|
||||
selectedCruiseStoryId = itemId || null;
|
||||
applyCruiseStorySelection(options);
|
||||
}
|
||||
|
||||
export function clearSelectedNewsItem() {
|
||||
selectedCruiseStoryId = null;
|
||||
applyCruiseStorySelection();
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
if (getActiveTVTab() === "news") {
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
if (event.detail?.visible && getActiveTVTab() === "news") {
|
||||
revealSelectedCruiseStory();
|
||||
}
|
||||
});
|
||||
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||
const refreshBtn = document.getElementById(id);
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
["news-open-external", "mobile-news-open-external"].forEach((id) => {
|
||||
const openBtn = document.getElementById(id);
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
});
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
|
||||
284
frontend/public/earth/js/search.js
Normal file
@@ -0,0 +1,284 @@
|
||||
let initialized = false;
|
||||
let resolveResultsFn = null;
|
||||
let onSelectResultFn = null;
|
||||
let currentResults = [];
|
||||
let activeIndex = -1;
|
||||
let searchTimerId = null;
|
||||
let isOpen = false;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
modal: document.getElementById("search-modal"),
|
||||
backdrop: document.getElementById("search-backdrop"),
|
||||
input: document.getElementById(
|
||||
isMobile ? "mobile-earth-search-input" : "earth-search-input",
|
||||
),
|
||||
clear: document.getElementById(
|
||||
isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
|
||||
),
|
||||
meta: document.getElementById(
|
||||
isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
|
||||
),
|
||||
results: document.getElementById(
|
||||
isMobile ? "mobile-earth-search-results" : "earth-search-results",
|
||||
),
|
||||
empty: document.getElementById(
|
||||
isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
|
||||
),
|
||||
close: document.getElementById("search-close"),
|
||||
};
|
||||
}
|
||||
|
||||
function setMeta(text) {
|
||||
const { meta } = getElements();
|
||||
if (meta) meta.textContent = text;
|
||||
}
|
||||
|
||||
function updateEmptyState(query) {
|
||||
const { empty } = getElements();
|
||||
if (!empty) return;
|
||||
if (!query) {
|
||||
empty.textContent = "支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。";
|
||||
return;
|
||||
}
|
||||
empty.textContent = "未找到匹配对象,可尝试名称、地点、NORAD、ASN、前缀等关键词。";
|
||||
}
|
||||
|
||||
function renderResults(query) {
|
||||
const { results, empty } = getElements();
|
||||
if (!results || !empty) return;
|
||||
|
||||
results.innerHTML = "";
|
||||
const hasResults = currentResults.length > 0;
|
||||
empty.hidden = hasResults;
|
||||
updateEmptyState(query);
|
||||
|
||||
if (!hasResults) return;
|
||||
|
||||
currentResults.forEach((result, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "earth-search-result";
|
||||
button.setAttribute("role", "option");
|
||||
button.dataset.index = String(index);
|
||||
button.innerHTML = `
|
||||
<span class="earth-search-result-icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
|
||||
</span>
|
||||
<span class="earth-search-result-copy">
|
||||
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
|
||||
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
|
||||
</span>
|
||||
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
|
||||
`;
|
||||
button.addEventListener("click", async () => {
|
||||
await selectResult(index);
|
||||
});
|
||||
results.appendChild(button);
|
||||
});
|
||||
|
||||
syncActiveResult();
|
||||
}
|
||||
|
||||
function syncActiveResult() {
|
||||
const { results } = getElements();
|
||||
if (!results) return;
|
||||
Array.from(results.children).forEach((node, index) => {
|
||||
node.classList.toggle("is-active", index === activeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function moveActiveResult(delta) {
|
||||
if (currentResults.length === 0) return;
|
||||
activeIndex =
|
||||
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
|
||||
currentResults.length;
|
||||
syncActiveResult();
|
||||
const { results } = getElements();
|
||||
const activeNode = results?.children?.[activeIndex];
|
||||
activeNode?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
async function selectResult(index) {
|
||||
const result = currentResults[index];
|
||||
if (!result || typeof onSelectResultFn !== "function") return;
|
||||
closeSearchPanel();
|
||||
try {
|
||||
await onSelectResultFn(result);
|
||||
} catch (error) {
|
||||
console.error("Search selection failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const { input, clear } = getElements();
|
||||
if (!input) return;
|
||||
|
||||
const query = input.value.trim();
|
||||
if (clear) {
|
||||
clear.hidden = query.length === 0;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
currentResults = [];
|
||||
activeIndex = -1;
|
||||
setMeta("输入关键词以搜索当前地球对象");
|
||||
renderResults("");
|
||||
return;
|
||||
}
|
||||
|
||||
setMeta("正在检索…");
|
||||
|
||||
try {
|
||||
const nextResults = await resolveResultsFn?.(query);
|
||||
currentResults = Array.isArray(nextResults) ? nextResults : [];
|
||||
activeIndex = currentResults.length > 0 ? 0 : -1;
|
||||
setMeta(`找到 ${currentResults.length} 个结果`);
|
||||
renderResults(query);
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
currentResults = [];
|
||||
activeIndex = -1;
|
||||
setMeta("搜索失败");
|
||||
renderResults(query);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
if (searchTimerId) {
|
||||
clearTimeout(searchTimerId);
|
||||
}
|
||||
searchTimerId = window.setTimeout(() => {
|
||||
searchTimerId = null;
|
||||
runSearch();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
const { modal, input } = getElements();
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
if (!isMobile && !modal?.classList.contains("is-open")) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeSearchPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target !== input) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveActiveResult(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveActiveResult(-1);
|
||||
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
selectResult(activeIndex).catch((error) => {
|
||||
console.warn("Selecting search result failed:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
|
||||
resolveResultsFn = resolveResults;
|
||||
onSelectResultFn = onSelectResult;
|
||||
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const inputs = ["earth-search-input", "mobile-earth-search-input"]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((node) => node instanceof HTMLInputElement);
|
||||
const clears = ["earth-search-clear", "mobile-earth-search-clear"]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((node) => node instanceof HTMLButtonElement);
|
||||
const close = document.getElementById("search-close");
|
||||
const backdrop = document.getElementById("search-backdrop");
|
||||
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener("input", scheduleSearch);
|
||||
input.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
clears.forEach((clear) => {
|
||||
clear.addEventListener("click", () => {
|
||||
const { input } = getElements();
|
||||
if (!input) return;
|
||||
input.value = "";
|
||||
input.focus();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Clearing search failed:", error);
|
||||
});
|
||||
});
|
||||
});
|
||||
close?.addEventListener("click", () => {
|
||||
closeSearchPanel();
|
||||
});
|
||||
backdrop?.addEventListener("click", () => {
|
||||
closeSearchPanel();
|
||||
});
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
}
|
||||
|
||||
export function openSearchPanel() {
|
||||
const { modal, input } = getElements();
|
||||
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
|
||||
if (isOpen) return;
|
||||
isOpen = true;
|
||||
document.body.classList.add("earth-search-open");
|
||||
modal?.classList.add("is-open");
|
||||
modal?.setAttribute("aria-hidden", "false");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Running search failed:", error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function focusSearchInput({ select = false } = {}) {
|
||||
const { input } = getElements();
|
||||
if (!(input instanceof HTMLInputElement)) return;
|
||||
input.focus();
|
||||
if (select) {
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshSearchResults() {
|
||||
return runSearch();
|
||||
}
|
||||
|
||||
export function closeSearchPanel() {
|
||||
const { modal } = getElements();
|
||||
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
|
||||
if (!isOpen) return;
|
||||
isOpen = false;
|
||||
document.body.classList.remove("earth-search-open");
|
||||
modal?.classList.remove("is-open");
|
||||
modal?.setAttribute("aria-hidden", "true");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:search-open-change", { detail: { open: false } }),
|
||||
);
|
||||
}
|
||||
|
||||
export function isSearchPanelOpen() {
|
||||
return isOpen;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ let terrainLoadPromise = null;
|
||||
let terrainReady = false;
|
||||
let terrainFailed = false;
|
||||
let terrainTileCache = new Map();
|
||||
let resolvedTileCache = new Map();
|
||||
let terrainVertexSamples = null;
|
||||
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
|
||||
@@ -67,7 +68,9 @@ async function decodeTerrainTile(z, x, y) {
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
return { data, width, height };
|
||||
const tileData = { data, width, height };
|
||||
resolvedTileCache.set(cacheKey, tileData);
|
||||
return tileData;
|
||||
})();
|
||||
|
||||
terrainTileCache.set(cacheKey, tilePromise);
|
||||
@@ -236,6 +239,7 @@ export function registerTerrainMesh(mesh) {
|
||||
terrainFailed = false;
|
||||
terrainLoadPromise = null;
|
||||
terrainTileCache = new Map();
|
||||
resolvedTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
@@ -287,9 +291,19 @@ export function clearTerrainData() {
|
||||
terrainFailed = false;
|
||||
terrainVertexSamples = null;
|
||||
terrainTileCache = new Map();
|
||||
resolvedTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
}
|
||||
|
||||
export function sampleElevationAt(lat, lon) {
|
||||
if (!terrainReady) return null;
|
||||
const z = TERRAIN_CONFIG.baseZoom;
|
||||
const { tileX, tileY, pixelX, pixelY } = latLonToTileSample(lat, lon, z, TERRAIN_CONFIG.tileSize);
|
||||
const tile = resolvedTileCache.get(`${z}/${tileX}/${tileY}`);
|
||||
if (!tile) return null;
|
||||
return Math.max(0, decodeTerrariumHeight(tile, pixelX, pixelY));
|
||||
}
|
||||
|
||||
export function setTerrainOpacity(nextOpacity) {
|
||||
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||
if (terrainMesh?.material) {
|
||||
|
||||
@@ -36,6 +36,7 @@ const tabPanelState = {
|
||||
live: null,
|
||||
news: null,
|
||||
};
|
||||
let mobileMetaCollapsed = false;
|
||||
|
||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||
@@ -56,23 +57,24 @@ const HLS_RETRY_CONFIG = {
|
||||
};
|
||||
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
// Outer media shell node.
|
||||
panel: document.getElementById("media-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
select: document.getElementById("tv-source-select"),
|
||||
title: document.getElementById("tv-source-title"),
|
||||
meta: document.getElementById("tv-source-meta"),
|
||||
catalog: document.getElementById("tv-source-catalog"),
|
||||
status: document.getElementById("tv-source-status"),
|
||||
notes: document.getElementById("tv-source-notes"),
|
||||
iframe: document.getElementById("tv-iframe"),
|
||||
video: document.getElementById("tv-video"),
|
||||
empty: document.getElementById("tv-empty-state"),
|
||||
refreshBtn: document.getElementById("tv-refresh"),
|
||||
openBtn: document.getElementById("tv-open-external"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
|
||||
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
|
||||
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
|
||||
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
|
||||
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
|
||||
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
|
||||
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
|
||||
video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
|
||||
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
|
||||
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
|
||||
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
|
||||
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
|
||||
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
|
||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||
newsHeaderControls: document.getElementById("tv-header-controls-news"),
|
||||
liveTabBtn: document.getElementById("tv-tab-live"),
|
||||
@@ -83,8 +85,43 @@ function getElements() {
|
||||
};
|
||||
}
|
||||
|
||||
function isMobileLayout() {
|
||||
return document.body.classList.contains("layout-mode-mobile");
|
||||
}
|
||||
|
||||
function syncMetaToggleState(collapsed) {
|
||||
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||
if (mobileOverviewBar instanceof HTMLElement) {
|
||||
mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
}
|
||||
const desktopToggle = document.getElementById("tv-meta-toggle");
|
||||
if (desktopToggle instanceof HTMLButtonElement) {
|
||||
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
desktopToggle.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
|
||||
);
|
||||
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
|
||||
}
|
||||
}
|
||||
|
||||
function isMetaCollapsed() {
|
||||
if (isMobileLayout()) {
|
||||
return mobileMetaCollapsed;
|
||||
}
|
||||
return mediaPanel?.isCollapsed() ?? false;
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
if (isMobileLayout()) {
|
||||
const { metaWrap } = getElements();
|
||||
mobileMetaCollapsed = Boolean(collapsed);
|
||||
metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed);
|
||||
syncMetaToggleState(mobileMetaCollapsed);
|
||||
return;
|
||||
}
|
||||
mediaPanel?.setCollapsed(collapsed);
|
||||
syncMetaToggleState(Boolean(collapsed));
|
||||
}
|
||||
|
||||
function syncPanelActiveTab(tab = activeTab) {
|
||||
@@ -119,6 +156,10 @@ function syncNewsDefaultMaxHeight() {
|
||||
}
|
||||
|
||||
function autoExpandMeta() {
|
||||
if (isMobileLayout()) {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
return;
|
||||
}
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
setMetaCollapsed(false);
|
||||
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
|
||||
@@ -153,7 +194,7 @@ function captureTabState(tab = activeTab) {
|
||||
tabPanelState[tab] = {
|
||||
layout: readPanelLayoutState(panel),
|
||||
metaCollapsed:
|
||||
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
|
||||
tab === "live" ? isMetaCollapsed() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,6 +392,7 @@ function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
mediaPanel?.setVisible(visible);
|
||||
document.body.classList.toggle("earth-media-open", visible);
|
||||
updateToggleButton(visible);
|
||||
syncSettingsToggle(visible);
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
||||
@@ -747,15 +789,59 @@ function getExternalUrl(source) {
|
||||
}
|
||||
|
||||
function updateOpenButton(source) {
|
||||
const { openBtn } = getElements();
|
||||
if (!openBtn) return;
|
||||
const targetUrl = getExternalUrl(source);
|
||||
openBtn.disabled = !targetUrl;
|
||||
openBtn.onclick = targetUrl
|
||||
? () => {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
: null;
|
||||
[
|
||||
document.getElementById("mobile-tv-open-external"),
|
||||
document.getElementById("tv-open-external"),
|
||||
].forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.disabled = !targetUrl;
|
||||
button.onclick = targetUrl
|
||||
? () => {
|
||||
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
function syncMobileOverviewSummary(source) {
|
||||
const headline = document.getElementById("mobile-tv-overview-headline");
|
||||
const summary = document.getElementById("mobile-tv-overview-summary");
|
||||
const tags = document.getElementById("mobile-tv-overview-tags");
|
||||
if (headline instanceof HTMLElement) {
|
||||
headline.textContent = source?.name || "暂无可用频道";
|
||||
}
|
||||
if (summary instanceof HTMLElement) {
|
||||
if (!source) {
|
||||
summary.textContent = "点击查看当前频道来源、目录和补充说明";
|
||||
} else {
|
||||
const parts = [
|
||||
source.provider,
|
||||
source.region,
|
||||
source.language,
|
||||
].filter(Boolean);
|
||||
summary.textContent = parts.length
|
||||
? parts.join(" · ")
|
||||
: "点击查看完整频道信息";
|
||||
}
|
||||
}
|
||||
if (tags instanceof HTMLElement) {
|
||||
const tagValues = source
|
||||
? [
|
||||
{ label: source.source_type || "频道", kind: "status" },
|
||||
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
|
||||
source.region ? { label: source.region, kind: "" } : null,
|
||||
].filter(Boolean).slice(0, 3)
|
||||
: [{ label: "待加载", kind: "status" }];
|
||||
tags.replaceChildren(
|
||||
...tagValues.map(({ label, kind }) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`;
|
||||
chip.textContent = label;
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function findSourceById(sourceId) {
|
||||
@@ -877,11 +963,12 @@ function renderSourceOptions() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
||||
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -913,12 +1000,17 @@ function renderSource(source) {
|
||||
const latestLabel = latestUpdatedAt
|
||||
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
|
||||
: "尚未同步";
|
||||
const collectorLabel = source?.collector_source ? ` · 采集器 ${source.collector_source}` : "";
|
||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${collectorLabel}`;
|
||||
const sourceOriginLabel = source?.collector_source
|
||||
? `采集源 ${source.collector_source}`
|
||||
: source
|
||||
? "内置源"
|
||||
: "";
|
||||
catalog.textContent = `共 ${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
|
||||
}
|
||||
if (notes) {
|
||||
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
|
||||
}
|
||||
syncMobileOverviewSummary(source);
|
||||
|
||||
if (!source || (!embeddedUrl && !videoUrl)) {
|
||||
destroyHlsPlayer();
|
||||
@@ -1024,6 +1116,7 @@ export function initTVPanel() {
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
} = getElements();
|
||||
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
|
||||
|
||||
if (panel && metaToggle) {
|
||||
mediaPanel = createHUDPanel({
|
||||
@@ -1038,6 +1131,10 @@ export function initTVPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
mobileMetaCollapsed = true;
|
||||
syncMetaToggleState(isMetaCollapsed());
|
||||
setMetaCollapsed(isMetaCollapsed());
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
@@ -1066,22 +1163,51 @@ export function initTVPanel() {
|
||||
showStatusMessage("已切换到态势新闻", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLSelectElement)) return;
|
||||
currentSourceId = target.value;
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((selectEl) => {
|
||||
selectEl?.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLSelectElement)) return;
|
||||
currentSourceId = target.value;
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
});
|
||||
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
[metaToggle, document.getElementById("tv-meta-toggle")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((toggleEl) => {
|
||||
toggleEl?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !isMetaCollapsed();
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
});
|
||||
|
||||
const toggleMobileMeta = (event) => {
|
||||
const interactiveTarget = event.target instanceof Element
|
||||
? event.target.closest("button, a, select, input, textarea, video, iframe")
|
||||
: null;
|
||||
if (interactiveTarget) return;
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
||||
const isNowCollapsed = !isMetaCollapsed();
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
};
|
||||
|
||||
mobileOverviewBar?.addEventListener("click", toggleMobileMeta);
|
||||
mobileOverviewBar?.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
toggleMobileMeta(event);
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", () => {
|
||||
refreshTVPanel();
|
||||
});
|
||||
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((refreshEl) => {
|
||||
refreshEl?.addEventListener("click", () => {
|
||||
refreshTVPanel();
|
||||
});
|
||||
});
|
||||
|
||||
liveTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("live");
|
||||
@@ -1090,24 +1216,32 @@ export function initTVPanel() {
|
||||
setActiveTab("news");
|
||||
});
|
||||
|
||||
iframe?.addEventListener("load", () => {
|
||||
if (iframe.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((iframeEl) => {
|
||||
iframeEl?.addEventListener("load", () => {
|
||||
if (iframeEl.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
});
|
||||
|
||||
video?.addEventListener("loadedmetadata", () => {
|
||||
if (video.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
[video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((videoEl) => {
|
||||
videoEl?.addEventListener("loadedmetadata", () => {
|
||||
if (videoEl.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
videoEl?.addEventListener("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setupResizeHandle();
|
||||
syncPanelActiveTab("live");
|
||||
|
||||
@@ -19,6 +19,20 @@ function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function getEarthStatTargets(statKey) {
|
||||
return Array.from(
|
||||
document.querySelectorAll(`[data-earth-stat="${statKey}"]`),
|
||||
);
|
||||
}
|
||||
|
||||
export function setEarthStatValue(statKey, value) {
|
||||
getEarthStatTargets(statKey).forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.textContent = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setElementDisplay(element, visible, displayValue = "block") {
|
||||
if (!element) return;
|
||||
element.style.display = visible ? displayValue : "none";
|
||||
@@ -155,7 +169,15 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
||||
const slider = getElement("zoom-slider");
|
||||
const cameraDistanceEl = getElement("camera-distance");
|
||||
|
||||
if (zoomValueEl) zoomValueEl.textContent = percent + "%";
|
||||
if (zoomValueEl) {
|
||||
const tooltip = zoomValueEl.querySelector(".tooltip");
|
||||
const label = `${percent}%`;
|
||||
if (zoomValueEl.firstChild?.nodeType === Node.TEXT_NODE) {
|
||||
zoomValueEl.firstChild.nodeValue = label;
|
||||
} else {
|
||||
zoomValueEl.insertBefore(document.createTextNode(label), tooltip || null);
|
||||
}
|
||||
}
|
||||
if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
|
||||
if (slider) slider.value = zoomLevel;
|
||||
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
|
||||
@@ -163,27 +185,22 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
||||
|
||||
// Update earth stats
|
||||
export function updateEarthStats(stats) {
|
||||
const cableCountEl = getElement("cable-count");
|
||||
const landingPointCountEl = getElement("landing-point-count");
|
||||
const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
|
||||
const bgpCollectorCountEl = getElement("bgp-collector-count");
|
||||
const bgpStatusSummaryEl = getElement("bgp-status-summary");
|
||||
const terrainStatusEl = getElement("terrain-status");
|
||||
const textureQualityEl = getElement("texture-quality");
|
||||
|
||||
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
|
||||
if (landingPointCountEl)
|
||||
landingPointCountEl.textContent = stats.landingPointCount || 0;
|
||||
if (bgpAnomalyCountEl)
|
||||
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
|
||||
if (bgpCollectorCountEl)
|
||||
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
|
||||
if (bgpStatusSummaryEl)
|
||||
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
|
||||
if (terrainStatusEl)
|
||||
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
|
||||
if (textureQualityEl)
|
||||
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
|
||||
const has = (key) => Object.prototype.hasOwnProperty.call(stats, key);
|
||||
if (has("cableCount")) setEarthStatValue("cable-count", String(stats.cableCount || 0));
|
||||
if (has("landingPointCount")) {
|
||||
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
|
||||
}
|
||||
if (has("satelliteCount")) setEarthStatValue("satellite-count", String(stats.satelliteCount || 0));
|
||||
if (has("computeCenterCount")) {
|
||||
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
|
||||
}
|
||||
if (has("bgpAnomalyCount")) setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
|
||||
if (has("bgpCollectorCount")) {
|
||||
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||
}
|
||||
if (has("bgpStatusSummary")) setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
|
||||
if (has("terrainOn")) setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
|
||||
if (has("textureQuality")) setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
|
||||
}
|
||||
|
||||
// Show/hide loading via status message
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as THREE from "three";
|
||||
|
||||
import { CONFIG } from "./constants.js";
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
// Convert latitude/longitude to 3D vector
|
||||
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
|
||||
const phi = (90 - lat) * (Math.PI / 180);
|
||||
@@ -90,3 +94,31 @@ export function calculateDistance(
|
||||
|
||||
return radius * c;
|
||||
}
|
||||
|
||||
export function getSurfaceMarkerCameraScale(camera, options = {}) {
|
||||
if (!camera) return 1;
|
||||
|
||||
const {
|
||||
altitudeOffset = 0,
|
||||
referenceFov = 75,
|
||||
min = 0.12,
|
||||
max = 3.0,
|
||||
} = options;
|
||||
|
||||
const cameraDistanceFromCenter = camera.position.length();
|
||||
const surfaceDistance = Math.max(
|
||||
1,
|
||||
cameraDistanceFromCenter - (CONFIG.earthRadius + altitudeOffset),
|
||||
);
|
||||
const referenceSurfaceDistance = Math.max(
|
||||
1,
|
||||
CONFIG.defaultCameraZ - (CONFIG.earthRadius + altitudeOffset),
|
||||
);
|
||||
const cameraFovRad = (((camera.fov || referenceFov)) * Math.PI) / 180;
|
||||
const referenceFovRad = (referenceFov * Math.PI) / 180;
|
||||
const worldPerPixel = surfaceDistance * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceSurfaceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const Earth = lazy(() => import('./pages/Earth/Earth'))
|
||||
const Settings = lazy(() => import('./pages/Settings/Settings'))
|
||||
const BGP = lazy(() => import('./pages/BGP/BGP'))
|
||||
const Playground = lazy(() => import('./pages/Playground/Playground'))
|
||||
const Logs = lazy(() => import('./pages/Logs/Logs'))
|
||||
|
||||
function App() {
|
||||
const { token } = useAuthStore()
|
||||
@@ -48,6 +49,7 @@ function App() {
|
||||
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
||||
<Route path="/bgp" element={<BGP />} />
|
||||
<Route path="/playground" element={<Playground />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AppstoreOutlined,
|
||||
ToolOutlined,
|
||||
InboxOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||
@@ -39,6 +40,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
|
||||
const showBanner = true
|
||||
const appVersion = `v${packageJson.version}`
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
|
||||
const menuItems: ItemType<MenuItemType>[] = [
|
||||
{
|
||||
@@ -83,6 +85,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
label: '运维与配置',
|
||||
children: [
|
||||
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
||||
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
],
|
||||
|
||||
@@ -132,13 +132,10 @@ function Scrollbar({
|
||||
resizeObserver.observe(viewport)
|
||||
resizeObserver.observe(trackX)
|
||||
resizeObserver.observe(trackY)
|
||||
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
|
||||
|
||||
mutationObserver.observe(viewport, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
})
|
||||
|
||||
viewport.addEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
|
||||
@@ -156,7 +156,6 @@ function ScrollbarOverlay({
|
||||
scheduleUpdate()
|
||||
})
|
||||
resizeObserver.observe(target)
|
||||
Array.from(target.children).forEach((child) => resizeObserver?.observe(child))
|
||||
scheduleUpdate()
|
||||
}
|
||||
|
||||
@@ -169,7 +168,6 @@ function ScrollbarOverlay({
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
window.addEventListener('resize', scheduleUpdate)
|
||||
|
||||