Compare commits

...

5 Commits

Author SHA1 Message Date
rayd1o
9b913a3b83 release: bump version to 0.59.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-16 05:02:05 +08:00
linkong
93eb41a9f7 release: bump version to 0.58.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
2026-05-15 17:40:07 +08:00
rayd1o
dd176a6ae6 release: bump version to 0.57.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-14 01:02:17 +08:00
linkong
f14ff6ec0f release: bump version to 0.56.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-13 18:21:03 +08:00
linkong
39854b9983 release: bump version to 0.55.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-13 15:31:29 +08:00
126 changed files with 9780 additions and 1251 deletions

7
.gitignore vendored
View File

@@ -8,6 +8,7 @@
.env
.env.local
.env.*.local
config/earth-boundary-sources.local.json
*.pem
*.key
*.crt
@@ -150,3 +151,9 @@ temp/
# Runtime Data
# ----------------------
data/ai/bgp-briefs/
data/earth-boundary-sources/
# Generated Earth boundary tile artifacts. Keep source configs and builders in
# Git; publish PMTiles/MVT artifacts through release/deploy storage instead of
# committing thousands of generated tile files.
frontend/public/earth/data/boundaries/

View File

@@ -244,7 +244,7 @@ bun run build
./planet.sh start --allow-lan
```
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`AI Provider 通过 Docker 发布到 `0.0.0.0:8010`。启动前脚本会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理
### 2. 先确认 WSL 内部服务正常
@@ -253,14 +253,16 @@ bun run build
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
curl http://localhost:8010/health
ss -ltnp | grep -E ':3000|:8000|:8010'
```
预期:
- `3000` 返回前端 HTML
- `8000/health` 返回健康检查 JSON
- `ss` 中能看到 `0.0.0.0:3000``0.0.0.0:8000`
- `8010/health` 返回 AI Provider 健康检查 JSON
- `ss` 中能看到 `0.0.0.0:3000``0.0.0.0:8000``0.0.0.0:8010`,或 Docker 已发布 `8010`
如果这一步不通,先不要继续做 Windows 转发。
@@ -271,42 +273,31 @@ ss -ltnp | grep -E ':3000|:8000'
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
curl http://localhost:8010/health
```
在常见的 WSL2 开发环境下Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
### 4. 如果需要让局域网设备访问,清理端口和防火墙
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行
`./planet.sh start --allow-lan` 不再启动额外的 Windows 端口转发进程。它直接让开发服务对 `3000` / `8000` / `8010` 开放,并在启动前尝试释放这些端口。端口被 Windows 侧 listener 或旧 `portproxy` 占用时,脚本会请求一次管理员 PowerShell 清理
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`
如果以前手动配置过持久 `portproxy`,若自动请求被取消,可以手动清理,避免 `iphlpsvc` 继续占用端口
```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
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
```
再放行 Windows 防火墙:
脚本会检测 Windows 防火墙是否已放行 `3000` / `8000` / `8010`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行
```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
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
```
检查转发规则是否生效:
```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 中执行:
@@ -321,6 +312,8 @@ ipconfig
- `http://<Windows局域网IP>:3000/earth`
- `http://<Windows局域网IP>:3000/admin`
- `http://<Windows局域网IP>:8000/health`
- `http://<Windows局域网IP>:8010/health`
例如:
@@ -329,7 +322,7 @@ ipconfig
### 6. 常见现象与判断
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常是 Windows 防火墙、网络配置或旧 `portproxy` 残留
- `whoami /groups``S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
### 7. 本项目一次性验证顺序
@@ -338,10 +331,12 @@ ipconfig
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`
3. WSL 中执行 `curl http://localhost:8010/health`
4. Windows 中执行 `curl http://localhost:3000`
5. Windows 中执行 `curl http://localhost:8000/health`
6. Windows 中执行 `curl http://localhost:8010/health`
7. 按脚本提示完成 Windows 防火墙或端口清理 UAC 请求
8. 用手机或其他电脑访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`
## 启动容错参数

117
TODO.md
View File

@@ -1,45 +1,76 @@
# TODO
- [x] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点
- [x] 开始做 BGP 异常和海缆/区域的关联展示
- [x] 做 Earth 侧的 `BGP activity layer`,让低 incident 密度时地图仍然有持续可感知的观测存在感
- [x] 给 Earth BGP 补三层状态表达:`平稳观测态 / 局部波动态 / 事件活跃态`
- [x] 把“当前无活跃事件”改造成“观测网络仍在运行、当前未发现聚合级事件”的状态表达
- [x] 做 collector / region 近 15 分钟 activity score 聚合接口或动态聚合逻辑
- [x] Earth 的 BGP incident 改成 `紧凑事件核 + 向外扩张环形 pulse`,替换当前大面积 glow
- [x] 为 BGP incident 建立符号系统:按事件类型用不同 marker而不是都用同一种亮点
- [x] 把 incident 地理定位从 `collector-centric` 改成 `prefix-centric`,优先使用 `prefix_geography`,其次 `prefix_scope`,再次 ASN 区域,最后才回退到观测区域质心
- [x] 新增 `prefix_geography` 数据层,不再把 `prefix_scope` 当成 prefix 地理归属本身
- [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源
- [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源
- [x] 把 RIR delegated 设计成 prefix geography 的 fallback而不是主来源
- [ ] `aiprovider` 建立 `provider -> api adapter -> compat policy` 的配置中心,优先落成 `json``yaml` 文件,运行时按 `provider/model` 读取兼容设置,而不是把专项兼容继续散落在 Python 分支里
- [ ] 为市面上主流 AI 服务补专项兼容配置并固化到配置文件中,至少覆盖 `OpenAI / Anthropic / MiniMax / Ollama / Moonshot / DeepSeek / Qwen / GLM / Gemini / OpenRouter / vLLM / LM Studio / One API`
- [ ] 在兼容配置中补齐可声明项:`api adapter``base_url pattern``auth header``thinking default``reasoning block mapping``stream path``tool-call capability``multimodal capability``provider-specific request patch`
- [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层
- [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 能力,再决定是先做脚本级统一入口还是控制台内置日志面板
- [ ] 重写控制台 UI逐步抛弃 Ant Design建立自有组件体系并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
- [ ] AIS v3.1:修复船只聚合完整性,`/geo/vessels` 合并 raw observation 聚合结果与 legacy `vessel_position + vessel_static` 最新结果,确保 BarentsWatch-only 船只不会因为 AISStream 子集存在而消失,并增加 raw/legacy/final unique MMSI 诊断统计
- [ ] AIS v3.2:把 AISStream 从收满 `max_messages` 后结束的批采集改成长连接 streaming service持续写入 raw observations通过内部 `/ws``vessels` channel 推送新船、位置和航向增量Earth 前端按 MMSI upsert marker
- [ ] AIS v3.3:修正 AISStream 采集页面状态语义,使用 connecting/streaming/reconnecting/stopped 与 indeterminate 状态展示运行时长、消息数、unique MMSI、message rate、最近消息和错误不再用一次性 REST 进度条表示长连接
- [ ] AIS v3.4修复船只身份字段和名称聚合MMSI/IMO/callsign 按字符串显示且不带千分位符;查询并列出所有仍以 MMSI 号码或 `MMSI <number>` 作为船名的记录标注来源、最近观测、message types 和缺失原因,并把这批 fallback-name 船只纳入名称聚合修复集合
- [ ] Earth Live Sync建立统一态势实时同步链路新增 `earth_summary` WS channel任意采集器成功后广播轻量 summary invalidation前端收到后重新拉 `/api/v1/visualization/geo/summary` 并更新 HUD同时为 BGP 增加 `bgp` WS channel使 BGP incidents/anomalies/collectors 在不刷新页面时也能 upsert 图层;卫星采集完成后触发 summary 刷新,必要时按 TLE 版本重新 hydrate 卫星数据
- [ ] AIS v4开放船只多源聚合策略配置支持 source priority、字段级规则、freshness 窗口和高级保护开关;保存时校验未知字段、非法模式和危险动态字段锁定,并在聚合接口返回命中的配置版本
- [ ] AIS v5实现船舶资料 enrichment 与冲突治理,按 `mmsi + imo + name + callsign` 异步补充船型细分、AIS 大类、旗国、尺寸、建造年份、运营方和图片缓存;详情面板展示缓存资料和字段来源,不在实时 AIS 请求链路现场抓第三方页面
- [ ] 为 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/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
This file is the active backlog only. Completed history belongs in `docs/CHANGELOG.md`; detailed designs belong in `docs/plans/`.
## Earth
- [x] High-precision country boundary tile framework: implement the static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md).
- [x] Add the `pmtiles-mvt` frontend tile provider contract, MVT decoder dependencies, static PMTiles Nginx handling, collector artifact registration, production readiness check, and user operation docs for Earth boundaries.
- [x] Split Earth boundary ingestion into standard source collectors (`earth_admin0_boundaries`, `earth_coastline`, `earth_claim_lines`) plus the downstream `earth_boundary_tiles` PMTiles builder.
- [ ] Replace debug GeoJSON boundary tiles with the real `earth-boundaries-china-pov-v1.pmtiles` production artifact after audited admin-0 / coastline / claim-line sources and the PMTiles toolchain are available.
- [ ] Import authoritative China POV / coastline / claim-line source packages through the three standard Earth boundary source collectors, then rebuild a versioned PMTiles artifact so highest zoom `8-10` preserves trusted source geometry instead of seed data.
- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, Kosovo, Gaza, and the official dashed maritime claim line before implementing final visual changes.
- [ ] Earth high-resolution basemap tiles: implement the viewport-loaded imagery layer described in [Earth High Resolution Basemap Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-resolution-basemap-tiles-plan.md), using high-precision coastline as the alignment reference instead of replacing the globe with one huge texture.
- [ ] Presentation controller ownership: replace the singleton card fallback in [presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) with a presentation/card token check before BGP/News migrate onto the shared controller, so connectors only attach to their owning card.
- [ ] BGP frontend maintainability: split [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by responsibility into data loading, marker rendering, overlays, and animation once the current interaction behavior is stable.
- [ ] Optional BGP marker experiment: evaluate HTML markers for BGP incident/collector points if WebGL marker density or fixed screen-size clickability becomes a real blocker.
- [ ] Earth news cruise: connect Earth news to the generic cruise queue via a news adapter rather than coupling news-specific sequencing into `main.js`.
## Compute Centers And Location
- [ ] Unknown compute-center locations: continue reducing unresolved records through the shared location pipeline, with confidence, precision, reason, and verification date preserved in GeoJSON/details.
- [ ] Compute-center registry: keep expanding the local canonical location registry with `canonical_name`, aliases, operator, country/region/city, coordinates, confidence, and source notes.
- [ ] Compute-center enrichment: improve source-page parsing for Epoch AI and related collectors by extracting location clues from detail pages, embedded JSON, schema.org, OpenGraph, script variables, PDFs, and press releases.
- [ ] Compute-center identity normalization: normalize operator / cluster / facility aliases such as `xAI / Colossus / Memphis`, `OpenAI / Stargate`, `CoreWeave`, `Lambda`, and `Crusoe`.
- [ ] Compute-center manual review: add an export/review/import workflow for unresolved or estimated locations and feed confirmed results back into the registry.
## AIS / Vessels
- [ ] AIS aggregation strategy v4: expose source priority, field-level merge rules, freshness windows, and protected dynamic-field rules in configuration, with validation and strategy version returned by vessel APIs.
- [ ] AIS vessel enrichment v5: add asynchronous vessel profile enrichment for ship type detail, AIS class, flag, dimensions, build year, operator, and cached media. Do not fetch third-party pages in the realtime AIS request path.
- [ ] AIS identity cleanup: continue identifying vessels whose display name is only `MMSI <number>` and backfill names from AISStream static messages, BarentsWatch static fields, or enrichment cache.
## AI Provider And Agents
- [ ] AI provider compatibility center: move provider/model compatibility rules into a JSON/YAML config read by runtime, instead of continuing to scatter provider-specific branches through Python code.
- [ ] Provider compatibility coverage: add explicit config for OpenAI, Anthropic, MiniMax, Ollama, Moonshot, DeepSeek, Qwen, GLM, Gemini, OpenRouter, vLLM, LM Studio, and One API.
- [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches.
- [ ] BGP geography fallback: evaluate `inetnum` / `inet6num` whois as a finer fallback layer after `prefix_geography`, `OpenGeoFeed`, and RIR delegated data.
## Platform
- [ ] Earth preferences scope: keep current device-local Earth preferences in `localStorage`; only design backend user preferences if account-level synchronization becomes a real product requirement.
- [ ] System logs: finish a usable Planet log viewing flow that covers backend, frontend, AI Provider, and collector/task logs, with filtering and tailing.
- [ ] Console UI modernization: gradually replace Ant Design with Planet-owned components and a consistent Tabler Icons based icon system.
- [ ] Earth live sync: design a unified realtime invalidation path for summary/BGP/satellite updates if polling and current WebSocket channels become insufficient.
## Archive
Archived items stay here so old context is not lost. Completed items remain checked; obsolete, invalid, or superseded items stay unchecked and include the reason.
### Completed
- [x] Refined BGP observer and anomaly `hover/click` feel.
- [x] Added BGP anomaly relationship display with cables / regions.
- [x] Added the Earth BGP activity layer so the map still feels alive when incident density is low.
- [x] Added BGP state expression for stable observation, local fluctuation, and active incident states.
- [x] Reframed "no active incident" as "observation network is running; no aggregate incident detected".
- [x] Added collector / region recent activity scoring.
- [x] Replaced oversized BGP incident glow with compact incident core plus outward pulse rings.
- [x] Added BGP incident symbol types instead of using one generic bright marker.
- [x] Switched BGP incident geography from collector-centric to prefix-centric priority.
- [x] Added `prefix_geography` as a separate data layer instead of treating `prefix_scope` as prefix geography.
- [x] Added IPtoASN / IPtoCountry as the main prefix-centric geography source.
- [x] Added OpenGeoFeed as a high-quality prefix geography override source.
- [x] Made RIR delegated data a prefix geography fallback rather than the primary source.
- [x] Added route leak and path instability / flap detectors after the activity layer work.
### Obsolete Or Superseded
- [ ] AIS v3.1 old `/geo/vessels` full-merge requirement. Superseded by `/api/v1/vessels/snapshot`, controlled legacy fallback, and diagnostics in the AIS aggregation plan.
- [ ] AIS v3.2 old framing of AISStream as a batch collector that needed conversion. Superseded by the implemented long-lived AISStream collector and realtime stream UI.
- [ ] AIS v3.3 old one-shot REST progress semantics for AISStream. Superseded by realtime stream status handling.
- [ ] AIS v3.4 broad identity cleanup wording. Folded into the active AIS identity cleanup and v5 enrichment tasks.
- [ ] Earth surface material overlay for boundary calibration. Superseded by the high-precision boundary tile plan; future work must use source-faithful boundary/coastline data rather than overlay calibration against the coarse base map.
- [ ] Hardcoded Earth news source extraction as a standalone task. Superseded by the broader Earth news source configuration and collector plans.
- [ ] Country-level compute-center fallback placement as a standalone task. Superseded by the shared location pipeline and registry/manual-review backlog.

View File

@@ -1 +1 @@
0.54.0
0.59.0

View File

@@ -17,9 +17,6 @@ class Settings(BaseSettings):
AI_HTTP_RETRY_ATTEMPTS: int = 2
AI_MAX_TOKENS: int = 1200
AI_ANTHROPIC_VERSION: str = "2023-06-01"
AI_ANALYSIS_SYSTEM_PROMPT: str = (
"你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。"
)
AI_PROVIDER_SERVICE_TOKEN: str = ""

View File

@@ -62,7 +62,6 @@ class ProviderService:
self.anthropic_version = str(
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
)
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
def get_status(self) -> AIProviderStatusResponse:
enabled = self.provider != "disabled"
@@ -95,15 +94,20 @@ class ProviderService:
prompt = self._build_prompt(payload)
if self.provider_api == "openai-completions":
data = await self._request_openai_compatible(model, prompt)
data = await self._request_openai_compatible(model, prompt, payload.system_prompt)
content = self._extract_openai_content(data)
content_blocks = self._extract_openai_blocks(data)
elif self.provider_api == "anthropic-messages":
data = await self._request_anthropic_messages(model, prompt, payload.thinking)
data = await self._request_anthropic_messages(
model,
prompt,
payload.thinking,
payload.system_prompt,
)
content = self._extract_anthropic_content(data)
content_blocks = self._extract_anthropic_blocks(data)
elif self.provider_api == "ollama-generate":
data = await self._request_ollama(model, prompt)
data = await self._request_ollama(model, prompt, payload.system_prompt)
content = self._extract_ollama_content(data)
content_blocks = self._extract_ollama_blocks(data)
else:
@@ -139,19 +143,28 @@ class ProviderService:
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
if payload.context:
sections.append(f"附加上下文:\n{payload.context}")
sections.append(
"请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。"
)
return "\n\n".join(sections)
async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]:
def _resolve_system_prompt(self, system_prompt: str | None) -> str | None:
resolved = str(system_prompt or "").strip()
return resolved or None
async def _request_openai_compatible(
self,
model: str,
prompt: str,
system_prompt: str | None = None,
) -> dict[str, Any]:
messages = []
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
if resolved_system_prompt:
messages.append({"role": "system", "content": resolved_system_prompt})
messages.append({"role": "user", "content": prompt})
request_body = {
"model": model,
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt},
],
"messages": messages,
"temperature": 0.2,
"max_tokens": self.max_tokens,
}
return await self._post(
path="/chat/completions",
@@ -167,10 +180,10 @@ class ProviderService:
model: str,
prompt: str,
thinking: dict[str, Any] | None = None,
system_prompt: str | None = None,
) -> dict[str, Any]:
request_body = {
"model": model,
"system": self.system_prompt,
"messages": [
{
"role": "user",
@@ -185,6 +198,9 @@ class ProviderService:
"max_tokens": self.max_tokens,
"temperature": 0.2,
}
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
if resolved_system_prompt:
request_body["system"] = resolved_system_prompt
resolved_thinking = self._resolve_anthropic_thinking(thinking)
if resolved_thinking:
request_body["thinking"] = resolved_thinking
@@ -218,19 +234,28 @@ class ProviderService:
model: str,
prompt: str,
thinking: dict[str, Any] | None = None,
system_prompt: str | None = None,
) -> dict[str, Any]:
return await self._request_anthropic_messages(model, prompt, thinking)
return await self._request_anthropic_messages(model, prompt, thinking, system_prompt)
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
async def _request_ollama(
self,
model: str,
prompt: str,
system_prompt: str | None = None,
) -> dict[str, Any]:
request_body = {
"model": model,
"stream": False,
"system": self.system_prompt,
"prompt": prompt,
"options": {
"temperature": 0.2,
"num_predict": self.max_tokens,
},
}
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
if resolved_system_prompt:
request_body["system"] = resolved_system_prompt
return await self._post(
path="/api/generate",
headers={

View File

@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
class SituationalAnalysisRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
objective: str = Field(..., min_length=1, max_length=20000)
context: dict[str, Any] = Field(default_factory=dict)
observations: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
system_prompt: str | None = Field(default=None, max_length=8000)
preferred_model: str | None = Field(default=None, max_length=200)
thinking: dict[str, Any] | None = None

View File

@@ -0,0 +1,2 @@
"""AI task prompt registry and runtime helpers."""

View File

@@ -0,0 +1,74 @@
[
{
"key": "earth.news.enrich",
"label": "Earth 新闻汉化与定位",
"group": "Earth 新闻",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title and summary based only on the supplied RSS headline, description, source, and date."
},
{
"key": "alerts.brief",
"label": "系统告警研判",
"group": "告警研判",
"version": "2026-05-16.1",
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
"prompt": "基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。"
},
{
"key": "alerts.situational.brief",
"label": "跨模块态势告警研判",
"group": "告警研判",
"version": "2026-05-16.1",
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
"prompt": "综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。"
},
{
"key": "bgp.brief",
"label": "BGP 态势简报",
"group": "BGP",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。"
},
{
"key": "location.factcheck.normalize",
"label": "位置事实核查结构化",
"group": "位置解析",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "Convert the supplied location factcheck text into exactly one strict JSON object. Extract only facts present in the text or original query."
},
{
"key": "location.factcheck.resolve",
"label": "位置事实核查兜底",
"group": "位置解析",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "Return exactly one JSON object for the most likely physical location. Use only fact-checkable public knowledge; return null fields rather than guessing when evidence is weak."
},
{
"key": "datasource.mapping",
"label": "数据源映射生成",
"group": "采集配置",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "Return only JSON for a deterministic mapping DSL. The JSON must contain source.items_path and fields. Do not include prose or code."
},
{
"key": "credential.guide",
"label": "采集器凭据教程",
"group": "采集配置",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "生成一份中文采集器凭据配置教程。只能根据 context.search_evidence 中的来源生成教程;如果证据不足,明确说明需要以官方页面为准。"
},
{
"key": "ai.connection_test",
"label": "AI Provider 连接测试",
"group": "运维测试",
"version": "2026-05-16.1",
"system_prompt": "",
"prompt": "Reply OK."
}
]

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_setting import SystemSetting
AI_PROMPTS_CATEGORY = "ai_prompts"
DEFAULT_PROMPTS_PATH = Path(__file__).with_name("default_prompts.json")
@dataclass(frozen=True)
class AIPromptDefinition:
key: str
label: str
group: str
version: str
system_prompt: str
prompt: str
@dataclass(frozen=True)
class EffectiveAIPrompt:
key: str
label: str
group: str
version: str
default_system_prompt: str
default_prompt: str
system_prompt: str
prompt: str
is_custom: bool
updated_at: str | None = None
@lru_cache(maxsize=1)
def list_prompt_definitions() -> tuple[AIPromptDefinition, ...]:
raw_items = json.loads(DEFAULT_PROMPTS_PATH.read_text(encoding="utf-8"))
return tuple(
AIPromptDefinition(
key=str(item["key"]),
label=str(item["label"]),
group=str(item["group"]),
version=str(item["version"]),
system_prompt=str(item.get("system_prompt") or ""),
prompt=str(item.get("prompt") or ""),
)
for item in raw_items
)
def get_prompt_definition(task_key: str) -> AIPromptDefinition:
for definition in list_prompt_definitions():
if definition.key == task_key:
return definition
raise KeyError(task_key)
async def _get_prompt_setting(db: AsyncSession) -> SystemSetting | None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == AI_PROMPTS_CATEGORY)
)
return result.scalar_one_or_none()
def _normalize_overrides(payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
raw = (payload or {}).get("overrides")
if not isinstance(raw, dict):
return {}
return {
str(key): dict(value)
for key, value in raw.items()
if isinstance(value, dict)
}
async def get_prompt_overrides(db: AsyncSession) -> dict[str, dict[str, Any]]:
if not hasattr(db, "execute"):
return {}
setting = await _get_prompt_setting(db)
return _normalize_overrides(setting.payload if setting else None)
def _effective_prompt(
definition: AIPromptDefinition,
override: dict[str, Any] | None,
) -> EffectiveAIPrompt:
override = override or {}
custom_system = override.get("system_prompt")
custom_prompt = override.get("prompt")
has_custom_system = isinstance(custom_system, str)
has_custom_prompt = isinstance(custom_prompt, str)
return EffectiveAIPrompt(
key=definition.key,
label=definition.label,
group=definition.group,
version=definition.version,
default_system_prompt=definition.system_prompt,
default_prompt=definition.prompt,
system_prompt=custom_system if has_custom_system else definition.system_prompt,
prompt=custom_prompt if has_custom_prompt else definition.prompt,
is_custom=has_custom_system or has_custom_prompt,
updated_at=str(override.get("updated_at") or "") or None,
)
async def list_effective_prompts(db: AsyncSession) -> list[EffectiveAIPrompt]:
overrides = await get_prompt_overrides(db)
return [
_effective_prompt(definition, overrides.get(definition.key))
for definition in list_prompt_definitions()
]
async def get_effective_prompt(db: AsyncSession | None, task_key: str) -> EffectiveAIPrompt:
definition = get_prompt_definition(task_key)
if db is None:
return _effective_prompt(definition, None)
overrides = await get_prompt_overrides(db)
return _effective_prompt(definition, overrides.get(task_key))
async def save_prompt_override(
db: AsyncSession,
task_key: str,
*,
system_prompt: str,
prompt: str,
) -> EffectiveAIPrompt:
definition = get_prompt_definition(task_key)
setting = await _get_prompt_setting(db)
payload = dict(setting.payload or {}) if setting else {}
overrides = _normalize_overrides(payload)
overrides[definition.key] = {
"system_prompt": system_prompt,
"prompt": prompt,
"updated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
}
payload["overrides"] = overrides
if setting is None:
setting = SystemSetting(category=AI_PROMPTS_CATEGORY, payload=payload)
db.add(setting)
else:
setting.payload = payload
await db.commit()
return _effective_prompt(definition, overrides[definition.key])
async def reset_prompt_override(db: AsyncSession, task_key: str) -> EffectiveAIPrompt:
definition = get_prompt_definition(task_key)
setting = await _get_prompt_setting(db)
if setting is None:
return _effective_prompt(definition, None)
payload = dict(setting.payload or {})
overrides = _normalize_overrides(payload)
overrides.pop(definition.key, None)
payload["overrides"] = overrides
setting.payload = payload
await db.commit()
return _effective_prompt(definition, None)
def serialize_effective_prompt(prompt: EffectiveAIPrompt) -> dict[str, Any]:
return {
"key": prompt.key,
"label": prompt.label,
"group": prompt.group,
"version": prompt.version,
"default_system_prompt": prompt.default_system_prompt,
"default_prompt": prompt.default_prompt,
"system_prompt": prompt.system_prompt,
"prompt": prompt.prompt,
"is_custom": prompt.is_custom,
"updated_at": prompt.updated_at,
}

View File

@@ -6,6 +6,7 @@ from app.api.v1 import (
datasource_config,
datasources,
docs,
earth,
tasks,
dashboard,
alerts,
@@ -34,6 +35,7 @@ api_router.include_router(
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
api_router.include_router(earth.router, prefix="/earth", tags=["earth"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])

View File

@@ -341,6 +341,7 @@ async def collect_bgp_collector_location(
provider_client=provider_client,
query=query,
entity_type="bgp_collector",
db=db,
attempted_queries=attempted_queries,
search_evidence=search_result.evidence,
)

View File

@@ -22,6 +22,7 @@ from app.models.vessel import AISRawObservation, AISSourceHealth
from app.core.security import get_current_user
from app.core.cache import cache
from app.core.time import to_iso8601_utc
from app.ai_tasks.prompts import get_effective_prompt
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.datasource_mapping import (
@@ -41,6 +42,8 @@ from app.services.custom_datasource_runtime import (
stop_custom_stream,
test_websocket_config,
)
DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping"
from app.services.datasource_connectivity import (
get_builtin_connection_status,
save_connectivity_success,
@@ -50,6 +53,15 @@ from app.services.datasource_connectivity import (
router = APIRouter()
def _default_builtin_config(name: str) -> dict[str, Any]:
return {"timeout": 30, "retry": 3}
def _default_builtin_source_type(name: str) -> str:
if name == "aisstream_vessels":
return "websocket"
return "http"
class DataSourceConfigCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
@@ -364,7 +376,7 @@ async def list_all_datasources(
db: AsyncSession = Depends(get_db),
):
"""List all data sources: YAML defaults + DB overrides"""
from app.core.data_sources import COLLECTOR_URL_KEYS, get_data_sources_config
from app.core.data_sources import get_data_sources_config
config = get_data_sources_config()
@@ -372,20 +384,22 @@ async def list_all_datasources(
db_configs = {c.name: c for c in db_query.scalars().all()}
result = []
for name, yaml_key in COLLECTOR_URL_KEYS.items():
for name, metadata in DEFAULT_DATASOURCES.items():
yaml_url = config.get_yaml_url(name)
db_config = db_configs.get(name)
default_config = _default_builtin_config(name)
default_url = yaml_url
result.append(
{
"name": name,
"default_url": yaml_url,
"endpoint": db_config.endpoint if db_config else yaml_url,
"default_url": default_url,
"endpoint": db_config.endpoint if db_config else default_url,
"is_overridden": db_config is not None and db_config.endpoint != yaml_url
if yaml_url
if default_url
else db_config is not None,
"is_active": db_config.is_active if db_config else True,
"source_type": db_config.source_type if db_config else "http",
"source_type": db_config.source_type if db_config else _default_builtin_source_type(name),
"auth_type": db_config.auth_type if db_config else "none",
"auth_configured": {
"api_key": bool((db_config.auth_config or {}).get("api_key"))
@@ -393,11 +407,11 @@ async def list_all_datasources(
else False,
},
"headers": db_config.headers if db_config else {},
"config": strip_connectivity_validation(db_config.config if db_config else {}),
"config": strip_connectivity_validation(db_config.config if db_config else default_config),
"config_id": db_config.id if db_config else None,
"description": db_config.description
if db_config
else f"Data source from YAML: {yaml_key}",
else f"内置采集器默认配置:{metadata.get('display_name') or metadata.get('name') or name}",
}
)
@@ -757,14 +771,12 @@ async def propose_datasource_mapping(
generated_by = "heuristic"
if payload.use_ai:
try:
prompt = await get_effective_prompt(db, DATASOURCE_MAPPING_PROMPT_KEY)
response = await ai_client.analyze(
SituationalAnalysisRequest(
title=f"Generate datasource mapping for {schema.key}",
objective=(
"Return only JSON for a deterministic mapping DSL. "
"The JSON must contain source.items_path and fields. "
"Do not include prose or code."
),
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
context={
"target_schema": schema.to_dict(),
"sample_payload": redacted_sample,

View File

@@ -150,6 +150,33 @@ async def _load_latest_task_ids(
return {datasource_id: task_id for datasource_id, task_id in result.all()}
async def _load_latest_tasks(
db: AsyncSession,
datasource_ids: list[int],
) -> dict[int, CollectionTask]:
if not datasource_ids:
return {}
ranked_tasks = (
select(
CollectionTask.id.label("task_id"),
CollectionTask.datasource_id.label("datasource_id"),
func.row_number().over(
partition_by=CollectionTask.datasource_id,
order_by=(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc()),
).label("row_num"),
)
.where(CollectionTask.datasource_id.in_(datasource_ids))
.subquery()
)
result = await db.execute(
select(CollectionTask)
.join(ranked_tasks, CollectionTask.id == ranked_tasks.c.task_id)
.where(ranked_tasks.c.row_num == 1)
)
return {task.datasource_id: task for task in result.scalars().all()}
async def _load_collected_record_counts(
db: AsyncSession,
sources: list[str],
@@ -208,7 +235,7 @@ async def _load_datasource_endpoint_overrides(
async def _load_datasource_list_context(
db: AsyncSession,
datasources: list[DataSource],
) -> tuple[dict[int, CollectionTask], dict[str, str]]:
) -> tuple[dict[int, CollectionTask], dict[int, CollectionTask], dict[str, str]]:
datasource_ids = [datasource.id for datasource in datasources]
sources = [datasource.source for datasource in datasources]
@@ -232,8 +259,9 @@ async def _load_datasource_list_context(
if stale_datasource_ids:
running_tasks = await _load_latest_running_tasks(db, datasource_ids)
latest_tasks = await _load_latest_tasks(db, datasource_ids)
endpoint_overrides = await _load_datasource_endpoint_overrides(db, sources)
return running_tasks, endpoint_overrides
return running_tasks, latest_tasks, endpoint_overrides
def _apply_datasource_query_filters(
@@ -251,11 +279,6 @@ def _apply_datasource_query_filters(
query = query.where(DataSource.is_active == is_active)
if priority:
query = query.where(DataSource.priority == priority)
if run_status and run_status not in {"running", "collected", "uncollected"}:
if run_status == "not_run":
query = query.where(DataSource.last_status.is_(None))
else:
query = query.where(DataSource.last_status == run_status)
if q:
like_value = f"%{q.strip()}%"
query = query.where(
@@ -272,6 +295,7 @@ def _filter_datasources_in_memory(
datasources: list[DataSource],
*,
running_tasks: dict[int, CollectionTask],
latest_tasks: dict[int, CollectionTask],
record_counts: dict[str, int],
product: Optional[str] = None,
run_status: Optional[str] = None,
@@ -281,6 +305,14 @@ def _filter_datasources_in_memory(
filtered: list[DataSource] = []
for datasource in datasources:
record_count = record_counts.get(datasource.source, 0)
latest_task = latest_tasks.get(datasource.id)
effective_status = (
"running"
if datasource.id in running_tasks
else latest_task.status
if latest_task is not None
else datasource.last_status
)
if product and datasource_product_key(datasource) != product:
continue
if collected is not None and (record_count > 0) != collected:
@@ -291,6 +323,10 @@ def _filter_datasources_in_memory(
continue
if run_status == "running" and datasource.id not in running_tasks:
continue
if run_status == "not_run" and effective_status is not None:
continue
if run_status not in {None, "running", "not_run", "collected", "uncollected"} and effective_status != run_status:
continue
if run_status == "collected" and record_count <= 0:
continue
if run_status == "uncollected" and record_count > 0:
@@ -639,11 +675,12 @@ async def list_datasources(
collector_list = []
config = get_data_sources_config()
running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
datasources = _filter_datasources_in_memory(
datasources,
running_tasks=running_tasks,
latest_tasks=latest_tasks,
record_counts=record_counts,
product=product,
run_status=run_status,
@@ -652,9 +689,11 @@ async def list_datasources(
)
for datasource in datasources:
running_task = running_tasks.get(datasource.id)
latest_task = latest_tasks.get(datasource.id)
display_task = running_task or latest_task
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
last_run_at = datasource.last_run_at
last_status = datasource.last_status
last_run_at = datasource.last_run_at or (latest_task.completed_at if latest_task else None)
last_status = datasource.last_status or (latest_task.status if latest_task else None)
collected_records = record_counts.get(datasource.source, 0)
collector_list.append(
@@ -675,16 +714,17 @@ async def list_datasources(
"last_run_at": to_iso8601_utc(last_run_at),
"last_status": last_status,
"is_running": running_task is not None,
"task_id": running_task.id if running_task else None,
"progress": running_task.progress if running_task else None,
"phase": running_task.phase if running_task else None,
"phase_progress": running_task.phase_progress if running_task else None,
"phase_message": running_task.phase_message if running_task else None,
"phase_current": running_task.phase_current if running_task else None,
"phase_total": running_task.phase_total if running_task else None,
"phase_unit": running_task.phase_unit if running_task else None,
"records_processed": running_task.records_processed if running_task else None,
"total_records": running_task.total_records if running_task else None,
"task_id": display_task.id if display_task else None,
"progress": display_task.progress if display_task else None,
"phase": display_task.phase if display_task else None,
"phase_progress": display_task.phase_progress if display_task else None,
"phase_message": display_task.phase_message if display_task else None,
"phase_current": display_task.phase_current if display_task else None,
"phase_total": display_task.phase_total if display_task else None,
"phase_unit": display_task.phase_unit if display_task else None,
"records_processed": display_task.records_processed if display_task else None,
"total_records": display_task.total_records if display_task else None,
"error_message": display_task.error_message if display_task else None,
"collected_records": collected_records,
"has_collected_data": collected_records > 0,
}
@@ -729,11 +769,12 @@ async def trigger_datasource_batch(
result = await db.execute(query)
datasources = result.scalars().all()
running_tasks, _ = await _load_datasource_list_context(db, datasources)
running_tasks, latest_tasks, _ = await _load_datasource_list_context(db, datasources)
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
datasources = _filter_datasources_in_memory(
datasources,
running_tasks=running_tasks,
latest_tasks=latest_tasks,
record_counts=record_counts,
product=None if payload.source_ids else payload.product,
run_status=None if payload.source_ids else payload.run_status,
@@ -935,6 +976,14 @@ async def get_task_status(
raise HTTPException(status_code=404, detail="Task not found")
else:
task = await get_running_task(db, datasource.id)
if task is None:
result = await db.execute(
select(CollectionTask)
.where(CollectionTask.datasource_id == datasource.id)
.order_by(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc())
.limit(1)
)
task = result.scalar_one_or_none()
if not task:
return {
@@ -963,4 +1012,5 @@ async def get_task_status(
"records_processed": task.records_processed,
"total_records": task.total_records,
"status": task.status,
"error_message": task.error_message,
}

118
backend/app/api/v1/earth.py Normal file
View File

@@ -0,0 +1,118 @@
"""Earth asset management APIs."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token, get_current_user, redis_client
from app.db.session import get_db
from app.models.user import User
from app.services.earth_boundaries import (
EarthBoundaryBuildError,
get_boundary_build_status,
get_boundary_status,
save_boundary_config,
start_boundary_build_job,
)
router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False)
class EarthBoundaryConfigPayload(BaseModel):
config: dict[str, Any] = Field(default_factory=dict)
@router.get("/boundaries/status")
async def get_earth_boundary_status():
return get_boundary_status()
async def _get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
db: AsyncSession = Depends(get_db),
) -> User | None:
if credentials is None:
return None
token = credentials.credentials
if redis_client.sismember("blacklisted_tokens", token):
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await db.execute(
text(
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
),
{"id": int(user_id)},
)
row = result.fetchone()
if row is None or not row[5]:
return None
user = User()
user.id = row[0]
user.username = row[1]
user.email = row[2]
user.password_hash = row[3]
user.role = row[4]
user.is_active = row[5]
user.gatekeeper_groups = row[6] or []
return user
def _is_loopback_request(request: Request) -> bool:
host = request.client.host if request.client else ""
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
def _require_local_or_user(request: Request, user: User | None) -> None:
if user is not None or _is_loopback_request(request):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required outside localhost",
)
@router.put("/boundaries/config")
async def update_earth_boundary_config(
payload: EarthBoundaryConfigPayload,
_current_user: User = Depends(get_current_user),
):
try:
return save_boundary_config(payload.config)
except EarthBoundaryBuildError as exc:
raise HTTPException(
status_code=400,
detail={"code": exc.code, "message": str(exc), "details": exc.details},
) from exc
@router.post("/boundaries/build")
async def build_earth_boundary_assets(
request: Request,
current_user: User | None = Depends(_get_optional_current_user),
):
_require_local_or_user(request, current_user)
try:
return await start_boundary_build_job()
except EarthBoundaryBuildError as exc:
raise HTTPException(
status_code=400,
detail={"code": exc.code, "message": str(exc), "details": exc.details},
) from exc
@router.get("/boundaries/build/status")
async def get_earth_boundary_build_status():
return get_boundary_build_status()

View File

@@ -1,5 +1,7 @@
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.services.earth_news import get_earth_news_payload
router = APIRouter()
@@ -9,5 +11,6 @@ router = APIRouter()
async def get_earth_feed(
lat: float | None = Query(None, description="Current Earth view center latitude"),
lon: float | None = Query(None, description="Current Earth view center longitude"),
db: AsyncSession = Depends(get_db),
):
return await get_earth_news_payload(lat=lat, lon=lon)
return await get_earth_news_payload(lat=lat, lon=lon, db=db)

View File

@@ -15,6 +15,13 @@ from app.core.time import to_iso8601_utc
from app.core.config import settings as app_settings
from app.core.data_sources import get_data_sources_config
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.ai_tasks.prompts import (
get_effective_prompt,
list_effective_prompts,
reset_prompt_override,
save_prompt_override,
serialize_effective_prompt,
)
from app.db.session import get_db
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
@@ -59,6 +66,8 @@ from app.services.scheduler import sync_datasource_job
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
router = APIRouter()
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
DEFAULT_SETTINGS = {
"system": {
@@ -250,6 +259,11 @@ class OCRIntegrationUpdate(BaseModel):
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
class AIPromptUpdate(BaseModel):
system_prompt: str = Field(default="", max_length=8000)
prompt: str = Field(min_length=1, max_length=20000)
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
@@ -484,7 +498,13 @@ def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> b
text = str(value).strip()
if not text:
return True
return text == current_preview or text.startswith("••••") or "*" in text
if text == current_preview or text.startswith("••••"):
return True
if "-" in text:
_prefix, masked = text.split("-", 1)
if masked and all(char in {"*", "", " ", "\t"} for char in masked):
return True
return all(char in {"*", "", " ", "\t"} for char in text)
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
@@ -561,6 +581,56 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
}
def _ai_provider_runtime_fingerprint(ai_payload: dict) -> dict:
runtime_config = _runtime_config_from_ai_payload(ai_payload)
llm_config = runtime_config.get("llm_config") or {}
return {
"service_url": runtime_config.get("service_url") or "",
"service_token": runtime_config.get("service_token") or "",
"timeout_seconds": int(runtime_config.get("timeout_seconds") or 0),
"retry_attempts": int(runtime_config.get("retry_attempts") or 0),
"provider": llm_config.get("provider") or "",
"provider_api": llm_config.get("provider_api") or "",
"base_url": llm_config.get("base_url") or "",
"model": llm_config.get("model") or "",
"api_key": llm_config.get("api_key") or "",
"max_tokens": int(llm_config.get("max_tokens") or 0),
"anthropic_version": llm_config.get("anthropic_version") or "",
}
async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
runtime_config = _runtime_config_from_ai_payload(ai_payload)
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
)
status_result = await client.get_status()
if not status_result.configured:
raise HTTPException(
status_code=400,
detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。",
)
prompt = await get_effective_prompt(None, AI_CONNECTION_TEST_PROMPT_KEY)
analysis_result = await client.analyze(
SituationalAnalysisRequest(
title="保存前完整连接测试",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"],
constraints=["回复尽量简短。"],
)
)
return {
"status": status_result.model_dump(),
"provider": analysis_result.provider,
"model": analysis_result.model,
}
def _web_search_provider_defaults(provider: str) -> dict:
return web_search_provider_defaults(provider).model_dump()
@@ -910,7 +980,10 @@ async def save_external_integrations_payload(
update: ExternalIntegrationsUpdate,
) -> dict:
current_payload = await get_setting_payload(db, "external_integrations")
current_ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
if _ai_provider_runtime_fingerprint(ai_payload) != _ai_provider_runtime_fingerprint(current_ai_payload):
await _validate_ai_provider_full_connection(ai_payload)
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
ocr_payload = _build_ocr_payload(current_payload, update.ocr)
@@ -1158,6 +1231,47 @@ async def get_external_integrations(
return {"integrations": await serialize_external_integrations(db)}
@router.get("/ai-prompts")
async def get_ai_prompts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
prompts = await list_effective_prompts(db)
return {"data": [serialize_effective_prompt(prompt) for prompt in prompts]}
@router.put("/ai-prompts/{task_key}")
async def update_ai_prompt(
task_key: str,
payload: AIPromptUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
prompt = await save_prompt_override(
db,
task_key,
system_prompt=payload.system_prompt,
prompt=payload.prompt,
)
except KeyError:
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
return {"data": serialize_effective_prompt(prompt)}
@router.post("/ai-prompts/{task_key}/reset")
async def reset_ai_prompt(
task_key: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
prompt = await reset_prompt_override(db, task_key)
except KeyError:
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
return {"data": serialize_effective_prompt(prompt)}
@router.get("/integrations/barentswatch/connectivity")
async def get_barentswatch_connectivity(
current_user: User = Depends(get_current_user),
@@ -1219,12 +1333,16 @@ async def connect_ai_provider_integration(
current_payload = await get_setting_payload(db, "external_integrations")
draft_ai_payload = _build_ai_provider_payload(current_payload, payload)
runtime_config = _runtime_config_from_ai_payload(draft_ai_payload)
quick_llm_config = {
**(runtime_config.get("llm_config") or {}),
"max_tokens": 1,
}
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
timeout=min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS),
retry_attempts=1,
llm_config=quick_llm_config,
)
try:
@@ -1236,29 +1354,24 @@ async def connect_ai_provider_integration(
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
analysis_result = await client.analyze(
prompt = await get_effective_prompt(db, AI_CONNECTION_TEST_PROMPT_KEY)
probe_result = await client.analyze(
SituationalAnalysisRequest(
title="连接测试",
objective="请用一句话回复连接可用。",
observations=["这是配置中心发起的 LLM 连接测试。"],
constraints=["回复尽量简短。"],
title="快速连接测试",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=[],
constraints=["Output only OK."],
)
)
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": draft_ai_payload, "web_search": current_web_search, "ocr": current_ocr},
)
return {
"success": True,
"connected": True,
"message": "AI Provider 连接成功,已保存为全局默认配置。",
"message": "连接测试通过",
"status": status_result.model_dump(),
"provider": analysis_result.provider,
"model": analysis_result.model,
"integrations": await serialize_external_integrations(db),
"provider": probe_result.provider,
"model": probe_result.model,
"mode": "quick_probe",
}
except HTTPException as exc:
return {

View File

@@ -1982,6 +1982,7 @@ async def collect_compute_center_location(
provider_client=provider_client,
query=query,
entity_type="compute_center",
db=db,
attempted_queries=attempted_queries,
search_evidence=search_result.evidence,
)

View File

@@ -58,7 +58,7 @@ async def websocket_endpoint(
is_anonymous = payload is None
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
supported_channels = ["vessels"] if is_anonymous else [
supported_channels = ["vessels", "earth_news"] if is_anonymous else [
"gpu_clusters",
"submarine_cables",
"ixp_nodes",
@@ -66,6 +66,7 @@ async def websocket_endpoint(
"dashboard",
"datasource_tasks",
"vessels",
"earth_news",
]
await manager.connect(websocket, user_id)

View File

@@ -257,6 +257,16 @@ DEFAULT_DATASOURCES = {
"credential_provider": "aisstream",
"credential_status": "supported",
},
"media_news_archive": {
"id": 33,
"name": "Media News Archive",
"display_name": "媒体新闻归档",
"module": "L4",
"priority": "P2",
"frequency_minutes": 720,
"is_free": True,
"requires_credentials": False,
},
}
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}

View File

@@ -1,6 +1,6 @@
from typing import AsyncGenerator
from sqlalchemy import text
from sqlalchemy import bindparam, text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
@@ -72,6 +72,74 @@ async def seed_default_datasources(session: AsyncSession):
await session.commit()
LEGACY_EARTH_BOUNDARY_SOURCES = (
"earth_admin0_boundaries",
"earth_coastline",
"earth_claim_lines",
"earth_boundary_tiles",
)
LEGACY_EARTH_BOUNDARY_DATATYPES = (
"earth_boundary_source",
"earth_boundary_tiles",
)
LEGACY_EARTH_BOUNDARY_IDS = (29, 30, 31, 32)
async def purge_legacy_earth_boundary_datasources(session: AsyncSession) -> None:
source_names = tuple(LEGACY_EARTH_BOUNDARY_SOURCES)
source_ids = tuple(LEGACY_EARTH_BOUNDARY_IDS)
data_types = tuple(LEGACY_EARTH_BOUNDARY_DATATYPES)
await session.execute(
text(
"""
DELETE FROM datasource_mapping_templates
WHERE target_schema IN :data_types
OR datasource_config_id IN (
SELECT id FROM datasource_configs WHERE name IN :source_names
)
"""
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
{"source_names": list(source_names), "data_types": list(data_types)},
)
await session.execute(
text("DELETE FROM datasource_configs WHERE name IN :source_names").bindparams(
bindparam("source_names", expanding=True)
),
{"source_names": list(source_names)},
)
await session.execute(
text(
"""
DELETE FROM collected_data
WHERE source IN :source_names OR data_type IN :data_types
"""
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
{"source_names": list(source_names), "data_types": list(data_types)},
)
await session.execute(
text(
"""
DELETE FROM data_snapshots
WHERE source IN :source_names OR datasource_id IN :source_ids
"""
).bindparams(bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)),
{"source_names": list(source_names), "source_ids": list(source_ids)},
)
await session.execute(
text("DELETE FROM collection_tasks WHERE datasource_id IN :source_ids").bindparams(
bindparam("source_ids", expanding=True)
),
{"source_ids": list(source_ids)},
)
await session.execute(
text("DELETE FROM data_sources WHERE source IN :source_names OR id IN :source_ids").bindparams(
bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)
),
{"source_names": list(source_names), "source_ids": list(source_ids)},
)
await session.commit()
DEFAULT_LOGIN_USERS = (
{
"username": "admin",
@@ -134,6 +202,7 @@ async def init_db():
import app.models.vessel # noqa: F401
import app.models.vessel_enrichment # noqa: F401
import app.models.datasource_mapping # noqa: F401
import app.models.earth_news # noqa: F401
logger.warning_event(
"Database pool settings active",
@@ -202,6 +271,18 @@ async def init_db():
"""
)
)
await conn.execute(
text(
"""
ALTER TABLE earth_news_items
ADD COLUMN IF NOT EXISTS content_language VARCHAR(32) NOT NULL DEFAULT 'en',
ADD COLUMN IF NOT EXISTS localizations JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS enrichment_status VARCHAR(80) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS enrichment_error TEXT,
ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ
"""
)
)
await conn.execute(
text(
"""
@@ -210,6 +291,22 @@ async def init_db():
"""
)
)
await conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_earth_news_enrichment_status
ON earth_news_items (enrichment_status)
"""
)
)
await conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_earth_news_enriched_at
ON earth_news_items (enriched_at)
"""
)
)
await conn.execute(
text(
"""
@@ -284,4 +381,5 @@ async def init_db():
await seed_default_bgp_collector_locations(session)
await seed_compute_center_locations_from_source_coords(session)
await seed_default_datasources(session)
await purge_legacy_earth_boundary_datasources(session)
await ensure_default_admin_user(session)

View File

@@ -18,6 +18,10 @@ from app.services.scheduler import (
stop_scheduler,
sync_scheduler_with_datasources,
)
from app.services.earth_news_worker import (
start_earth_news_target_worker,
stop_earth_news_target_worker,
)
configure_logging()
@@ -53,7 +57,9 @@ async def lifespan(app: FastAPI):
start_scheduler()
await sync_scheduler_with_datasources()
broadcaster.start()
start_earth_news_target_worker()
yield
await stop_earth_news_target_worker()
broadcaster.stop()
stop_scheduler()

View File

@@ -16,6 +16,7 @@ from app.models.playground_message import PlaygroundMessage
from app.models.system_log import SystemLog, AuditLog
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic
from app.models.datasource_mapping import DataSourceMappingTemplate
from app.models.earth_news import EarthNewsItem
__all__ = [
"User",
@@ -43,4 +44,5 @@ __all__ = [
"AISConflictRecord",
"AISSourceHealth",
"DataSourceMappingTemplate",
"EarthNewsItem",
]

View File

@@ -0,0 +1,40 @@
from sqlalchemy import Boolean, Column, DateTime, Float, Index, JSON, String, Text
from sqlalchemy.sql import func
from app.db.session import Base
class EarthNewsItem(Base):
__tablename__ = "earth_news_items"
id = Column(String(160), primary_key=True)
title = Column(String(500), nullable=False)
summary = Column(Text, nullable=False, default="")
content_language = Column(String(32), nullable=False, default="en")
localizations = Column(JSON, nullable=False, default=dict)
url = Column(Text, nullable=False)
source = Column(String(255), nullable=False, default="")
feed_name = Column(String(255), nullable=False, default="")
region = Column(String(80), nullable=False, index=True)
homepage_url = Column(Text, nullable=False, default="")
published_at = Column(DateTime(timezone=True), nullable=True, index=True)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
location_label = Column(String(255), nullable=False)
location_source = Column(String(80), nullable=False, default="region_anchor")
verified = Column(Boolean, nullable=False, default=False, index=True)
location_meta = Column(JSON, nullable=False, default=dict)
first_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
last_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
resolved_at = Column(DateTime(timezone=True), nullable=True, index=True)
enrichment_status = Column(String(80), nullable=False, default="pending", index=True)
enrichment_error = Column(Text, nullable=True)
enriched_at = Column(DateTime(timezone=True), nullable=True, index=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
Index("idx_earth_news_region_published", "region", "published_at"),
Index("idx_earth_news_region_seen", "region", "last_seen_at"),
)

View File

@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
class SituationalAnalysisRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
objective: str = Field(..., min_length=1, max_length=20000)
context: dict[str, Any] = Field(default_factory=dict)
observations: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
system_prompt: str | None = Field(default=None, max_length=8000)
preferred_model: str | None = Field(default=None, max_length=200)
thinking: dict[str, Any] | None = None

View File

@@ -8,6 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
from app.ai_tasks.prompts import get_effective_prompt
ALERT_BRIEF_PROMPT_KEY = "alerts.brief"
def _format_counter(counter: Counter[str], empty_text: str = "") -> str:
@@ -84,11 +87,13 @@ async def build_alert_brief_request(
"top_datasources": dict(datasource_counts.most_common(6)),
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
}
prompt = await get_effective_prompt(db, ALERT_BRIEF_PROMPT_KEY)
return (
SituationalAnalysisRequest(
title="告警态势 AI 简报",
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=facts,
constraints=[
"明确区分事实、推断与建议。",

View File

@@ -11,9 +11,12 @@ from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.models.bgp_observation import BGPObservation
from app.schemas.ai import SituationalAnalysisRequest
from app.ai_tasks.prompts import get_effective_prompt
from app.services.bgp_collectors import build_bgp_collector_coverage
from app.services.bgp_enrichment import lookup_prefix_geography
BGP_BRIEF_PROMPT_KEY = "bgp.brief"
def _format_counter(counter: dict[str, int], empty_text: str = "") -> str:
if not counter:
@@ -243,10 +246,12 @@ async def build_bgp_brief_request(
for prefix, item in list(prefix_geographies.items())[:8]
},
}
prompt = await get_effective_prompt(db, BGP_BRIEF_PROMPT_KEY)
return SituationalAnalysisRequest(
title="BGP 态势 AI 简报",
objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=observations_lines,
constraints=[
"明确区分事实、推断与建议。",

View File

@@ -36,6 +36,7 @@ from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
from app.services.collectors.aisstream import AISStreamCollector
from app.services.collectors.vessel_ais import VesselAISCollector
@@ -65,6 +66,7 @@ collector_registry.register(IPtoASNPrefixGeoCollector())
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
collector_registry.register(NRODelegatedPrefixGeoCollector())
collector_registry.register(NewsLiveStreamsCollector())
collector_registry.register(MediaNewsArchiveCollector())
collector_registry.register(VesselAISCollector())
collector_registry.register(AISStreamCollector())
@@ -100,6 +102,7 @@ __all__ = [
"OpenGeoFeedPrefixGeoCollector",
"NRODelegatedPrefixGeoCollector",
"NewsLiveStreamsCollector",
"MediaNewsArchiveCollector",
"VesselAISCollector",
"AISStreamCollector",
]

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Any
from app.services.collectors.base import BaseCollector
from app.services.earth_news_store import list_all_earth_news_records
class MediaNewsArchiveCollector(BaseCollector):
name = "media_news_archive"
priority = "P2"
module = "L4"
frequency_hours = 12
data_type = "news_item"
fail_on_empty = False
async def fetch(self) -> list[dict[str, Any]]:
if not self._db_session:
return []
records = await list_all_earth_news_records(self._db_session)
items: list[dict[str, Any]] = []
for record in records:
location_meta = dict(record.location_meta or {})
target = location_meta.get("target") if isinstance(location_meta.get("target"), dict) else {}
country = target.get("country")
city = target.get("city")
items.append(
{
"id": record.id,
"source_id": record.id,
"name": record.title,
"title": record.title,
"description": record.summary,
"country": country,
"city": city,
"latitude": record.latitude,
"longitude": record.longitude,
"reference_date": record.published_at,
"metadata": {
"url": record.url,
"source": record.source,
"feed_name": record.feed_name,
"region": record.region,
"homepage_url": record.homepage_url,
"published_at": record.published_at.isoformat() if record.published_at else None,
"location_label": record.location_label,
"location_source": record.location_source,
"verified": record.verified,
"location_meta": location_meta,
"first_seen_at": record.first_seen_at.isoformat() if record.first_seen_at else None,
"last_seen_at": record.last_seen_at.isoformat() if record.last_seen_at else None,
"resolved_at": record.resolved_at.isoformat() if record.resolved_at else None,
},
}
)
return items

View File

@@ -7,6 +7,7 @@ from typing import Any
from sqlalchemy import select
from app.ai_tasks.prompts import get_effective_prompt
from app.models.system_setting import SystemSetting
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient
@@ -15,6 +16,7 @@ from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
CREDENTIAL_GUIDE_PROMPT_KEY = "credential.guide"
@dataclass(frozen=True)
@@ -240,14 +242,12 @@ async def generate_credential_guide(
guide["sources"] = []
return guide
prompt = await get_effective_prompt(db, CREDENTIAL_GUIDE_PROMPT_KEY)
response = await ai_client.analyze(
SituationalAnalysisRequest(
title=f"Generate credential guide for {provider}",
objective=(
default.prompt
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
+ "如果证据不足,明确说明需要以官方页面为准。"
),
objective=f"{default.prompt}\n{prompt.prompt}",
system_prompt=prompt.system_prompt or None,
context={
"provider": provider,
"current_default_guide": default.markdown,

View File

@@ -8,6 +8,7 @@ import re
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.target_schema_registry import TargetSchema, get_target_schema
@@ -254,6 +255,12 @@ def _best_field_match(field_name: str, candidates: list[str]) -> str | None:
"lat": ("lat", "latitude", "y"),
"lon": ("lon", "lng", "longitude", "x"),
"mmsi": ("mmsi",),
"geometry": ("geometry", "geom"),
"properties": ("properties", "props"),
"source_kind": ("source_kind", "kind", "type"),
"feature_count": ("feature_count", "features_count", "count"),
"artifact_path": ("artifact_path", "path", "file"),
"sha256": ("sha256", "hash", "checksum"),
"sog": ("sog", "speed", "speedOverGround"),
"cog": ("cog", "course", "courseOverGround"),
"received_at": ("received_at", "timestamp", "time", "updated_at"),

View File

@@ -0,0 +1,671 @@
"""Earth boundary static asset service."""
from __future__ import annotations
import asyncio
import hashlib
import json
import shutil
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
import httpx
REPO_ROOT = Path(__file__).resolve().parents[3]
SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources"
SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json"
BUILD_RESULT_PATH = SOURCE_OUTPUT_DIR / "build-result.json"
BUILD_JOB_PATH = SOURCE_OUTPUT_DIR / "build-job.json"
BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1"
BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json"
PMTILES_ARTIFACT_PATH = (
REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
)
LEGACY_GEOJSON_PATH = REPO_ROOT / "frontend/public/earth/data/countries-admin0.min.geojson"
POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json"
LOCAL_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.local.json"
EXAMPLE_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.example.json"
BOUNDARY_SOURCE_KINDS = {
"earth_admin0_boundaries": "admin0-boundaries",
"earth_coastline": "coastline",
"earth_claim_lines": "claim-lines",
}
DEFAULT_PUBLIC_BOUNDARY_SOURCES = {
"earth_admin0_boundaries": {
"displayName": "Natural Earth Admin-0 Countries",
"sourceKind": "admin0-boundaries",
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
"method": "GET",
"headers": {},
"auth_type": "none",
"license": "Natural Earth public domain",
},
"earth_coastline": {
"displayName": "Natural Earth Coastline",
"sourceKind": "coastline",
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
"method": "GET",
"headers": {},
"auth_type": "none",
"license": "Natural Earth public domain",
},
"earth_claim_lines": {
"displayName": "Natural Earth Disputed Boundaries",
"sourceKind": "claim-lines",
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
"method": "GET",
"headers": {},
"auth_type": "none",
"license": "Natural Earth public domain",
},
}
BUILD_CONFIG = {
"builder": "scripts/build_earth_boundary_pmtiles.py",
"format": "pmtiles+mvt",
"production_target": "pmtiles-mvt",
}
class EarthBoundaryBuildError(RuntimeError):
def __init__(self, message: str, *, code: str = "build_failed", details: Any = None) -> None:
super().__init__(message)
self.code = code
self.details = details
_build_job_lock = asyncio.Lock()
_build_task: asyncio.Task | None = None
_build_job_state: dict[str, Any] = {}
def _utc_now_iso() -> str:
return datetime.now(UTC).isoformat()
def _public_job_state() -> dict[str, Any]:
if _build_job_state:
return dict(_build_job_state)
return _read_json(BUILD_JOB_PATH)
def get_boundary_build_status() -> dict[str, Any]:
return {"job": _public_job_state()}
def _set_job_state(**updates: Any) -> dict[str, Any]:
global _build_job_state
current = dict(_build_job_state)
current.update(updates)
current["updated_at"] = _utc_now_iso()
_build_job_state = current
_write_json(BUILD_JOB_PATH, current)
return current
def _append_job_log(message: str) -> None:
logs = list(_build_job_state.get("logs") or [])
logs.append({"time": _utc_now_iso(), "message": message})
_set_job_state(logs=logs[-40:])
def _update_job_progress(progress: float, phase: str, message: str, **extra: Any) -> None:
bounded_progress = max(0, min(100, int(round(progress))))
_set_job_state(
status="running",
progress=bounded_progress,
phase=phase,
message=message,
**extra,
)
def _read_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as f:
payload = json.load(f)
return payload if isinstance(payload, dict) else {}
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
f.write("\n")
def _sha256_bytes(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _stable_json_hash(payload: Any) -> str:
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str:
suffix = Path(endpoint.split("?", 1)[0]).suffix.lower()
if suffix in {".json", ".geojson", ".zip", ".pbf"}:
return suffix
if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]:
return ".geojson"
if "json" in content_type:
return ".json"
return ".dat"
def _json_feature_count(payload: Any) -> int:
if isinstance(payload, dict) and isinstance(payload.get("features"), list):
return len(payload["features"])
if isinstance(payload, list):
return len(payload)
return 1 if payload else 0
def _directory_stats(path: Path) -> dict[str, int]:
if not path.exists():
return {"file_count": 0, "size_bytes": 0}
files = [item for item in path.rglob("*") if item.is_file()]
return {"file_count": len(files), "size_bytes": sum(item.stat().st_size for item in files)}
def _load_source_feature_collection(source: dict[str, Any]) -> dict[str, Any]:
path = REPO_ROOT / source["path"]
payload = _read_json(path)
features = payload.get("features") if isinstance(payload, dict) else None
return {
"type": "FeatureCollection",
"features": features if isinstance(features, list) else [],
}
def _write_high_precision_geojson_manifest(
sources: list[dict[str, Any]],
build_input_hash: str,
missing_tools: list[str],
) -> dict[str, Any]:
BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
coastline = next(source for source in sources if source["kind"] == "coastline")
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
admin0_payload = _load_source_feature_collection(admin0)
coastline_payload = _load_source_feature_collection(coastline)
claim_payload = _load_source_feature_collection(claim_lines)
for feature in coastline_payload["features"]:
props = feature.setdefault("properties", {})
if isinstance(props, dict):
props["PLANET_LAYER"] = "coastline"
base_payload = {
"type": "FeatureCollection",
"features": [*admin0_payload["features"], *coastline_payload["features"]],
}
base_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-base.geojson"
hover_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-hover.geojson"
claim_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-claims.geojson"
_write_json(base_path, base_payload)
_write_json(hover_path, admin0_payload)
_write_json(claim_path, claim_payload)
manifest = {
"version": "natural-earth-v1",
"builtAt": _utc_now_iso(),
"tileProvider": "geojson-high-precision",
"format": "geojson-directory",
"buildInputHash": build_input_hash,
"base": base_path.name,
"hoverIndex": hover_path.name,
"claimLine": claim_path.name,
"sourceFeatureCount": {
"admin0": len(admin0_payload["features"]),
"coastline": len(coastline_payload["features"]),
"claimLines": len(claim_payload["features"]),
},
"pmtiles": None,
"missingTools": missing_tools,
}
_write_json(BOUNDARY_MANIFEST_PATH, manifest)
return manifest
def _relative(path: Path) -> str:
return str(path.relative_to(REPO_ROOT))
def load_boundary_config() -> tuple[dict[str, Any], str]:
if LOCAL_CONFIG_PATH.exists():
return _read_json(LOCAL_CONFIG_PATH), "local"
return _read_json(EXAMPLE_CONFIG_PATH), "example"
def save_boundary_config(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise EarthBoundaryBuildError("Earth boundary config must be a JSON object", code="invalid_config")
_write_json(LOCAL_CONFIG_PATH, payload)
return get_boundary_status()
def _source_configs(payload: dict[str, Any]) -> dict[str, Any]:
raw_sources = payload.get("collectorConfigs") or payload.get("sources") or {}
return raw_sources if isinstance(raw_sources, dict) else {}
def _is_placeholder_endpoint(endpoint: Any) -> bool:
value = str(endpoint or "").strip()
return not value or "example.com" in value
def _source_configs_with_defaults(payload: dict[str, Any]) -> dict[str, Any]:
raw_sources = _source_configs(payload)
merged: dict[str, Any] = {}
for source_key, default_config in DEFAULT_PUBLIC_BOUNDARY_SOURCES.items():
configured = raw_sources.get(source_key)
if not isinstance(configured, dict) or _is_placeholder_endpoint(configured.get("endpoint")):
merged[source_key] = dict(default_config)
else:
merged[source_key] = {**default_config, **configured}
for source_key, source_config in raw_sources.items():
if source_key not in merged:
merged[source_key] = source_config
return merged
def _build_input_hash(source_manifest: dict[str, Any]) -> str:
return _stable_json_hash(
{
"source_manifest_schema": source_manifest.get("schema"),
"sources": [
{
"id": source.get("id"),
"sha256": source.get("sha256"),
"kind": source.get("kind"),
}
for source in source_manifest.get("sources", [])
],
"pov_policy": source_manifest.get("povPolicy"),
"build_config": BUILD_CONFIG,
}
)
def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool:
return (
bool(boundary_manifest)
and boundary_manifest.get("buildInputHash") == build_input_hash
and boundary_manifest.get("tileProvider") == "pmtiles-mvt"
and PMTILES_ARTIFACT_PATH.exists()
)
def get_boundary_status() -> dict[str, Any]:
config_payload, config_source = load_boundary_config()
effective_source_configs = _source_configs_with_defaults(config_payload)
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
pmtiles_exists = PMTILES_ARTIFACT_PATH.exists()
manifest_exists = BOUNDARY_MANIFEST_PATH.exists()
high_precision_ready = (
manifest_exists
and (
(
boundary_manifest.get("tileProvider") == "pmtiles-mvt"
and pmtiles_exists
)
or boundary_manifest.get("tileProvider") == "geojson-high-precision"
)
)
legacy_exists = LEGACY_GEOJSON_PATH.exists()
provider = (
boundary_manifest.get("tileProvider")
if high_precision_ready
else "legacy-geojson" if legacy_exists else "missing"
)
return {
"provider": provider,
"high_precision_ready": high_precision_ready,
"fallback_available": legacy_exists,
"config_source": config_source,
"config_path": _relative(LOCAL_CONFIG_PATH),
"config_exists": LOCAL_CONFIG_PATH.exists(),
"config": config_payload,
"effective_default_sources": [
source_key
for source_key, source_config in effective_source_configs.items()
if source_key in DEFAULT_PUBLIC_BOUNDARY_SOURCES
and source_config.get("endpoint") == DEFAULT_PUBLIC_BOUNDARY_SOURCES[source_key]["endpoint"]
],
"manifest": {
"path": _relative(BOUNDARY_MANIFEST_PATH),
"exists": manifest_exists,
"tileProvider": boundary_manifest.get("tileProvider"),
"buildInputHash": boundary_manifest.get("buildInputHash"),
"builtAt": boundary_manifest.get("builtAt"),
},
"pmtiles": {
"path": _relative(PMTILES_ARTIFACT_PATH),
"exists": pmtiles_exists,
"size_bytes": PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0,
},
"legacy": {
"path": _relative(LEGACY_GEOJSON_PATH),
"exists": legacy_exists,
"size_bytes": LEGACY_GEOJSON_PATH.stat().st_size if legacy_exists else 0,
},
"source_manifest": {
"path": _relative(SOURCE_MANIFEST_PATH),
"exists": SOURCE_MANIFEST_PATH.exists(),
},
"last_build": _read_json(BUILD_RESULT_PATH),
"current_job": _public_job_state(),
}
async def _download_source(
source_key: str,
source_config: dict[str, Any],
progress_callback: Any = None,
) -> dict[str, Any]:
endpoint = str(source_config.get("endpoint") or "").strip()
if _is_placeholder_endpoint(endpoint):
raise EarthBoundaryBuildError(
f"{source_key} endpoint is not configured",
code="source_not_configured",
details={"source": source_key},
)
method = str(source_config.get("method") or "GET").upper()
if method not in {"GET", "POST"}:
raise EarthBoundaryBuildError(
f"{source_key} method must be GET or POST",
code="invalid_config",
details={"source": source_key, "method": method},
)
if endpoint.startswith("file://") or Path(endpoint).expanduser().exists():
payload = Path(endpoint.removeprefix("file://")).expanduser().read_bytes()
content_type = "application/octet-stream"
if progress_callback:
progress_callback(1, len(payload), len(payload))
else:
timeout = float(source_config.get("timeout") or 120)
headers = source_config.get("headers") if isinstance(source_config.get("headers"), dict) else {}
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream(method, endpoint, headers=headers) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "")
total = int(response.headers.get("content-length") or 0)
chunks = []
downloaded = 0
async for chunk in response.aiter_bytes():
if not chunk:
continue
chunks.append(chunk)
downloaded += len(chunk)
if progress_callback:
progress_callback(
(downloaded / total) if total else None,
downloaded,
total,
)
payload = b"".join(chunks)
extension = _artifact_extension(endpoint, content_type, payload)
parsed: Any = None
if extension in {".json", ".geojson"}:
parsed = json.loads(payload.decode("utf-8"))
feature_count = _json_feature_count(parsed)
if feature_count <= 0:
raise EarthBoundaryBuildError(
f"{source_key} downloaded payload contains no features",
code="empty_source",
details={"source": source_key},
)
sha256 = _sha256_bytes(payload)
source_dir = SOURCE_OUTPUT_DIR / source_key
source_dir.mkdir(parents=True, exist_ok=True)
artifact_path = source_dir / f"{sha256}{extension}"
artifact_path.write_bytes(payload)
return {
"id": source_key,
"kind": source_config.get("sourceKind") or BOUNDARY_SOURCE_KINDS[source_key],
"path": _relative(artifact_path),
"sha256": sha256,
"featureCount": feature_count,
"license": source_config.get("license"),
}
async def _run_step(args: list[str]) -> dict[str, Any]:
process = await asyncio.create_subprocess_exec(
sys.executable,
*args,
cwd=REPO_ROOT,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout_bytes, stderr_bytes = await process.communicate()
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
payload: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "returncode": process.returncode}
last_line = stdout.splitlines()[-1:] or []
if last_line:
try:
payload["result"] = json.loads(last_line[0])
except json.JSONDecodeError:
payload["result"] = last_line[0]
if process.returncode != 0:
raise EarthBoundaryBuildError(
stderr or stdout or f"command failed: {' '.join(args)}",
code="build_command_failed",
details=payload,
)
return payload
async def build_boundary_assets(progress_callback: Any = None) -> dict[str, Any]:
config_payload, config_source = load_boundary_config()
source_configs = _source_configs_with_defaults(config_payload)
missing = [source for source in BOUNDARY_SOURCE_KINDS if source not in source_configs]
if missing:
raise EarthBoundaryBuildError(
f"Missing Earth boundary source configs: {', '.join(missing)}",
code="missing_sources",
details={"missing": missing},
)
sources = []
source_keys = list(BOUNDARY_SOURCE_KINDS)
for index, source_key in enumerate(source_keys):
source_config = source_configs[source_key]
if not isinstance(source_config, dict):
raise EarthBoundaryBuildError(
f"{source_key} config must be an object",
code="invalid_config",
details={"source": source_key},
)
source_start = 8 + index * 18
source_end = source_start + 18
if progress_callback:
progress_callback(source_start, "download", f"正在下载 {source_key}")
def report_download_progress(ratio: float | None, downloaded: int, total: int) -> None:
if not progress_callback:
return
if ratio is None:
progress_callback(source_start + 8, "download", f"{source_key} 已下载 {downloaded} bytes")
return
progress_callback(
source_start + (source_end - source_start) * ratio,
"download",
f"{source_key} 下载 {int(ratio * 100)}%",
downloaded_bytes=downloaded,
total_bytes=total,
)
sources.append(await _download_source(source_key, source_config, report_download_progress))
source_manifest = {
"schema": "planet-earth-boundary-sources/v2",
"sources": sources,
"povPolicy": _read_json(POV_POLICY_PATH),
}
if progress_callback:
progress_callback(65, "manifest", "正在写入边界源 manifest")
_write_json(SOURCE_MANIFEST_PATH, source_manifest)
build_input_hash = _build_input_hash(source_manifest)
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash)
missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None]
if missing_tools and not build_skipped:
if progress_callback:
progress_callback(82, "build", "缺少 PMTiles 工具,正在生成 GeoJSON 高清包")
boundary_manifest = _write_high_precision_geojson_manifest(
sources,
build_input_hash,
missing_tools,
)
result = {
"status": "built_geojson_fallback",
"code": "missing_tools",
"missing_tools": missing_tools,
"sources": sources,
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
"manifest": boundary_manifest,
}
_write_json(BUILD_RESULT_PATH, result)
if progress_callback:
progress_callback(96, "finalize", "GeoJSON 高清国界包已生成")
return {**get_boundary_status(), "build": result}
if build_skipped:
if progress_callback:
progress_callback(96, "unchanged", "高精国界已是最新")
build_result = {
"status": "unchanged",
"reason": "source manifest and build config hash unchanged",
"buildInputHash": build_input_hash,
}
else:
if progress_callback:
progress_callback(72, "build", "正在构建 PMTiles/MVT")
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
coastline = next(source for source in sources if source["kind"] == "coastline")
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
build_result = await _run_step(
[
"scripts/build_earth_boundary_pmtiles.py",
"--admin0-source",
admin0["path"],
"--coastline-source",
coastline["path"],
"--claims-source",
claim_lines["path"],
"--output",
_relative(PMTILES_ARTIFACT_PATH),
"--manifest",
_relative(BOUNDARY_MANIFEST_PATH),
"--build-input-hash",
build_input_hash,
"--pov-policy",
_relative(POV_POLICY_PATH),
]
)
if progress_callback:
progress_callback(95, "finalize", "正在校验构建产物")
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR)
result = {
"status": "unchanged" if build_skipped else "built",
"sources": sources,
"source_manifest": _relative(SOURCE_MANIFEST_PATH),
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
"pmtiles_artifact": _relative(PMTILES_ARTIFACT_PATH),
"pmtiles_exists": PMTILES_ARTIFACT_PATH.exists(),
"boundary_stats": boundary_stats,
"manifest": boundary_manifest,
"build_result": build_result,
}
_write_json(BUILD_RESULT_PATH, result)
return {**get_boundary_status(), "build": result}
async def _run_boundary_build_job(job_id: str) -> None:
def report(progress: float, phase: str, message: str, **extra: Any) -> None:
if _build_job_state.get("id") != job_id:
return
_update_job_progress(progress, phase, message, **extra)
try:
report(3, "prepare", "正在准备高精国界构建")
result = await build_boundary_assets(report)
_set_job_state(
id=job_id,
status="succeeded",
progress=100,
phase="complete",
message="高精国界构建完成",
finished_at=_utc_now_iso(),
result={
"provider": result.get("provider"),
"high_precision_ready": result.get("high_precision_ready"),
"pmtiles": result.get("pmtiles"),
"manifest": result.get("manifest"),
},
)
_append_job_log("高精国界构建完成")
except EarthBoundaryBuildError as exc:
_set_job_state(
id=job_id,
status="failed",
progress=_build_job_state.get("progress", 0),
phase="failed",
message=str(exc),
code=exc.code,
details=exc.details,
finished_at=_utc_now_iso(),
)
_append_job_log(str(exc))
except Exception as exc: # pragma: no cover - defensive guard for background task
_set_job_state(
id=job_id,
status="failed",
progress=_build_job_state.get("progress", 0),
phase="failed",
message=str(exc),
code="build_failed",
finished_at=_utc_now_iso(),
)
_append_job_log(str(exc))
async def start_boundary_build_job() -> dict[str, Any]:
global _build_task
async with _build_job_lock:
if _build_task and not _build_task.done():
return {"accepted": False, "job": _public_job_state()}
job_id = uuid4().hex
_set_job_state(
id=job_id,
status="queued",
progress=0,
phase="queued",
message="高精国界构建已加入队列",
logs=[],
started_at=_utc_now_iso(),
finished_at=None,
code=None,
details=None,
)
_append_job_log("高精国界构建已启动")
_build_task = asyncio.create_task(_run_boundary_build_job(job_id))
return {"accepted": True, "job": _public_job_state()}

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import hashlib
import html
import json
import math
import re
from typing import Any
from urllib.parse import quote
@@ -13,6 +15,13 @@ import xml.etree.ElementTree as ET
import httpx
from bs4 import BeautifulSoup
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country
from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
@@ -20,6 +29,11 @@ REQUEST_TIMEOUT = 12.0
MAX_ITEMS_PER_SOURCE = 6
MAX_ITEMS_TOTAL = 12
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS
MAX_TARGET_INFERENCE_CONCURRENCY = 3
TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0
DEFAULT_NEWS_LOCALE = "zh-CN"
NEWS_ENRICH_PROMPT_KEY = "earth.news.enrich"
@dataclass(frozen=True)
@@ -49,6 +63,17 @@ class NewsFeedSource:
priority: int = 100
@dataclass(frozen=True)
class NewsTargetLocation:
latitude: float
longitude: float
label: str
source: str
confidence: float | None = None
country: str | None = None
city: str | None = None
@dataclass
class ParsedNewsItem:
id: str
@@ -60,6 +85,18 @@ class ParsedNewsItem:
feed_region: str
homepage_url: str
published_at: datetime | None
content_language: str = "en"
localizations: dict[str, dict[str, str]] = field(default_factory=dict)
enrichment_status: str = "pending"
enrichment_error: str | None = None
enriched_at: datetime | None = None
target_location: NewsTargetLocation | None = None
target_resolution_stage: str = "unresolved"
target_ai_attempted: bool = False
target_ai_status: str = "not_attempted"
target_ai_error: str | None = None
target_debug_note: str | None = None
location_patch: dict[str, Any] | None = None
@dataclass
@@ -236,6 +273,17 @@ NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
_news_target_geocode = build_default_nominatim_geocoder(user_agent=USER_AGENT)
_CITY_HINTS: tuple[dict[str, str | None], ...] = (
{"name": "Beijing", "country": "中国"},
{"name": "Havana", "country": "古巴"},
{"name": "Kyiv", "country": "乌克兰"},
{"name": "Bangkok", "country": "泰国"},
{"name": "Tehran", "country": "伊朗"},
{"name": "Moscow", "country": "俄罗斯"},
{"name": "Taipei", "country": "中国(台湾)"},
{"name": "Hong Kong", "country": "中国(香港)"},
)
def determine_focus_region(lat: float | None, lon: float | None) -> str:
@@ -258,6 +306,461 @@ def get_region_anchor(region: str) -> RegionAnchor:
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
def _coerce_str(value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
cleaned = re.sub(r"\s+", " ", value).strip()
return cleaned or None
def _contains_location_alias(text: str, alias: str) -> bool:
normalized_alias = _coerce_str(alias)
if not normalized_alias:
return False
if re.search(r"[A-Za-z]", normalized_alias):
pattern = r"(?<![A-Za-z])" + re.escape(normalized_alias) + r"(?![A-Za-z])"
return re.search(pattern, text, flags=re.IGNORECASE) is not None
return normalized_alias in text
def _iter_searchable_country_variants(
canonical: str,
variants: list[str],
) -> tuple[str, ...]:
searchable: list[str] = []
seen: set[str] = set()
for variant in (canonical, *variants):
normalized = _coerce_str(variant)
if not normalized:
continue
if re.fullmatch(r"[A-Z]{2,3}", normalized):
continue
if len(normalized) <= 2:
continue
key = normalized.casefold()
if key in seen:
continue
seen.add(key)
searchable.append(normalized)
return tuple(searchable)
def _coerce_float(value: Any) -> float | None:
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(parsed):
return None
return parsed
def _first_json_object(text: str) -> dict[str, Any] | None:
if not text:
return None
decoder = json.JSONDecoder()
for index, char in enumerate(text):
if char != "{":
continue
try:
payload, _ = decoder.raw_decode(text[index:])
except ValueError:
continue
if isinstance(payload, dict):
return payload
return None
def _normalize_localizations(value: Any) -> dict[str, dict[str, str]]:
if not isinstance(value, dict):
return {}
normalized: dict[str, dict[str, str]] = {}
for locale, payload in value.items():
locale_key = _coerce_str(locale)
if not locale_key or not isinstance(payload, dict):
continue
title = _coerce_str(payload.get("title"))
summary = _coerce_str(payload.get("summary"))
entry: dict[str, str] = {}
if title:
entry["title"] = title
if summary:
entry["summary"] = summary
if entry:
normalized[locale_key] = entry
return normalized
def _get_locale_text(
item: ParsedNewsItem,
key: str,
*,
locale: str = DEFAULT_NEWS_LOCALE,
) -> str:
localized = item.localizations.get(locale)
if isinstance(localized, dict):
value = _coerce_str(localized.get(key))
if value:
return value
return ""
def _has_default_localization(item: ParsedNewsItem) -> bool:
localized = item.localizations.get(DEFAULT_NEWS_LOCALE)
if not isinstance(localized, dict):
return False
return bool(_coerce_str(localized.get("title")) and _coerce_str(localized.get("summary")))
def apply_enrichment_patch_to_item(
item: ParsedNewsItem,
patch: dict[str, Any],
) -> ParsedNewsItem:
item.location_patch = patch
if "content_language" in patch:
item.content_language = _coerce_str(patch.get("content_language")) or item.content_language
if "localizations" in patch:
item.localizations = _normalize_localizations(patch.get("localizations"))
if "enrichment_status" in patch:
item.enrichment_status = _coerce_str(patch.get("enrichment_status")) or item.enrichment_status
if "enrichment_error" in patch:
item.enrichment_error = _coerce_str(patch.get("enrichment_error"))
if "enriched_at" in patch:
item.enriched_at = _parse_datetime(_coerce_str(patch.get("enriched_at")))
return item
async def _geocode_target_location(query: str) -> dict[str, Any] | None:
return await asyncio.to_thread(_news_target_geocode, query)
async def _build_target_location_from_payload(
payload: dict[str, Any],
) -> NewsTargetLocation | None:
country = normalize_country(payload.get("country"))
city = _coerce_str(payload.get("city"))
matched_location_name = _coerce_str(payload.get("matched_location_name"))
confidence = _coerce_float(payload.get("confidence"))
if confidence is not None:
confidence = max(0.0, min(confidence, 1.0))
latitude = _coerce_float(payload.get("latitude"))
longitude = _coerce_float(payload.get("longitude"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
label = matched_location_name or ", ".join(part for part in (city, country) if part) or "关联位置"
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=label,
source="ai_inferred_target",
confidence=confidence,
country=country,
city=city,
)
geocode_queries: list[str] = []
for value in (
", ".join(part for part in (city, country) if part),
matched_location_name,
city,
country,
):
normalized = _coerce_str(value)
if normalized and normalized not in geocode_queries:
geocode_queries.append(normalized)
for query in geocode_queries:
try:
result = await _geocode_target_location(query)
except Exception:
continue
if not isinstance(result, dict):
continue
latitude = _coerce_float(result.get("lat"))
longitude = _coerce_float(result.get("lon"))
if latitude in (None, 0.0) or longitude in (None, 0.0):
continue
label = (
_coerce_str(result.get("display_name"))
or matched_location_name
or ", ".join(part for part in (city, country) if part)
or query
)
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=label,
source="ai_inferred_target",
confidence=confidence,
country=country,
city=city,
)
centroid = get_country_centroid(country)
if centroid:
label = matched_location_name or city or country or "关联位置"
return NewsTargetLocation(
latitude=centroid["latitude"],
longitude=centroid["longitude"],
label=label,
source="ai_inferred_target",
confidence=confidence,
country=country,
city=city,
)
return None
async def _extract_target_location_from_text(item: ParsedNewsItem) -> NewsTargetLocation | None:
combined_text = " ".join(part for part in (item.title, item.summary) if part).strip()
if not combined_text:
return None
for hint in _CITY_HINTS:
city_name = _coerce_str(hint.get("name"))
if not city_name or not _contains_location_alias(combined_text, city_name):
continue
country = normalize_country(hint.get("country"))
geocode_query = ", ".join(part for part in (city_name, country) if part)
try:
result = await _geocode_target_location(geocode_query)
except Exception:
result = None
if isinstance(result, dict):
latitude = _coerce_float(result.get("lat"))
longitude = _coerce_float(result.get("lon"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=_coerce_str(result.get("display_name")) or geocode_query,
source="headline_location_hint",
confidence=0.78,
country=country,
city=city_name,
)
centroid = get_country_centroid(country)
if centroid:
return NewsTargetLocation(
latitude=centroid["latitude"],
longitude=centroid["longitude"],
label=geocode_query,
source="headline_location_hint",
confidence=0.68,
country=country,
city=city_name,
)
for canonical, variants in COUNTRY_VARIANTS_MAP.items():
if not get_country_centroid(canonical):
continue
searchable_variants = _iter_searchable_country_variants(canonical, variants)
if not any(_contains_location_alias(combined_text, variant) for variant in searchable_variants):
continue
centroid = get_country_centroid(canonical)
if not centroid:
continue
return NewsTargetLocation(
latitude=centroid["latitude"],
longitude=centroid["longitude"],
label=canonical,
source="headline_country_hint",
confidence=0.62,
country=canonical,
city=None,
)
return None
async def _infer_news_target_location(
item: ParsedNewsItem,
*,
provider_client: AIProviderClient | None,
prompt: EffectiveAIPrompt | None = None,
) -> NewsTargetLocation | None:
target, _localizations = await _infer_news_enrichment(
item,
provider_client=provider_client,
prompt=prompt,
)
return target
async def _infer_news_enrichment(
item: ParsedNewsItem,
*,
provider_client: AIProviderClient | None,
prompt: EffectiveAIPrompt | None = None,
) -> tuple[NewsTargetLocation | None, dict[str, dict[str, str]]]:
text_hint = await _extract_target_location_from_text(item)
content_error: str | None = None
if text_hint is not None and text_hint.city:
item.target_resolution_stage = text_hint.source
item.target_ai_attempted = False
item.target_ai_status = "skipped_text_hint"
item.target_ai_error = None
item.target_debug_note = f"text hint matched {text_hint.label}"
localizations: dict[str, dict[str, str]] = {}
if provider_client is None:
if text_hint is None or not text_hint.city:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_attempted = False
item.target_ai_status = "unavailable"
item.target_ai_error = "AI provider is not configured or unavailable for earth-feed."
item.target_debug_note = (
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
)
item.enrichment_status = "unavailable"
item.enrichment_error = "AI provider is not configured or unavailable for earth-feed."
return text_hint, localizations
if text_hint is None or not text_hint.city:
item.target_ai_attempted = True
item.target_ai_status = "attempted"
item.target_ai_error = None
item.target_debug_note = (
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
)
item.enrichment_status = "attempted"
item.enrichment_error = None
prompt = prompt or await get_effective_prompt(None, NEWS_ENRICH_PROMPT_KEY)
request = SituationalAnalysisRequest(
title="Enrich Earth news item with event location and zh-CN content",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
context={
"news_item": {
"title": item.title,
"summary": item.summary,
"source": item.source,
"feed_name": item.feed_name,
"feed_region": item.feed_region,
"url": item.url,
"published_at": (
item.published_at.isoformat().replace("+00:00", "Z")
if item.published_at
else None
),
},
"required_json_schema": {
"location": {
"country": "string|null",
"city": "string|null",
"matched_location_name": "string|null",
"latitude": "number|null",
"longitude": "number|null",
"confidence": "number from 0 to 1",
"reasoning_summary": "short string",
},
"localizations": {
"zh-CN": {
"title": "faithful Simplified Chinese title",
"summary": "1-2 sentence faithful Simplified Chinese summary",
}
},
},
},
constraints=[
"Return only strict JSON. Do not wrap it in markdown.",
"For localizations, do not add facts that are absent from the RSS headline, description, source, or date.",
"If the RSS description is thin, write a conservative summary that says only what is supported.",
"Keep zh-CN summary concise, factual, and non-promotional.",
"Prefer the event location, not the newsroom or publisher headquarters.",
"When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.",
"Use null for unknown fields instead of inventing details.",
"Calibrate confidence conservatively: 0.75+ only when the city is strongly supported, 0.55-0.74 for country-level or likely city inference, below 0.55 when weak.",
],
)
try:
response = await provider_client.analyze(request)
except Exception as exc:
if text_hint is None or not text_hint.city:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "provider_error"
item.target_ai_error = str(exc)
item.enrichment_status = "provider_error"
item.enrichment_error = str(exc)
return text_hint, localizations
payload = _first_json_object(response.content)
if not isinstance(payload, dict):
if text_hint is None or not text_hint.city:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "parse_error"
item.target_ai_error = "AI response did not contain a parseable JSON object."
item.enrichment_status = "parse_error"
item.enrichment_error = "AI response did not contain a parseable JSON object."
return text_hint, localizations
localizations = _normalize_localizations(payload.get("localizations"))
if not localizations:
content_error = "AI returned no usable localizations."
location_payload = payload.get("location") if isinstance(payload.get("location"), dict) else payload
if text_hint is not None and text_hint.city:
target = text_hint
else:
target = await _build_target_location_from_payload(location_payload)
if target is None:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "no_result"
item.target_ai_error = "AI returned no usable target coordinates or geocodeable location."
target = text_hint
elif target.confidence is not None and target.confidence < 0.45:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "low_confidence"
item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}"
target = text_hint
else:
item.target_resolution_stage = target.source
item.target_ai_status = "success"
item.target_ai_error = None
item.target_debug_note = f"ai inferred {target.label}"
item.localizations = localizations
if localizations and item.target_ai_status in {"success", "skipped_text_hint"}:
item.enrichment_status = "success"
item.enrichment_error = None
elif localizations:
item.enrichment_status = "content_only"
item.enrichment_error = item.target_ai_error
else:
item.enrichment_status = "location_only" if target is not None else "no_result"
item.enrichment_error = content_error or item.target_ai_error
item.enriched_at = datetime.now(UTC) if localizations else None
return target, localizations
async def _enrich_items_with_target_locations(
items: list[ParsedNewsItem],
*,
provider_client: AIProviderClient | None,
prompt: EffectiveAIPrompt | None = None,
) -> list[ParsedNewsItem]:
if not items:
return items
semaphore = asyncio.Semaphore(MAX_TARGET_INFERENCE_CONCURRENCY)
async def enrich(item: ParsedNewsItem) -> ParsedNewsItem:
async with semaphore:
target = await _infer_news_target_location(
item,
provider_client=provider_client,
prompt=prompt,
)
item.target_location = target
return item
return list(await asyncio.gather(*(enrich(item) for item in items)))
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
return sorted(
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
@@ -385,23 +888,170 @@ 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
def _serialize_anchor(anchor: RegionAnchor) -> dict[str, Any]:
return {
"region": anchor.region,
"label": anchor.label,
"latitude": anchor.latitude,
"longitude": anchor.longitude,
}
def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | None:
if target is None:
return None
return {
"latitude": target.latitude,
"longitude": target.longitude,
"label": target.label,
"source": target.source,
"confidence": target.confidence,
"country": target.country,
"city": target.city,
}
def _serialize_enriched_at(value: datetime | None) -> str | None:
return value.isoformat().replace("+00:00", "Z") if value else None
def _content_patch(item: ParsedNewsItem) -> dict[str, Any]:
return {
"content_language": item.content_language,
"localizations": item.localizations,
"enrichment_status": item.enrichment_status,
"enrichment_error": item.enrichment_error,
"enriched_at": _serialize_enriched_at(item.enriched_at),
}
def build_anchor_location_patch(
item: ParsedNewsItem,
*,
queued: bool = False,
queue_available: bool | None = None,
) -> dict[str, Any]:
anchor = get_region_anchor(item.feed_region)
if queued:
resolution_stage = "queued"
ai_status = "queued"
debug_note = "queued for async target location inference"
else:
resolution_stage = item.target_resolution_stage
ai_status = item.target_ai_status
debug_note = item.target_debug_note
content_patch = _content_patch(item)
if queued and content_patch["enrichment_status"] == "pending":
content_patch["enrichment_status"] = "queued"
return {
"latitude": anchor.latitude,
"longitude": anchor.longitude,
"location_label": anchor.label,
"location_source": "region_anchor",
"verified": False,
"location_meta": {
"resolution_stage": resolution_stage,
"ai_attempted": item.target_ai_attempted,
"ai_status": ai_status,
"ai_error": item.target_ai_error,
"debug_note": debug_note,
"queue_available": queue_available,
"target": None,
"anchor": _serialize_anchor(anchor),
},
**content_patch,
}
def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation | None) -> dict[str, Any]:
if target is None:
return build_anchor_location_patch(item)
anchor = get_region_anchor(item.feed_region)
return {
"latitude": target.latitude,
"longitude": target.longitude,
"location_label": target.label,
"location_source": target.source,
"verified": True,
"location_meta": {
"resolution_stage": item.target_resolution_stage,
"ai_attempted": item.target_ai_attempted,
"ai_status": item.target_ai_status,
"ai_error": item.target_ai_error,
"debug_note": item.target_debug_note,
"target": _serialize_target(target),
"anchor": _serialize_anchor(anchor),
},
**_content_patch(item),
}
def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]:
published_at = item.published_at
return {
"id": item.id,
"title": item.title,
"summary": item.summary,
"content_language": item.content_language,
"localizations": item.localizations,
"enrichment_status": item.enrichment_status,
"enrichment_error": item.enrichment_error,
"enriched_at": _serialize_enriched_at(item.enriched_at),
"url": item.url,
"source": item.source,
"feed_name": item.feed_name,
"feed_region": item.feed_region,
"homepage_url": item.homepage_url,
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
}
def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem:
return ParsedNewsItem(
id=str(payload.get("id") or ""),
title=str(payload.get("title") or ""),
summary=str(payload.get("summary") or ""),
content_language=str(payload.get("content_language") or "en"),
localizations=_normalize_localizations(payload.get("localizations")),
enrichment_status=str(payload.get("enrichment_status") or "pending"),
enrichment_error=_coerce_str(payload.get("enrichment_error")),
enriched_at=_parse_datetime(_coerce_str(payload.get("enriched_at"))),
url=str(payload.get("url") or ""),
source=str(payload.get("source") or ""),
feed_name=str(payload.get("feed_name") or ""),
feed_region=str(payload.get("feed_region") or "global"),
homepage_url=str(payload.get("homepage_url") or ""),
published_at=_parse_datetime(_coerce_str(payload.get("published_at"))),
)
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
published_at = item.published_at
location_patch = item.location_patch or build_target_location_patch(item, item.target_location)
return {
"id": item.id,
"title": item.title,
"summary": item.summary,
"content_language": item.content_language,
"localizations": item.localizations,
"display_title": _get_locale_text(item, "title"),
"display_summary": _get_locale_text(item, "summary"),
"url": item.url,
"source": item.source,
"feed_name": item.feed_name,
"region": item.feed_region,
"display_region": get_region_anchor(item.feed_region).label,
"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,
"latitude": location_patch["latitude"],
"longitude": location_patch["longitude"],
"location_label": location_patch["location_label"],
"location_source": location_patch["location_source"],
"verified": location_patch["verified"],
"location_meta": location_patch["location_meta"],
"enrichment_status": item.enrichment_status,
"enrichment_error": item.enrichment_error,
"enriched_at": _serialize_enriched_at(item.enriched_at),
"is_focus_match": item.feed_region == active_region,
}
@@ -426,6 +1076,7 @@ def _build_payload(
"lon": lon,
"region": active_region,
"label": profile.label,
"display_region": get_region_anchor(active_region).label,
"accent": profile.accent,
},
"sources": _serialize_sources(sources),
@@ -472,6 +1123,68 @@ def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: li
)
async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> list[ParsedNewsItem]:
if not items:
return items
from app.services.earth_news_queue import (
enqueue_target_location_job,
get_cached_target_location_patch,
)
async def enqueue_item(item: ParsedNewsItem, *, force: bool = False) -> bool:
return await enqueue_target_location_job(build_target_location_job_payload(item), force=force)
async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem:
cached_patch = await get_cached_target_location_patch(item.id)
if cached_patch:
apply_enrichment_patch_to_item(item, cached_patch)
if not _has_default_localization(item):
queued = await enqueue_item(item, force=True)
if queued and item.enrichment_status in {
"pending",
"unavailable",
"provider_error",
"parse_error",
"no_result",
"location_only",
}:
item.enrichment_status = "queued"
return item
queued = await enqueue_item(item)
item.location_patch = build_anchor_location_patch(
item,
queued=queued,
queue_available=queued,
)
return item
return list(await asyncio.gather(*(apply_location(item) for item in items)))
async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None:
if not items:
return
from app.services.earth_news_queue import enqueue_target_location_job
await asyncio.gather(
*(
enqueue_target_location_job(
build_target_location_job_payload(item),
force=not _has_default_localization(item),
)
for item in items
if (
item.location_patch is None
or item.location_patch.get("verified") is False
or not _has_default_localization(item)
)
)
)
async def _fetch_source(
client: httpx.AsyncClient,
source: NewsFeedSource,
@@ -484,11 +1197,10 @@ async def _fetch_source(
return source, [], str(exc)
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
active_region = determine_focus_region(lat, lon)
sources = get_sources_for_region(active_region)
async def _fetch_rss_items_for_sources(
sources: list[NewsFeedSource],
) -> tuple[list[ParsedNewsItem], list[str]]:
errors: list[str] = []
async with httpx.AsyncClient(
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
@@ -502,9 +1214,29 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
errors.append(f"{source.name}: {error}")
continue
fetched_items.extend(items)
return fetched_items, errors
def _needs_rss_supplement(*, item_count: int, newest_at: datetime | None) -> bool:
if item_count < MAX_ITEMS_TOTAL:
return True
if newest_at is None:
return True
age_seconds = (datetime.now(UTC) - newest_at).total_seconds()
return age_seconds > RSS_SUPPLEMENT_MAX_AGE_SECONDS
async def _get_earth_news_payload_from_rss_only(
*,
lat: float | None,
lon: float | None,
active_region: str,
sources: list[NewsFeedSource],
) -> dict[str, Any]:
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
if ranked_items:
ranked_items = await _apply_cached_locations_and_enqueue(ranked_items)
_store_region_cache(active_region, items=ranked_items, sources=sources)
return _build_payload(
lat=lat,
@@ -518,6 +1250,7 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
cached = _get_cached_region_feed(active_region)
if cached:
cached.items = await _apply_cached_locations_and_enqueue(cached.items)
return _build_payload(
lat=lat,
lon=lon,
@@ -538,3 +1271,55 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
errors=errors,
stale=False,
)
async def get_earth_news_payload(
lat: float | None = None,
lon: float | None = None,
*,
provider_client: AIProviderClient | None = None,
db: AsyncSession | None = None,
) -> dict[str, Any]:
del provider_client
active_region = determine_focus_region(lat, lon)
sources = get_sources_for_region(active_region)
if db is None:
return await _get_earth_news_payload_from_rss_only(
lat=lat,
lon=lon,
active_region=active_region,
sources=sources,
)
from app.services.earth_news_store import (
get_earth_news_freshness,
list_earth_news_items,
upsert_earth_news_items,
)
errors: list[str] = []
item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region)
should_supplement = _needs_rss_supplement(item_count=item_count, newest_at=newest_at)
if should_supplement:
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
await upsert_earth_news_items(db, ranked_fetched_items)
items = await list_earth_news_items(
db,
active_region=active_region,
limit=MAX_ITEMS_TOTAL,
)
await _enqueue_unverified_locations(items)
stale = bool(errors and items)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=items,
sources=sources,
errors=errors,
stale=stale,
)

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Protocol
import redis.asyncio as redis
from redis.exceptions import ResponseError
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__, service="earth_news")
TARGET_LOCATION_STREAM = "earth_news:target_location:jobs"
TARGET_LOCATION_GROUP = "earth_news_target_location"
TARGET_LOCATION_DEAD_LETTER_STREAM = "earth_news:target_location:dead"
TARGET_LOCATION_RESULT_TTL_SECONDS = 60 * 60 * 12
TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS = 60 * 60 * 6
TARGET_LOCATION_MAX_ATTEMPTS = 3
_redis_client: redis.Redis | None = None
@dataclass(frozen=True)
class NewsTargetLocationMessage:
message_id: str
item_id: str
payload: dict[str, Any]
attempts: int = 0
class NewsTargetLocationQueue(Protocol):
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
...
async def consume_batch(
self,
*,
consumer_name: str,
count: int,
block_ms: int,
) -> list[NewsTargetLocationMessage]:
...
async def ack(self, message_id: str) -> None:
...
async def retry_or_dead_letter(
self,
message: NewsTargetLocationMessage,
*,
error: str,
) -> None:
...
def _get_redis_client() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def _result_key(item_id: str) -> str:
return f"earth_news:target_location:result:{item_id}"
def _queued_key(item_id: str) -> str:
return f"earth_news:target_location:queued:{item_id}"
class RedisStreamsNewsTargetLocationQueue:
def __init__(self, client: redis.Redis | None = None) -> None:
self.client = client or _get_redis_client()
self._group_ready = False
async def _ensure_group(self) -> None:
if self._group_ready:
return
try:
await self.client.xgroup_create(
TARGET_LOCATION_STREAM,
TARGET_LOCATION_GROUP,
id="0",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise
self._group_ready = True
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
await self._ensure_group()
if force:
await self.client.delete(_result_key(item_id), _queued_key(item_id))
elif await self.client.exists(_result_key(item_id)):
return False
queued = await self.client.set(
_queued_key(item_id),
"1",
nx=True,
ex=TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS,
)
if not queued:
return bool(await self.client.exists(_queued_key(item_id)))
await self.client.xadd(
TARGET_LOCATION_STREAM,
{
"item_id": item_id,
"attempts": "0",
"payload": json.dumps(payload, ensure_ascii=False),
},
)
return True
async def consume_batch(
self,
*,
consumer_name: str,
count: int,
block_ms: int,
) -> list[NewsTargetLocationMessage]:
await self._ensure_group()
streams = await self.client.xreadgroup(
TARGET_LOCATION_GROUP,
consumer_name,
{TARGET_LOCATION_STREAM: ">"},
count=count,
block=block_ms,
)
messages: list[NewsTargetLocationMessage] = []
for _stream_name, stream_messages in streams:
for message_id, fields in stream_messages:
raw_payload = fields.get("payload")
item_id = fields.get("item_id")
if not raw_payload or not item_id:
await self.ack(message_id)
continue
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
await self.ack(message_id)
continue
attempts = int(fields.get("attempts") or 0)
messages.append(
NewsTargetLocationMessage(
message_id=message_id,
item_id=item_id,
payload=payload,
attempts=attempts,
)
)
return messages
async def ack(self, message_id: str) -> None:
await self.client.xack(TARGET_LOCATION_STREAM, TARGET_LOCATION_GROUP, message_id)
async def retry_or_dead_letter(
self,
message: NewsTargetLocationMessage,
*,
error: str,
) -> None:
await self.ack(message.message_id)
if message.attempts + 1 >= TARGET_LOCATION_MAX_ATTEMPTS:
await self.client.xadd(
TARGET_LOCATION_DEAD_LETTER_STREAM,
{
"item_id": message.item_id,
"attempts": str(message.attempts + 1),
"error": error,
"payload": json.dumps(message.payload, ensure_ascii=False),
},
)
return
await self.client.xadd(
TARGET_LOCATION_STREAM,
{
"item_id": message.item_id,
"attempts": str(message.attempts + 1),
"payload": json.dumps(message.payload, ensure_ascii=False),
},
)
def get_news_target_location_queue() -> NewsTargetLocationQueue:
return RedisStreamsNewsTargetLocationQueue()
async def enqueue_target_location_job(payload: dict[str, Any], *, force: bool = False) -> bool:
item_id = str(payload.get("id") or "")
if not item_id:
return False
try:
queue = get_news_target_location_queue()
return await queue.enqueue(item_id=item_id, payload=payload, force=force)
except Exception as exc:
logger.warning_event(
"Failed to enqueue Earth news target location job",
event="earth_news.target_location.enqueue_failed",
context={"item_id": item_id, "error": str(exc)},
)
return False
async def get_cached_target_location_patch(item_id: str) -> dict[str, Any] | None:
try:
raw_value = await _get_redis_client().get(_result_key(item_id))
except Exception as exc:
logger.warning_event(
"Failed to read Earth news target location cache",
event="earth_news.target_location.cache_read_failed",
context={"item_id": item_id, "error": str(exc)},
)
return None
if not raw_value:
return None
try:
value = json.loads(raw_value)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
async def save_target_location_patch(item_id: str, patch: dict[str, Any]) -> None:
client = _get_redis_client()
await client.setex(
_result_key(item_id),
TARGET_LOCATION_RESULT_TTL_SECONDS,
json.dumps(patch, ensure_ascii=False),
)
await client.delete(_queued_key(item_id))

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.earth_news import EarthNewsItem
from app.services.earth_news import (
ParsedNewsItem,
apply_enrichment_patch_to_item,
build_anchor_location_patch,
)
def _coerce_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]:
return {
"latitude": record.latitude,
"longitude": record.longitude,
"location_label": record.location_label,
"location_source": record.location_source,
"verified": record.verified,
"location_meta": dict(record.location_meta or {}),
}
def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem:
item = ParsedNewsItem(
id=record.id,
title=record.title,
summary=record.summary or "",
url=record.url,
source=record.source or "",
feed_name=record.feed_name or "",
feed_region=record.region or "global",
homepage_url=record.homepage_url or "",
published_at=_coerce_datetime(record.published_at),
content_language=record.content_language or "en",
localizations=dict(record.localizations or {}),
enrichment_status=record.enrichment_status or "pending",
enrichment_error=record.enrichment_error,
enriched_at=_coerce_datetime(record.enriched_at),
)
return apply_enrichment_patch_to_item(item, _location_patch_from_record(record))
def _query_sort_key(active_region: str):
return (
EarthNewsItem.region != active_region,
EarthNewsItem.published_at.is_(None),
EarthNewsItem.published_at.desc().nullslast(),
EarthNewsItem.feed_name.asc(),
)
async def list_earth_news_items(
db: AsyncSession,
*,
active_region: str,
limit: int,
) -> list[ParsedNewsItem]:
regions = {"global", active_region}
result = await db.execute(
select(EarthNewsItem)
.where(EarthNewsItem.region.in_(regions))
.order_by(*_query_sort_key(active_region))
.limit(limit)
)
return [record_to_parsed_news_item(record) for record in result.scalars().all()]
async def get_earth_news_freshness(
db: AsyncSession,
*,
active_region: str,
) -> tuple[int, datetime | None]:
regions = {"global", active_region}
result = await db.execute(
select(
func.count(EarthNewsItem.id),
func.max(func.coalesce(EarthNewsItem.published_at, EarthNewsItem.last_seen_at)),
).where(EarthNewsItem.region.in_(regions))
)
count, newest = result.one()
item_count = int(count or 0)
if item_count == 0:
return 0, None
return item_count, _coerce_datetime(newest)
async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) -> int:
if not items:
return 0
now = datetime.now(UTC)
existing_result = await db.execute(
select(EarthNewsItem).where(EarthNewsItem.id.in_([item.id for item in items]))
)
existing = {record.id: record for record in existing_result.scalars().all()}
changed = 0
for item in items:
record = existing.get(item.id)
if record is None:
patch = build_anchor_location_patch(item)
record = EarthNewsItem(
id=item.id,
title=item.title,
summary=item.summary,
content_language=item.content_language,
localizations=dict(item.localizations or {}),
url=item.url,
source=item.source,
feed_name=item.feed_name,
region=item.feed_region,
homepage_url=item.homepage_url,
published_at=item.published_at,
latitude=patch["latitude"],
longitude=patch["longitude"],
location_label=patch["location_label"],
location_source=patch["location_source"],
verified=patch["verified"],
location_meta=patch["location_meta"],
first_seen_at=now,
last_seen_at=now,
enrichment_status=item.enrichment_status,
enrichment_error=item.enrichment_error,
enriched_at=item.enriched_at,
)
db.add(record)
changed += 1
continue
record.title = item.title
record.summary = item.summary
record.url = item.url
record.source = item.source
record.feed_name = item.feed_name
record.region = item.feed_region
record.homepage_url = item.homepage_url
record.published_at = item.published_at
record.last_seen_at = now
if item.localizations:
record.content_language = item.content_language
record.localizations = dict(item.localizations or {})
record.enrichment_status = item.enrichment_status
record.enrichment_error = item.enrichment_error
record.enriched_at = item.enriched_at
changed += 1
await db.flush()
return changed
async def update_earth_news_item_location(
db: AsyncSession,
*,
item_id: str,
patch: dict[str, Any],
) -> bool:
record = await db.get(EarthNewsItem, item_id)
if record is None:
return False
record.latitude = float(patch["latitude"])
record.longitude = float(patch["longitude"])
record.location_label = str(patch["location_label"])
record.location_source = str(patch["location_source"])
record.verified = bool(patch["verified"])
record.location_meta = dict(patch.get("location_meta") or {})
record.resolved_at = datetime.now(UTC) if record.verified else None
await db.flush()
return True
async def update_earth_news_item_enrichment(
db: AsyncSession,
*,
item_id: str,
patch: dict[str, Any],
) -> bool:
record = await db.get(EarthNewsItem, item_id)
if record is None:
return False
if "latitude" in patch:
record.latitude = float(patch["latitude"])
record.longitude = float(patch["longitude"])
record.location_label = str(patch["location_label"])
record.location_source = str(patch["location_source"])
record.verified = bool(patch["verified"])
record.location_meta = dict(patch.get("location_meta") or {})
record.resolved_at = datetime.now(UTC) if record.verified else None
if "content_language" in patch:
record.content_language = str(patch.get("content_language") or "en")
if "localizations" in patch:
record.localizations = dict(patch.get("localizations") or {})
if "enrichment_status" in patch:
record.enrichment_status = str(patch.get("enrichment_status") or "pending")
if "enrichment_error" in patch:
record.enrichment_error = patch.get("enrichment_error")
if patch.get("enriched_at"):
try:
parsed_enriched_at = datetime.fromisoformat(
str(patch["enriched_at"]).replace("Z", "+00:00")
)
except ValueError:
parsed_enriched_at = datetime.now(UTC)
record.enriched_at = _coerce_datetime(parsed_enriched_at)
elif patch.get("localizations"):
record.enriched_at = datetime.now(UTC)
await db.flush()
return True
async def list_unverified_earth_news_items(
db: AsyncSession,
*,
active_region: str,
limit: int,
) -> list[ParsedNewsItem]:
regions = {"global", active_region}
result = await db.execute(
select(EarthNewsItem)
.where(EarthNewsItem.region.in_(regions))
.where(EarthNewsItem.verified.is_(False))
.order_by(*_query_sort_key(active_region))
.limit(limit)
)
return [record_to_parsed_news_item(record) for record in result.scalars().all()]
async def list_all_earth_news_records(db: AsyncSession) -> list[EarthNewsItem]:
result = await db.execute(
select(EarthNewsItem).order_by(
EarthNewsItem.published_at.desc().nullslast(),
EarthNewsItem.last_seen_at.desc(),
)
)
return list(result.scalars().all())

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from socket import gethostname
from typing import Any
from app.core.logging import get_logger
from app.core.websocket.broadcaster import broadcaster
from app.db.session import async_session_factory
from app.services.ai_client import AIProviderClient
from app.ai_tasks.prompts import get_effective_prompt
from app.services.earth_news import (
NEWS_ENRICH_PROMPT_KEY,
_infer_news_enrichment,
build_target_location_patch,
parsed_news_item_from_job_payload,
)
from app.services.earth_news_queue import (
NewsTargetLocationMessage,
get_news_target_location_queue,
save_target_location_patch,
)
from app.services.earth_news_store import update_earth_news_item_enrichment as update_earth_news_item_location
logger = get_logger(__name__, service="earth_news")
WORKER_BATCH_SIZE = 4
WORKER_BLOCK_MS = 5000
WORKER_BACKOFF_SECONDS = 5.0
_worker_task: asyncio.Task | None = None
async def _build_provider_client() -> AIProviderClient | None:
try:
from app.api.v1.settings import get_runtime_ai_provider_config
async with async_session_factory() as session:
runtime_config = await get_runtime_ai_provider_config(session)
return AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
)
except Exception as exc:
logger.warning_event(
"Failed to build Earth news AI provider client",
event="earth_news.target_location.provider_unavailable",
context={"error": str(exc)},
)
return None
async def process_target_location_message(
message: NewsTargetLocationMessage,
*,
provider_client: AIProviderClient | None,
) -> dict[str, Any]:
item = parsed_news_item_from_job_payload(message.payload)
async with async_session_factory() as session:
prompt = await get_effective_prompt(session, NEWS_ENRICH_PROMPT_KEY)
target, localizations = await _infer_news_enrichment(
item,
provider_client=provider_client,
prompt=prompt,
)
item.target_location = target
item.localizations = localizations or item.localizations
patch = build_target_location_patch(item, target)
await save_target_location_patch(item.id, patch)
async with async_session_factory() as session:
await update_earth_news_item_location(session, item_id=item.id, patch=patch)
await session.commit()
await broadcaster.broadcast_custom(
"earth_news",
{
"item_id": item.id,
"patch": patch,
},
)
return patch
async def _run_target_location_worker() -> None:
consumer_name = f"{gethostname()}:{id(asyncio.current_task())}"
queue = get_news_target_location_queue()
while True:
try:
messages = await queue.consume_batch(
consumer_name=consumer_name,
count=WORKER_BATCH_SIZE,
block_ms=WORKER_BLOCK_MS,
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning_event(
"Earth news target location worker queue read failed",
event="earth_news.target_location.worker_read_failed",
context={"error": str(exc)},
)
await asyncio.sleep(WORKER_BACKOFF_SECONDS)
continue
if not messages:
continue
provider_client = await _build_provider_client()
for message in messages:
try:
await process_target_location_message(message, provider_client=provider_client)
await queue.ack(message.message_id)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning_event(
"Earth news target location worker job failed",
event="earth_news.target_location.worker_job_failed",
context={"item_id": message.item_id, "error": str(exc)},
)
with suppress(Exception):
await queue.retry_or_dead_letter(message, error=str(exc))
def start_earth_news_target_worker() -> None:
global _worker_task
if _worker_task is None or _worker_task.done():
_worker_task = asyncio.create_task(_run_target_location_worker())
async def stop_earth_news_target_worker() -> None:
global _worker_task
task = _worker_task
if task is None:
return
task.cancel()
with suppress(asyncio.CancelledError):
await task
_worker_task = None

View File

@@ -7,8 +7,11 @@ import re
from dataclasses import dataclass
from typing import Any, Iterable
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.countries import COUNTRY_ENTRIES, normalize_country
from app.schemas.ai import SituationalAnalysisRequest
from app.ai_tasks.prompts import get_effective_prompt
from app.services.ai_client import AIProviderClient
from app.services.ai_tools.evidence_store import normalize_search_evidence
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
@@ -23,6 +26,8 @@ from app.services.location.text import (
VALID_LLM_PRECISIONS = {"precise", "site", "city"}
DEFAULT_MIN_CONFIDENCE = 0.55
LOCATION_NORMALIZE_PROMPT_KEY = "location.factcheck.normalize"
LOCATION_RESOLVE_PROMPT_KEY = "location.factcheck.resolve"
MODEL_CONFIDENCE_WEIGHT = 0.25
_geocode_llm_city = build_default_nominatim_geocoder()
_LLM_LOCATION_NAME_KEYS = (
@@ -876,6 +881,7 @@ async def _repair_location_payload_from_text(
raw_text: str,
query: LocationQuery,
entity_type: str,
db: AsyncSession | None = None,
) -> dict[str, Any] | None:
"""Second-pass structure repair for models that answer in prose.
@@ -884,12 +890,11 @@ async def _repair_location_payload_from_text(
"""
if not coerce_str(raw_text):
return None
prompt = await get_effective_prompt(db, LOCATION_NORMALIZE_PROMPT_KEY)
request = SituationalAnalysisRequest(
title=f"Normalize location factcheck for {entity_type}",
objective=(
"Convert the supplied location factcheck text into exactly one strict "
"JSON object. Extract only facts present in the text or original query."
),
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
context={
"entity_type": entity_type,
"location_query": _query_context(query),
@@ -929,6 +934,7 @@ async def collect_llm_location_fallback_candidate(
provider_client: AIProviderClient,
query: LocationQuery,
entity_type: str,
db: AsyncSession | None = None,
attempted_queries: Iterable[str] = (),
search_evidence: list[dict[str, Any]] | None = None,
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
@@ -946,13 +952,11 @@ async def collect_llm_location_fallback_candidate(
attempted_queries=[attempt],
failure_reason="LLM location factcheck skipped: no WebSearch evidence.",
)
prompt = await get_effective_prompt(db, LOCATION_RESOLVE_PROMPT_KEY)
request = SituationalAnalysisRequest(
title=f"Location factcheck fallback for {entity_type}",
objective=(
"Return exactly one JSON object for the most likely physical location. "
"Use only fact-checkable public knowledge; return null fields rather "
"than guessing when evidence is weak."
),
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
context={
"entity_type": entity_type,
"location_query": _query_context(query),
@@ -1001,6 +1005,7 @@ async def collect_llm_location_fallback_candidate(
raw_text=response.content,
query=query,
entity_type=entity_type,
db=db,
)
if payload is None:
payload = _payload_from_free_text(response.content, query=query)

View File

@@ -175,19 +175,29 @@ async def run_collector_task(collector_name: str):
)
try:
collector._datasource_id = datasource.id
datasource_id = datasource.id
datasource_source = datasource.source
collector._datasource_id = datasource_id
logger.info_event(
"Running collector",
event="collector.run.started",
context={"collector_name": collector_name, "datasource_id": datasource.id},
context={"collector_name": collector_name, "datasource_id": datasource_id},
)
task_result = await collector.run(db)
datasource = await db.get(DataSource, datasource_id)
if datasource is None:
logger.error_event(
"Datasource disappeared after collector run",
event="collector.run.datasource_missing_after_run",
context={"collector_name": collector_name, "datasource_id": datasource_id},
)
return
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = task_result.get("status")
if datasource.last_status == "success":
effective_candidate = await get_builtin_effective_candidate(db, datasource.source)
effective_candidate = await get_builtin_effective_candidate(db, datasource_source)
checksum, _credential_context = await build_builtin_connectivity_checksum(
datasource.source,
datasource_source,
effective_candidate["endpoint"],
effective_candidate["auth_type"],
effective_candidate["headers"],
@@ -196,7 +206,7 @@ async def run_collector_task(collector_name: str):
)
await save_connectivity_success(
db,
datasource.source,
datasource_source,
checksum,
{"status_code": None},
connected_by="collection",
@@ -205,9 +215,11 @@ async def run_collector_task(collector_name: str):
logger.info_event(
"Collector completed",
event="collector.run.completed",
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
context={"collector_name": collector_name, "datasource_id": datasource_id, "result": task_result},
)
except asyncio.CancelledError:
await db.rollback()
datasource = await db.get(DataSource, datasource_id)
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "cancelled"
await db.commit()
@@ -218,6 +230,8 @@ async def run_collector_task(collector_name: str):
)
raise
except Exception as exc:
await db.rollback()
datasource = await db.get(DataSource, datasource_id)
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "failed"
await db.commit()

View File

@@ -10,8 +10,11 @@ from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
from app.schemas.ai import SituationalAnalysisRequest
from app.ai_tasks.prompts import get_effective_prompt
from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record
SITUATIONAL_ALERT_BRIEF_PROMPT_KEY = "alerts.situational.brief"
def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "") -> str:
if not pairs:
@@ -158,10 +161,12 @@ async def build_situational_alert_brief_request(
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,
"latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None,
}
prompt = await get_effective_prompt(db, SITUATIONAL_ALERT_BRIEF_PROMPT_KEY)
request = SituationalAnalysisRequest(
title="态势告警 AI 简报",
objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=facts,
constraints=[
"明确区分事实、推断与建议。",

View File

@@ -0,0 +1,177 @@
import json
import pytest
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.services.collectors.registry import collector_registry
from app.services import earth_boundaries
def write_geojson(path, name="Test"):
path.write_text(
json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"name": name},
"geometry": {
"type": "Polygon",
"coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]],
},
}
],
}
),
encoding="utf-8",
)
def patch_paths(monkeypatch, tmp_path):
repo = tmp_path
source_dir = repo / "data/earth-boundary-sources"
boundary_dir = repo / "frontend/public/earth/data/boundaries/v1"
pmtiles = repo / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
legacy = repo / "frontend/public/earth/data/countries-admin0.min.geojson"
config = repo / "config/earth-boundary-sources.local.json"
example = repo / "config/earth-boundary-sources.example.json"
policy = repo / "config/earth-boundary-pov-policy.china-v1.json"
for path in (source_dir, boundary_dir, pmtiles.parent, legacy.parent, config.parent):
path.mkdir(parents=True, exist_ok=True)
policy.write_text('{"productionTileFormat":"pmtiles+mvt"}\n', encoding="utf-8")
example.write_text('{"collectorConfigs":{}}\n', encoding="utf-8")
monkeypatch.setattr(earth_boundaries, "REPO_ROOT", repo)
monkeypatch.setattr(earth_boundaries, "SOURCE_OUTPUT_DIR", source_dir)
monkeypatch.setattr(earth_boundaries, "SOURCE_MANIFEST_PATH", source_dir / "manifest.json")
monkeypatch.setattr(earth_boundaries, "BUILD_RESULT_PATH", source_dir / "build-result.json")
monkeypatch.setattr(earth_boundaries, "BUILD_JOB_PATH", source_dir / "build-job.json")
monkeypatch.setattr(earth_boundaries, "BOUNDARY_OUTPUT_DIR", boundary_dir)
monkeypatch.setattr(earth_boundaries, "BOUNDARY_MANIFEST_PATH", boundary_dir / "manifest.json")
monkeypatch.setattr(earth_boundaries, "PMTILES_ARTIFACT_PATH", pmtiles)
monkeypatch.setattr(earth_boundaries, "LEGACY_GEOJSON_PATH", legacy)
monkeypatch.setattr(earth_boundaries, "LOCAL_CONFIG_PATH", config)
monkeypatch.setattr(earth_boundaries, "EXAMPLE_CONFIG_PATH", example)
monkeypatch.setattr(earth_boundaries, "POV_POLICY_PATH", policy)
return {
"repo": repo,
"config": config,
"legacy": legacy,
"pmtiles": pmtiles,
"manifest": boundary_dir / "manifest.json",
}
def test_boundary_status_uses_legacy_provider_when_pmtiles_missing(monkeypatch, tmp_path):
paths = patch_paths(monkeypatch, tmp_path)
write_geojson(paths["legacy"])
status = earth_boundaries.get_boundary_status()
assert status["provider"] == "legacy-geojson"
assert status["fallback_available"] is True
assert status["high_precision_ready"] is False
def test_boundary_status_prefers_high_precision_when_manifest_and_pmtiles_exist(monkeypatch, tmp_path):
paths = patch_paths(monkeypatch, tmp_path)
write_geojson(paths["legacy"])
paths["pmtiles"].write_bytes(b"pmtiles")
paths["manifest"].write_text('{"tileProvider":"pmtiles-mvt"}\n', encoding="utf-8")
status = earth_boundaries.get_boundary_status()
assert status["provider"] == "pmtiles-mvt"
assert status["high_precision_ready"] is True
def test_save_boundary_config_writes_local_config(monkeypatch, tmp_path):
paths = patch_paths(monkeypatch, tmp_path)
payload = {"collectorConfigs": {"earth_admin0_boundaries": {"endpoint": "file:///tmp/a.geojson"}}}
status = earth_boundaries.save_boundary_config(payload)
assert paths["config"].exists()
assert status["config_source"] == "local"
assert status["config"] == payload
@pytest.mark.asyncio
async def test_build_reports_missing_tools_after_source_artifacts(monkeypatch, tmp_path):
paths = patch_paths(monkeypatch, tmp_path)
source_files = {}
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
source_path = paths["repo"] / f"{source}.geojson"
write_geojson(source_path, name=source)
source_files[source] = source_path
paths["config"].write_text(
json.dumps(
{
"collectorConfigs": {
source: {
"sourceKind": kind,
"endpoint": str(source_files[source]),
"method": "GET",
}
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
progress_events = []
status = await earth_boundaries.build_boundary_assets(
lambda progress, phase, message, **_extra: progress_events.append((progress, phase, message))
)
assert status["provider"] == "geojson-high-precision"
assert status["high_precision_ready"] is True
assert (paths["repo"] / "data/earth-boundary-sources/manifest.json").exists()
assert paths["manifest"].exists()
assert any(phase == "download" for _progress, phase, _message in progress_events)
@pytest.mark.asyncio
async def test_start_boundary_build_job_records_geojson_fallback_success(monkeypatch, tmp_path):
paths = patch_paths(monkeypatch, tmp_path)
monkeypatch.setattr(earth_boundaries, "_build_task", None)
monkeypatch.setattr(earth_boundaries, "_build_job_state", {})
source_files = {}
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
source_path = paths["repo"] / f"{source}.geojson"
write_geojson(source_path, name=source)
source_files[source] = source_path
paths["config"].write_text(
json.dumps(
{
"collectorConfigs": {
source: {
"sourceKind": kind,
"endpoint": str(source_files[source]),
"method": "GET",
}
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
response = await earth_boundaries.start_boundary_build_job()
await earth_boundaries._build_task
status = earth_boundaries.get_boundary_build_status()
assert response["accepted"] is True
assert status["job"]["status"] == "succeeded"
assert status["job"]["result"]["provider"] == "geojson-high-precision"
def test_earth_boundary_collectors_are_not_registered_as_datasources():
removed = set(earth_boundaries.BOUNDARY_SOURCE_KINDS) | {"earth_boundary_tiles"}
assert removed.isdisjoint(DEFAULT_DATASOURCES)
for source in removed:
assert collector_registry.get(source) is None

View File

@@ -1,6 +1,20 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from app.services.earth_news import ParsedNewsItem, _serialize_item
import pytest
from app.services.earth_news import (
NewsFeedSource,
NewsTargetLocation,
ParsedNewsItem,
_enrich_items_with_target_locations,
_extract_target_location_from_text,
_serialize_item,
get_earth_news_payload,
)
from app.services.earth_news_queue import NewsTargetLocationMessage
from app.services.earth_news_worker import process_target_location_message
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
def test_serialize_item_includes_region_anchor_for_cruise():
@@ -21,7 +35,10 @@ def test_serialize_item_includes_region_anchor_for_cruise():
assert payload["latitude"] == 1.3521
assert payload["longitude"] == 103.8198
assert payload["location_label"] == "亚太"
assert payload["location_inferred"] is True
assert payload["location_source"] == "region_anchor"
assert payload["verified"] is False
assert payload["location_meta"]["target"] is None
assert payload["location_meta"]["anchor"]["region"] == "asia-pacific"
assert payload["is_focus_match"] is True
assert payload["published_at"] == "2026-04-23T02:30:00Z"
@@ -44,6 +61,687 @@ def test_serialize_item_falls_back_to_global_anchor():
assert payload["latitude"] == 20.0
assert payload["longitude"] == 0.0
assert payload["location_label"] == "全球"
assert payload["location_inferred"] is True
assert payload["location_source"] == "region_anchor"
assert payload["verified"] is False
assert payload["location_meta"]["anchor"]["region"] == "global"
assert payload["is_focus_match"] is False
assert payload["published_at"] is None
def test_serialize_item_includes_inferred_target_location():
item = ParsedNewsItem(
id="bbc-world:f55310fb667b",
title="Watch: What happened on day one of Trump's China visit?",
summary=(
"China welcomed US President Donald Trump with cheering children "
"and a troop parade."
),
url="https://example.com/china-visit",
source="BBC World",
feed_name="BBC World",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
target_location=NewsTargetLocation(
latitude=39.9042,
longitude=116.4074,
label="Beijing, China",
source="ai_inferred_target",
confidence=0.88,
country="中国",
city="Beijing",
),
)
payload = _serialize_item(item, active_region="global")
assert payload["latitude"] == 39.9042
assert payload["longitude"] == 116.4074
assert payload["location_label"] == "Beijing, China"
assert payload["location_source"] == "ai_inferred_target"
assert payload["verified"] is True
assert payload["location_meta"]["target"]["confidence"] == 0.88
assert payload["location_meta"]["target"]["country"] == "中国"
assert payload["location_meta"]["target"]["city"] == "Beijing"
assert payload["location_meta"]["resolution_stage"] == "unresolved"
assert payload["location_meta"]["ai_attempted"] is False
assert payload["location_meta"]["ai_status"] == "not_attempted"
assert payload["location_meta"]["ai_error"] is None
@pytest.mark.asyncio
async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatch):
item = ParsedNewsItem(
id="bbc-world:f55310fb667b",
title="Watch: What happened on day one of Trump's China visit?",
summary="China welcomed US President Donald Trump before a long meeting with Xi Jinping.",
url="https://example.com/china-visit",
source="BBC World",
feed_name="BBC World",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
)
async def fake_geocode(_query: str):
return {
"lat": "39.9042",
"lon": "116.4074",
"display_name": "Beijing, China",
}
class FakeProviderClient:
async def analyze(self, _request):
class Response:
content = (
'{"country":"China","city":"Beijing","matched_location_name":"Beijing, China",'
'"latitude":null,"longitude":null,"confidence":0.88}'
)
return Response()
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
enriched = await _enrich_items_with_target_locations(
[item],
provider_client=FakeProviderClient(),
)
assert len(enriched) == 1
assert enriched[0].target_location is not None
assert enriched[0].target_location.latitude == 39.9042
assert enriched[0].target_location.longitude == 116.4074
assert enriched[0].target_location.label == "Beijing, China"
assert enriched[0].target_resolution_stage == "ai_inferred_target"
assert enriched[0].target_ai_attempted is True
assert enriched[0].target_ai_status == "success"
assert enriched[0].target_ai_error is None
@pytest.mark.asyncio
async def test_enrich_items_with_target_locations_adds_localizations(monkeypatch):
item = ParsedNewsItem(
id="global-scan:localized",
title="Global leaders meet to discuss energy security",
summary="Officials said the talks focused on supply chains and grid resilience.",
url="https://example.com/energy-security",
source="Example Source",
feed_name="Global Monitor / World",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
)
async def fake_geocode(_query: str):
return {
"lat": "50.1109",
"lon": "8.6821",
"display_name": "Frankfurt am Main, Germany",
}
class FakeProviderClient:
async def analyze(self, _request):
class Response:
content = (
'{"location":{"country":"Germany","city":"Frankfurt",'
'"matched_location_name":"Frankfurt, Germany",'
'"latitude":null,"longitude":null,"confidence":0.77},'
'"localizations":{"zh-CN":{"title":"全球领导人讨论能源安全",'
'"summary":"官员表示,会谈聚焦供应链和电网韧性。"}}}'
)
return Response()
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
enriched = await _enrich_items_with_target_locations(
[item],
provider_client=FakeProviderClient(),
)
payload = _serialize_item(enriched[0], active_region="global")
assert payload["title"] == "Global leaders meet to discuss energy security"
assert payload["summary"] == "Officials said the talks focused on supply chains and grid resilience."
assert payload["localizations"]["zh-CN"]["title"] == "全球领导人讨论能源安全"
assert payload["display_title"] == "全球领导人讨论能源安全"
assert payload["display_summary"] == "官员表示,会谈聚焦供应链和电网韧性。"
assert payload["enrichment_status"] == "success"
@pytest.mark.asyncio
async def test_extract_target_location_from_text_uses_country_hint(monkeypatch):
item = ParsedNewsItem(
id="bbc-world:country-hint",
title="Giant new dinosaur identified from fossils in Thailand",
summary="The nagatitan is the largest dinosaur found in South-East Asia.",
url="https://example.com/thailand-dinosaur",
source="BBC World",
feed_name="BBC World",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 17, 28, 56, tzinfo=UTC),
)
target = await _extract_target_location_from_text(item)
assert target is not None
assert target.country == "泰国"
assert target.latitude == 15.87
assert target.longitude == 100.9925
assert target.source == "headline_country_hint"
@pytest.mark.asyncio
async def test_enrich_items_with_target_locations_records_ai_provider_error():
item = ParsedNewsItem(
id="global-scan:no-hint",
title="The New Geopolitics of Power: Whoever Controls Electrons Wins the Decade",
summary="A broad analysis of industrial policy and energy systems.",
url="https://example.com/geopolitics-power",
source="Example Source",
feed_name="Global Monitor / World",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 17, 3, 10, tzinfo=UTC),
)
class FailingProviderClient:
async def analyze(self, _request):
raise RuntimeError("upstream ai timeout")
enriched = await _enrich_items_with_target_locations(
[item],
provider_client=FailingProviderClient(),
)
assert len(enriched) == 1
assert enriched[0].target_location is None
assert enriched[0].target_resolution_stage == "unresolved"
assert enriched[0].target_ai_attempted is True
assert enriched[0].target_ai_status == "provider_error"
assert enriched[0].target_ai_error == "upstream ai timeout"
@pytest.mark.asyncio
async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job(monkeypatch):
source = NewsFeedSource(
id="test-feed",
name="Test Feed",
region="global",
homepage_url="https://example.com",
feed_url="https://example.com/rss.xml",
)
item = ParsedNewsItem(
id="test-feed:timeout",
title="Example story",
summary="Example summary",
url="https://example.com/story",
source="Test Feed",
feed_name="Test Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
)
async def fake_fetch_source(_client, feed_source):
return feed_source, [item], None
async def fake_get_cached_target_location_patch(_item_id):
return None
enqueued_payloads = []
async def fake_enqueue_target_location_job(payload, **_kwargs):
enqueued_payloads.append(payload)
return True
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
monkeypatch.setattr(
"app.services.earth_news_queue.get_cached_target_location_patch",
fake_get_cached_target_location_patch,
)
monkeypatch.setattr(
"app.services.earth_news_queue.enqueue_target_location_job",
fake_enqueue_target_location_job,
)
payload = await get_earth_news_payload(provider_client=None)
assert len(payload["items"]) == 1
assert payload["items"][0]["id"] == "test-feed:timeout"
assert payload["items"][0]["display_title"] == ""
assert payload["items"][0]["display_summary"] == ""
assert payload["items"][0]["latitude"] == 20.0
assert payload["items"][0]["longitude"] == 0.0
assert payload["items"][0]["location_source"] == "region_anchor"
assert payload["items"][0]["verified"] is False
assert payload["items"][0]["location_meta"]["ai_status"] == "queued"
assert enqueued_payloads[0]["id"] == "test-feed:timeout"
assert payload["errors"] == []
@pytest.mark.asyncio
async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypatch):
db = object()
item = ParsedNewsItem(
id="db:fresh",
title="Fresh database story",
summary="Stored summary",
url="https://example.com/fresh",
source="Stored Source",
feed_name="Stored Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
)
item.location_patch = {
"latitude": 39.9057136,
"longitude": 116.3912972,
"location_label": "北京市, 中国",
"location_source": "headline_location_hint",
"verified": True,
"location_meta": {"target": {"city": "Beijing"}, "anchor": {"region": "global"}},
}
async def fake_get_earth_news_freshness(_db, *, active_region):
return 12, datetime.now(UTC)
async def fake_list_earth_news_items(_db, *, active_region, limit):
assert limit == 12
return [item]
async def fail_fetch(_sources):
raise AssertionError("fresh database items should not fetch RSS")
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fail_fetch)
payload = await get_earth_news_payload(db=db)
assert payload["items"][0]["id"] == "db:fresh"
assert payload["items"][0]["verified"] is True
assert payload["items"][0]["latitude"] == 39.9057136
assert payload["stale"] is False
@pytest.mark.asyncio
async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatch):
db = object()
source = NewsFeedSource(
id="test-feed",
name="Test Feed",
region="global",
homepage_url="https://example.com",
feed_url="https://example.com/rss.xml",
)
item = ParsedNewsItem(
id="test-feed:init",
title="Initial RSS story",
summary="Initial summary",
url="https://example.com/init",
source="Test Feed",
feed_name="Test Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
)
item.location_patch = {
"latitude": 20.0,
"longitude": 0.0,
"location_label": "全球",
"location_source": "region_anchor",
"verified": False,
"location_meta": {"target": None, "anchor": {"region": "global"}},
}
upserted = []
enqueued = []
async def fake_get_earth_news_freshness(_db, *, active_region):
return 0, None
async def fake_fetch_rss_items_for_sources(_sources):
return [item], []
async def fake_upsert_earth_news_items(_db, items):
upserted.extend(items)
return len(items)
async def fake_list_earth_news_items(_db, *, active_region, limit):
return [item]
async def fake_enqueue_target_location_job(payload, **_kwargs):
enqueued.append(payload)
return True
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
payload = await get_earth_news_payload(db=db)
assert upserted[0].id == "test-feed:init"
assert payload["items"][0]["id"] == "test-feed:init"
assert payload["items"][0]["verified"] is False
assert enqueued[0]["id"] == "test-feed:init"
@pytest.mark.asyncio
async def test_earth_news_payload_supplements_stale_database_items(monkeypatch):
db = object()
old_item = ParsedNewsItem(
id="db:old",
title="Old story",
summary="Old summary",
url="https://example.com/old",
source="Stored Source",
feed_name="Stored Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 3, 0, tzinfo=UTC),
)
old_item.location_patch = {
"latitude": 20.0,
"longitude": 0.0,
"location_label": "全球",
"location_source": "region_anchor",
"verified": False,
"location_meta": {"target": None, "anchor": {"region": "global"}},
}
fetched = []
async def fake_get_earth_news_freshness(_db, *, active_region):
return 12, datetime(2026, 5, 14, 3, 0, tzinfo=UTC)
async def fake_fetch_rss_items_for_sources(_sources):
fetched.append(True)
return [old_item], []
async def fake_upsert_earth_news_items(_db, items):
return len(items)
async def fake_list_earth_news_items(_db, *, active_region, limit):
return [old_item]
async def fake_enqueue_target_location_job(_payload, **_kwargs):
return True
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
payload = await get_earth_news_payload(db=db)
assert fetched == [True]
assert payload["items"][0]["id"] == "db:old"
@pytest.mark.asyncio
async def test_earth_news_payload_merges_cached_location_patch(monkeypatch):
source = NewsFeedSource(
id="test-feed",
name="Test Feed",
region="global",
homepage_url="https://example.com",
feed_url="https://example.com/rss.xml",
)
item = ParsedNewsItem(
id="test-feed:cached",
title="Cached story",
summary="Cached summary",
url="https://example.com/cached",
source="Test Feed",
feed_name="Test Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
)
cached_patch = {
"latitude": 39.9057136,
"longitude": 116.3912972,
"location_label": "北京市, 中国",
"location_source": "headline_location_hint",
"verified": True,
"location_meta": {
"resolution_stage": "headline_location_hint",
"ai_attempted": False,
"ai_status": "skipped_text_hint",
"ai_error": None,
"debug_note": "text hint matched 北京市, 中国",
"target": {"city": "Beijing"},
"anchor": {"region": "global"},
},
}
async def fake_fetch_source(_client, feed_source):
return feed_source, [item], None
async def fake_get_cached_target_location_patch(_item_id):
return cached_patch
enqueued = []
async def fake_enqueue_target_location_job(payload, **_kwargs):
enqueued.append(payload)
return True
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
monkeypatch.setattr(
"app.services.earth_news_queue.get_cached_target_location_patch",
fake_get_cached_target_location_patch,
)
monkeypatch.setattr(
"app.services.earth_news_queue.enqueue_target_location_job",
fake_enqueue_target_location_job,
)
payload = await get_earth_news_payload(provider_client=None)
assert payload["items"][0]["latitude"] == 39.9057136
assert payload["items"][0]["longitude"] == 116.3912972
assert payload["items"][0]["verified"] is True
assert payload["items"][0]["location_source"] == "headline_location_hint"
assert enqueued[0]["id"] == "test-feed:cached"
@pytest.mark.asyncio
async def test_earth_news_payload_requeues_cached_failed_localization(monkeypatch):
source = NewsFeedSource(
id="test-feed",
name="Test Feed",
region="global",
homepage_url="https://example.com",
feed_url="https://example.com/rss.xml",
)
item = ParsedNewsItem(
id="test-feed:failed-localization",
title="Failed localization story",
summary="English source summary.",
url="https://example.com/failed-localization",
source="Test Feed",
feed_name="Test Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
)
cached_patch = {
"latitude": 20.0,
"longitude": 0.0,
"location_label": "全球",
"location_source": "region_anchor",
"verified": False,
"location_meta": {"target": None, "anchor": {"region": "global"}},
"content_language": "en",
"localizations": {},
"enrichment_status": "parse_error",
"enrichment_error": "AI response did not contain a parseable JSON object.",
"enriched_at": None,
}
enqueued = []
async def fake_fetch_source(_client, feed_source):
return feed_source, [item], None
async def fake_get_cached_target_location_patch(_item_id):
return cached_patch
async def fake_enqueue_target_location_job(payload, **_kwargs):
enqueued.append(payload)
return True
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
monkeypatch.setattr(
"app.services.earth_news_queue.get_cached_target_location_patch",
fake_get_cached_target_location_patch,
)
monkeypatch.setattr(
"app.services.earth_news_queue.enqueue_target_location_job",
fake_enqueue_target_location_job,
)
payload = await get_earth_news_payload(provider_client=None)
assert enqueued[0]["id"] == "test-feed:failed-localization"
assert payload["items"][0]["display_title"] == ""
assert payload["items"][0]["enrichment_status"] == "queued"
@pytest.mark.asyncio
async def test_worker_processes_target_location_message_and_returns_patch(monkeypatch):
message = NewsTargetLocationMessage(
message_id="1-0",
item_id="bbc-world:worker",
payload={
"id": "bbc-world:worker",
"title": "Ukraine rescuers pull dead from rubble of Kyiv flats",
"summary": "Massive Russian drone and missile attacks in Ukraine's capital.",
"url": "https://example.com/kyiv",
"source": "BBC World",
"feed_name": "BBC World",
"feed_region": "global",
"homepage_url": "https://www.bbc.com/news/world",
"published_at": "2026-05-14T13:16:32Z",
},
)
async def fake_geocode(_query: str):
return {
"lat": "50.4500336",
"lon": "30.5241361",
"display_name": "Київ, Україна",
}
saved = {}
broadcasted = {}
async def fake_save_target_location_patch(item_id, patch):
saved["item_id"] = item_id
saved["patch"] = patch
async def fake_update_earth_news_item_location(_session, *, item_id, patch):
saved["db_item_id"] = item_id
saved["db_patch"] = patch
return True
async def fake_broadcast_custom(channel, data):
broadcasted["channel"] = channel
broadcasted["data"] = data
class FakeSession:
async def commit(self):
saved["committed"] = True
class FakeSessionFactory:
async def __aenter__(self):
return FakeSession()
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
monkeypatch.setattr(
"app.services.earth_news_worker.save_target_location_patch",
fake_save_target_location_patch,
)
monkeypatch.setattr(
"app.services.earth_news_worker.update_earth_news_item_location",
fake_update_earth_news_item_location,
)
monkeypatch.setattr(
"app.services.earth_news_worker.async_session_factory",
lambda: FakeSessionFactory(),
)
monkeypatch.setattr(
"app.services.earth_news_worker.broadcaster.broadcast_custom",
fake_broadcast_custom,
)
patch = await process_target_location_message(message, provider_client=None)
assert patch["latitude"] == 50.4500336
assert patch["longitude"] == 30.5241361
assert patch["location_source"] == "headline_location_hint"
assert patch["verified"] is True
assert saved["item_id"] == "bbc-world:worker"
assert saved["db_item_id"] == "bbc-world:worker"
assert saved["committed"] is True
assert broadcasted["channel"] == "earth_news"
assert broadcasted["data"]["item_id"] == "bbc-world:worker"
@pytest.mark.asyncio
async def test_media_news_archive_collector_maps_news_items(monkeypatch):
collector = MediaNewsArchiveCollector()
collector._db_session = object()
record = SimpleNamespace(
id="bbc-world:archive",
title="Archived news",
summary="Archived summary",
url="https://example.com/archive",
source="BBC World",
feed_name="BBC World",
region="global",
homepage_url="https://www.bbc.com/news/world",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
latitude=39.9057136,
longitude=116.3912972,
location_label="北京市, 中国",
location_source="headline_location_hint",
verified=True,
location_meta={"target": {"country": "中国", "city": "Beijing"}},
content_language="en",
localizations={"zh-CN": {"title": "归档新闻", "summary": "归档概要"}},
enrichment_status="success",
enrichment_error=None,
enriched_at=datetime(2026, 5, 15, 3, 6, tzinfo=UTC),
first_seen_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC),
last_seen_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
resolved_at=datetime(2026, 5, 15, 3, 5, tzinfo=UTC),
)
async def fake_list_all_earth_news_records(_db):
return [record]
monkeypatch.setattr(
"app.services.collectors.media_news_archive.list_all_earth_news_records",
fake_list_all_earth_news_records,
)
items = await collector.fetch()
assert items[0]["source_id"] == "bbc-world:archive"
assert collector.data_type == "news_item"
assert items[0]["country"] == "中国"
assert items[0]["city"] == "Beijing"
assert items[0]["latitude"] == 39.9057136
assert items[0]["metadata"]["verified"] is True
assert "localizations" not in items[0]["metadata"]
assert "enrichment_status" not in items[0]["metadata"]

View File

@@ -0,0 +1,92 @@
from types import SimpleNamespace
import pytest
from app.ai_tasks.prompts import (
get_effective_prompt,
list_effective_prompts,
reset_prompt_override,
save_prompt_override,
)
class _ScalarResult:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
class _PromptSettingsDB:
def __init__(self, payload=None):
self.record = SimpleNamespace(category="ai_prompts", payload=payload) if payload is not None else None
self.added = None
self.commits = 0
async def execute(self, _statement):
return _ScalarResult(self.record)
def add(self, record):
self.record = record
self.added = record
async def commit(self):
self.commits += 1
@pytest.mark.asyncio
async def test_prompt_defaults_are_loaded_without_override():
db = _PromptSettingsDB()
prompt = await get_effective_prompt(db, "earth.news.enrich")
assert prompt.key == "earth.news.enrich"
assert prompt.is_custom is False
assert "strict JSON" in prompt.prompt
@pytest.mark.asyncio
async def test_prompt_override_save_and_reset():
db = _PromptSettingsDB()
saved = await save_prompt_override(
db,
"alerts.brief",
system_prompt="system custom",
prompt="prompt custom",
)
assert saved.is_custom is True
assert saved.system_prompt == "system custom"
assert saved.prompt == "prompt custom"
assert db.commits == 1
effective = await get_effective_prompt(db, "alerts.brief")
assert effective.prompt == "prompt custom"
reset = await reset_prompt_override(db, "alerts.brief")
assert reset.is_custom is False
assert reset.prompt != "prompt custom"
@pytest.mark.asyncio
async def test_prompt_list_marks_custom_items():
db = _PromptSettingsDB(
{
"overrides": {
"bgp.brief": {
"system_prompt": "",
"prompt": "custom bgp prompt",
"updated_at": "2026-05-16T00:00:00Z",
}
}
}
)
prompts = await list_effective_prompts(db)
by_key = {prompt.key: prompt for prompt in prompts}
assert by_key["bgp.brief"].is_custom is True
assert by_key["bgp.brief"].prompt == "custom bgp prompt"
assert by_key["earth.news.enrich"].is_custom is False

View File

@@ -0,0 +1,12 @@
from app.api.v1.settings import _is_secret_placeholder
def test_secret_placeholder_treats_masked_values_as_placeholder():
assert _is_secret_placeholder("••••••••", "••••1234") is True
assert _is_secret_placeholder("********", "") is True
assert _is_secret_placeholder(" * * * ", "") is True
def test_secret_placeholder_preserves_real_keys_with_asterisks():
assert _is_secret_placeholder("sk-live-*real-key*", "") is False
assert _is_secret_placeholder("token_with*embedded*star", "") is False

View File

@@ -0,0 +1,92 @@
{
"schema": "planet-earth-boundary-pov-policy/v1",
"profile": "china-pov-v1",
"description": "Product boundary policy for the China POV Earth boundary build. This file declares intent only; geometry must come from audited source packages and be applied offline before PMTiles/MVT generation.",
"defaultCountryHandling": "source-admin0-with-reviewed-overrides",
"rules": [
{
"id": "china-zangnan",
"name": "Zangnan / South Tibet",
"action": "union_to_country",
"targetIsoA3": "CHN",
"subtractFromIsoA3": ["IND"],
"hoverIsoA3": "CHN",
"labelPolicy": "show_country_only"
},
{
"id": "china-aksai-chin",
"name": "Aksai Chin",
"action": "union_to_country",
"targetIsoA3": "CHN",
"subtractFromIsoA3": ["IND"],
"hoverIsoA3": "CHN",
"labelPolicy": "show_country_only"
},
{
"id": "china-taiwan-penghu",
"name": "Taiwan and Penghu",
"action": "union_to_country",
"targetIsoA3": "CHN",
"hoverIsoA3": "CHN",
"labelPolicy": "show_country_only"
},
{
"id": "china-diaoyu-chiwei",
"name": "Diaoyu Dao, affiliated islands, and Chiwei Yu",
"action": "union_to_country",
"targetIsoA3": "CHN",
"hoverIsoA3": "CHN",
"labelPolicy": "show_country_only"
},
{
"id": "china-south-china-sea-islands",
"name": "Dongsha, Xisha, Zhongsha, Nansha, Huangyan Dao, Zengmu Ansha and related islands/reefs",
"action": "union_to_country",
"targetIsoA3": "CHN",
"hoverIsoA3": "CHN",
"labelPolicy": "show_country_only"
},
{
"id": "china-maritime-claim-line",
"name": "South China Sea dashed maritime claim line",
"action": "render_claim_line",
"targetIsoA3": "CHN",
"geometryRole": "claim_line_only",
"landPolygonEffect": "none"
},
{
"id": "kosovo",
"name": "Kosovo",
"action": "render_as_disputed_with_parent",
"parentIsoA3": "SRB",
"hoverIsoA3": "SRB",
"boundaryStyle": "disputed_internal",
"labelPolicy": "show_parent_country"
},
{
"id": "gaza",
"name": "Gaza Strip",
"action": "render_as_region_of_country",
"targetIsoA3": "PSE",
"hoverIsoA3": "PSE",
"boundaryStyle": "admin_or_disputed",
"labelPolicy": "show_country_only"
}
],
"sourceRequirements": {
"geometryMustBeAudited": true,
"noHandDrawnClaimLines": true,
"noFrontendRuntimePovPatch": true,
"artifactIsolation": "one PMTiles/MVT artifact per POV profile"
},
"officialPositionNotes": [
{
"id": "kosovo",
"note": "China has emphasized respect for Serbia's sovereignty and territorial integrity and the framework of UNSC Resolution 1244."
},
{
"id": "gaza",
"note": "China supports the two-state solution and an independent State of Palestine based on the 1967 borders with East Jerusalem as its capital; Gaza governance should follow Palestinians governing Palestine."
}
]
}

View File

@@ -0,0 +1,112 @@
{
"policy": {
"runtimeFetch": false,
"profile": "china-pov-v1",
"povPolicyPath": "config/earth-boundary-pov-policy.china-v1.json",
"productionTileFormat": "pmtiles+mvt",
"debugTileFormat": "geojson-directory",
"description": "Default Earth boundary update sources. These public Natural Earth endpoints make local high-precision boundary download work out of the box; replace with audited internal sources for production if needed."
},
"collectorConfigs": {
"earth_admin0_boundaries": {
"displayName": "Earth Admin-0 国界源",
"sourceKind": "admin0-boundaries",
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
"method": "GET",
"headers": {},
"auth_type": "none",
"license": "Natural Earth public domain",
"mapping_json": {
"source": {
"items_path": "$.features[*]"
},
"fields": {
"source_id": {
"path": "$.properties.id",
"type": "string"
},
"name": {
"path": "$.properties.name",
"type": "string"
},
"geometry": {
"path": "$.geometry",
"type": "object"
},
"properties": {
"path": "$.properties",
"type": "object"
}
}
}
},
"earth_coastline": {
"displayName": "Earth 海岸线源",
"sourceKind": "coastline",
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
"method": "GET",
"headers": {},
"auth_type": "none",
"license": "Natural Earth public domain",
"mapping_json": {
"source": {
"items_path": "$.features[*]"
},
"fields": {
"source_id": {
"path": "$.properties.id",
"type": "string"
},
"name": {
"path": "$.properties.name",
"type": "string"
},
"geometry": {
"path": "$.geometry",
"type": "object"
},
"properties": {
"path": "$.properties",
"type": "object"
}
}
}
},
"earth_claim_lines": {
"displayName": "Earth 主张线源",
"sourceKind": "claim-lines",
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
"method": "GET",
"headers": {},
"auth_type": "none",
"license": "Natural Earth public domain",
"mapping_json": {
"source": {
"items_path": "$.features[*]"
},
"fields": {
"source_id": {
"path": "$.properties.id",
"type": "string"
},
"name": {
"path": "$.properties.name",
"type": "string"
},
"geometry": {
"path": "$.geometry",
"type": "object"
},
"properties": {
"path": "$.properties",
"type": "object"
}
}
}
}
},
"notes": [
"Earth can download these sources directly from the toolbar settings when no local source override exists.",
"If tippecanoe/pmtiles are unavailable, the backend generates a GeoJSON high-precision package so the feature remains usable."
]
}

View File

@@ -8,6 +8,88 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.59.0] — 2026-05-16
Released: 2026-05-16
### Highlights
- 将 Earth 国界从采集器体系迁移为 Earth 静态资产,恢复低精 GeoJSON fallback并新增 Earth 工具栏高精国界下载/构建进度与热应用。
- 重组后台“运维与配置”:新增 Earth 内容与采集管理二级入口,电视直播、国界精度、采集器、采集调度各归其位,未接入模块以占位页呈现。
- 新增 AI task prompt 覆盖管理,按稳定 task key 管理新闻汉化、告警研判、BGP 简报等业务提示词,避免全局 prompt 污染。
### Added / Fixed / Improved
- Earth 新闻锚点链路增加队列化 enrichment 状态、Redis Streams 后台精修和 WebSocket patch 语义,前端汉化/锚点策略更稳定。
- 国界 hover 与 interactable tooltip 解耦,鼠标位于国家 polygon 内时保持国界高亮,同时卫星/船只/BGP 等对象仍可显示自身信息。
- 新增 `/api/v1/earth/boundaries/*` 状态、配置、构建和进度接口,并在启动初始化中清理旧 boundary datasource/task/snapshot 历史入口。
- 补齐中英文用户手册、FAQ、quickstart、运维手册和开发者上下文文档明确用户 UI、运维操作和开发者稳定边界。
---
## [0.58.0] — 2026-05-15
Released: 2026-05-15
### Highlights
- 新增 Earth 高精度国界 PMTiles/MVT 前端链路,移除旧低精度 GeoJSON 国界兜底,国界缺失时显式报错。
- 将 Earth 边界数据拆成 Admin-0、coastline、claim-lines 三个标准源采集器,并把 `earth_boundary_tiles` 收口为下游 PMTiles 构建器。
- 修复 Earth 远距缩放下海陆基座与高清贴图 z-fighting 导致的雪花/黑块闪烁,并记录地表多层 shell 的深度间距规则。
### Added / Fixed / Improved
- 新增 `earth_boundary_source` 目标 schema、China POV policy 配置、PMTiles readiness/build 脚本和 collector artifact 登记流程。
- Earth 新闻巡航改为优先使用可缓存的目标地点解析队列,并补充媒体新闻归档采集器与回归测试。
- 调整数据源列表任务状态展示,让 Earth 采集器失败/未就绪状态可见,不再表现为“未执行”。
- 更新中英文采集器、数据源设置、Earth 图层顺序、运维 runbook、FAQ、规则和计划文档。
---
## [0.57.0] — 2026-05-14
Released: 2026-05-14
### Highlights
- 新增 WSL `--allow-lan` 临时 Windows relay保持本机 `localhost:3000` / `localhost:8000` 不变,同时用 Windows 局域网 IP 暴露相同端口。
- 启动脚本会检测旧 `netsh interface portproxy` 冲突并请求管理员 PowerShell 清理,避免 `svchost.exe / iphlpsvc` 持久占用 3000/8000。
- 修复 Vite CJS Node API deprecated warning将前端配置迁移到 ESM并让脚本按实际后端端口注入代理目标。
### Added / Fixed / Improved
- 新增 [scripts/windows-lan-relay.ps1](/home/ray/dev/linkong/planet/scripts/windows-lan-relay.ps1),在 Windows 侧启动随 WSL 服务健康状态自动退出的 TCP relay。
- `planet.sh --allow-lan` 仅在 WSL + PowerShell 可用时启用 Windows relay并自动检查 Windows 防火墙规则;非 WSL 环境保持原有路径。
- 更新中英文 README、FAQ 和运维文档,说明旧 portproxy 清理、UAC 防火墙授权、同端口 localhost/LAN 访问和故障恢复方式。
---
## [0.56.0] — 2026-05-13
Released: 2026-05-13
### Highlights
- 修复 Earth 卫星 SGP4 坐标口径,当前点/短尾迹使用地固坐标,锁定预测轨道使用固定地球姿态下的闭合惯性轨道。
- 优化真实高度压缩显示上限,将高轨显示控制在地球半径外约四分之一,保持 GEO/MEO/LEO 分层同时避免轨迹过远。
- 统一 BGP 光晕与图标色调,并更新超算中心建筑图标和 Earth 新闻/HUD 面板体验。
### Added / Fixed / Improved
- 修正卫星详情卡近地点/远地点高度计算,避免把轨道半径误显示为离地高度。
- 调整 BGP event / collector halo 的浅色派生规则,使红色事件、橙色活跃观测站和蓝色 idle 观测站保持各自色相。
- 补充中英文用户手册、FAQ、Earth frontend context、render order、layer style reference 和相关计划文档。
---
## [0.55.0] — 2026-05-13
Released: 2026-05-13
### Highlights
- 新增 Earth 卫星真实高度显示开关,默认按 TLE/SGP4 真实轨道高度压缩分层,关闭后恢复旧版同层球面。
- 将卫星轨迹显示保留在设置面板,并让轨迹、预测轨道和锁定视觉跟随真实高度开关即时刷新。
- 修复 Earth 设置面板 switch 圆点垂直居中问题并同步更新用户手册、FAQ 和 Earth 开发者文档。
### Added / Fixed / Improved
- 新增 `satelliteRealAltitudeEnabled` 本地偏好和真实高度压缩参数,保留 TLE 缺失/传播失败时的 fallback 固定高度。
- 更新卫星渲染高度、轨迹、预测轨道和设置持久化链路,避免同一轨迹混入两套高度模型。
- 补充中英文 manual、quickstart、FAQ、Earth frontend context、render order 和 layer style reference。
---
## [0.54.0] — 2026-05-13
Released: 2026-05-13

View File

@@ -19,7 +19,7 @@
- [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 高精度国界静态瓦片计划](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-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)

View File

@@ -0,0 +1,52 @@
# AI Prompt Settings and Task Registry Plan
## Summary
Add an AI prompt settings tab under the operations AI settings page. Operators can select a business AI task from a dropdown, edit its prompt, save the override, and reset it back to the shipped default. Runtime LLM calls must resolve prompts through a task registry instead of embedding large prompt blocks at each call site.
Default prompts are shipped as versioned resource data, not scattered business-code literals. Business services reference stable task keys, and the runtime resolves the effective prompt from the database override first, then the shipped default resource.
## Key Changes
- Add a backend task prompt registry with stable keys, labels, groups, versions, default system prompts, and default task prompts.
- Store operator overrides in the existing `SystemSetting` table under an `ai_prompts` category. Store only custom overrides; defaults remain in the versioned prompt resource.
- Add settings APIs:
- `GET /api/v1/settings/ai-prompts`
- `PUT /api/v1/settings/ai-prompts/{task_key}`
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
- Migrate business LLM entrypoints to resolve prompts by task key. `aiprovider` remains a pure model adapter and does not inject business prompts.
- Add a “提示词” tab to `/ai`. The tab shows a grouped task dropdown, current/default prompt status, editable prompt fields, save, and reset-to-default controls.
## Initial Tasks
- `earth.news.enrich` — Earth news localization and location enrichment.
- `alerts.brief` — system alert AI brief.
- `alerts.situational.brief` — situational alert AI brief.
- `bgp.brief` — BGP AI brief.
- `location.factcheck.normalize` — location factcheck normalization.
- `location.factcheck.resolve` — location factcheck fallback resolution.
- `datasource.mapping` — datasource mapping DSL generation.
- `credential.guide` — credential guide generation.
- `ai.connection_test` — AI provider connection test.
Playground and public free-form analyze endpoints stay caller-controlled and are not shown in the prompt settings dropdown.
## Test Plan
- Backend uses `uv`:
- `uv run pytest tests/test_settings_ai_prompts.py`
- `uv run pytest tests/test_earth_news.py`
- `uv run pytest tests/test_api.py`
- Frontend uses `bun`:
- `cd frontend && bun run build`
- Manual checks:
- Prompt dropdown switches task content correctly.
- Save persists an override and runtime calls use it.
- Reset deletes the override and restores the shipped default.
- Alert prompts do not leak into news, BGP, datasource, or location tasks.
## Assumptions
- This iteration does not add prompt history, approval workflows, A/B testing, or per-user prompt variants.
- Strict JSON tasks may fail validation if an operator edits away the output contract; existing task-specific failure and retry behavior remains responsible for recovery.
- The UI stays Chinese-only in this iteration.

View File

@@ -13,7 +13,7 @@
| 采集时是否调用 LLM | 不调用;采集链路必须确定性、可审计、可复现 |
| 自定义数据如何进入 Earth | 必须映射到已支持的目标 schema或先进入通用数据沉淀 |
| 外部凭证放置位置 | Settings / 外部集成统一管理 provider tokenDataSources 引用 provider profile |
| TimescaleDB | 放入 TODO高频时序数据稳定后再评估迁移 |
| TimescaleDB | 高频时序数据稳定后再评估迁移 |
---
@@ -57,7 +57,7 @@ flowchart LR
| schema | 用途 | Earth 可视化 |
|-------|------|-------------|
| `vessel_ais` | 船只 AIS 位置、航速、航向、MMSI 等 | 进入船舶图层 |
| `geo_points` | 通用点位数据,包含经纬度、名称、类型、时间 | 进入通用 geo layerTODO |
| `geo_points` | 通用点位数据,包含经纬度、名称、类型、时间 | 未来进入通用 geo layer |
| `news_events` | 新闻/事件类数据,带时间、地点、摘要、来源 | 复用新闻/事件链路 |
| `compute_centers` | 算力中心、机房、数据中心数据 | 复用算力中心图层 |
| `generic_records` | 未知结构化数据沉淀 | 不直接展示 |
@@ -294,7 +294,7 @@ PostgreSQL 可以承载当前规模的采集、关系查询、JSONB 沉淀和基
- 查询模式还没稳定。
- 需要快速迭代 schema 与 mapping。
### TODOTimescaleDB
### TimescaleDB 后续评估
以下条件满足后,再评估 TimescaleDB
@@ -324,7 +324,7 @@ PostgreSQL 可以承载当前规模的采集、关系查询、JSONB 沉淀和基
- Settings 中保存 provider credentials。
- API 返回配置时必须 mask secret。
- LLM prompt 只能包含脱敏 sample 和 schema 说明。
- 后续 TODO引入字段级加密或 KMS。
- 后续引入字段级加密或 KMS。
### Mapping 治理

View File

@@ -31,7 +31,7 @@
3. **登录与找回密码** — 登录页、忘记密码流程
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
5. **Console 总览** — 左侧菜单结构、各路由用途
6. **配置数据采集器**`/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证BarentsWatch / AISStream 两个典型例子
6. **配置数据采集器**`/collection-management?tab=collector_credentials`:选择 collector、连接测试、保存凭证BarentsWatch / AISStream 两个典型例子
7. **配置 AI 凭证**`/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理工具 tabWebSearch、OCR
8. **系统设置**`/settings` 其他子 tab系统设置、电视直播源、SMTP 邮件)
9. **用户管理(管理员)**`/users`创建、删除、改角色、Gatekeeper 权限组
@@ -48,7 +48,7 @@
- 打开管理员给你的 URL
- 注册账号 + 邮箱验证
- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector再到 `/ai` 配模型)
- 登录后第一次做什么(建议先到 `/collection-management?tab=collector_credentials` 配一个 collector再到 `/ai` 配模型)
- 看 Earth
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。

View File

@@ -0,0 +1,71 @@
# Earth High Precision Boundary PMTiles Plan
## Status
Superseded status:
This plan originally treated boundaries as collector-managed source records. The current implementation has moved country boundaries out of the datasource / collector lifecycle. Boundaries are now Earth static rendering assets managed by `Operations and Configuration -> Earth Content -> Boundary Precision` and `/api/v1/earth/boundaries/*`. The bundled low-precision GeoJSON is the default fallback, and high precision is an opt-in local PMTiles build.
Historical implementation notes below are retained only as context and must not be used as the current architecture:
- Three standard source collectors now handle real source ingestion: `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`.
- Each source collector reads endpoint / headers / auth / `target_schema=earth_boundary_source` from Collector Settings, downloads the configured payload, stores the full artifact under `data/earth-boundary-sources/<collector>/<sha256>.*`, and writes a hash / feature-count / artifact-path record to `CollectedData`.
- The backend `earth_boundary_tiles` item is now a downstream PMTiles builder. It refuses to run until the three source records exist, then skips rebuilds when source / POV policy / build config are unchanged.
- The frontend boundary layer now requires the production `pmtiles-mvt` provider and no longer falls back to legacy low-precision GeoJSON.
- Generated loose boundary data is ignored by Git and is not the production deployment format.
- Production PMTiles builds require external `tippecanoe` and `pmtiles` CLIs; missing tools fail the builder clearly instead of registering fake tile records.
Still required before claiming true one-to-one high precision:
- Replace the repository seed GeoJSON with audited high-precision admin boundary, coastline, and claim-line source packages.
- Run a real geometry preparation step that applies the China POV policy through union / subtract / validity repair before PMTiles creation.
- Build and publish `earth-boundaries-china-pov-v1.pmtiles` plus its manifest.
## Summary
The Earth boundary layer should use one static PMTiles archive containing MVT tiles instead of thousands of loose GeoJSON files. The artifact is POV-specific: `earth-boundaries-china-pov-v1.pmtiles` has China POV baked in during offline source preparation, and the browser never patches political boundaries at runtime.
Production should serve a single PMTiles artifact through static hosting and HTTP range requests. In development or on machines that have not opted into high precision, missing PMTiles falls back to the bundled low-precision GeoJSON so the Earth base remains usable.
## Key Implementation Rules
- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Earth Content boundary precision settings; `config/earth-boundary-sources.example.json` remains the versioned example template.
- China POV geometry is applied before tiling:
- Zangnan and Aksai Chin are unioned into China and subtracted from India.
- Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, and South China Sea islands are China hover/country features.
- The South China Sea dashed line is a claim-line layer only; it never consumes Malaysian, Philippine, Vietnamese, or other land polygons.
- Kosovo is not an independent country surface in this profile; Gaza is a Palestine region.
- PMTiles/MVT layer names are fixed for the frontend:
- `boundary_admin0`
- `boundary_disputed_internal`
- `coastline`
- `claim_line`
- The frontend provider is selected from local high-precision preference plus the boundary manifest:
- `tileProvider: "pmtiles-mvt"` reads the PMTiles artifact.
- Missing high-precision preference, missing manifest, or missing PMTiles artifact falls back to low-precision GeoJSON.
- Redis is not part of v1. Static PMTiles plus browser/CDN range caching is the default performance model.
## Cleanup And Documentation
- Do not commit generated loose tiles under `frontend/public/earth/data/boundaries/` or source downloads under `data/earth-boundary-sources/`.
- Remove stale generated debug data before production builds; regenerate it only when smoke testing the debug path.
- Keep the high-level plan, backend collector docs, layer style docs, and ops runbook aligned whenever the provider contract changes.
- After implementation changes, provide user-facing operation steps covering source configuration, artifact build/deploy, page verification, and fallback troubleshooting.
## Verification
- The Earth boundary build API reports missing source configuration or missing tools clearly, without creating datasource collection records.
- The PMTiles builder fails as not ready when source artifacts exist but `tippecanoe` / `pmtiles` are missing.
- Running the PMTiles builder twice returns `unchanged` on the second run when inputs are stable.
- `git add . --dry-run` does not include generated loose boundary tiles or source downloads.
- `/home/ray/.bun/bin/bun run build` passes in `frontend`.
- Manual Earth checks confirm:
- PMTiles range requests are issued only for visible tiles.
- Boundary toggle, hover tooltip, and country highlight still work.
- PMTiles failure reports a high-precision boundary error; machines without high-precision enabled continue drawing low-precision fallback boundaries.
## Assumptions
- "One-to-one" means source-faithful to the selected audited vector source, not hand-tuned to a screenshot.
- The China POV artifact is static and versioned; no runtime region-based POV switching is planned.
- The repository low-precision seed file is retained as the runtime fallback for country boundaries.

View File

@@ -0,0 +1,118 @@
# Earth High Resolution Basemap Tiles Plan
## Summary
High-precision borders now expose a separate visual problem: the vector coastline and border data are more accurate than the current raster Earth texture. The next step is a high-resolution basemap tile layer that aligns visually with the high-precision coastline instead of replacing the globe with one huge static image.
Do not solve this by committing a larger single world texture. A single 16K/32K raster still wastes memory, loads slowly, and becomes blurry or misaligned when zooming into coastal detail. The target architecture is viewport-based raster tiles with cache control, similar to terrain tiles.
## Goals
- Render a high-resolution Earth imagery basemap that visually matches the high-precision coastline and country boundary layer.
- Load imagery by visible bbox / tile key instead of loading a whole-world giant texture.
- Keep the current global texture only as a low-zoom background, not as the source of truth for coastlines at inspection zoom.
- Let imagery failures degrade only the imagery layer; high-precision borders and hover must continue working.
- Keep generated imagery cache out of Git.
## Data Sources
Candidate sources, in recommended order:
- NASA GIBS / Blue Marble / VIIRS style imagery for permissive global coverage and stable tile service behavior.
- Sentinel-2 cloudless style public imagery if licensing and tile access are acceptable.
- A self-hosted raster pyramid generated offline from audited global imagery if third-party online tile terms are unsuitable.
The selected source must document:
- license / attribution
- max zoom and native resolution
- tile matrix / projection
- cache policy
- whether commercial or public deployment is allowed
## Architecture
```text
global low-zoom texture
→ visible Earth bbox from camera raycast
→ Web Mercator tile keys by zoom
→ raster tile fetch/cache
→ project tile image patches onto Earth surface
→ high-precision coastline / border layer remains above imagery
```
Implementation should mirror the existing terrain tile discipline:
- dedupe in-flight requests
- LRU cache for decoded images / textures
- debounce camera movement
- cancel or ignore stale viewport requests
- cap max tiles per frame / per view
- expose loading/error diagnostics
## Rendering Rules
- The high-resolution imagery layer is visual only. It must not define country hover, coastline, or border geometry.
- The high-precision coastline remains the visual alignment reference.
- The border layer render order stays above the basemap imagery.
- Low zoom may use the current global texture for speed.
- Mid/high zoom overlays imagery tiles only for the visible region plus a small prefetch ring.
- Do not draw decorative gradients or fake coastlines to hide mismatch.
## Frontend Work
- Add a new `basemap-imagery.js` module instead of expanding `country-boundaries.js`.
- Add config in `constants.js`:
- source URL template
- attribution
- min/max zoom
- tile cache limit
- debounce interval
- opacity
- enable/disable setting
- Add Earth settings control:
- `高清底图`: off / auto / on
- default `auto`
- Add debug counters for:
- active tile count
- cached tile count
- failed tile count
- current imagery zoom
## Backend / Ops Work
- If using a third-party tile service directly, document attribution and rate-limit behavior.
- If proxying tiles, add backend cache with request coalescing and timeout limits.
- If self-hosting, add an offline builder that writes ignored tile artifacts under a dedicated data directory.
- Update Nginx static serving if self-hosted raster tiles are used.
## Performance Budget
- Desktop target: keep visible imagery tiles under a configurable cap, initially 64.
- Mobile target: lower max zoom and tile cap by default.
- Decode and upload textures incrementally; avoid blocking Earth startup on high-resolution imagery.
- First Earth paint must still use the existing lightweight global texture.
## Verification
- Compare high-precision coastline against imagery in coastal areas such as southeast China, Taiwan, Hainan, the Korean peninsula, Japan, and island chains in the South China Sea.
- Verify zooming / panning does not create visible tile thrash or long blank periods.
- Verify failed imagery requests do not hide borders or break hover.
- Verify memory stabilizes after repeated pan/zoom due to LRU eviction.
- Run `/home/ray/.bun/bin/bun run build`.
## User Operation Steps
After implementation, the user should be able to:
1. Open Earth settings.
2. Set `高清底图` to `auto` or `on`.
3. Open Earth and zoom into a coastline.
4. See imagery tiles refine under the high-precision boundary/coastline layer.
5. Use diagnostics to confirm which imagery zoom and tile source are active.
## Assumptions
- The existing high-precision vector coastline is the alignment reference.
- This plan improves visual texture fidelity; it does not replace the boundary data pipeline.
- A single larger static Earth texture is rejected as the primary solution.

View File

@@ -273,7 +273,7 @@ hover / locked 使用少量 overlay
- 算力中心保留现有业务 icon但接入统一 hover / locked / glow。已完成
- 登陆点曾接入同一套 `Points` 渲染,但 pin 类 SVG 在地球边缘会被深度测试裁切;当前保留专用 `THREE.Sprite`,并使用 canvas 生成黄色扁平球,贴到海缆层级。
- TODO登陆点暂不迁移到完整 Interactable。后续若要统一交互接口优先考虑 Sprite-backed adapter只对齐 `getMarkers()``getPointerIntersections()``setMarkerState()``updateVisualState()` 等外观协议,不强行复用 `THREE.Points`、atlas 和跨图层避让。
- 登陆点暂不迁移到完整 Interactable。后续若要统一交互接口优先考虑 Sprite-backed adapter只对齐 `getMarkers()``getPointerIntersections()``setMarkerState()``updateVisualState()` 等外观协议,不强行复用 `THREE.Points`、atlas 和跨图层避让。
- 检查图例、搜索和 info-card 是否只依赖业务 payload而不是依赖渲染对象类型。
### Phase 4形成 Earth 图标层规范

View File

@@ -37,7 +37,7 @@
## Non-goals
- 不改变桌面端 hover 交互。
-替换 `countries-admin0.min.geojson` 数据源。
-引入旧低精度国界兜底;移动端中心国家能力必须复用生产 PMTiles/MVT 国界源。
- 不新增后端 API。
- 不把国家面填充做成新的 selected country 面状 shader。
- 不为移动端增加永久准星 UI除非后续产品明确需要视觉准星。
@@ -249,4 +249,3 @@ mobileCenterHoverGlowOpacity
3. 性能保护:加入节流、经纬度阈值和禁用态清理。
4. 验证:本地构建通过,移动端 viewport 手动检查通过。
5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。

View File

@@ -0,0 +1,105 @@
# Earth Surface Hover Info Plan
Status: Implemented.
## Goal
为 Earth 桌面和 compact 鼠标地表 hover 增加可配置的提示内容,让用户可以选择只看国家信息、只看位置数据,或同时查看国家和经纬海拔。
当前地表 hover 已经具备两类信息:
- 命中国家时显示国家、ISO、大洲并高亮国界。
- 未命中国家时显示纬度、经度、海拔。
新方案把这两类信息合并成一个清晰的设置项:`悬停提示`
## User-facing behavior
设置项放在桌面设置和移动端设置的 `视图` 区,使用分段控件:
- `国家`陆地命中国家时显示国家名、ISO、大洲太平洋等海洋区域不显示地表 tooltip。
- `位置`:陆地和海洋都显示纬度、经度、海拔;不触发国家 tooltip 和国家边界 hover 高亮。
- `完整`:陆地命中国家时显示国家信息和纬度、经度、海拔;海洋区域显示纬度、经度、海拔。
默认值为 `完整`,因为它保留现有国家识别价值,同时满足 hover 时查看经纬海拔的需求。
海洋在 `国家` 模式下保持沉默,而不是显示大洋名称。原因是当前项目没有海域/大洋边界数据源;用经纬度粗判太平洋、大西洋等范围容易产生误导。如果用户需要海洋位置,使用 `位置``完整`
## Implementation plan
### Settings state
`frontend/public/earth/js/constants.js` 增加:
```js
export const SURFACE_HOVER_INFO_MODES = {
COUNTRY: "country",
POSITION: "position",
FULL: "full",
};
export const DEFAULT_SURFACE_HOVER_INFO_MODE =
SURFACE_HOVER_INFO_MODES.FULL;
```
`frontend/public/earth/js/controls.js`
-`EARTH_SETTINGS_VERSION``10` 升到 `11`
- 在 shared settings 中新增 `surfaceHoverInfoMode`
- 新增导出:
- `getSurfaceHoverInfoMode()`
- `setSurfaceHoverInfoMode(mode, { persist, suppressStatus })`
- normalize 时只接受 `country | position | full`,否则回退到 `full`
- reset settings 后恢复为 `full`
### Settings UI
在桌面设置 `视图` 区和移动端设置 `视图` 区加入同一组按钮:
```html
<button data-surface-hover-info-mode="country">国家</button>
<button data-surface-hover-info-mode="position">位置</button>
<button data-surface-hover-info-mode="full">完整</button>
```
控件同步规则沿用现有卫星显示风格和巡航模块的模式:
- 当前模式按钮添加 `is-active`
- 当前模式按钮设置 `aria-pressed="true"`
- 切换后保存到 Earth settings localStorage。
### Hover tooltip logic
`frontend/public/earth/js/main.js` 的地表 hover 分支中读取 `getSurfaceHoverInfoMode()`,统一构造 tooltip。
行为规则:
- 如果没有命中地球:清除国家 hover 并隐藏 tooltip。
- `position`
- 调用 `clearCountryBoundaryHover()`
- 显示 `纬度 / 经度 / 海拔`
- `country`
- 仅当国界图层开启并命中国家时显示国家 tooltip 和国界 hover。
- 海洋、国界图层关闭、未加载国界数据时隐藏地表 tooltip。
- `full`
- 国界图层开启且命中国家时显示国家信息加位置信息。
- 未命中国家或国界图层关闭时显示位置信息。
海拔继续使用 `sampleElevationAt(lat, lon)`。暂无采样时显示 `—`,不因 hover 主动加载地形瓦片。
## Acceptance criteria
1. `完整` 模式下hover 陆地显示国家信息和经纬海拔hover 海洋显示经纬海拔。
2. `国家` 模式下hover 陆地显示国家信息hover 太平洋等海洋不显示地表 tooltip。
3. `位置` 模式下hover 陆地和海洋都显示经纬海拔,国家边界不高亮。
4. 关闭国界图层后,`完整` 模式回退为只显示位置。
5. 船只、BGP、算力中心、海缆等对象 hover tooltip 优先级不变。
6. 移动端中心国家高亮不受这个鼠标 hover 设置影响。
7. 设置刷新后保持,重置后恢复为 `完整`
## Verification
-`frontend` 下运行 `/home/ray/.bun/bin/bun run build`
- 手动验证三种模式的陆地和海洋 hover 行为。
- 验证设置持久化和重置。
- 验证对象 hover 仍优先于地表 hover。

View File

@@ -1,14 +1,14 @@
# 实时船只监控系统 — 实施计划
**状态**规划中
**状态**历史计划;实时 AIS 与聚合接口已由 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) 接管
**创建日期**2026-04-27
**优先数据源**BarentsWatch AIS免费但需要 OAuth client credentials→ AISHub / MarineTrafficTODO付费
**优先数据源**BarentsWatch AIS免费但需要 OAuth client credentialsAISStream realtimeAISHub / MarineTraffic 保留为付费备选
## 已确认决策
| 项目 | 决策 |
|-----|------|
| 数据源 | BarentsWatch 先行AISHub / MarineTraffic TODO |
| 数据源 | BarentsWatch 先行;AISStream realtime 已成为全球实时补充;AISHub / MarineTraffic 保留为付费备选 |
| 船只规模 | BarentsWatch 阶段全部显示;全球数据接入后按需加船型过滤(默认 Cargo + Tanker + Passenger |
| 更新频率 | 准实时:前端 5 分钟轮询,后端 Collector 每分钟拉取写库 |
| 历史轨迹 | 保留(`vessel_position` 表保留 24h后期按需扩展 |
@@ -23,9 +23,9 @@
| 来源类型 | 典型服务 | 覆盖范围 | 成本 | 状态 |
|---------|---------|---------|------|------|
| **BarentsWatch AIS API** | live.ais.barentswatch.no | 挪威海域实时 | 免费,需要 AIS API client credentials | **当前使用** |
| **AISHub** | aishub.net | 全球实时 | 免费/小额 | TODO付费接入 |
| **MarineTraffic API** | marinetraffic.com | 全球实时 | $50$500/月 | TODO评估 tier |
| **VesselFinder API** | vesselfinder.com | 全球实时 | $50$300/月 | TODO备选 |
| **AISHub** | aishub.net | 全球实时 | 免费/小额 | 付费备选 |
| **MarineTraffic API** | marinetraffic.com | 全球实时 | $50$500/月 | 评估 tier |
| **VesselFinder API** | vesselfinder.com | 全球实时 | $50$300/月 | 备选 |
| **自建 SDR 接收** | RTL-SDR + AIS-catcher | 仅本地 3050km | 硬件 $30 | 不考虑 |
| **NOAA 历史数据** | Marine Cadastre | 美国近海历史 | 免费 | 可用于冷启动 |
@@ -36,16 +36,9 @@
- 字段mmsi, lat, lon, sog, cog, heading, nav_status, name, vessel_type, flag
- 刷新频率:数据约 3060s 更新一次,可随意轮询
### TODO多源 AIS 与实时流接入
### 多源 AIS 与实时流接入历史
- [ ] 接入 AISStream WebSocket 采集器,作为 BarentsWatch 覆盖不足的实时补充
- [ ] 将 BarentsWatch、AISStream、自定义 `vessel_ais` 映射源统一写入原始观测层
- [ ] 通过聚合接口做去重、字段合并、冲突记录和默认来源选择
- [ ] 开放字段级聚合策略配置,让用户决定不同字段优先信任哪个来源
- [ ] 评估 AISHub 订阅(全球覆盖,约 $30/月),接入全球实时流
- [ ] 评估 MarineTraffic API tier对比 AISHub 数据质量与成本
- [ ] 实现多数据源适配器,通过 `datasource_config` 切换
- [ ] 真实高频 AIS 稳定接入后,评估将 `vessel_position` 迁移为 TimescaleDB hypertable保留 Postgres 原生分区作为备选)
AISStream WebSocket collector、`/api/v1/vessels/snapshot``/ws` vessels channel 已在后续计划中落地。仍有价值的后续项集中维护在根目录 [TODO](/home/ray/dev/linkong/planet/TODO.md) 的 AIS / Vessels 小节。
多源 AIS 的详细设计见 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)。
@@ -252,7 +245,7 @@ IMO 9811000
### Phase 4 — 性能与生产化23 天)
- `vessel_position` 按天分区7 天自动清理
- TODO真实数据量达到百万级/日后,`vessel_position` 升级为 TimescaleDB hypertable配置 retention policy 与压缩策略
- 真实数据量达到百万级/日后,再评估是否将船只时序数据升级为 TimescaleDB hypertable配置 retention policy 与压缩策略
- GeoJSON endpoint 用 Redis 缓存 15s
- 若需 bbox 精确查询,引入 PostGIS `geography` + `ST_DWithin`
- InstancedMesh + frustum culling目标 5 万船只 60fps

View File

@@ -0,0 +1,131 @@
# EarthFeed 新闻坐标与异步精修计划
## 目标
EarthFeed 的每条新闻都直接携带巡航可用坐标。初始响应使用新闻所属大区的锚点坐标,后台通过消息队列异步推理更精确的目标地址,完成后用实时补丁替换原新闻坐标,实现前端无感更新。
## 返回结构
`GET /api/v1/news/earth-feed` 返回:
```json
{
"generated_at": "2026-05-15T03:16:43Z",
"focus": {
"lat": null,
"lon": null,
"region": "global",
"label": "全球焦点",
"accent": "#d6e6ff"
},
"sources": [
{
"id": "bbc-world",
"name": "BBC World",
"region": "global",
"homepage_url": "https://www.bbc.com/news/world"
}
],
"items": [
{
"id": "bbc-world:af01519ba7dd",
"title": "Flattery and fanfare as Trump welcomed to China - but thorny issues remain",
"summary": "The leaders of the world's two superpowers were all smiles...",
"url": "https://www.bbc.com/news/articles/cdxpypg9dgeo",
"source": "BBC World",
"feed_name": "BBC World",
"region": "global",
"homepage_url": "https://www.bbc.com/news/world",
"published_at": "2026-05-14T13:02:13Z",
"latitude": 39.9057136,
"longitude": 116.3912972,
"location_label": "北京市, 中国",
"location_source": "headline_location_hint",
"verified": true,
"location_meta": {
"resolution_stage": "headline_location_hint",
"ai_attempted": false,
"ai_status": "skipped_text_hint",
"ai_error": null,
"debug_note": "text hint matched 北京市, 中国",
"target": {
"latitude": 39.9057136,
"longitude": 116.3912972,
"label": "北京市, 中国",
"source": "headline_location_hint",
"confidence": 0.78,
"country": "中国",
"city": "Beijing"
},
"anchor": {
"region": "global",
"label": "全球",
"latitude": 20.0,
"longitude": 0.0
}
},
"is_focus_match": true
}
],
"errors": [],
"stale": false
}
```
字段规则:
- `latitude` / `longitude`:前端巡航唯一读取的坐标。
- `location_label`:当前坐标展示名。
- `location_source``region_anchor``headline_location_hint``headline_country_hint``ai_inferred_target` 等。
- `verified``false` 表示仍是大区锚点;`true` 表示已经由标题规则、国家规则或 AI 得到目标地址。
- `location_meta`调试、诊断、AI 状态、目标地址和锚点详情都放这里,不再展开成 `t_*` 主字段。
## 后台队列
当前使用 Redis Streams
- stream`earth_news:target_location:jobs`
- consumer group`earth_news_target_location`
- result cache`earth_news:target_location:result:{item_id}`
- dedupe key`earth_news:target_location:queued:{item_id}`
请求流程:
1. RSS 拉取并排序。
2. 每条新闻先生成大区锚点坐标,`verified=false`
3. 若 Redis 已有该新闻的精修结果,则合并结果返回。
4. 若没有精修结果,则把新闻 job 入队,接口立即返回。
Worker 流程:
1. 从队列消费新闻 job。
2. 先跑标题/国家规则,再视情况调用 AI。
3. 写入 result cache。
4. 广播 WebSocket 补丁:
```json
{
"type": "data_frame",
"channel": "earth_news",
"timestamp": "2026-05-15T03:17:00Z",
"payload": {
"item_id": "bbc-world:af01519ba7dd",
"patch": {
"latitude": 39.9057136,
"longitude": 116.3912972,
"location_label": "北京市, 中国",
"location_source": "headline_location_hint",
"verified": true,
"location_meta": {}
}
}
}
```
## 可迁移性
业务代码只调用队列接口,不直接依赖 Redis Streams 细节。以后迁移 Kafka 时新增 Kafka adapter保持 job payload、result patch 和 worker 推理逻辑不变。
## 前端规则
新闻面板和巡航都只读取新闻项内的 `latitude` / `longitude`。实时补丁到达后按 `item_id` 合并到现有 `payload.items`,重新渲染并触发 `earth:news-payload-updated`,巡航下一轮自然使用精修坐标。

View File

@@ -95,9 +95,18 @@ The AI settings page uses:
- `POST /api/v1/settings/integrations/ai-provider/connect`
- `GET /api/v1/settings/integrations/ai-provider/secrets`
- `GET /api/v1/settings/integrations/ai-provider/presets`
- `GET /api/v1/settings/ai-prompts`
- `PUT /api/v1/settings/ai-prompts/{task_key}`
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview.
The `ai-prompts` endpoints back the Prompts tab in AI settings. Shipped defaults come from versioned backend resources, while business code references stable task keys. The API stores only operator overrides. Resetting a prompt removes the override and falls back to the current shipped default.
### Prompt Boundary
`aiprovider` is a pure model adapter and does not inject a global business system prompt. News localization, alert briefing, BGP briefing, location factcheck, datasource mapping, and credential guide generation each resolve their own effective prompt by task key. Alert-analysis system prompts are only sent by alert-related tasks and do not leak into other LLM calls.
### AI provider internal API
Internal-only endpoints:
@@ -287,7 +296,6 @@ SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
Optional provider-specific keys:

View File

@@ -89,6 +89,8 @@ async def run(self, db):
AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table.
Earth boundaries are no longer data collectors. They are Earth static rendering assets: the Earth Assets settings panel owns source configuration, and `/api/v1/earth/boundaries/*` builds `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`. When no high-precision PMTiles artifact is available locally, the frontend uses the bundled low-precision GeoJSON fallback and does not write boundary records to `CollectedData`.
TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh.
## IV. Data Format (stored in CollectedData table)
@@ -219,7 +221,8 @@ backend/app/services/collectors/
├── peeringdb.py # PeeringDB collector
├── telegeraphy.py # TeleGeography submarine cable collector
├── vessel_ais.py # BarentsWatch AIS vessel collector
── aisstream.py # AISStream WebSocket vessel collector
── aisstream.py # AISStream WebSocket vessel collector
└── earth_boundaries.py # Earth boundary source verification and static tile artifact collector
backend/app/services/
├── custom_datasource_runtime.py # Custom REST / WebSocket mapping runtime
@@ -297,7 +300,7 @@ State semantics:
- `reconnecting`: upstream or network interruption; the collector records `AISSourceHealth` and waits before reconnecting.
- `stopped` / `cancelled`: stopped by a test limit or user action.
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Collection Management -> Collectors -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
The console manages AISStream from `/datasources -> Realtime Streams`, not from the normal finite collection progress bar. The realtime stream API aggregates runtime state, health, configuration preview, and raw observation counters:
@@ -365,9 +368,9 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l
`/api/v1/data-products/*` is for aggregate panels and keeps a global statistics scope independent of the map bbox. `/api/v1/layers/*` is for map rendering, requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`; low zoom falls back to a smaller response cap and reports `degraded`, `truncated`, `limit_clamped`, and `stats_scope=viewport` in `diagnostics`. Non-vessel layers currently reuse the existing GeoJSON converters before the guard layer; future product-specific queries can push bbox filtering deeper.
## X. Collector Settings And Connectivity Validation
## X. Collectors And Connectivity Validation
The console "Collector Settings" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling:
The console "Collectors" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling:
- endpoint
- auth type
@@ -388,7 +391,7 @@ POST /api/v1/settings/credential-guides/{provider}/generate
POST /api/v1/settings/credential-guides/{provider}/reset
```
See [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
See [Collectors and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
## XI. Data Usage

View File

@@ -8,12 +8,12 @@ The console now separates the "data source catalog" from "collector configuratio
- Lists all data sources, including built-in and custom sources.
- Clicking a name only opens an information drawer.
- Focuses on status, manual collection, and running collection tasks.
- `/settings?tab=collector_credentials`
- Displays as "Collector Settings".
- `/collection-management?tab=collector_credentials`
- Displays as "Collectors".
- Owns endpoint, headers, base parameters, and credentials.
- Every collector exposes a connection button for health checks.
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to collector settings instead of being scattered across the data source list and system settings.
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to Collectors instead of being scattered across the data source list and system settings.
## User-Facing Rules
@@ -53,7 +53,7 @@ Current behavior:
`data-source-bulk-toolbar__running-pill` is the styling entry point for the "Collecting" pill. It is aligned with other status tags, while hover treatment, arrow affordance, and blue outline indicate interactivity.
### Collector Settings
### Collectors
File:
@@ -61,7 +61,7 @@ File:
Current behavior:
- The `collector_credentials` tab is displayed as "Collector Settings".
- The `collector_credentials` tab is displayed as "Collectors" under `/collection-management`.
- A select lists built-in collectors and supports maintaining custom supplemental sources that merge into built-in data.
- The only button beside the select is a plug icon for health checks.
- Status tags below the select show:
@@ -307,6 +307,8 @@ Files:
Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel.
Earth high-precision boundaries no longer use custom-source target schemas. Boundaries are Earth static assets: the Earth Assets settings panel saves local source configuration and triggers PMTiles builds without writing records to `CollectedData`.
### Configuration Semantics
Important fields:
@@ -316,7 +318,7 @@ Important fields:
- `auth_type`: `none`, `bearer`, `api_key`, or `basic`.
- `headers`: static request headers.
- `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query.
- `config.target_schema`: for example `vessel_ais`.
- `config.target_schema`: for example `vessel_ais`, `geo_points`, or `generic_records`.
- `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`.
- `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`.

View File

@@ -157,6 +157,7 @@ Current design:
- symbol-driven event cores
- outward ring pulses
- reduced diffuse glow compared with older Earth builds
- event region halos, collector coverage halos, and radar pulses derive a lighter tint from their own icon color instead of using a fixed teal; red high-severity events, orange active collectors, and blue idle collectors keep their hue family while broad halos stay softer than the icon
5. The right-side stats now show:
- BGP events
- collector count

View File

@@ -122,6 +122,9 @@ Responsibilities:
- Globe sphere, cloud layer, atmosphere
- Real terrain mesh
- Terrain tile fetch, decode, displacement, and shading
- Whole-globe land/ocean and border base overlays
The Earth surface is a stack of near-concentric shells, not a single mesh. The base sphere and HD texture overlay in `earth.js`, plus the land/ocean base in `country-boundaries.js`, need explicit radius separation. At far zoom, GPU depth precision drops; neighboring shells that are too close can z-fight and show black flicker blocks or snow. The current stable spacing is `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`. When adding or adjusting whole-globe surface overlays, update [Earth Render Layer Order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md) and verify at 50% zoom.
### 7. Layer Modules
@@ -205,7 +208,15 @@ Terrain should not block startup when it is not the restored visible layer. Afte
Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset.
Settings that affect visual layers (terrain opacity, day/night mode, satellite display style, etc.) are read during initialization and applied immediately.
Settings that affect visual layers and surface interaction (terrain opacity, day/night mode, satellite display style, satellite idle breathing, real satellite altitude, track display, hover tooltip mode, etc.) are read during initialization and applied immediately.
The surface hover tooltip preference is persisted by `controls.js` as `shared.surfaceHoverInfoMode`, while `main.js` composes the actual tooltip in the globe-surface hover branch. `Country` shows country details only when a country polygon is hit and stays silent over ocean; `Position` shows latitude, longitude, and sampled terrain elevation and clears country-boundary hover; `Full` shows country + position on land and position over ocean.
The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. `maxRealAltitudeOffset = 25` is a visual cap tuned for the current camera and `earthRadius = 100`: GEO / MEO remain clearly higher than LEO, but the highest orbits stay within about 25% beyond the globe radius so selection targets, red trails, and the globe do not feel disconnected.
SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for the whole future orbit, projecting the inertial orbit plane onto the current globe pose; that keeps the line closed and keeps the visual orbit inclination aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude.
Boundary precision is stored separately by `country-boundaries.js` under `planet.earth.boundaries.highPrecisionEnabled`. When high precision is off, Earth keeps using the bundled low-precision `countries-admin0.min.geojson` fallback even if high-precision manifest/PMTiles files exist locally. When high precision is on but the artifact is missing, the Earth toolbar settings call `/api/v1/earth/boundaries/build` and poll progress. After success, `reloadCountryBoundaries()` hot-swaps the boundary layer without refreshing the page. Boundary hover is independent from interactable hover: a country polygon remains highlighted whenever the surface coordinate is inside it, while the tooltip can still prioritize a satellite, vessel, BGP marker, or other interactable.
## Current Terrain Pipeline

View File

@@ -23,7 +23,7 @@ This document records the material, color, opacity, line width, radius offset, a
| Earth base specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
| Earth base shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
| Earth base opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | Standalone HD texture sphere radius |
| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | Standalone HD texture sphere radius; must keep enough depth separation from the land/ocean base and Earth base sphere to avoid far-zoom z-fighting |
| HD texture opacity | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | HD texture `MeshPhongMaterial.opacity` |
| HD texture renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
| HD texture specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | Reduces specular highlight in direct-light areas to avoid blown-out texture |
@@ -78,11 +78,19 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Country border data path | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON input |
| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | High-precision PMTiles manifest; missing manifest uses the low-precision fallback |
| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | Prefer high-precision PMTiles/MVT, then fall back to legacy GeoJSON |
| PMTiles artifact path | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | Production single-file PMTiles/MVT artifact |
| Low-precision fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | Default land/ocean base and hover data when no high-precision boundary asset has been built locally |
| MVT layer names | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | Fixed layer names decoded by the PMTiles provider |
| Boundary tile base path | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest base path |
| Boundary tile zoom thresholds | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | Production PMTiles zoom selection |
| Boundary tile cache limit | `COUNTRY_BOUNDARY_CONFIG.tileCacheLimit` | `150` | Frontend LRU cache entries for loaded tile geometries |
| Boundary tile debounce | `COUNTRY_BOUNDARY_CONFIG.tileDebounceMs` | `180` | View-change debounce before requesting visible tiles |
| Ocean fill color | local `OCEAN_HEX` | `0x010609` | Land/ocean base canvas background |
| Land fill color | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | Land/ocean base canvas land |
| Land/ocean base opacity | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` radius |
| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.32` | `country-land-ocean` radius; separated from the Earth base sphere to avoid snow / black block flicker at 50% zoom |
| Land/ocean base renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
| Land/ocean mask size | `landMaskWidth / landMaskHeight` | `2048 / 1024` | Canvas / DataTexture size |
| Country tint color | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | Tint when HD texture is off |
@@ -91,16 +99,16 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
| Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line |
| Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity |
| Border dimmed opacity on hover | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | Normal border opacity during hover |
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; slightly above HD texture `0.10` and below terrain base `0.16` to reduce floating |
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; line layers rely on renderOrder and independent geometry, not whole-globe shell depth spacing |
| Border line renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | Normal border line level |
| Border hover color | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | Neon red-orange |
| Border hover opacity | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | Hover line opacity |
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | Hover line radius; close to the surface but above normal border lines |
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.115` | Hover line radius; matches the normal border geometry to avoid double-edge ghosting during highlight changes |
| Border hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | Hover line level |
| Border hover glow opacity | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | Glow line opacity |
| Border hover glow line width | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | Glow `LineBasicMaterial.linewidth` |
| Border hover glow level offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | Glow renderOrder = `2.29` |
| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | Glow radius = hover radius + 0.04 |
| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0` | Glow uses the same radius as the hover line to avoid coastline detail misalignment |
## Real Terrain
@@ -160,10 +168,13 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Satellite display radius offset | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | Satellite point position |
| Satellite fallback radius offset | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | Legacy same-sphere position when real altitude is disabled or TLE propagation fails |
| Satellite altitude compression scale | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 altitude compression; larger values soften high-orbit separation |
| Satellite real-altitude cap | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | Altitude clamp for the compressed display mapping, covering GEO-range altitude |
| Satellite real-altitude display offset range | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 25` | Compressed offset added to `CONFIG.earthRadius`; `25` is about one quarter of the current globe radius `100`, keeping high-orbit separation visible without pushing GEO/MEO trails too far away |
| Satellite dot base pixel size | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | Point shader size |
| Satellite backdrop dot scale | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | Backdrop dot size |
| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | Breathing animation |
| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | Breathing animation |
| Satellite dot breathing speed | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | Dot opacity animation |
| Satellite backdrop renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
| Satellite dot renderOrder | inline | `6` | `satellitePoints.renderOrder` |
@@ -230,7 +241,9 @@ AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel
| Medium color | `BGP_CONFIG.severityColors.medium` | `0xffd166` | Medium-severity event |
| Low color | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | Low-severity event |
| Collector base color | `BGP_CONFIG.collectorColor` | `0x6db7ff` | Default collector color |
| Region color | `BGP_CONFIG.regionColor` | `0x2dd4bf` | Region overlay |
| BGP halo neutral tint | `BGP_CONFIG.halo.tintNeutralColor` | `0xffffff` | Broad halos derive from the matching event / collector icon color and blend toward this neutral, keeping the same hue family without becoming identical |
| BGP halo icon-color blend | `BGP_CONFIG.halo.tintBlend` | `0.72` | Higher values stay closer to the icon color; currently used by event region halos, collector coverage halos, and collector radar pulse |
| Region color | `BGP_CONFIG.regionColor` | `0x2dd4bf` | Legacy region baseline; broad BGP halos now prefer colors derived from the event or collector icon |
## Celestial and Starfield

View File

@@ -103,7 +103,7 @@ Goals:
## Collector Configuration
`news_live_streams` does not need a separate new page; it reuses Collector Settings under `/settings`:
`news_live_streams` does not need a separate new page; it reuses Collectors under `/collection-management`:
- `endpoint`
- Channel directory JSON API URL

View File

@@ -6,8 +6,8 @@ Note: the layer control panel order and the registration / startup load order ar
| Order type | Current sequence | Notes |
| --- | --- | --- |
| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Border Lines → Grid | Controlled by `displayOrder`, sorted by operational relevance. |
| Registration / startup load order | Grid → Border Lines / Land-Ocean Base → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; the startup queue reads persisted layer visibility first, skips normal layers explicitly saved as hidden, and HD Texture does not download the texture when disabled; Border Lines are the exception: the land-ocean base always preloads, while the persisted state only controls interactive border lines and hover; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. |
| Control panel order | Cables → Satellites → Compute Centers → Vessels → BGP → Terrain → HD Texture → Cloud Layer → Border Lines → Grid | Controlled by `displayOrder`, sorted by operational relevance; satellite trails moved to Settings and are no longer a layer-list item. |
| Registration / startup load order | Grid → Border Lines / Land-Ocean Base → HD Texture → Cloud Layer → Cables → Compute Centers → Vessels → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; the startup queue reads persisted layer visibility first, skips normal layers explicitly saved as hidden, and HD Texture does not download the texture when disabled; Border Lines are the exception: the land-ocean base always preloads, while the persisted state only controls interactive border lines and hover; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. |
## Surface Layer Stack
@@ -17,8 +17,8 @@ Note: the layer control panel order and the registration / startup load order ar
| -1 | Earth occluder sphere | `earth.js` | Invisible inner sphere | Writes depth buffer | Occludes objects behind the Earth. |
| 0 | Earth base sphere | `earth.js` | `CONFIG.earthRadius` | Surface picking fallback target | Dark base; still visible when all optional map layers are off. |
| 0.2 | Country dark tint | `country-boundaries.js` | `tintAltitudeOffset` | Raycast disabled | Used when HD texture is off. |
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off. |
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset` | Surface picking target when visible | HD texture always overlays the land/ocean base fill. |
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. |
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
| 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. |
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
@@ -29,9 +29,9 @@ Note: the layer control panel order and the registration / startup load order ar
| 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. |
| 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. |
| 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. |
| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Below satellite dots. |
| 6 | Satellite dots | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets | Satellite overlay path | Used for selected/locked satellite emphasis. |
| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..25`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. |
| 6 | Satellite dots | `satellites.js` | Same compressed / fallback height as satellite backdrop dots | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw a closed inertial orbit; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
| 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. |
## Toggle Behavior
@@ -43,6 +43,18 @@ Note: the layer control panel order and the registration / startup load order ar
| Terrain on | Displayed above HD texture, but below country border hover, footprints, satellites, and other emphasis layers. |
| Cloud layer | Only controls cloud mesh visibility. |
| Border Lines off | Hides only interactive border lines and hover, clearing hover state; the land/ocean base fill remains as the Earth base map. |
| Real Satellite Altitude off | Satellite dots, trails, and predicted orbits use the legacy same-sphere height; satellites with missing TLE data or failed propagation also use this fallback height. |
## Depth Spacing Rules
The Earth surface is not a single mesh. It is a stack of near-concentric shells: base sphere, land/ocean base, HD texture, terrain, clouds, atmosphere, and the occluder. Radius offsets that look harmless at close zoom can collapse into the same depth-buffer pixels at zoomed-out views such as 50%, causing z-fighting that appears as black blocks, snow, or flicker.
Maintenance rules:
- Do not reach first for hiding layers at far zoom. Check neighboring shell `altitudeOffset`, `renderOrder`, `depthTest`, and `depthWrite` first.
- Whole-globe overlays such as the land/ocean base and HD texture must keep explicit separation from `CONFIG.earthRadius`; the current stable values are `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`.
- Any new whole-globe or near-whole-globe surface overlay must be screenshot-verified at 50% zoom and at common close zooms, with no black blocks, snow, flicker, or obvious floating.
- If these radii change, update this document and the intent around the constants in `frontend/public/earth/js/constants.js`.
## Interaction Rules

View File

@@ -55,12 +55,12 @@ tasklist /svc /fi "PID eq 4700"
For temporary troubleshooting, you can stop IP Helper from Administrator PowerShell:
```powershell
Stop-Service iphlpsvc
Stop-Service iphlpsvc -Force
```
This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. If the Windows forwarding rule must stay, use a different Planet backend port.
This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. `--allow-lan` now binds `3000` / `8000` / `8010` directly, so persistent portproxy is no longer required.
If the script prints `failed-stop-service` or `failed-stop-process`, the current shell does not have permission to clear the Windows listener. Startup stops immediately instead of launching the backend into the same port conflict.
If the script prints `Windows 侧端口 ... 存在监听者`, or Vite reports `Port 3000 is already in use` followed by `Windows listener ... services=iphlpsvc`, an old Windows listener still owns the port. The script requests Administrator PowerShell cleanup for that port. If the automatic cleanup is canceled, inspect `netsh interface portproxy show all`, delete the matching `listenport` rule, confirm the PID and services with `netstat` / `tasklist` if no portproxy rule exists, and temporarily run `Stop-Service iphlpsvc -Force` when appropriate. After old rules are gone, rerun `./planet.sh restart --allow-lan`; LAN devices still use `3000` / `8000` / `8010`.
### Which startup flags change default ports?
@@ -87,6 +87,7 @@ Check in this order before changing firewall rules:
# In WSL or the shell running Planet
curl http://localhost:3000
curl http://localhost:8000/health
curl http://localhost:8010/health
```
Then verify from Windows PowerShell:
@@ -94,6 +95,7 @@ Then verify from Windows PowerShell:
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
curl http://localhost:8010/health
```
If both localhost checks pass but a phone or another computer cannot connect, start with LAN enabled:
@@ -108,22 +110,28 @@ The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not rec
./planet.sh restart -f 3000 --allow-lan
```
If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection <Windows LAN IP> -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side forwarding or firewall policy rather than Vite or `.zshrc`.
If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection <Windows LAN IP> -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side port ownership, stale `portproxy`, or firewall policy rather than Vite or `.zshrc`.
For traditional WSL NAT networking, configure portproxy and firewall from Administrator PowerShell:
`./planet.sh start --allow-lan` directly exposes `3000` / `8000` / `8010` and checks port availability, stale `portproxy`, and Windows Firewall before startup. If a Windows-side listener owns a port, the script requests Administrator PowerShell cleanup. When inbound allow rules are missing, it also triggers a UAC Administrator PowerShell request to create them. If the automatic request is canceled, clean up manually:
```powershell
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
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 delete v4tov4 listenaddress=0.0.0.0 listenport=8010
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
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
```
LAN devices should use the Windows external port, for example `http://<Windows LAN IP>:3000/earth`.
If `wslinfo --networking-mode` prints `mirrored`, also check Hyper-V firewall. Even when ordinary Windows Firewall rules exist, Hyper-V firewall can still block external devices from reaching WSL. From Administrator PowerShell, allow the required ports:
```powershell
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
New-NetFirewallHyperVRule -Name "Planet-AIProvider-8010" -DisplayName "Planet AI Provider 8010" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8010 -Action Allow
```
Use these commands to inspect the current Hyper-V firewall state:
@@ -286,7 +294,7 @@ If only AI Provider is unhealthy, restart just that service:
### Connectivity validation passes, but collection cannot read credentials. Why?
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Settings -> Collector Settings, especially for AISStream's long-lived WebSocket collector.
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Collection Management -> Collectors, especially for AISStream's long-lived WebSocket collector.
If `AISSTREAM_API_KEY` only lives in `~/.zshrc`, confirm the backend process actually inherited it. Otherwise validation may pass while the collector runtime has no key.
@@ -300,7 +308,7 @@ export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
For stable operation, save credentials in Collector Settings so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
For stable operation, save credentials in Collectors so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
## Docs / Permissions
@@ -315,12 +323,31 @@ Docs visibility is controlled by Gatekeeper groups:
## Earth Common Tasks
### Why does Earth say the boundary endpoint is not configured, or only show low precision boundaries?
Country boundaries have moved out of the collector system. They are no longer generated by datasource collection tasks. The low-precision boundary file is bundled with the frontend and is the expected fallback when no local high-precision PMTiles artifact exists.
There are two high-precision entry points:
- Earth page settings gear -> Boundary Precision: switching to High Precision starts the first background download/build, shows percentage progress, and applies the result automatically.
- Console `Operations and Configuration -> Earth Content -> Boundary Precision`: use this to inspect provider, manifest, PMTiles, fallback state, edit source JSON, or rebuild manually.
If the UI says the update source is incomplete, save the source configuration from `Earth Content -> Boundary Precision`. The private local config is written to `config/earth-boundary-sources.local.json`; do not commit it. Falling back to low precision is normal when no high-precision artifact has been built.
### Why did collecting a location candidate not write anything?
Collecting and saving are two separate actions. Candidates can be previewed on Earth first. A candidate is written only after clicking Save or using the unresolved list's one-click adopt flow.
Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint.
### Why are satellites no longer on one sphere?
Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. The maximum display offset is `25`, about one quarter of the current globe radius; this is a readability compromise that separates GEO / MEO / LEO without drawing real kilometers to scale. This setting also affects satellite trails and the predicted orbit shown after locking a satellite.
Turn off "Real Satellite Altitude" in Earth Settings to restore the legacy same-sphere satellite display. Satellites with missing TLE data or failed propagation still fall back to the legacy fixed height, so they do not disappear just because a real altitude cannot be computed.
Low-inclination high-orbit satellites should stay near the equator or a fixed longitude band, not form a near north-south ring. Earth converts the SGP4 inertial position to Earth-fixed coordinates for the current dot; the locked predicted orbit fixes the current globe pose and draws one inertial orbit, so it should close and preserve the correct inclination. If a future details card shows inclination near `0°` but the predicted orbit looks polar, check the ECI/TEME-to-ECF conversion and whether the predicted orbit accidentally uses per-sample `gstime`.
### Why does Motion Debug not show camera video?
With the Browser Camera source, the debug panel shows the local browser camera preview and draws the skeleton over it. If `Skeleton Only` is enabled, the video preview is hidden and the panel keeps only the dark canvas plus red/green skeleton.

View File

@@ -33,6 +33,8 @@ Current admin-related routes:
- `/alerts/situational`
- `/bgp`
- `/ai`
- `/earth-content`
- `/collection-management`
- `/settings`
`/earth` is a standalone display page and is not part of the console shell.
@@ -277,6 +279,42 @@ Constraints:
- Do not let tables blow out the full page
- New table areas should reuse `TableScrollRegion` / `ScrollbarOverlay`
### Datasource Directory Page
[DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) is the datasource directory and collection operation page. It should not grow back into a configuration editor.
Current page boundary:
- Built-in and custom sources are merged as `UnifiedDataSource`.
- The list shows type, state, last run, collection progress, and actions.
- Clicking a name opens a read-only drawer.
- Endpoint, headers, and config are displayed here, not edited.
- Credential-bearing collectors point users to `Collection Management -> Collectors`.
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?tab=collector_credentials`.
### Collectors Page
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` tab is shown as `Collectors` under `/collection-management`.
Current boundary:
- The dropdown selects built-in collectors.
- The plug icon beside the dropdown runs the health check.
- Credential-bearing collectors place credential forms above base config.
- Free collectors show endpoint, default endpoint, headers, timeout, and retry.
- BarentsWatch AIS keeps its dedicated credential form.
### Earth Content Page
`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), but its ownership is separate from System Settings:
- `TV Livestream` owns the Earth media-panel source configuration.
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.
Do not add Earth experience resources or collection-lifecycle tabs back into `/settings`; collection belongs to `/collection-management`, and Earth display resources belong to `/earth-content`.
### 3. Complex Workspace Pages
Examples:

View File

@@ -76,15 +76,17 @@ The console at `http://localhost:3000/admin` is built with React + Ant Design. T
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
| Situational Alerts | `/alerts/situational` | Situational analysis alerts |
| AI | `/ai` | Model providers, tools, testbench |
| Earth Content | `/earth-content` | TV livestreams, boundary precision, base-map and layer-resource entry points |
| Collection Management | `/collection-management` | Collectors, scheduling, collection history entry points |
| Logs | `/logs` | Usually visible only to super admin |
| Users | `/users` | Create/delete users, change roles/groups |
| Settings | `/settings` | System, SMTP, TV, collectors |
| System Settings | `/settings` | Display, notification, security, SMTP |
Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups.
## Configure Data Collectors
`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials.
`/collection-management?tab=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials. Legacy `/settings?tab=collector_credentials` redirects here; the datasource directory remains at `/datasources`.
Steps:
@@ -125,7 +127,7 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
Steps:
1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
1. Open `/collection-management?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
2. Fill the AISStream API Key
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
4. Click the plug icon to test; confirm it reports `Reachable`
@@ -139,10 +141,11 @@ Steps:
## Configure AI Credentials
`/ai?tab=providers` is the AI management entry. Two key sub-tabs:
`/ai?tab=providers` is the AI management entry. Three key sub-tabs:
- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test
- `Tools`: a dropdown for specific tools — currently WebSearch and OCR
- `Prompts`: a task dropdown for news localization, alert analysis, BGP briefs, and other LLM tasks. Operators can edit the prompt or reset it to the default
### Model Providers
@@ -163,6 +166,10 @@ The plug icon at the end of the Base URL input runs a connection test. A passing
- **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out
- **OCR**: provider, base URL, API key, model/engine, recognition languages, timeout, max file size, output format
### Prompts
After selecting a task, the page shows the effective prompt, whether it is customized, the shipped default version, and a reset button. Saving affects only that task. Reset restores the default prompt from the current release package. Business facts, context, and output schemas are still assembled by the backend for each task.
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
## System Settings
@@ -173,8 +180,26 @@ The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
- **Notifications**: alert email switch, recipient, critical/warning/daily summary
- **Security**: session timeout, max login attempts, password policy
- **SMTP Email**: outgoing email used by registration and password reset (visible to `admin` / `super_admin` only)
- **TV Livestream**: TV source management
- **AI / WebSearch / OCR**: see above
TV livestreams and boundary precision moved to `/earth-content`; collectors and scheduling moved to `/collection-management`; AI Provider / WebSearch / OCR live at `/ai`.
### Earth Content
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
- **TV Livestream**: manages sources shown in the Earth media panel.
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action.
- **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference.
### Collection Management
`/collection-management` is also under Operations and Configuration and owns the collection lifecycle:
- **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks.
- **Collection Scheduling**: the existing scheduling configuration.
- **Collection History / Snapshots**: a placeholder for future collection task, snapshot, and collected-data browsing.
### SMTP Email Settings
@@ -204,7 +229,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
## Data Exploration
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/collection-management -> Collectors`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records"
- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts
@@ -229,7 +254,7 @@ A single globe view of: BGP events and observations, satellites and tracks, cabl
### Layer Control
The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, tracks, terrain.
The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, terrain. Satellite tracks are no longer listed as a standalone layer; they are controlled from Settings.
Dependencies:
@@ -255,7 +280,11 @@ Candidates preview on Earth directly. Saving a compute-center candidate writes t
### Settings
The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset.
The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset.
"Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. The high-orbit display height is capped at about one quarter of the globe radius, so GEO / MEO objects remain visually separated from LEO without spreading trails and selection targets too far apart. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden.
"Hover Tooltip" controls the tooltip shown when the pointer hovers over the globe surface: `Country` shows country details only when land matches a country, and stays silent over oceans such as the Pacific; `Position` shows latitude, longitude, and elevation over land and ocean; `Full` is the default and shows country + position over land and position over ocean.
These settings live in browser local storage; switching browsers or clearing site data resets them.
@@ -310,6 +339,7 @@ Mobile uses a drawer layout: layer control moves into a drawer; search/settings/
- **Earth does not open**: confirm the frontend is online; if not on port `3000`, use the port printed by the startup log
- **Layers have no data**: open `/datasources` to check source status, collected-record state, and the latest run result; then `/data` or `/bgp` for records
- **Satellites / BGP / cables load slowly**: those layers depend on backend APIs and external data sources; the first load waits for startup tasks
- **Satellites are not all on one shell**: this is the default compressed real-altitude display. Disable "Real Satellite Altitude" in Settings to return to the legacy same-sphere view
## Docs Site

View File

@@ -177,7 +177,7 @@ Frontend startup now has an additional pre-start cleanup retry layer:
- `PORT_PRESTART_RETRIES`: defaults to 3 attempts.
- `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds.
`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and stops startup immediately instead of launching the service into the same port error. Non-WSL environments do not run the Windows cleanup path. At that point, use Administrator PowerShell to clear the portproxy/service ownership, or choose another port.
`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it requests Administrator PowerShell to delete stale `portproxy` rules, stop services that own the port, or force-stop the owning process. If the administrator request is canceled, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and Administrator PowerShell recovery commands, then stops startup immediately instead of launching the service into the same port error. If the frontend Vite process only discovers `Port 3000 is already in use` after launch, the script prints the same Windows listener recovery commands. Non-WSL environments do not run the Windows cleanup path. `--allow-lan` now exposes `3000` / `8000` / `8010` directly and no longer starts an extra Windows forwarding process; old persistent portproxy rules should be removed.
## Issue 4: `restart` Behavior

View File

@@ -120,7 +120,7 @@ Useful for:
- Demoing Earth from a phone or tablet
- Other LAN machines reaching the same dev instance
`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines hitting `http://<Windows LAN IP>:3000` still need Windows port forwarding and firewall rules.
`--allow-lan` directly exposes the frontend, backend, and AI Provider from the development machine: frontend `3000`, backend `8000`, and AI Provider `8010`. Before startup, the script checks all three ports. If WSL/Linux cannot release a port and a Windows-side listener or stale `portproxy` rule owns it, the script requests Administrator PowerShell cleanup. When Planet runs in WSL, Windows can usually reach it through `localhost`; other LAN machines reaching the Windows LAN IP still need Windows Firewall allow rules.
Diagnose in this order:
@@ -128,19 +128,24 @@ Diagnose in this order:
# From the shell running Planet
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
curl http://localhost:8010/health
ss -ltnp | grep -E ':3000|:8000|:8010'
```
If WSL shows `0.0.0.0:3000` / `0.0.0.0:8000` but the LAN IP still fails, configure Windows from an elevated PowerShell:
If the services are running but the LAN IP still fails, first remove stale `portproxy` rules and confirm Windows Firewall allows the ports. The script checks this automatically and requests Administrator PowerShell when needed. Manual fallback commands:
```powershell
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
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 delete v4tov4 listenaddress=0.0.0.0 listenport=8010
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
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
```
LAN devices should use the Windows external ports, for example `http://<Windows LAN IP>:3000/earth`, `http://<Windows LAN IP>:8000/health`, and `http://<Windows LAN IP>:8010/health`.
## AI Provider Environment and Builds
AI Provider runtime configuration lives in two places:
@@ -236,6 +241,15 @@ uv sync
uv run pytest backend/tests/test_otp_service.py
```
## Earth Boundary PMTiles Operations
1. In the console, open `Operations and Configuration -> Earth Content -> Boundary Precision` to save boundary source configuration. The local config is written to `config/earth-boundary-sources.local.json`; do not commit it.
2. Click "Build high precision boundaries", or switch the Earth toolbar settings gear to High Precision for the first build. The backend downloads the three source packages to `data/earth-boundary-sources/`, writes the source manifest, and invokes the PMTiles build script.
3. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools return a clear API error and do not write data-source collection records.
4. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest.
5. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy.
6. If no high-precision manifest/PMTiles exists locally, Earth uses the bundled `frontend/public/earth/data/countries-admin0.min.geojson` fallback. If high-precision assets exist but tile requests fail, troubleshoot PMTiles range requests, manifest provider, Nginx `.pmtiles` static serving, and sha256 consistency.
## Related Docs
- [planet.sh Startup Mechanism](/home/ray/dev/linkong/planet/docs/technical/en/ops-planet-sh-startup.md)

View File

@@ -11,7 +11,7 @@ Open the URL your administrator gave you, e.g. `http://planet.example.com`. A lo
Entry points are split in two:
- Public: `/earth` (3D situational view), `/docs` (public documentation)
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system configuration)
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system settings), `/earth-content` (Earth content), `/collection-management` (collection management)
## 2. Register
@@ -32,7 +32,7 @@ The default role is `viewer`: you can sign in but only see public pages. For col
After landing on the `/admin` dashboard, here's a recommended walk-through:
1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters
4. `/alerts/system`: verify system alerts look right
@@ -48,7 +48,7 @@ Once in, verify:
- Search finds cables, satellites, compute centers, BGP events
- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth
- Mouse drag, wheel zoom, and the zoom percentage indicator work
- The settings panel can switch rotate / cruise / motion modes
- The settings panel can switch rotate / cruise / motion modes; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
## 5. Recover a Lost Password

View File

@@ -95,9 +95,18 @@ AI 配置页使用的接口:
- `POST /api/v1/settings/integrations/ai-provider/connect`
- `GET /api/v1/settings/integrations/ai-provider/secrets`
- `GET /api/v1/settings/integrations/ai-provider/presets`
- `GET /api/v1/settings/ai-prompts`
- `PUT /api/v1/settings/ai-prompts/{task_key}`
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
`ai-prompts` 接口用于运维配置页的“提示词”Tab。默认提示词来自后端随发布包携带的版本化资源业务代码只引用稳定 task key接口只保存运维覆盖值。重置时删除覆盖值并恢复当前发布包中的缺省提示词。
### 提示词边界
`aiprovider` 是纯模型适配器,不注入通用业务 system prompt。新闻汉化、告警研判、BGP 简报、位置 factcheck、数据源映射和凭据教程等入口各自通过 task key 解析有效提示词。告警研判 prompt 只会在告警相关 task 中作为 system prompt 传入,不会污染其它 LLM 调用。
### AI Provider 内部 API
仅供内部调用的接口:
@@ -287,7 +296,6 @@ SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
可选 provider 专属 key

View File

@@ -90,6 +90,8 @@ async def run(self, db):
AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。
Earth 国界不再属于采集器体系。它是 Earth 静态渲染资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback不会向 `CollectedData` 写入国界记录。
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
## 四、数据格式 (统一存储到 CollectedData 表)
@@ -224,7 +226,7 @@ if datasource.last_status == "success":
)
```
这个记录用于控制台“采集器设置”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时才需要重新验证。
这个记录用于控制台“采集管理 -> 采集器”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时才需要重新验证。
相关实现见:
@@ -244,7 +246,8 @@ backend/app/services/collectors/
├── peeringdb.py # PeeringDB采集器
├── telegeraphy.py # TeleGeography海底光缆采集器
├── vessel_ais.py # BarentsWatch AIS 船只采集器
── aisstream.py # AISStream WebSocket 船只采集器
── aisstream.py # AISStream WebSocket 船只采集器
└── earth_boundaries.py # Earth 国界源校验和静态瓦片 artifact 采集器
backend/app/services/
├── custom_datasource_runtime.py # 自定义 REST / WebSocket 映射运行时
@@ -264,8 +267,8 @@ backend/app/models/
| 采集器 | credential provider | 凭证来源 |
| --- | --- | --- |
| `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` |
| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到采集器设置或注入后端环境) |
| `barentswatch_vessels` | `barentswatch` | 控制台“采集管理 -> 采集器、环境变量、`~/.zshrc` |
| `aisstream_vessels` | `aisstream` | 控制台“采集管理 -> 采集器、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到“采集管理 -> 采集器或注入后端环境) |
| `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` |
### BarentsWatch AIS
@@ -324,7 +327,7 @@ AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默
- `reconnecting`:上游断开或网络异常,采集器记录 `AISSourceHealth` 后等待重连。
- `stopped` / `cancelled`:任务被测试上限或用户停止。
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“采集管理 -> 采集器 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
控制台通过 `/datasources -> 实时流` 管理 AISStream而不是把它放进普通有限采集任务的进度条。实时流 API 会聚合运行态、健康状态、配置摘要和 raw observation 计数:
@@ -394,7 +397,7 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l
## 十、采集器设置与连接验证
控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态而是由后端计算 checksum
控制台的“采集管理 -> 采集器”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态而是由后端计算 checksum
- endpoint
- auth type

View File

@@ -8,12 +8,12 @@
- 展示所有数据源,包括内置和自定义。
- 点击名称只打开信息抽屉。
- 负责查看状态、触发采集和查看采集中任务。
- `/settings?tab=collector_credentials`
- 显示为“采集器设置”。
- `/collection-management?tab=collector_credentials`
- 显示为“采集器”。
- 负责 endpoint、请求头、基础参数和凭证配置。
- 所有采集器都提供连接按钮,用于健康检查。
这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器设置”,而不是散落在数据源列表和系统设置多个入口里。
这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器”,而不是散落在数据源列表和系统设置多个入口里。
## 用户侧规则
@@ -53,7 +53,7 @@
`data-source-bulk-toolbar__running-pill` 是“采集中”标签的样式入口。它和其他状态标签同排,但通过 hover、箭头和蓝色描边表达可交互性。
### 采集器设置
### 采集器
文件:
@@ -61,7 +61,7 @@
当前行为:
- `collector_credentials` tab 展示为“采集器设置”。
- `collector_credentials` tab `/collection-management`展示为“采集器”。
- 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源。
- 下拉框右侧只有一个插头图标按钮,用于健康检查。
- 下拉框下方用状态标签展示:
@@ -309,6 +309,8 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=
自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations再通过 `vessels` WebSocket channel 推送给 Earth。
Earth 高精度边界不再使用自定义源目标 schema。国界是 Earth 静态资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存本机源配置并触发 PMTiles 构建,不写入 `CollectedData`
### 配置语义
关键字段:
@@ -318,7 +320,7 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=
- `auth_type``none``bearer``api_key``basic`
- `headers`:静态请求头。
- `auth_config`token、API key、basic 用户名密码API key 支持 header 或 query。
- `config.target_schema`:例如 `vessel_ais`
- `config.target_schema`:例如 `vessel_ais``geo_points``generic_records`
- `config.delivery_mode`REST 默认 `polling`WebSocket 默认 `realtime_stream`
- `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`

View File

@@ -157,6 +157,7 @@ BGP 模块正在从一个只展示异常的演示功能,演进为分层观测
- 由符号驱动的事件核心
- 向外扩散的环形脉冲
- 相比旧版 Earth 更少的弥散 glow
- 事件区域 halo、collector coverage halo 和雷达 pulse 从各自图标色派生浅色调,而不是使用固定青绿色;红色高危事件、橙色活跃 collector 和蓝色 idle collector 保持各自色相一致,面状光晕则比图标更轻
7. 右侧统计现在显示:
- BGP events
- collector count

View File

@@ -123,12 +123,16 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
职责:
- 地球球体、云层、大气
- 真实地形 mesh
- terrain tile 拉取、解码、位移、着色
- 海陆基座与国界底图的整球 overlay
Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座球、高清材质 overlay、云层/大气,以及 `country-boundaries.js` 的海陆基座都需要明确半径间距。远距视图下 GPU 深度精度会下降,相邻 shell 过近会 z-fighting表现为黑色闪烁块或雪花。当前稳定策略是让海陆基座使用 `landAltitudeOffset = 0.32`,高清材质使用 `textureOverlayAltitudeOffset = 0.48`;后续新增或调整整球地表 overlay 时,必须同步检查 [Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md),并在 50% 缩放视图验证。
### 7. 图层模块
@@ -419,11 +423,22 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
- 旋转模式
- 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源)
- HUD 面板显示/隐藏
- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP`
- 图层控制开关:`地形 / 卫星 / 海缆 / BGP`
- 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示`
- 地表 hover 提示偏好:`国家 / 位置 / 完整`
- 国界精度偏好:低精 fallback / 高精 PMTiles
- 地形透明度
也就是说Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`
地表 hover 提示由 `controls.js` 持久化为 `shared.surfaceHoverInfoMode`,实际 tooltip 在 `main.js` 的地表 hover 分支组合。`国家` 模式只在命中国家时显示国家信息,海洋区域不显示地表 tooltip`位置` 模式只显示纬度、经度和地形采样海拔,并清除国家边界 hover`完整` 模式在陆地显示国家 + 位置,在海洋显示位置。
卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。`maxRealAltitudeOffset = 25` 是当前相机和 `earthRadius = 100` 下的视觉上限:它让 GEO / MEO 比 LEO 明显更高,但把最高轨道控制在地球半径外约 25%,避免选择点、红色轨迹和主体地球之间出现过大的空场。
SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`把未来一圈惯性轨道投到当前地球姿态上显示因此会闭合并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。
国界精度偏好独立存储在 `country-boundaries.js``planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles也继续加载低精 `countries-admin0.min.geojson` fallback开启高精但高精产物缺失时Earth 工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮如果鼠标同时命中卫星、船只、BGP 等 interactabletooltip 显示 interactable 信息,但国界高亮不应闪烁。
## 当前地形链路
真实地形首次启用会慢,原因不只是一个:

View File

@@ -26,7 +26,7 @@
| 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.textureOverlayAltitudeOffset` | `0.48` | 独立高清材质球半径;必须与海陆基座和地球基座保持足够深度间距,避免远距 z-fighting |
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
| 高清材质 specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | 降低直射区域镜面高光,避免贴图死白 |
@@ -84,11 +84,19 @@
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 高精 PMTiles manifest缺失时使用低精度 fallback |
| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | 优先高精 PMTiles/MVT缺失时降级到 legacy GeoJSON |
| PMTiles 产物路径 | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | 生产单文件 PMTiles/MVT artifact |
| 低精度 fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | 本地未构建高精国界时的缺省海陆基座和 hover 数据 |
| MVT 图层名 | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | PMTiles provider 解码时读取的固定 layer 名 |
| 国界瓦片基础路径 | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest 基础路径 |
| 国界瓦片缩放阈值 | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | 生产 PMTiles zoom 选择 |
| 国界瓦片缓存上限 | `COUNTRY_BOUNDARY_CONFIG.tileCacheLimit` | `150` | 前端已加载瓦片几何的 LRU 缓存条目数 |
| 国界瓦片 debounce | `COUNTRY_BOUNDARY_CONFIG.tileDebounceMs` | `180` | 视野变化后请求可见瓦片前的防抖时间 |
| 海洋填充色 | 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` 半径 |
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.32` | `country-land-ocean` 半径;与地球基座拉开以避免 50% 缩放时雪花/黑块闪烁 |
| 海陆基座 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 |
@@ -97,16 +105,16 @@
| 国界线颜色 | `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.115` | 普通国界线半径;略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感 |
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | 普通国界线半径;线层靠 renderOrder 和独立 geometry 叠加,不作为整球基座深度间距参考 |
| 国界线 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.14` | hover 实线半径;贴近地表但高于普通国界线 |
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.115` | 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 |
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0` | glow hover 实线共用半径,避免海岸细节处双线错位 |
## 真实地形
@@ -166,10 +174,13 @@
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 卫星显示半径偏移 | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | 卫星点位置 |
| 卫星 fallback 半径偏移 | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | 关闭真实高度或 TLE 传播失败时的旧版同层球面位置 |
| 卫星真实高度压缩尺度 | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 高度压缩映射,数值越大高轨差异越温和 |
| 卫星真实高度上限 | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | 压缩映射的高度截断,覆盖 GEO 附近高度 |
| 卫星真实高度显示偏移范围 | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 25` | 压缩后映射到 `CONFIG.earthRadius + offset``25` 约为当前地球半径 `100` 的四分之一,用于保留高轨分层同时避免 GEO/MEO 轨迹离主体过远 |
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size |
| 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 |
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | 呼吸动画 |
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | 呼吸动画 |
| 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 |
| 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor |
| 卫星背景点透明度 | inline | `0.42` | backdrop point opacity |
@@ -249,7 +260,9 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
| medium 颜色 | `BGP_CONFIG.severityColors.medium` | `0xffd166` | 中危事件 |
| low 颜色 | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | 低危事件 |
| collector 基础色 | `BGP_CONFIG.collectorColor` | `0x6db7ff` | collector 默认色 |
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
| BGP 光晕浅色基准 | `BGP_CONFIG.halo.tintNeutralColor` | `0xffffff` | 面状 halo 先取对应事件/collector 图标色,再向该中性色混合,保持同色调但不完全同色 |
| BGP 光晕图标色混合比例 | `BGP_CONFIG.halo.tintBlend` | `0.72` | 值越高越接近图标色;当前用于事件区域 halo、collector coverage halo 和 collector radar pulse |
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖旧基准;新的面状 BGP halo 优先从事件或 collector 图标色派生 |
| 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 | local `BGP_COLLECTOR_RENDER_ORDER` | `4.4` | 观测站主图标,与船只同层 |

View File

@@ -103,7 +103,7 @@
## 采集器配置方式
`news_live_streams` 不需要单独新页面,直接复用控制台 `/settings` 的“采集器设置”:
`news_live_streams` 不需要单独新页面,直接复用控制台 `/collection-management` 的“采集器”配置
- `endpoint`
- 频道目录 JSON API 地址

View File

@@ -7,7 +7,7 @@
| 顺序类型 | 当前顺序 | 说明 |
| --- | --- | --- |
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界线 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
| 控制面板顺序 | 海缆 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界线 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列;卫星轨迹已迁到设置面板,不再作为图层列表项。 |
| 注册 / 启动加载顺序 | 经纬线 → 国界线 / 海陆基座 → 高清材质 → 大气云图 → 海缆 → 算力中心 → 船只 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;启动队列会先读取保存的图层可见状态,明确关闭的普通图层不预加载,高清材质关闭时不下载贴图;国界线图层例外,海陆基座始终预加载,保存状态只控制可交互国界线和 hover轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
## 地表图层栈
@@ -18,23 +18,23 @@
| -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` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 |
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
| 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 |
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制;地形 `depthWrite: false`,所以地形开启时仍可见。 |
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.115` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;与普通国界线同源半径对齐,避免重影;中国和中国(台湾)共享高亮组。 |
| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-testedIridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 |
| 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4``BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking并参与同坐标避让 | BGP 观测站主图标与船只同层BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 |
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1``CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`hover / locked 为单点 `THREE.Points` overlay | `depthTest: true``main.js` 使用屏幕空间 picking只取正面 marker参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow交互态叠加同尺寸 glow低于算力中心 `4.5`。 |
| 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 |
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder;默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..25`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 |
| 6 | 卫星点 | `satellites.js` | 与卫星背景点使用同一压缩高度 / fallback 高度 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制闭合惯性轨道;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
## 开关联动
@@ -46,6 +46,18 @@
| 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 |
| 大气云图 | 只控制云图 mesh 显隐。 |
| 国界线 off | 只隐藏可交互国界线和 hover高亮状态会清除海陆基座填充仍作为 Earth 底图保留。 |
| 真实卫星高度 off | 卫星点、轨迹和预测轨道都使用旧版同层球面;缺失 TLE 或传播失败的卫星也使用同一 fallback 高度。 |
## 深度间距规则
Earth 的地表不是单一 mesh而是多层近似同心球基座球、海陆基座、高清材质、地形、云层、大气和遮挡球。近距看起来只差几个小数的半径偏移在 50% 这类远距视图下会被深度缓冲压到同一批像素,导致 z-fighting表现为黑块、雪花或闪烁。
维护规则:
- 不要用“远距隐藏图层”作为第一反应;先检查相邻 shell 的 `altitudeOffset``renderOrder``depthTest``depthWrite`
- 海陆基座和高清材质这类整球 overlay 必须与 `CONFIG.earthRadius` 保持明确间距;当前稳定值为 `landAltitudeOffset = 0.32``textureOverlayAltitudeOffset = 0.48`
- 新增整球或近整球地表 overlay 时,必须在 50% 缩放和常用近距视图各截一次图,确认没有黑块、雪花、闪烁,也没有明显漂浮感。
- 如果必须调整这些半径,需同步更新本文和 `frontend/public/earth/js/constants.js` 的注释/常量意图。
## 交互规则

View File

@@ -55,12 +55,12 @@ tasklist /svc /fi "PID eq 4700"
临时排障可以在管理员 PowerShell 中停止 IP Helper
```powershell
Stop-Service iphlpsvc
Stop-Service iphlpsvc -Force
```
这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;如果必须保留 Windows 转发,改用不同后端端口更稳
这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;`--allow-lan` 会直接绑定 `3000` / `8000` / `8010`,不再需要保留持久 portproxy
如果脚本输出 `failed-stop-service``failed-stop-process`,说明当前权限无法清理 Windows listener。脚本会停止启动避免后端再次遇到同一端口冲突
如果脚本输出 `Windows 侧端口 ... 存在监听者`,或 Vite 报 `Port 3000 is already in use` 后显示 `Windows listener ... services=iphlpsvc`,说明旧的 Windows listener 仍在占用端口。脚本会请求管理员 PowerShell 清理对应端口;如果自动清理被取消,再手动检查 `netsh interface portproxy show all`,删除对应 `listenport` 规则。如果没有 portproxy 规则,再用 `netstat` / `tasklist` 确认服务,必要时临时 `Stop-Service iphlpsvc -Force`。清理旧规则后重新运行 `./planet.sh restart --allow-lan`,局域网仍访问 `3000` / `8000` / `8010`
### 默认端口冲突时应该改哪些参数?
@@ -89,6 +89,7 @@ Stop-Service iphlpsvc
# 在 WSL 或运行 Planet 的 shell 中
curl http://localhost:3000
curl http://localhost:8000/health
curl http://localhost:8010/health
```
再到 Windows PowerShell 验证:
@@ -96,6 +97,7 @@ curl http://localhost:8000/health
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
curl http://localhost:8010/health
```
如果 WSL 和 Windows localhost 都通,但手机或其他电脑访问不通,再考虑局域网开放:
@@ -110,22 +112,28 @@ curl http://localhost:8000/health
./planet.sh restart -f 3000 --allow-lan
```
如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection <Windows局域网IP> -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧转发或防火墙。
如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection <Windows局域网IP> -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧端口占用、旧 `portproxy` 或防火墙。
传统 WSL NAT 场景下,管理员 PowerShell 中配置 portproxy 和防火墙
`./planet.sh start --allow-lan` 会直接开放 `3000` / `8000` / `8010`,并在启动前检测端口、旧 `portproxy` 和 Windows 防火墙规则。端口被 Windows 侧 listener 占用时,脚本会请求管理员 PowerShell 清理;缺少入站放行时,也会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理
```powershell
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
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 delete v4tov4 listenaddress=0.0.0.0 listenport=8010
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
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
```
局域网设备访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`
如果 `wslinfo --networking-mode` 输出 `mirrored`,还需要检查 Hyper-V firewall。普通 Windows 防火墙规则存在时Hyper-V firewall 仍可能拦截外部设备进入 WSL。管理员 PowerShell 中按端口放行:
```powershell
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
New-NetFirewallHyperVRule -Name "Planet-AIProvider-8010" -DisplayName "Planet AI Provider 8010" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8010 -Action Allow
```
也可以用下面命令确认当前 Hyper-V firewall 状态:
@@ -288,7 +296,7 @@ USB 摄像头透传到 WSL 属于高级路径;脚本不会默认把无摄像
### 采集器连接验证通过,但正式采集拿不到凭证怎么办?
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“设置 -> 采集器设置”,尤其是 AISStream 这类长连接 collector。
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“采集管理 -> 采集器”,尤其是 AISStream 这类长连接 collector。
如果只把 `AISSTREAM_API_KEY` 放在 `~/.zshrc`,需要确认后端进程实际继承了该变量。否则可能出现连接验证可用,但 collector 运行时没有 key 的情况。
@@ -302,7 +310,7 @@ export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
稳定运行时,优先在控制台采集器设置中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
稳定运行时,优先在控制台“采集管理 -> 采集器中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
## Docs / 权限
@@ -317,12 +325,31 @@ Docs 按 Gatekeeper 权限组控制可见性:
## Earth 常见操作
### 为什么国界提示“未配置 endpoint”或只能看到低精度
国界已经从采集器体系移出,不再通过数据源采集任务生成。低精度国界是随前端打包的兜底资产,本地未构建高精 PMTiles 时会自动使用它。
要启用高精国界,有两条入口:
- Earth 页面齿轮设置里的“国界精度”:切到“高精”会启动首次后台下载/构建,并显示百分比,完成后自动应用。
- 控制台 `运维与配置 -> Earth 内容 -> 国界精度`:适合查看 provider、manifest、PMTiles、fallback 状态,编辑源配置 JSON或手动重建。
如果看到“更新源未配置完整”,先到 `Earth 内容 -> 国界精度` 保存源配置;本机私有配置写入 `config/earth-boundary-sources.local.json`,不要提交到仓库。没有高精产物时,使用低精 fallback 是正常行为。
### Earth 位置候选采集后没有写入怎么办?
“采集候选”和“保存候选”是两步。候选可以先在 Earth 上预览,只有点击保存或使用待定位列表中的“一键采用”后,才会写入维表并刷新图层。
算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。
### 为什么卫星看起来不在同一个球面上?
Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4但高度会经过压缩映射低轨卫星靠近地球高轨卫星更远同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。
如果需要旧版所有卫星位于同一显示球面的效果,在 Earth 设置里关闭“真实卫星高度”。缺失 TLE 或传播失败的卫星仍会回退到旧版固定高度,不会因为无法计算真实高度而消失。
低倾角高轨卫星应该沿赤道附近或固定经度附近分布而不是绕出接近南北向的大圈。Earth 会先把 SGP4 的惯性系位置转换成地固坐标再绘制当前点;锁定后的预测轨道则固定当前地球姿态来画一圈惯性轨道,所以应该闭合并保留正确倾角。如果以后看到详情卡倾角接近 `0°`,但预测轨道像极轨一样竖着绕,优先检查 ECI/TEME 到 ECF 的转换和预测轨道是否错误使用了逐采样 `gstime`
### 动捕调试面板为什么看不到摄像头画面?
如果输入源是 Browser Camera调试面板会显示浏览器本机摄像头实时预览并在画面上绘制骨架。如果勾选了 `只显示骨骼`,视频预览会被隐藏,只显示深色背景和红/绿骨架。

View File

@@ -33,6 +33,8 @@
- `/alerts/situational`
- `/bgp`
- `/ai`
- `/earth-content`
- `/collection-management`
- `/settings`
`/earth` 是独立展示页,不属于控制台骨架。
@@ -288,9 +290,9 @@
- 点击名称打开只读抽屉。
- 抽屉中明确显示“内置数据源”或“自定义数据源”。
- endpoint、headers、config 只展示,不在这里编辑。
- 需要凭证的采集器提示用户到“设置 -> 采集器设置”维护。
- 需要凭证的采集器提示用户到“采集管理 -> 采集器”维护。
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/settings?tab=collector_credentials`
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?tab=collector_credentials`
页面顶部的总进度区域新增 `采集中 N` 标签:
@@ -303,7 +305,7 @@
### 采集器设置页
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 中的 `collector_credentials` tab 当前显示为“采集器设置”。
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是 Earth 内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前`/collection-management`显示为“采集器”。
当前页面边界:
@@ -334,6 +336,16 @@
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
### Earth 内容页
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
- `国界精度` 管理 Earth 静态国界资产provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
- `地球底图``图层资源``三维素材``新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。
系统级 `/settings` 不应再新增 Earth 体验资源或采集生命周期 tab采集相关入口属于 `/collection-management`Earth 展示资源属于 `/earth-content`
### 3. 复杂工作区页面
例如:

View File

@@ -76,15 +76,17 @@
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
| 态势告警 | `/alerts/situational` | 态势研判告警 |
| AI | `/ai` | 模型供应商、工具、测试台 |
| Earth 内容 | `/earth-content` | 电视直播、国界精度、底图和图层资源入口 |
| 采集管理 | `/collection-management` | 采集器、采集调度、采集历史入口 |
| 系统日志 | `/logs` | 通常仅 super admin 可见 |
| 用户管理 | `/users` | 创建/删除/改角色/调权限组 |
| 系统置 | `/settings` | 系统、SMTP、TV、采集器设置 |
| 系统置 | `/settings` | 系统显示、通知、安全、SMTP |
权限不足时菜单项会自动隐藏。如果发现某个菜单看不到,先确认自己的角色和 Gatekeeper 权限组。
## 配置数据采集器
`/settings?tab=collector_credentials` 是"采集器设置"页。这里统一维护所有采集器的连接配置,不仅是凭证。
`/collection-management?tab=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证。旧链接 `/settings?tab=collector_credentials` 会自动跳转到这个入口;数据源目录仍保留在 `/datasources`
操作步骤:
@@ -128,7 +130,7 @@
操作步骤:
1. `/settings?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
1. `/collection-management?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
2.`AISStream 凭证` 填入 API Key
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
4. 点击插头图标进行连接测试,确认显示 `可用`
@@ -142,10 +144,11 @@
## 配置 AI 凭证
`/ai?tab=providers` 是 AI 模型管理入口。包含个核心子 tab
`/ai?tab=providers` 是 AI 模型管理入口。包含个核心子 tab
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
- `提示词`通过功能入口下拉菜单选择新闻汉化、告警研判、BGP 简报等 LLM 任务,手动调整提示词或重置为缺省
### 模型供应商
@@ -166,6 +169,10 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
- **WebSearch**provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰
- **OCR**provider、Base URL、API Key、模型/engine、识别语言、超时、最大文件大小、输出格式
### 提示词
选择功能入口后,页面会显示当前提示词、是否已自定义、缺省版本和重置按钮。保存只影响该功能入口;重置会恢复当前发布包中的缺省提示词。业务事实、上下文和输出 schema 仍由后端按功能入口自动传入。
旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`
## 系统设置
@@ -176,8 +183,26 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
- **通知策略**:告警邮件开关、收件邮箱、严重/警告/每日摘要通知
- **安全策略**:会话超时、最大登录尝试、密码策略
- **SMTP 邮件**:注册和找回密码所需的发件配置(仅 `admin` / `super_admin` 可见)
- **电视直播**:电视直播源管理
- **AI / WebSearch / OCR**:见上节
电视直播和国界精度已经移到 `/earth-content`,采集器和采集调度已经移到 `/collection-management`AI Provider / WebSearch / OCR`/ai`
### Earth 内容
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
- **电视直播**:维护 Earth 媒体面板里的直播源。
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
### 采集管理
`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期:
- **采集器**:维护 endpoint、请求头、凭证、timeout、retry并运行连接检查。
- **采集调度**:维护原有调度相关设置。
- **采集历史 / 快照**:当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力。
### SMTP 邮件设置
@@ -207,7 +232,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
## 数据探索
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
- `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
- `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警
@@ -232,7 +257,7 @@ Earth `http://localhost:3000/earth` 是公开 3D 态势页面,不需要登录
### 图层控制
右侧图层面板用于打开或关闭图层。常见图层经纬线、国界线、高清材质、大气云图、海缆、算力中心、BGP 观测、卫星、AIS 船只、轨迹、地形。
右侧图层面板用于打开或关闭图层。常见图层经纬线、国界线、高清材质、大气云图、海缆、算力中心、BGP 观测、卫星、AIS 船只、地形。卫星轨迹不再作为单独图层出现在图层列表中,改在设置面板里控制
依赖关系:
@@ -258,7 +283,11 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
### 设置
设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。
“悬停提示”控制鼠标悬停地表时的 tooltip 内容:`国家` 只在陆地命中国家时显示国家信息,太平洋等海洋区域不弹出地表提示;`位置` 在陆地和海洋都显示纬度、经度和海拔;`完整` 是默认模式,陆地显示国家 + 位置,海洋显示位置。
这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。
@@ -313,6 +342,7 @@ Earth 预留了动作捕捉控制入口。实时链路两种输入源:
- **Earth 打不开**:先确认前端服务是否在线;如果端口不是 `3000`,使用启动输出的实际端口
- **图层没有数据**:进 `/datasources` 看数据源状态、是否已采集和最近执行结果,再到 `/data``/bgp` 看是否有记录
- **卫星 / BGP / 海缆加载慢**:这些图层依赖后端接口和外部数据源,首次加载需要等启动任务完成
- **卫星看起来不在同一层**:这是默认的真实高度压缩显示。想回到旧版同层球面,可在设置中关闭“真实卫星高度”
## Docs 文档站

View File

@@ -155,7 +155,7 @@ wait_for_port_release() {
- `PORT_PRESTART_RETRIES`:默认 3 次。
- `PORT_PRESTART_RETRY_INTERVAL`:默认 2 秒。
`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时才会检查 Windows 侧 listener尝试通过 PowerShell 停止对应服务或强制结束对应进程。若没有权限,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情立即停止启动,不再继续拉起服务碰同一个端口错误。非 WSL 环境不会尝试 Windows 清理路径。此时需要用管理员 PowerShell 清理 portproxy/服务占用,或改用其他端口
`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时才会检查 Windows 侧 listener请求管理员 PowerShell 删除旧 `portproxy`、停止占用端口的服务或强制结束对应进程。若管理员请求被取消,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情和管理员 PowerShell 处理命令,然后立即停止启动,不再继续拉起服务碰同一个端口错误。前端 Vite 启动后才发现 `Port 3000 is already in use` 时,也会打印同一套 Windows listener 处理命令。非 WSL 环境不会尝试 Windows 清理路径。`--allow-lan` 直接开放 `3000` / `8000` / `8010`,不再启动额外的 Windows 端口转发进程;旧的持久 portproxy 规则应清理掉
## 问题三:端口检测用 Python

View File

@@ -120,7 +120,7 @@
- 手机或平板演示 Earth
- 局域网其他机器访问同一开发实例
`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时Windows 本机一般可以通过 `localhost` 访问,局域网其他机器访问 `http://<Windows局域网IP>:3000` 还需要 Windows 端口转发和防火墙放行。
`--allow-lan` 会让前端、后端和 AI Provider 直接对开发机开放:前端 `3000`、后端 `8000`、AI Provider `8010`。脚本启动前会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。WSL 中运行时Windows 本机一般可以通过 `localhost` 访问,局域网其他机器访问 Windows 局域网 IP 还需要 Windows 防火墙放行。
建议按顺序排查:
@@ -128,19 +128,24 @@
# 在运行 Planet 的 shell 中
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
curl http://localhost:8010/health
ss -ltnp | grep -E ':3000|:8000|:8010'
```
如果看到 `0.0.0.0:3000``0.0.0.0:8000`,但局域网 IP 仍访问失败,在管理员 PowerShell 中配置
如果服务已经启动但局域网 IP 仍访问失败,优先清理旧 `portproxy` 并确认 Windows 防火墙放行。脚本会自动检测并请求管理员 PowerShell 处理;自动请求被取消时,手动兜底命令如下
```powershell
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
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 delete v4tov4 listenaddress=0.0.0.0 listenport=8010
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
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
```
局域网设备访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth``http://<Windows局域网IP>:8000/health``http://<Windows局域网IP>:8010/health`
## AI Provider 环境变量与构建
AI Provider 运行期配置可以放在两处:
@@ -236,6 +241,15 @@ uv sync
uv run pytest backend/tests/test_otp_service.py
```
## Earth 国界 PMTiles 操作步骤
1. 在控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存国界源配置;本机配置写入 `config/earth-boundary-sources.local.json`,不要提交。
2. 点击“构建高精国界”,或在 Earth 页面工具栏齿轮中切到“高精”触发首次构建。后端会下载三类源到 `data/earth-boundary-sources/`,生成 source manifest并调用 PMTiles 构建脚本。
3. 构建器需要本机 PATH 里有 `tippecanoe``pmtiles`。缺工具时接口返回明确错误,不会写入数据源采集记录。
4. 构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。
5. 部署后打开 Earth开启“国界线”放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。
6. 如果本地没有高精 manifest/PMTilesEarth 会使用 `frontend/public/earth/data/countries-admin0.min.geojson` 低精度 fallback如果高精产物存在但瓦片请求失败按 PMTiles range 请求、manifest provider、Nginx `.pmtiles` 静态返回和 sha256 一致性排查。
## 相关文档
- [planet.sh 启动机制](/home/ray/dev/linkong/planet/docs/technical/zh/ops-planet-sh-startup.md)

View File

@@ -11,7 +11,7 @@
入口分两类:
- 公开页:`/earth`3D 态势)、`/docs`(公共文档)
- 登录后:`/admin`(控制台)、`/ai`AI`/settings`(系统配置
- 登录后:`/admin`(控制台)、`/ai`AI`/settings`(系统设置)、`/earth-content`Earth 内容)、`/collection-management`(采集管理
## 2. 注册账号
@@ -32,7 +32,7 @@
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
2. `/ai?tab=providers`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常
@@ -48,7 +48,7 @@
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
- 设置面板的旋转 / 巡航 / 动捕模式可以切换
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
## 5. 找回密码

View File

@@ -16,12 +16,17 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.54.0`
- `dev` 当前开发分支历史推导到:`0.59.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 |
| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 |
| `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay保持 localhost 与局域网同用 3000/8000并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 |
| `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 |
| `0.55.0` | feature | `dev` | `pending` | Earth 卫星新增真实高度压缩显示开关轨迹和预测轨道跟随高度模式切换并补齐设置面板、FAQ、用户手册和开发者文档 |
| `0.54.0` | feature | `dev` | `pending` | 新增 Earth 轨迹显示设置并迁移到桌面/移动设置面板,持久化轨迹偏好,同时优化卫星呼吸闪烁参数和算力中心图标资产 |
| `0.53.0` | feature | `dev` | `pending` | 新增 Gitea Actions CI/CD、生产镜像、Kubernetes Helm chart 与 staging 部署 smoke test明确 Vite 生产构建和 planet.sh 开发入口边界 |
| `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 |

View File

@@ -6,9 +6,12 @@
"name": "planet-frontend",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@mapbox/vector-tile": "^2.0.4",
"antd": "^5.12.5",
"axios": "^1.6.2",
"dayjs": "^1.11.10",
"pbf": "^4.0.1",
"pmtiles": "^4.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable": "^3.1.3",
@@ -19,6 +22,7 @@
"zustand": "^4.4.7",
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
@@ -142,6 +146,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
"@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="],
"@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="],
"@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="],
@@ -226,6 +234,10 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
@@ -288,6 +300,8 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@@ -336,12 +350,18 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"pbf": ["pbf@4.0.1", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pmtiles": ["pmtiles@4.4.1", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="],
@@ -430,6 +450,8 @@
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
@@ -458,6 +480,8 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],

View File

@@ -50,6 +50,23 @@ server {
try_files $uri =404;
}
location = /earth/data/boundaries/v1/manifest.json {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location ~ ^/earth/data/boundaries/.*\.pmtiles$ {
types { application/octet-stream pmtiles; }
add_header Accept-Ranges bytes;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location /earth/data/boundaries/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -1,13 +1,16 @@
{
"name": "planet-frontend",
"version": "0.54.0",
"version": "0.59.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@mapbox/vector-tile": "^2.0.4",
"antd": "^5.12.5",
"axios": "^1.6.2",
"dayjs": "^1.11.10",
"pbf": "^4.0.1",
"pmtiles": "^4.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable": "^3.1.3",
@@ -20,6 +23,7 @@
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/node": "^24.0.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.10"

View File

@@ -1,6 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"/>
<path fill="#38bdf8" d="M3 19h1V6.36a1.5 1.5 0 0 1 1.026-1.423l8-2.666A1.5 1.5 0 0 1 15 3.694V19h1V9.99a.5.5 0 0 1 .598-.49l2.196.44A1.5 1.5 0 0 1 20 11.41V19h1a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2"/>
</g>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" class="bi bi-buildings-fill" viewBox="0 0 16 16">
<path d="M15 .5a.5.5 0 0 0-.724-.447l-8 4A.5.5 0 0 0 6 4.5v3.14L.342 9.526A.5.5 0 0 0 0 10v5.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V14h1v1.5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V.5ZM2 11h1v1H2v-1Zm2 0h1v1H4v-1Zm-1 2v1H2v-1h1Zm1 0h1v1H4v-1Zm9-10v1h-1V3h1ZM8 5h1v1H8V5Zm1 2v1H8V7h1ZM8 9h1v1H8V9Zm2 0h1v1h-1V9Zm-1 2v1H8v-1h1Zm1 0h1v1h-1v-1Zm3-2v1h-1V9h1Zm-1 2h1v1h-1v-1Zm-2-4h1v1h-1V7Zm3 0v1h-1V7h1Zm-2-2v1h-1V5h1Zm1 0h1v1h-1V5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -1,7 +1,7 @@
/* earth-stats.css — compact KPI grid panel */
.hud-panel-stats {
top: var(--hud-offset);
bottom: var(--hud-offset);
right: var(--hud-offset);
border-radius: 0; /* square / angular — matches layer panel */
padding: 0;
@@ -129,9 +129,9 @@
/* ── Layout-expanded: slide off-screen ───────────────────────── */
.earth-app.layout-expanded .hud-panel-stats:not([data-dragged="true"]) {
top: var(--hud-offset);
bottom: var(--hud-offset);
right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-stats {

View File

@@ -1309,23 +1309,44 @@
}
.earth-mobile-settings-segmented {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
--item-count: 2;
--active-index: 0;
position: relative;
display: inline-grid;
grid-template-columns: repeat(var(--item-count), minmax(0, 1fr));
padding: 4px;
border: 1px solid rgba(212, 227, 244, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
overflow: hidden;
}
.earth-mobile-settings-segmented::before {
content: "";
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: calc((100% - 8px) / var(--item-count));
border-radius: 999px;
background: rgba(122, 180, 255, 0.16);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transform: translateX(calc(var(--active-index) * 100%));
transition: transform 180ms ease;
}
.earth-mobile-settings-pill {
border: 1px solid rgba(212, 227, 244, 0.1);
position: relative;
z-index: 1;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
background: transparent;
color: var(--hud-text-soft);
padding: 10px 14px;
}
.earth-mobile-settings-pill.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.24);
background: rgba(122, 180, 255, 0.14);
}
.earth-mobile-settings-chip-group {
@@ -1390,12 +1411,13 @@
.earth-mobile-settings-switch-track::after {
content: "";
position: absolute;
top: 4px;
top: 50%;
left: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transform: translateY(-50%);
transition: transform 0.18s ease;
}
@@ -1404,7 +1426,7 @@
}
.earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track::after {
transform: translateX(16px);
transform: translate(16px, -50%);
}
label.is-disabled.earth-mobile-settings-card {
@@ -1629,9 +1651,12 @@ label.is-disabled.earth-mobile-settings-card {
.earth-status-message,
.earth-error-message {
position: absolute;
top: calc(20px * var(--hud-scale));
left: 50%;
transform: translate(-50%, -18px);
top: calc(var(--hud-offset) + calc(2px * var(--hud-scale)));
left: min(
calc(var(--hud-offset) + calc(340px * var(--hud-scale)) + calc(12px * var(--hud-scale))),
calc(100vw - min(calc(440px * var(--hud-scale)), 74vw) - var(--hud-offset))
);
transform: translateY(-18px);
display: none;
align-items: center;
gap: calc(10px * var(--hud-scale));
@@ -1664,10 +1689,20 @@ label.is-disabled.earth-mobile-settings-card {
.earth-status-message.visible,
.earth-error-message.visible {
transform: translate(-50%, 0);
transform: translateY(0);
opacity: 1;
}
.earth-status-message.earth-status-message--ticker-stack {
top: calc(var(--hud-offset) + calc(50px * var(--hud-scale)));
left: 50%;
transform: translate(-50%, -18px);
}
.earth-status-message.earth-status-message--ticker-stack.visible {
transform: translate(-50%, 0);
}
.earth-status-message.gesture {
min-width: 0;
padding-right: calc(16px * var(--hud-scale));
@@ -1675,7 +1710,7 @@ label.is-disabled.earth-mobile-settings-card {
}
.earth-error-message {
top: calc(62px * var(--hud-scale));
top: calc(var(--hud-offset) + calc(92px * var(--hud-scale)));
z-index: 211;
min-width: min(calc(220px * var(--hud-scale)), 58vw);
}
@@ -2242,7 +2277,11 @@ label.is-disabled.earth-mobile-settings-card {
}
.earth-settings-segmented {
display: inline-flex;
--item-count: 2;
--active-index: 0;
position: relative;
display: inline-grid;
grid-template-columns: repeat(var(--item-count), minmax(0, 1fr));
align-self: flex-start;
padding: 4px;
border-radius: 999px;
@@ -2251,10 +2290,30 @@ label.is-disabled.earth-mobile-settings-card {
rgba(255, 255, 255, 0.03);
border: 1px solid rgba(212, 227, 244, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
gap: 4px;
overflow: hidden;
}
.earth-settings-segmented::before {
content: "";
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: calc((100% - 8px) / var(--item-count));
border-radius: 999px;
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(0, 0, 0, 0.2);
transform: translateX(calc(var(--active-index) * 100%));
transition: transform 180ms ease, opacity 180ms ease;
}
.earth-settings-segmented-btn {
position: relative;
z-index: 1;
border: 0;
background: transparent;
color: var(--hud-text-soft);
@@ -2266,9 +2325,7 @@ label.is-disabled.earth-mobile-settings-card {
letter-spacing: 0.02em;
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
@@ -2279,12 +2336,73 @@ label.is-disabled.earth-mobile-settings-card {
.earth-settings-segmented-btn.is-active {
color: var(--hud-title);
}
.earth-settings-segmented-btn:disabled {
cursor: default;
opacity: 0.54;
transform: none;
}
.earth-settings-boundary-action-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.earth-settings-segmented--boundary {
flex: 0 1 auto;
margin-right: auto;
}
.earth-settings-reload-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(30px * var(--hud-scale));
height: calc(30px * var(--hud-scale));
border: 1px solid rgba(122, 180, 255, 0.24);
border-radius: 999px;
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(0, 0, 0, 0.2);
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.18), rgba(72, 101, 139, 0.24));
color: var(--hud-title);
cursor: pointer;
vertical-align: middle;
transition:
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
}
.earth-settings-reload-action--text {
width: auto;
min-width: calc(46px * var(--hud-scale));
padding: 0 calc(12px * var(--hud-scale));
font: inherit;
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 600;
}
.earth-settings-reload-action[hidden] {
display: none;
}
.earth-settings-reload-action:hover {
transform: translateY(-1px);
border-color: rgba(125, 197, 255, 0.46);
}
.earth-settings-reload-action:disabled {
cursor: default;
opacity: 0.52;
transform: none;
}
.earth-settings-reload-action .material-symbols-rounded {
font-size: calc(1rem * var(--hud-scale));
}
.earth-settings-chip-group {
@@ -2418,6 +2536,41 @@ label.is-disabled.earth-mobile-settings-card {
font-variant-numeric: tabular-nums;
}
.earth-boundary-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
width: 100%;
}
.earth-boundary-progress[hidden] {
display: none;
}
.earth-boundary-progress__track {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: rgba(135, 162, 190, 0.26);
}
.earth-boundary-progress__bar {
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #55d6be, #7cb7ff);
transition: width 180ms ease;
}
.earth-boundary-progress__value {
min-width: 40px;
color: var(--hud-text-soft);
font-size: calc(0.66rem * var(--hud-scale));
text-align: right;
font-variant-numeric: tabular-nums;
}
.earth-settings-copy {
display: flex;
flex-direction: column;
@@ -2477,13 +2630,14 @@ label.is-disabled.earth-mobile-settings-card {
.earth-settings-switch-track::after {
content: "";
position: absolute;
top: calc(3px * var(--hud-scale));
top: 50%;
left: calc(3px * var(--hud-scale));
width: calc(16px * var(--hud-scale));
height: calc(16px * var(--hud-scale));
border-radius: 50%;
background: #edf4fc;
box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26);
transform: translateY(-50%);
transition: transform 0.18s ease;
}
@@ -2493,7 +2647,7 @@ label.is-disabled.earth-mobile-settings-card {
}
.earth-settings-switch input:checked + .earth-settings-switch-track::after {
transform: translateX(calc(16px * var(--hud-scale)));
transform: translate(calc(16px * var(--hud-scale)), -50%);
}
.earth-settings-item.is-disabled {

View File

@@ -1,5 +1,232 @@
/* news-panel.css */
.earth-news-ticker {
--news-ticker-width: min(calc(820px * var(--hud-scale)), 58vw);
position: absolute;
top: calc(var(--hud-offset) + calc(2px * var(--hud-scale)));
left: 50%;
transform: translateX(-50%);
width: var(--news-ticker-width);
height: calc(38px * var(--hud-scale));
border: 0;
border-top: 1px solid var(--hud-border);
border-bottom: 1px solid var(--hud-border);
border-radius: 0;
padding: 0 calc(18px * var(--hud-scale));
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: calc(12px * var(--hud-scale));
color: var(--hud-text);
background:
linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.82) 11%, rgba(7, 17, 31, 0.78) 89%, transparent 100%),
radial-gradient(circle at 50% -80%, rgba(145, 186, 255, 0.18), transparent 58%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
0 14px 34px rgba(1, 7, 16, 0.22);
backdrop-filter: blur(16px) saturate(122%);
-webkit-backdrop-filter: blur(16px) saturate(122%);
cursor: pointer;
z-index: 24;
overflow: hidden;
transition: opacity 0.18s ease, transform 0.24s ease;
}
.earth-news-ticker.is-hidden {
opacity: 0;
pointer-events: none;
}
.earth-news-ticker::before,
.earth-news-ticker::after {
content: "";
position: absolute;
top: -1px;
bottom: -1px;
width: calc(96px * var(--hud-scale));
pointer-events: none;
z-index: 2;
}
.earth-news-ticker::before {
left: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08));
}
.earth-news-ticker::after {
right: 0;
background: linear-gradient(270deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08));
}
.earth-news-ticker__region {
position: relative;
z-index: 3;
color: var(--hud-accent-strong);
font-size: calc(0.66rem * var(--hud-scale));
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
}
.earth-news-ticker__viewport {
min-width: 0;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
}
.earth-news-ticker__track {
display: inline-flex;
align-items: center;
gap: calc(30px * var(--hud-scale));
min-width: max-content;
color: rgba(232, 242, 252, 0.92);
font-size: calc(0.84rem * var(--hud-scale));
font-weight: 600;
line-height: 1;
white-space: nowrap;
animation: earthNewsTickerScroll var(--news-ticker-duration, 28s) linear infinite;
}
.earth-news-ticker:hover .earth-news-ticker__track,
.earth-news-ticker:focus-visible .earth-news-ticker__track {
animation-play-state: paused;
}
.earth-news-ticker__item {
display: inline-flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
}
.earth-news-ticker__source {
color: var(--hud-text-soft);
font-size: calc(0.64rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
}
.earth-news-hud {
position: fixed;
left: 50%;
top: max(calc(86px * var(--hud-scale)), 10vh);
transform: translateX(-50%);
width: min(calc(720px * var(--hud-scale)), calc(100vw - 40px));
height: min(calc(660px * var(--hud-scale)), calc(100vh - 140px));
padding: calc(14px * var(--hud-scale));
display: flex;
flex-direction: column;
gap: var(--hud-gap-sm);
z-index: 220;
min-width: calc(420px * var(--hud-scale));
min-height: calc(360px * var(--hud-scale));
}
.earth-news-hud.hud-panel-hidden {
display: none;
}
.earth-news-hud.is-morphing {
display: flex;
overflow: hidden;
min-width: 0;
min-height: 0;
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
height 0.28s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.2s ease;
}
.earth-news-hud.is-morphing > * {
opacity: 0;
}
.earth-news-hud.is-resizing {
transition: none !important;
user-select: none;
}
.earth-news-hud__header {
flex: 0 0 auto;
}
.earth-news-hud__body {
flex: 1 1 auto;
min-height: 0;
display: flex;
}
.earth-news-hud-edge {
position: absolute;
z-index: 10;
}
.earth-news-hud-edge[data-edge="r"] {
right: 0;
top: calc(12px * var(--hud-scale));
bottom: calc(12px * var(--hud-scale));
width: calc(6px * var(--hud-scale));
cursor: ew-resize;
}
.earth-news-hud-edge[data-edge="b"] {
bottom: 0;
left: calc(12px * var(--hud-scale));
right: calc(12px * var(--hud-scale));
height: calc(6px * var(--hud-scale));
cursor: ns-resize;
}
.earth-news-hud-edge[data-edge="l"] {
left: 0;
top: calc(12px * var(--hud-scale));
bottom: calc(12px * var(--hud-scale));
width: calc(6px * var(--hud-scale));
cursor: ew-resize;
}
.earth-news-hud-edge[data-edge="br"] {
right: 0;
bottom: 0;
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
cursor: nwse-resize;
}
.earth-news-hud-edge[data-edge="bl"] {
left: 0;
bottom: 0;
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
cursor: nesw-resize;
}
.earth-news-hud-edge[data-edge="br"]::before {
content: "";
position: absolute;
inset: calc(4px * var(--hud-scale));
border-right: 2px solid rgba(223, 235, 248, 0.4);
border-bottom: 2px solid rgba(223, 235, 248, 0.4);
transition: border-color 0.18s ease;
}
.earth-news-hud-edge[data-edge="br"]:hover::before {
border-color: rgba(244, 249, 255, 0.75);
}
@keyframes earthNewsTickerScroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.news-panel-title-row {
display: flex;
align-items: center;
@@ -33,6 +260,10 @@
overflow: hidden;
}
.earth-news-hud .news-panel-body {
width: 100%;
}
.news-panel-focus {
display: grid;
grid-template-columns: 1fr auto;
@@ -189,3 +420,8 @@
line-height: 1.5;
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
}
.layout-mode-mobile .earth-news-ticker,
.layout-mode-mobile .earth-news-hud {
display: none !important;
}

View File

@@ -1,15 +1,15 @@
/* media-panel
* Outer HUD shell: #media-panel
* Inner live pane: #tv-panel
* Inner news pane: #news-panel
*/
.hud-panel-media {
bottom: var(--hud-offset);
top: var(--hud-offset);
right: var(--hud-offset);
width: calc(420px * var(--hud-scale));
max-width: calc(100vw - 32px);
max-height: calc(100vh - (2 * var(--hud-offset)));
min-height: 0;
min-width: calc(300px * var(--hud-scale));
padding: calc(10px * var(--hud-scale));
display: flex;
@@ -18,11 +18,8 @@
z-index: 18;
}
.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) {
max-height: min(
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
calc(100vh - (2 * var(--hud-offset)))
);
.hud-panel-media:has(.tv-panel-meta-wrap.is-collapsed) {
gap: 0;
}
.hud-panel-media.is-reforming {
@@ -37,6 +34,10 @@
gap: var(--hud-gap-xs);
}
.hud-panel-media:has(.tv-panel-meta-wrap.is-collapsed) .hud-panel__header {
margin-bottom: var(--hud-gap-sm);
}
.hud-panel-media .hud-panel__title-group {
flex: 0 0 auto;
min-width: auto;
@@ -54,8 +55,7 @@
user-select: auto;
}
.hud-panel-media .hud-panel__header .tv-panel-select,
.hud-panel-media .hud-panel__header .media-panel-tab {
.hud-panel-media .hud-panel__header .tv-panel-select {
cursor: pointer;
user-select: auto;
}
@@ -68,10 +68,6 @@
min-width: 0;
}
.tv-panel-header-controls--news {
justify-content: flex-end;
}
.tv-panel-content {
display: flex;
flex: 1 1 auto;
@@ -88,6 +84,10 @@
overflow: hidden;
}
.tv-tab-pane:has(.tv-panel-meta-wrap.is-collapsed) {
gap: 0;
}
.tv-panel-toolbar-actions {
display: inline-flex;
align-items: center;
@@ -95,13 +95,6 @@
flex-shrink: 0;
}
.tv-panel-status {
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.08em;
text-transform: uppercase;
}
.tv-panel-select {
flex: 1 1 auto;
min-width: 0;
@@ -124,7 +117,8 @@
.tv-panel-meta-wrap {
overflow: hidden;
max-height: calc(120px * var(--hud-scale));
flex: 0 0 auto;
max-height: none;
opacity: 1;
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
}
@@ -140,24 +134,70 @@
.tv-panel-meta {
display: grid;
gap: calc(4px * var(--hud-scale));
min-height: 0;
}
.tv-panel-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: calc(6px * var(--hud-scale));
min-width: 0;
}
.tv-panel-title {
min-width: 0;
color: var(--hud-text);
font-size: calc(0.96rem * var(--hud-scale));
font-weight: 600;
overflow-wrap: anywhere;
}
.tv-panel-tag {
flex: 0 0 auto;
border: 1px solid rgba(137, 179, 217, 0.22);
border-radius: calc(999px * var(--hud-scale));
background: rgba(108, 153, 192, 0.12);
color: rgba(214, 232, 248, 0.86);
padding: calc(2px * var(--hud-scale)) calc(7px * var(--hud-scale));
font-size: calc(0.62rem * var(--hud-scale));
font-weight: 700;
line-height: 1.35;
letter-spacing: 0.04em;
}
.tv-panel-tag--status {
border-color: rgba(111, 213, 161, 0.34);
background: rgba(111, 213, 161, 0.13);
color: rgba(174, 244, 202, 0.92);
}
.tv-panel-tag--warning {
border-color: rgba(255, 209, 102, 0.36);
background: rgba(255, 209, 102, 0.13);
color: rgba(255, 226, 149, 0.92);
}
.tv-panel-tag--error {
border-color: rgba(255, 117, 117, 0.36);
background: rgba(255, 117, 117, 0.13);
color: rgba(255, 184, 184, 0.94);
}
.tv-panel-subtitle {
color: var(--hud-text-muted);
font-size: calc(0.74rem * var(--hud-scale));
line-height: 1.35;
white-space: normal;
overflow-wrap: anywhere;
}
.tv-panel-notes {
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
}
.tv-panel-catalog {
@@ -165,6 +205,8 @@
font-size: calc(0.66rem * var(--hud-scale));
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: normal;
overflow-wrap: anywhere;
}
.tv-panel-player {
@@ -203,37 +245,6 @@
background: #050a14;
}
.media-panel-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: calc(8px * var(--hud-scale));
}
.media-panel-tab {
border: 1px solid rgba(201, 225, 247, 0.12);
border-radius: calc(12px * var(--hud-scale));
background: rgba(255, 255, 255, 0.03);
color: var(--hud-text-muted);
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
font-size: calc(0.78rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.media-panel-tab:hover {
color: var(--hud-text);
border-color: rgba(214, 235, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.media-panel-tab--active {
color: var(--hud-accent-strong);
border-color: rgba(120, 180, 255, 0.24);
background: rgba(120, 180, 255, 0.12);
}
.tv-tab-pane[hidden] {
display: none !important;
}
@@ -345,9 +356,9 @@
}
.earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
bottom: var(--hud-offset);
top: var(--hud-offset);
right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
}
/* Media panel keeps its fixed width on all screen sizes.

View File

@@ -8,6 +8,9 @@
{
"imports": {
"three": "https://esm.sh/three@0.128.0",
"pmtiles": "https://esm.sh/pmtiles@4.4.1",
"@mapbox/vector-tile": "https://esm.sh/@mapbox/vector-tile@2.0.4",
"pbf": "https://esm.sh/pbf@4.0.1",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
"hls.js": "https://esm.sh/hls.js@1.6.15",
@@ -513,7 +516,7 @@
<div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-panel" data-drag-self="true">
<div class="hud-panel__header hud-panel-drag-handle">
<div class="hud-panel__title-group">
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
<span class="hud-panel-title hud-panel__title tv-panel-header-title">Live 新闻</span>
</div>
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
@@ -529,32 +532,22 @@
</button>
</div>
</div>
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
<div class="news-panel-title-row">
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
</div>
<div class="tv-panel-toolbar-actions">
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
<span class="material-symbols-rounded">refresh</span>
</button>
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
<span class="material-symbols-rounded">open_in_new</span>
</button>
</div>
</div>
<div class="hud-panel__actions">
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭媒体情报面板">
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭 Live 新闻面板">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div class="tv-panel-content">
<section id="tv-panel" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
<section id="tv-panel" class="tv-tab-pane tv-tab-pane--active">
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
<div class="tv-panel-meta" id="tv-panel-meta">
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
<div class="tv-panel-title-row">
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
<span id="tv-source-origin" class="tv-panel-tag">待加载</span>
<span id="tv-source-status" class="tv-panel-tag tv-panel-tag--status">等待加载</span>
</div>
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
@@ -574,34 +567,8 @@
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
</div>
</section>
<section id="news-panel" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
<div id="news-panel-body" class="news-panel-body">
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
<div class="news-panel-focus">
<div>
<div class="news-focus-kicker">当前关注区域</div>
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
</div>
<div class="news-board">
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
<div id="news-board-list" class="news-board-list"></div>
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
</div>
</div>
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</section>
</div>
<div class="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
<button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
<button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
</div>
<div class="tv-panel-edge" data-edge="r"></div>
<div class="tv-panel-edge" data-edge="b"></div>
<div class="tv-panel-edge" data-edge="l"></div>
@@ -609,6 +576,59 @@
<div class="tv-panel-edge" data-edge="bl"></div>
</div>
<button id="desktop-news-ticker" class="earth-news-ticker" type="button" aria-label="打开态势新闻">
<span id="news-ticker-region" class="earth-news-ticker__region">GLOBAL</span>
<span class="earth-news-ticker__viewport">
<span id="news-ticker-track" class="earth-news-ticker__track">正在准备全球态势新闻...</span>
</span>
</button>
<div id="news-hud-panel" class="hud-panel earth-news-hud hud-panel-draggable hud-panel-hidden" role="dialog" aria-modal="false" aria-label="态势聚合新闻">
<div class="hud-panel__header earth-news-hud__header hud-panel-drag-handle">
<div class="hud-panel__title-group">
<span class="hud-panel-title hud-panel__title">态势聚合新闻</span>
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
</div>
<div class="hud-panel__actions">
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
<span class="material-symbols-rounded">refresh</span>
</button>
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
<span class="material-symbols-rounded">open_in_new</span>
</button>
<button id="news-hud-close" class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" aria-label="收起态势新闻">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div id="news-panel" class="earth-news-hud__body">
<div id="news-panel-body" class="news-panel-body">
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
<div class="news-panel-focus">
<div>
<div class="news-focus-kicker">当前关注区域</div>
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
</div>
<div class="news-board">
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
<div id="news-board-list" class="news-board-list"></div>
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
</div>
</div>
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</div>
<div class="earth-news-hud-edge" data-edge="r"></div>
<div class="earth-news-hud-edge" data-edge="b"></div>
<div class="earth-news-hud-edge" data-edge="l"></div>
<div class="earth-news-hud-edge" data-edge="br"></div>
<div class="earth-news-hud-edge" data-edge="bl"></div>
</div>
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="tooltip" class="earth-tooltip"></div>
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
@@ -869,6 +889,17 @@
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</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">控制鼠标悬停地表时显示国家、位置或完整信息</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择悬停提示内容">
<button type="button" class="earth-mobile-settings-pill" data-surface-hover-info-mode="country" aria-pressed="false">国家</button>
<button type="button" class="earth-mobile-settings-pill" data-surface-hover-info-mode="position" aria-pressed="false">位置</button>
<button type="button" class="earth-mobile-settings-pill is-active" data-surface-hover-info-mode="full" aria-pressed="true">完整</button>
</div>
</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星呼吸闪烁</span>
@@ -879,6 +910,16 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<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-satellite-real-altitude-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">轨迹显示</span>
@@ -1180,6 +1221,38 @@
</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"
data-surface-hover-info-mode="country"
aria-pressed="false"
>
国家
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-surface-hover-info-mode="position"
aria-pressed="false"
>
位置
</button>
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-surface-hover-info-mode="full"
aria-pressed="true"
>
完整
</button>
</div>
</div>
<label class="earth-settings-item" for="toggle-satellite-idle-breathing">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星呼吸闪烁</span>
@@ -1190,6 +1263,16 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-satellite-real-altitude">
<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-satellite-real-altitude" type="checkbox" data-satellite-real-altitude-toggle checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-trails-display">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">轨迹显示</span>
@@ -1270,7 +1353,7 @@
<label class="earth-settings-item" for="toggle-view-stats">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">全球态势</span>
<span class="earth-settings-item-subtitle">控制右角全球态势统计面板显示</span>
<span class="earth-settings-item-subtitle">控制右角全球态势统计面板显示</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-view-stats" type="checkbox" data-settings-panel="earth-stats">
@@ -1280,7 +1363,7 @@
<label class="earth-settings-item" for="toggle-view-tv">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">新闻直播</span>
<span class="earth-settings-item-subtitle">控制电视直播 / 态势聚合显示</span>
<span class="earth-settings-item-subtitle">控制右上角 Live 新闻面板显示</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
@@ -1337,6 +1420,32 @@
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">国界精度</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title" id="boundary-precision-status">国界精度</span>
<span class="earth-settings-item-subtitle" id="boundary-precision-detail">正在读取高清国界状态...</span>
</div>
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" hidden>
<div class="earth-boundary-progress__track">
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar"></div>
</div>
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value">0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" aria-pressed="false">高精</button>
</div>
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">系统</div>
<div class="earth-settings-list">

View File

@@ -414,6 +414,14 @@ function blendHexColors(fromHex, toHex, ratio) {
return colorScratchA.getHex();
}
function getHaloTintColor(baseColor) {
return blendHexColors(
BGP_CONFIG.halo.tintNeutralColor,
baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.halo.tintBlend,
);
}
function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
@@ -1131,7 +1139,7 @@ function attachCollectorEffectSprites(marker) {
statusCore.renderOrder = 4;
const coverageHalo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
color: getHaloTintColor(activity.color),
opacity: 0.0,
scale: activity.coverageHaloScale * 0.7,
});
@@ -1524,6 +1532,9 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.position.copy(marker.position);
marker.userData.coverageHalo.material.opacity = coverageOpacity * haloOpacityMul;
marker.userData.coverageHalo.material.color.setHex(
getHaloTintColor(marker.userData.baseColor),
);
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012) * haloScaleMul,
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012) * haloScaleMul,
@@ -1753,8 +1764,9 @@ export function showBGPEventOverlay(marker, earth) {
const overlayItems = [];
validRegions.forEach((region) => {
const eventBaseColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
color: getHaloTintColor(eventBaseColor),
opacity: 0.24,
scale: BGP_CONFIG.regionScale,
});
@@ -1795,9 +1807,11 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8);
const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost;
const pulseHaloScale = haloScale * 1.32;
const collectorBaseColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const collectorHaloColor = getHaloTintColor(collectorBaseColor);
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
color: collectorHaloColor,
opacity: 0.11,
scale: haloScale * 0.78,
});
@@ -1812,7 +1826,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
bgpCollectorRadarGroup.add(halo);
const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
color: collectorHaloColor,
opacity: 0.065,
scale: pulseHaloScale * 0.82,
});
@@ -1820,7 +1834,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
pulseHalo.renderOrder = 1;
bgpCollectorRadarGroup.add(pulseHalo);
const innerRing = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
color: collectorBaseColor,
opacity: 0.12,
scale: Math.max(haloScale * 0.34, 5.5),
});
@@ -1840,7 +1854,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
const sectorHalfWidth = Math.PI * 0.22;
const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI);
const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI);
const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const coverageColor = collectorBaseColor;
const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4;
const leftBoundaryPoints = createRadialBoundaryPoints(

View File

@@ -39,6 +39,15 @@ export const SATELLITE_DISPLAY_STYLES = {
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT;
export const SURFACE_HOVER_INFO_MODES = {
COUNTRY: "country",
POSITION: "position",
FULL: "full",
};
export const DEFAULT_SURFACE_HOVER_INFO_MODE =
SURFACE_HOVER_INFO_MODES.FULL;
export const CRUISE_CONFIG = {
dwellMs: 7_000,
focusDurationMs: 1_400,
@@ -177,9 +186,29 @@ export const TERRAIN_CONFIG = {
};
export const COUNTRY_BOUNDARY_CONFIG = {
dataPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href,
tileManifestPath: new URL("../data/boundaries/v1/manifest.json", import.meta.url).href,
tileBasePath: new URL("../data/boundaries/v1/", import.meta.url).href,
tileProvider: "auto",
pmtilesPath: new URL("../data/boundaries/earth-boundaries-china-pov-v1.pmtiles", import.meta.url).href,
legacyFallbackPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href,
mvtLayerNames: {
boundary: ["boundary_admin0", "boundary_disputed_internal", "coastline"],
claim: ["claim_line"],
},
tileZoomThresholds: [
{ minViewZoom: 5.2, tileZoom: 10 },
{ minViewZoom: 4.6, tileZoom: 9 },
{ minViewZoom: 4.0, tileZoom: 8 },
{ minViewZoom: 3.4, tileZoom: 7 },
{ minViewZoom: 2.8, tileZoom: 6 },
{ minViewZoom: 1.6, tileZoom: 5 },
],
tilePrefetchRing: 1,
tileDebounceMs: 180,
tileCacheLimit: 150,
lineAltitudeOffset: 0.115,
hoverAltitudeOffset: 0.14,
hoverAltitudeOffset: 0.115,
hoverMissStickyMs: 160,
lineColor: 0x7fc7ff,
lineOpacity: 0.58,
lineRenderOrder: 2.2,
@@ -190,13 +219,13 @@ export const COUNTRY_BOUNDARY_CONFIG = {
hoverGlowOpacity: 0.38,
hoverGlowLineWidth: 3,
hoverGlowRenderOrderOffset: 0.01,
hoverGlowRadiusOffset: 0.04,
hoverGlowRadiusOffset: 0,
tintAltitudeOffset: 0.04,
tintColor: 0x0b1830,
tintRenderOrder: 0.2,
landColor: 0x080f1b,
landOpacity: 1.0,
landAltitudeOffset: 0.08,
landAltitudeOffset: 0.32,
landRenderOrder: 0.86,
landMaskWidth: 2048,
landMaskHeight: 1024,
@@ -331,7 +360,11 @@ export const SATELLITE_CONFIG = {
hydrateFullAfterInitialLoad: false,
trailLength: 10,
trailLineWidth: 3,
displayAltitudeOffset: 8,
fallbackAltitudeOffset: 8,
altitudeCompressionKm: 1200,
maxDisplayAltitudeKm: 40000,
minRealAltitudeOffset: 4,
maxRealAltitudeOffset: 25,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotBaseSize: 2.8,
@@ -427,6 +460,8 @@ export const BGP_CONFIG = {
collectorScale: 11.5,
collectorPulseScale: 16.5,
collectorCoverageScale: 22.5,
tintNeutralColor: 0xffffff,
tintBlend: 0.72,
},
sizeStabilization: {
enabled: true,
@@ -477,7 +512,7 @@ export const EARTH_MATERIAL_CONFIG = {
shininess: 12,
emissive: 0x010609,
opacity: 1,
textureOverlayAltitudeOffset: 0.1,
textureOverlayAltitudeOffset: 0.48,
textureOverlayOpacity: 0.88,
textureOverlayRenderOrder: 0.96,
textureOverlaySpecular: 0x05080d,
@@ -521,7 +556,9 @@ export const EARTH_MATERIAL_CONFIG = {
sunDirection: { x: 1, y: 0.2, z: 0.4 },
nightFloor: 0.24,
dayBoost: 1.12,
featherScale: 0.001,
twilightWidth: 0.2,
twilightFeatherScale: 1.0,
twilightIntensity: 0.14,
twilightColor: 0x4ea0ff,
nightTintColor: 0x0b1830,

View File

@@ -4,11 +4,13 @@ import * as THREE from "three";
import {
CONFIG,
CRUISE_MODULES,
DEFAULT_SURFACE_HOVER_INFO_MODE,
DEFAULT_SATELLITE_DISPLAY_STYLE,
DEFAULT_CRUISE_MODULES,
EARTH_CONFIG,
ROTATION_MODE,
SATELLITE_DISPLAY_STYLES,
SURFACE_HOVER_INFO_MODES,
} from "./constants.js";
import {
setEarthStatValue,
@@ -32,6 +34,7 @@ import {
} from "./terrain.js";
import {
reloadData,
reloadCountryBoundaries,
clearLockedObject,
clearLockedObjectAndInfo,
setCablesEnabled,
@@ -51,7 +54,9 @@ import {
getSatelliteCount,
getSatelliteDisplayStyle,
getSatelliteIdleBreathingEnabled,
getSatelliteRealAltitudeEnabled,
setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled,
setSatelliteRealAltitudeEnabled as applySatelliteRealAltitudeEnabled,
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
} from "./satellites.js";
import {
@@ -60,7 +65,12 @@ import {
} from "./interactable.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
import {
getHighPrecisionBoundariesEnabled,
getShowCountryBoundaries,
setHighPrecisionBoundariesEnabled,
toggleCountryBoundaries,
} from "./country-boundaries.js";
import {
toggleComputeCenters,
getShowComputeCenters,
@@ -111,6 +121,8 @@ let motionProvider = DEFAULT_MOTION_PROVIDER;
let motionDebugSkeletonOnly = false;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
let boundaryBuildPollTimer = null;
let boundaryBuildAttemptedThisSession = false;
let earthObj = null;
let listeners = [];
@@ -141,7 +153,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 10;
const EARTH_SETTINGS_VERSION = 11;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
@@ -150,6 +162,7 @@ const MOTION_PROVIDER_DEFAULT_VERSION = 7;
const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
@@ -177,6 +190,9 @@ const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
const ALLOWED_SURFACE_HOVER_INFO_MODES = new Set(
Object.values(SURFACE_HOVER_INFO_MODES),
);
const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.BGP]: "BGP",
[CRUISE_MODULES.NEWS]: "新闻",
@@ -190,6 +206,12 @@ function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live";
}
function normalizeSurfaceHoverInfoMode(mode) {
return ALLOWED_SURFACE_HOVER_INFO_MODES.has(mode)
? mode
: DEFAULT_SURFACE_HOVER_INFO_MODE;
}
function detectLayoutMode() {
const width = window.innerWidth;
const height = window.innerHeight;
@@ -794,7 +816,9 @@ function getCurrentSharedSettingsSnapshot() {
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
};
}
@@ -848,8 +872,13 @@ function cloneEarthSettings(settings) {
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
satelliteIdleBreathingEnabled:
settings.shared.satelliteIdleBreathingEnabled !== false,
satelliteRealAltitudeEnabled:
settings.shared.satelliteRealAltitudeEnabled !== false,
interactableCompactDotsEnabled:
settings.shared.interactableCompactDotsEnabled !== false,
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
settings.shared.surfaceHoverInfoMode,
),
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
@@ -975,11 +1004,19 @@ function normalizeEarthSettings(rawSettings, defaults) {
typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean"
? sharedSettings.satelliteIdleBreathingEnabled
: defaults.shared.satelliteIdleBreathingEnabled;
const nextSatelliteRealAltitudeEnabled =
typeof sharedSettings?.satelliteRealAltitudeEnabled === "boolean"
? sharedSettings.satelliteRealAltitudeEnabled
: defaults.shared.satelliteRealAltitudeEnabled;
const nextInteractableCompactDotsEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.interactableCompactDotsEnabled === "boolean"
? sharedSettings.interactableCompactDotsEnabled
: defaults.shared.interactableCompactDotsEnabled;
const nextSurfaceHoverInfoMode =
(rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION
? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode)
: defaults.shared.surfaceHoverInfoMode;
const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean"
? sharedSettings.trailsEnabled
: typeof inputLayerVisibility.trails === "boolean"
@@ -1006,7 +1043,9 @@ function normalizeEarthSettings(rawSettings, defaults) {
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
},
views: {
desktop: {
@@ -1045,6 +1084,7 @@ function syncMotionProviderControls(nextProvider = motionProvider) {
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
syncSegmentedControlSliders();
}
function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) {
@@ -1175,6 +1215,16 @@ function syncCruiseModuleControls() {
});
}
function syncSegmentedControlSliders() {
document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => {
if (!(segmented instanceof HTMLElement)) return;
const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill"));
const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active")));
segmented.style.setProperty("--item-count", String(Math.max(1, buttons.length)));
segmented.style.setProperty("--active-index", String(activeIndex));
});
}
function syncSatelliteDisplayStyleControls() {
const activeStyle = getSatelliteDisplayStyle();
document.querySelectorAll("[data-satellite-display-style]").forEach((button) => {
@@ -1184,6 +1234,7 @@ function syncSatelliteDisplayStyleControls() {
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
syncSegmentedControlSliders();
}
function syncSatelliteIdleBreathingToggle() {
@@ -1195,6 +1246,15 @@ function syncSatelliteIdleBreathingToggle() {
});
}
function syncSatelliteRealAltitudeToggle() {
const enabled = getSatelliteRealAltitudeEnabled();
document.querySelectorAll("[data-satellite-real-altitude-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function syncInteractableCompactDotsToggle() {
const enabled = getInteractableCompactDotsEnabled();
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => {
@@ -1204,6 +1264,18 @@ function syncInteractableCompactDotsToggle() {
});
}
function syncSurfaceHoverInfoModeControls() {
const activeMode = getSurfaceHoverInfoMode();
document.querySelectorAll("[data-surface-hover-info-mode]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const mode = normalizeSurfaceHoverInfoMode(button.dataset.surfaceHoverInfoMode);
const active = mode === activeMode;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
syncSegmentedControlSliders();
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
@@ -1294,6 +1366,27 @@ export function setSatelliteIdleBreathingEnabled(
return enabled;
}
export function setSatelliteRealAltitudeEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applySatelliteRealAltitudeEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteRealAltitudeEnabled = enabled;
syncSatelliteRealAltitudeToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(
enabled ? "卫星真实高度已开启" : "卫星已切换为旧版同层高度",
"info",
);
}
return enabled;
}
export function setInteractableCompactDotsEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
@@ -1312,6 +1405,42 @@ export function setInteractableCompactDotsEnabled(
return enabled;
}
export function getSurfaceHoverInfoMode() {
return normalizeSurfaceHoverInfoMode(earthSettingsState?.shared?.surfaceHoverInfoMode);
}
export function setSurfaceHoverInfoMode(
nextMode,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedMode = normalizeSurfaceHoverInfoMode(nextMode);
const previousMode = getSurfaceHoverInfoMode();
if (normalizedMode === previousMode) {
syncSurfaceHoverInfoModeControls();
return normalizedMode;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.surfaceHoverInfoMode = normalizedMode;
syncSurfaceHoverInfoModeControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const label =
normalizedMode === SURFACE_HOVER_INFO_MODES.COUNTRY
? "国家"
: normalizedMode === SURFACE_HOVER_INFO_MODES.POSITION
? "位置"
: "完整";
showStatusMessage(`悬停提示已切换为:${label}`, "info");
}
return normalizedMode;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -1387,10 +1516,18 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setSatelliteRealAltitudeEnabled(settings.shared.satelliteRealAltitudeEnabled, {
persist: false,
suppressStatus: true,
});
setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, {
persist: false,
suppressStatus: true,
});
setSurfaceHoverInfoMode(settings.shared.surfaceHoverInfoMode, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -2578,6 +2715,204 @@ export function setDayNightInteractable(enabled) {
});
}
function getBoundaryPrecisionEls() {
return {
status: document.getElementById("boundary-precision-status"),
detail: document.getElementById("boundary-precision-detail"),
progressWrap: document.getElementById("boundary-precision-progress-wrap"),
progressBar: document.getElementById("boundary-precision-progress-bar"),
progressValue: document.getElementById("boundary-precision-progress-value"),
buildButton: document.getElementById("boundary-precision-build"),
rebuildButton: document.getElementById("boundary-precision-rebuild"),
disableButton: document.getElementById("boundary-precision-disable"),
};
}
async function fetchBoundaryPrecisionJson(path, options = {}) {
const response = await fetch(path, {
cache: "no-store",
...options,
headers: {
"content-type": "application/json",
...(options.headers || {}),
},
});
const contentType = response.headers.get("content-type") || "";
if (!response.ok) {
let detail = `HTTP ${response.status}`;
if (contentType.includes("application/json")) {
const payload = await response.json().catch(() => null);
detail = payload?.detail?.message || payload?.detail || detail;
}
throw new Error(detail);
}
if (!contentType.includes("application/json")) {
throw new Error("后端没有返回 JSON 状态");
}
return response.json();
}
function renderBoundaryPrecisionStatus(payload = {}) {
const els = getBoundaryPrecisionEls();
const job = payload.job || payload.current_job || {};
const highReady = Boolean(payload.high_precision_ready || job?.result?.high_precision_ready);
const enabled = getHighPrecisionBoundariesEnabled();
const running = job.status === "queued" || job.status === "running";
const failed = job.status === "failed" && boundaryBuildAttemptedThisSession;
const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
const failureMessage = job.code === "source_not_configured" || job.code === "missing_sources"
? `高清国界更新源未配置完整:${job.message || job.code}`
: `高清国界下载失败:${job.message || job.code || "请检查更新源"}`;
if (els.status) {
els.status.textContent = "国界精度";
}
if (els.detail) {
els.detail.textContent = running
? (job.message || "正在准备高清国界")
: failed
? failureMessage
: highReady
? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。")
: "当前使用低精国界;切到高精会下载并构建。";
}
if (els.progressWrap) {
els.progressWrap.hidden = !running;
}
if (els.progressBar) {
els.progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
}
if (els.progressValue) {
els.progressValue.textContent = job.status === "failed"
? `失败:${job.message || job.code || "构建失败"}`
: `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
}
if (els.buildButton instanceof HTMLButtonElement) {
els.buildButton.disabled = running;
els.buildButton.textContent = "高精";
els.buildButton.classList.toggle("is-active", enabled || running);
els.buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
}
if (els.rebuildButton instanceof HTMLButtonElement) {
const shouldShowRebuild = highReady && enabled && !running;
els.rebuildButton.hidden = !shouldShowRebuild;
els.rebuildButton.disabled = !shouldShowRebuild;
}
if (els.disableButton instanceof HTMLButtonElement) {
els.disableButton.disabled = running;
els.disableButton.classList.toggle("is-active", !enabled && !running);
els.disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
}
syncSegmentedControlSliders();
}
async function refreshBoundaryPrecisionStatus() {
const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/status");
renderBoundaryPrecisionStatus(payload);
return payload;
}
function stopBoundaryBuildPolling() {
if (boundaryBuildPollTimer) {
window.clearInterval(boundaryBuildPollTimer);
boundaryBuildPollTimer = null;
}
}
function startBoundaryBuildPolling() {
stopBoundaryBuildPolling();
boundaryBuildPollTimer = window.setInterval(async () => {
try {
const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build/status");
renderBoundaryPrecisionStatus(payload);
const status = payload.job?.status;
if (status === "succeeded" || status === "failed") {
stopBoundaryBuildPolling();
await refreshBoundaryPrecisionStatus();
if (status === "succeeded" && boundaryBuildAttemptedThisSession) {
setHighPrecisionBoundariesEnabled(true);
await reloadCountryBoundaries({ suppressStatus: true });
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage("高精国界已下载并应用", "info");
}
}
} catch (error) {
stopBoundaryBuildPolling();
showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning");
}
}, 1000);
}
async function startBoundaryPrecisionBuild() {
renderBoundaryPrecisionStatus({
job: { status: "queued", progress: 0, message: "正在启动高精国界构建" },
});
boundaryBuildAttemptedThisSession = true;
await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" });
showStatusMessage("高精国界构建已启动", "info");
startBoundaryBuildPolling();
}
async function setupBoundaryPrecisionControls() {
const els = getBoundaryPrecisionEls();
if (!els.buildButton && !els.disableButton) return;
try {
const payload = await refreshBoundaryPrecisionStatus();
const jobStatus = payload.current_job?.status;
if (jobStatus === "queued" || jobStatus === "running") {
startBoundaryBuildPolling();
}
} catch (error) {
renderBoundaryPrecisionStatus({});
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
}
if (els.buildButton instanceof HTMLButtonElement) {
bindListener(els.buildButton, "click", async () => {
try {
const statusPayload = await refreshBoundaryPrecisionStatus();
const job = statusPayload.job || statusPayload.current_job || {};
const highReady = Boolean(statusPayload.high_precision_ready || job?.result?.high_precision_ready);
if (highReady) {
if (getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(true);
await reloadCountryBoundaries({ suppressStatus: true });
showStatusMessage("已切换到高精国界", "info");
await refreshBoundaryPrecisionStatus().catch(() => {});
return;
}
await startBoundaryPrecisionBuild();
} catch (error) {
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
}
});
}
if (els.rebuildButton instanceof HTMLButtonElement) {
bindListener(els.rebuildButton, "click", async () => {
try {
await startBoundaryPrecisionBuild();
} catch (error) {
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
}
});
}
if (els.disableButton instanceof HTMLButtonElement) {
bindListener(els.disableButton, "click", async () => {
try {
if (!getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(false);
await reloadCountryBoundaries({ suppressStatus: true });
showStatusMessage("已切换到低精国界", "info");
await refreshBoundaryPrecisionStatus().catch(() => {});
} catch (error) {
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
}
});
}
}
function setupSettingsControls() {
const settingsTrigger = document.getElementById("settings-trigger");
const settingsClose = document.getElementById("settings-close");
@@ -2625,6 +2960,7 @@ function setupSettingsControls() {
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
const surfaceHoverInfoModeButtons = document.querySelectorAll("[data-surface-hover-info-mode]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
@@ -2707,6 +3043,14 @@ function setupSettingsControls() {
});
});
surfaceHoverInfoModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
setSurfaceHoverInfoMode(target.dataset.surfaceHoverInfoMode);
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
@@ -2714,6 +3058,13 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-satellite-real-altitude-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setSatelliteRealAltitudeEnabled(toggle.checked);
});
});
document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
@@ -2767,10 +3118,15 @@ function setupSettingsControls() {
syncRotationModeButtons();
syncCruiseModuleControls();
syncSatelliteDisplayStyleControls();
syncSatelliteIdleBreathingToggle();
syncSatelliteRealAltitudeToggle();
syncInteractableCompactDotsToggle();
syncSurfaceHoverInfoModeControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
void setupBoundaryPrecisionControls();
}
function setupHudPanelControls() {
@@ -2959,7 +3315,7 @@ function setupDraggableHudPanels() {
bindListener(handle, "pointerdown", (event) => {
if (isMobileLayout()) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-player, .tv-panel-edge, .earth-news-hud-edge, .legend-bar-btn, .news-story-card")) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
@@ -4076,6 +4432,7 @@ function syncRotationModeButtons() {
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", isActive ? "true" : "false");
});
syncSegmentedControlSliders();
}
function updateRotateUI() {

View File

@@ -1,6 +1,9 @@
import * as THREE from "three";
import { PMTiles } from "pmtiles";
import { VectorTile } from "@mapbox/vector-tile";
import Pbf from "pbf";
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import { latLonToVector3, screenToEarthCoords, vector3ToLatLon } from "./utils.js";
// ─── Module state ──────────────────────────────────────────────────────────────
let _earthObj = null;
@@ -8,21 +11,75 @@ let _features = [];
let _landMesh = null;
let _tintMesh = null;
let _boundaryLines = null;
let _coastlineLines = null;
let _claimGroup = null;
let _hoverGlowLines = null;
let _hoverLines = null;
let _hoveredFeature = null;
let _hoveredGroupKey = null;
let _hoverClearTimer = null;
let _lastHoverHitAt = 0;
let _lastHoverInfo = null;
let _hoverGeometryCache = new Map();
let _tileManifest = null;
let _tileProvider = "pmtiles-mvt";
let _boundaryProviderState = "unloaded";
let _pmtilesArchive = null;
let _tileCache = new Map();
let _tileLru = [];
let _inFlightTiles = new Map();
let _activeTileKeys = new Set();
let _lastTileSignature = "";
let _tileUpdateTimer = null;
let _visible = false;
let _landFillEnabled = true;
let _landFillSuppressed = false;
let _tintEnabled = false;
let _landTexture = null;
let _loaded = false;
let _loadPromise = null;
let _tileAssetVersion = "";
const HIGH_PRECISION_BOUNDARIES_STORAGE_KEY = "planet.earth.boundaries.highPrecisionEnabled";
function canUseLocalStorage() {
try {
return typeof window !== "undefined" && !!window.localStorage;
} catch (_) {
return false;
}
}
export function getHighPrecisionBoundariesEnabled() {
if (!canUseLocalStorage()) return false;
return window.localStorage.getItem(HIGH_PRECISION_BOUNDARIES_STORAGE_KEY) === "true";
}
export function setHighPrecisionBoundariesEnabled(enabled) {
const nextEnabled = Boolean(enabled);
if (canUseLocalStorage()) {
window.localStorage.setItem(
HIGH_PRECISION_BOUNDARIES_STORAGE_KEY,
nextEnabled ? "true" : "false",
);
}
return nextEnabled;
}
const OCEAN_HEX = 0x010609;
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
function configureLandMaskTexture(texture) {
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
texture.anisotropy = 1;
texture.needsUpdate = true;
return texture;
}
function hexToStyle(hex) {
return `#${hex.toString(16).padStart(6, "0")}`;
}
@@ -35,32 +92,14 @@ function hexToRgb(hex) {
];
}
function buildLandTexture(features) {
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
function drawLandMaskCanvas(features, width, height) {
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;
return null;
}
// Ocean background
@@ -93,21 +132,43 @@ function buildLandTexture(features) {
}
}
const imageData = ctx.getImageData(0, 0, width, height);
const tex = new THREE.DataTexture(
new Uint8Array(imageData.data),
return canvas;
}
function buildTextureFromCanvas(canvas) {
const tex = new THREE.CanvasTexture(canvas);
configureLandMaskTexture(tex);
tex.flipY = true;
tex.userData = {
...(tex.userData || {}),
sourceCanvas: canvas,
};
return tex;
}
function buildFallbackLandTexture(width, height) {
const oceanRgb = hexToRgb(OCEAN_HEX);
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,
);
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;
return configureLandMaskTexture(fallbackTexture);
}
function buildLandTexture(features) {
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
const canvas = drawLandMaskCanvas(features, width, height);
return canvas ? buildTextureFromCanvas(canvas) : buildFallbackLandTexture(width, height);
}
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
@@ -145,6 +206,14 @@ function makeTintMesh() {
// ─── Boundary line geometry ────────────────────────────────────────────────────
function boundaryLineRadius({ claim = false } = {}) {
return (
CONFIG.earthRadius +
COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset +
(claim ? 0.018 : 0)
);
}
function ringToSegments(ring, radius, out) {
const n = ring.length;
if (n < 2) return;
@@ -161,12 +230,16 @@ function featureToSegments(geom, radius) {
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
} else if (geom.type === "MultiPolygon") {
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
} else if (geom.type === "LineString") {
ringToSegments(geom.coordinates, radius, pts);
} else if (geom.type === "MultiLineString") {
geom.coordinates.forEach(line => ringToSegments(line, radius, pts));
}
return pts;
}
function buildBoundaryLines(features) {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
const r = boundaryLineRadius();
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
transparent: true,
@@ -178,7 +251,7 @@ function buildBoundaryLines(features) {
const all = [];
for (const feat of features) {
const pts = featureToSegments(feat.geometry, r);
all.push(...pts);
for (const point of pts) all.push(point);
}
const geo = all.length > 0
@@ -192,6 +265,366 @@ function buildBoundaryLines(features) {
return lines;
}
function isStandaloneCoastlineFeature(feature) {
const properties = feature?.properties || {};
return (
properties.PLANET_LAYER === "coastline" ||
properties.featurecla === "Coastline"
);
}
function buildClaimLines() {
const mat = new THREE.LineDashedMaterial({
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
transparent: true,
opacity: 0.82,
dashSize: 0.7,
gapSize: 0.42,
depthTest: true,
depthWrite: false,
});
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
lines.name = "country-boundary-china-claims";
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function makeBoundaryTileObject(features, { claim = false } = {}) {
const radius = boundaryLineRadius({ claim });
const points = featureListToSegments(features, radius);
const geometry = makeLineGeometry(points);
const material = claim
? _claimGroup?.material?.clone?.() || buildClaimLines().material
: _boundaryLines?.material?.clone?.() || new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
transparent: true,
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
depthTest: true,
depthWrite: false,
});
const lines = new THREE.LineSegments(geometry, material);
lines.name = claim ? "country-boundary-claim-tile" : "country-boundary-tile";
lines.renderOrder = claim
? COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02
: COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.01;
lines.visible = _visible;
lines.raycast = () => {};
if (claim && typeof lines.computeLineDistances === "function") {
lines.computeLineDistances();
}
return lines;
}
function disposeTileObject(object) {
if (!object) return;
if (_earthObj) _earthObj.remove(object);
object.geometry?.dispose?.();
object.material?.dispose?.();
}
function touchTileKey(key) {
_tileLru = _tileLru.filter(item => item !== key);
_tileLru.push(key);
}
function trimTileCache() {
const limit = COUNTRY_BOUNDARY_CONFIG.tileCacheLimit;
while (_tileLru.length > limit) {
const key = _tileLru.shift();
if (!key || _activeTileKeys.has(key)) continue;
const cached = _tileCache.get(key);
_tileCache.delete(key);
disposeTileObject(cached?.object);
}
}
function setTileObjectVisible(object, visible) {
if (object) object.visible = _visible && visible;
}
function setActiveTileKeys(nextKeys) {
_activeTileKeys = new Set(nextKeys);
_tileCache.forEach((entry, key) => {
setTileObjectVisible(entry.object, _activeTileKeys.has(key));
});
}
function tileUrlForKey(key) {
const [kind, z, x, y] = key.split("/");
const prefix = COUNTRY_BOUNDARY_CONFIG.tileBasePath.replace(/\/?$/, "/");
const versionSuffix = _tileAssetVersion ? `?v=${encodeURIComponent(_tileAssetVersion)}` : "";
if (kind === "claim") {
return `${prefix}china-claims/${z}/${x}/${y}.geojson${versionSuffix}`;
}
return `${prefix}${z}/${x}/${y}.geojson${versionSuffix}`;
}
function versionedBoundaryAssetUrl(path) {
const url = new URL(path, COUNTRY_BOUNDARY_CONFIG.tileBasePath);
if (_tileAssetVersion) url.searchParams.set("v", _tileAssetVersion);
return url.href;
}
function manifestProvider(manifest) {
const configured = COUNTRY_BOUNDARY_CONFIG.tileProvider;
if (configured && configured !== "auto") return configured;
return manifest?.tileProvider || manifest?.format || "pmtiles-mvt";
}
async function fetchJsonAsset(url, { required = false } = {}) {
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
if (required) throw new Error(`${url} HTTP ${resp.status}`);
return null;
}
const text = await resp.text();
try {
return JSON.parse(text);
} catch (err) {
if (required) throw err;
console.warn("[country-boundaries] JSON asset unavailable", url, err);
return null;
}
}
function manifestPmtilesUrl(manifest) {
const fromManifest =
manifest?.pmtiles?.url ||
manifest?.pmtiles?.path ||
manifest?.artifacts?.pmtiles?.url ||
manifest?.artifacts?.pmtiles?.path ||
manifest?.artifact;
if (!fromManifest) return COUNTRY_BOUNDARY_CONFIG.pmtilesPath;
return new URL(fromManifest, COUNTRY_BOUNDARY_CONFIG.tileBasePath).href;
}
function ensurePmtilesArchive() {
if (_pmtilesArchive) return _pmtilesArchive;
const url = manifestPmtilesUrl(_tileManifest);
_pmtilesArchive = new PMTiles(url);
return _pmtilesArchive;
}
function getMvtLayerNames(kind) {
const layerConfig = COUNTRY_BOUNDARY_CONFIG.mvtLayerNames || {};
if (kind === "claim") return layerConfig.claim || ["claim_line"];
return layerConfig.boundary || ["boundary_admin0", "boundary_disputed_internal", "coastline"];
}
async function loadPmtilesMvtFeatures(key) {
const [kind, zText, xText, yText] = key.split("/");
const z = Number(zText);
const x = Number(xText);
const y = Number(yText);
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) return [];
const archive = ensurePmtilesArchive();
const tile = await archive.getZxy(z, x, y);
if (!tile?.data) return [];
const vectorTile = new VectorTile(new Pbf(new Uint8Array(tile.data)));
const features = [];
for (const layerName of getMvtLayerNames(kind)) {
const layer = vectorTile.layers[layerName];
if (!layer) continue;
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i).toGeoJSON(x, y, z);
if (feature?.geometry) features.push(feature);
}
}
return features;
}
async function loadDebugGeojsonFeatures(key) {
const resp = await fetch(tileUrlForKey(key));
if (!resp.ok) {
if (resp.status === 404) return [];
throw new Error(`boundary tile ${key} HTTP ${resp.status}`);
}
const payload = await resp.json();
return (payload.features || []).filter(f => f.geometry);
}
async function loadBoundaryTile(key) {
if (_tileCache.has(key)) {
touchTileKey(key);
return _tileCache.get(key);
}
if (_inFlightTiles.has(key)) return _inFlightTiles.get(key);
const promise = (async () => {
if (_tileProvider !== "pmtiles-mvt") {
throw new Error(`不支持的国界瓦片 provider: ${_tileProvider}`);
}
const features = await loadPmtilesMvtFeatures(key);
if (features.length === 0) return null;
const entry = {
object: makeBoundaryTileObject(features, { claim: key.startsWith("claim/") }),
};
_earthObj.add(entry.object);
_tileCache.set(key, entry);
touchTileKey(key);
trimTileCache();
return entry;
})().catch(err => {
console.warn("[country-boundaries] tile load failed", key, err);
return null;
}).finally(() => {
_inFlightTiles.delete(key);
});
_inFlightTiles.set(key, promise);
return promise;
}
function lonToTileX(lon, zoom) {
const n = 2 ** zoom;
return Math.max(0, Math.min(n - 1, Math.floor(((lon + 180) / 360) * n)));
}
function latToTileY(lat, zoom) {
const n = 2 ** zoom;
const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
const rad = clamped * Math.PI / 180;
return Math.max(
0,
Math.min(n - 1, Math.floor((1 - Math.asinh(Math.tan(rad)) / Math.PI) / 2 * n)),
);
}
function tileZoomForViewZoom(viewZoom) {
const thresholds = COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds || [];
const maxZoom = _tileManifest?.tiles?.maxZoom ?? 0;
for (const threshold of thresholds) {
if (viewZoom >= threshold.minViewZoom) {
return Math.min(threshold.tileZoom, maxZoom);
}
}
return null;
}
function bboxFromVisibleEarth(camera, renderer, earth) {
if (!camera || !renderer?.domElement || !earth) return null;
const rect = renderer.domElement.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const samples = [
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
[rect.left, rect.top],
[rect.right, rect.top],
[rect.left, rect.bottom],
[rect.right, rect.bottom],
[rect.left + rect.width * 0.5, rect.top],
[rect.left + rect.width * 0.5, rect.bottom],
[rect.left, rect.top + rect.height * 0.5],
[rect.right, rect.top + rect.height * 0.5],
];
const coords = [];
for (const [x, y] of samples) {
const point = screenToEarthCoords(x, y, camera, earth, renderer.domElement);
if (!point) continue;
coords.push(vector3ToLatLon(point));
}
if (coords.length === 0) return null;
const lats = coords.map(coord => coord.lat);
const lons = coords.map(coord => coord.lon);
const latMin = Math.max(-85.05112878, Math.min(...lats));
const latMax = Math.min(85.05112878, Math.max(...lats));
const latPad = Math.max(2, (latMax - latMin) * 0.18);
const rawLonMin = Math.max(-180, Math.min(...lons));
const rawLonMax = Math.min(180, Math.max(...lons));
const rawLonSpan = rawLonMax - rawLonMin;
if (rawLonSpan > 180) {
const shifted = lons.map(lon => lon < 0 ? lon + 360 : lon);
const shiftedMin = Math.min(...shifted);
const shiftedMax = Math.max(...shifted);
const shiftedPad = Math.max(2, (shiftedMax - shiftedMin) * 0.18);
const west = shiftedMin - shiftedPad;
const east = shiftedMax + shiftedPad;
const ranges = [];
if (west < 180) ranges.push({ west: Math.max(-180, west), east: 180 });
if (east > 180) ranges.push({ west: -180, east: Math.min(180, east - 360) });
return {
south: Math.max(-85.05112878, latMin - latPad),
north: Math.min(85.05112878, latMax + latPad),
ranges: ranges.length > 0 ? ranges : [{ west: -180, east: 180 }],
};
}
const lonPad = Math.max(2, rawLonSpan * 0.18);
return {
west: Math.max(-180, rawLonMin - lonPad),
south: Math.max(-85.05112878, latMin - latPad),
east: Math.min(180, rawLonMax + lonPad),
north: Math.min(85.05112878, latMax + latPad),
};
}
function tileKeysForBbox(bbox, zoom, { claim = false } = {}) {
if (Array.isArray(bbox.ranges)) {
return bbox.ranges.flatMap(range =>
tileKeysForBbox({ ...bbox, west: range.west, east: range.east, ranges: null }, zoom, { claim }),
);
}
const prefetch = COUNTRY_BOUNDARY_CONFIG.tilePrefetchRing;
const n = 2 ** zoom;
const xMin = lonToTileX(bbox.west, zoom);
const xMax = lonToTileX(bbox.east, zoom);
const yMin = latToTileY(bbox.north, zoom);
const yMax = latToTileY(bbox.south, zoom);
const keys = [];
for (let x = Math.max(0, xMin - prefetch); x <= Math.min(n - 1, xMax + prefetch); x++) {
for (let y = Math.max(0, yMin - prefetch); y <= Math.min(n - 1, yMax + prefetch); y++) {
keys.push(`${claim ? "claim" : "boundary"}/${zoom}/${x}/${y}`);
}
}
return keys;
}
async function refreshBoundaryTiles({ camera, renderer, earth, viewZoom }) {
if (!_loaded || !_visible || !_tileManifest) return;
if (_tileProvider !== "pmtiles-mvt") return;
const tileZoom = tileZoomForViewZoom(viewZoom);
if (!tileZoom) {
_lastTileSignature = "";
setActiveTileKeys([]);
updateBoundaryLineDimState();
return;
}
const bbox = bboxFromVisibleEarth(camera, renderer, earth);
if (!bbox) return;
const keys = tileKeysForBbox(bbox, tileZoom);
if (_tileManifest?.chinaClaims?.available) {
keys.push(...tileKeysForBbox(bbox, tileZoom, { claim: true }));
}
const signature = keys.slice().sort().join("|");
if (signature === _lastTileSignature) return;
_lastTileSignature = signature;
setActiveTileKeys(keys);
updateBoundaryLineDimState();
await Promise.all(keys.map(async key => {
const entry = await loadBoundaryTile(key);
if (!entry) return;
entry.object.userData.tileKey = key;
setTileObjectVisible(entry.object, _activeTileKeys.has(key));
}));
}
export function updateCountryBoundaryTiles(context = {}) {
if (_tileUpdateTimer) return;
_tileUpdateTimer = setTimeout(() => {
_tileUpdateTimer = null;
refreshBoundaryTiles(context);
}, COUNTRY_BOUNDARY_CONFIG.tileDebounceMs);
}
function buildHoverLines() {
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
@@ -236,6 +669,15 @@ function setBoundaryLinesDimmed(dimmed) {
_boundaryLines.material.needsUpdate = true;
}
function updateBoundaryLineDimState() {
if (_coastlineLines?.material) {
_coastlineLines.material.opacity = COUNTRY_BOUNDARY_CONFIG.lineOpacity;
_coastlineLines.material.needsUpdate = true;
_coastlineLines.visible = _visible && !_hoveredFeature;
}
setBoundaryLinesDimmed(Boolean(_hoveredFeature) || _activeTileKeys.size > 0);
}
function setHoverLinesVisible(visible) {
const nextVisible = _visible && Boolean(visible);
if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible;
@@ -243,7 +685,12 @@ function setHoverLinesVisible(visible) {
}
function featureListToSegments(features, radius) {
return features.flatMap(f => featureToSegments(f.geometry, radius));
const all = [];
for (const feature of features) {
const points = featureToSegments(feature.geometry, radius);
for (const point of points) all.push(point);
}
return all;
}
function makeLineGeometry(points) {
@@ -265,12 +712,26 @@ function setLineGeometry(line, geometry) {
line.geometry = geometry;
}
function cancelPendingHoverClear() {
if (!_hoverClearTimer) return;
clearTimeout(_hoverClearTimer);
_hoverClearTimer = null;
}
function scheduleHoverClear(delayMs) {
if (_hoverClearTimer) return;
_hoverClearTimer = setTimeout(() => {
_hoverClearTimer = null;
clearCountryBoundaryHover({ cancelSticky: false });
}, Math.max(0, delayMs));
}
function getHoverGeometries(groupKey, features) {
const cacheKey = groupKey || features[0] || "__empty__";
const cached = _hoverGeometryCache.get(cacheKey);
if (cached) return cached;
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const coreRadius = boundaryLineRadius();
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
const geometries = {
core: markCachedHoverGeometry(
@@ -357,6 +818,8 @@ export function createCountryBoundaryLayer(earthObj) {
_earthObj = earthObj;
_tintMesh = makeTintMesh();
_earthObj.add(_tintMesh);
_claimGroup = buildClaimLines();
_earthObj.add(_claimGroup);
}
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
@@ -365,18 +828,86 @@ export async function loadCountryBoundaries() {
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();
let geojson = { type: "FeatureCollection", features: [] };
let baseGeojson = null;
let claimGeojson = null;
const highPrecisionEnabled = getHighPrecisionBoundariesEnabled();
const manifest = highPrecisionEnabled
? await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.tileManifestPath)
: null;
if (manifest) {
const provider = manifestProvider(manifest);
const pmtilesUrl = manifestPmtilesUrl(manifest);
const pmtilesResp = provider === "pmtiles-mvt"
? await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" })
: null;
const highPrecisionReady = (
["pmtiles-mvt", "geojson-high-precision"].includes(provider) &&
(provider !== "pmtiles-mvt" || pmtilesResp?.ok)
);
if (highPrecisionReady) {
_tileManifest = manifest;
_tileProvider = provider;
_boundaryProviderState = provider;
_tileAssetVersion = [
_tileManifest.version,
_tileManifest.builtAt,
_tileManifest.sourceFeatureCount,
_tileManifest.pmtiles?.sha256,
].filter(Boolean).join("-");
const basePath = _tileManifest.base || _tileManifest.baseGeojson;
const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson;
if (basePath) baseGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(basePath));
if (hoverPath) {
geojson = await fetchJsonAsset(versionedBoundaryAssetUrl(hoverPath)) || geojson;
}
const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path;
if (claimPath) claimGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(claimPath));
}
}
if (_tileManifest && !(geojson.features || []).length) {
console.warn("[country-boundaries] high precision hover index unavailable; using legacy fallback");
_tileManifest = null;
_pmtilesArchive = null;
claimGeojson = null;
}
if (!_tileManifest) {
geojson = await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath, { required: true });
baseGeojson = geojson;
_tileProvider = "legacy-geojson";
_boundaryProviderState = "legacy-geojson";
_tileAssetVersion = "legacy";
}
_features = (geojson.features || []).filter(f => f.geometry);
const baseFeatures = (baseGeojson?.features || _features).filter(f => f.geometry);
const boundaryBaseFeatures = baseFeatures.filter(
feature => !isStandaloneCoastlineFeature(feature),
);
const coastlineFeatures = baseFeatures.filter(isStandaloneCoastlineFeature);
const tex = buildLandTexture(_features);
_landMesh = makeLandMesh(tex);
_landTexture = buildLandTexture(_features);
_landMesh = makeLandMesh(_landTexture);
_earthObj.add(_landMesh);
_boundaryLines = buildBoundaryLines(_features);
_boundaryLines = buildBoundaryLines(boundaryBaseFeatures);
_earthObj.add(_boundaryLines);
_coastlineLines = buildBoundaryLines(coastlineFeatures);
_coastlineLines.name = "country-coastline-all";
_earthObj.add(_coastlineLines);
if (claimGeojson?.features?.length && _claimGroup) {
const claimPoints = featureListToSegments(
claimGeojson.features,
boundaryLineRadius({ claim: true }),
);
_claimGroup.geometry?.dispose();
_claimGroup.geometry = makeLineGeometry(claimPoints);
if (typeof _claimGroup.computeLineDistances === "function") {
_claimGroup.computeLineDistances();
}
}
_hoverGlowLines = buildHoverGlowLines();
_earthObj.add(_hoverGlowLines);
@@ -421,13 +952,22 @@ export function toggleCountryBoundaries(
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
if (_boundaryLines) _boundaryLines.visible = _visible;
if (_coastlineLines) _coastlineLines.visible = _visible && !_hoveredFeature;
_tileCache.forEach((entry, key) => {
setTileObjectVisible(entry.object, _visible && _activeTileKeys.has(key));
});
if (_claimGroup) _claimGroup.visible = _visible && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine);
setHoverLinesVisible(_hoveredFeature);
if (!_visible) {
cancelPendingHoverClear();
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
_lastHoverInfo = null;
setHoverLinesVisible(false);
setActiveTileKeys([]);
_lastTileSignature = "";
updateBoundaryLineDimState();
}
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
@@ -458,12 +998,18 @@ export function getShowCountryBoundaries() {
return _visible;
}
export function getCountryBoundaryProviderState() {
return _boundaryProviderState;
}
/** Clear the hover highlight without hiding the full layer. */
export function clearCountryBoundaryHover() {
if (!_hoveredFeature) return;
export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
if (cancelSticky) cancelPendingHoverClear();
if (!_hoveredFeature && !_lastHoverInfo) return;
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
_lastHoverInfo = null;
updateBoundaryLineDimState();
setHoverLinesVisible(false);
}
@@ -478,15 +1024,30 @@ export function updateCountryBoundaryHover(coords) {
const found = _features.find(f => featureContains(lat, lon, f)) || null;
const groupKey = getCountryHighlightGroupKey(found);
if (!found && _hoveredFeature) {
const stickyMs = Math.max(0, COUNTRY_BOUNDARY_CONFIG.hoverMissStickyMs || 0);
const elapsedMs = Date.now() - _lastHoverHitAt;
if (stickyMs > 0 && elapsedMs < stickyMs) {
scheduleHoverClear(stickyMs - elapsedMs);
return _lastHoverInfo;
}
}
if (found) {
cancelPendingHoverClear();
_lastHoverHitAt = Date.now();
_lastHoverInfo = makeCountryInfo(found);
}
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
_hoveredFeature = found;
_hoveredGroupKey = groupKey;
if (_hoverLines) {
if (!found) {
setBoundaryLinesDimmed(false);
updateBoundaryLineDimState();
setHoverLinesVisible(false);
} else {
setBoundaryLinesDimmed(true);
updateBoundaryLineDimState();
const highlightFeatures = getHighlightFeatures(found);
const geometries = getHoverGeometries(groupKey, highlightFeatures);
setLineGeometry(_hoverGlowLines, geometries.glow);
@@ -496,13 +1057,17 @@ export function updateCountryBoundaryHover(coords) {
}
}
return found ? makeCountryInfo(found) : null;
if (!found) _lastHoverInfo = null;
return found ? _lastHoverInfo : null;
}
/** Dispose all Three.js objects and reset state. */
export function clearCountryBoundaryData() {
_hoveredFeature = null;
_hoveredGroupKey = null;
cancelPendingHoverClear();
_lastHoverHitAt = 0;
_lastHoverInfo = null;
function disposeObj(obj) {
if (!obj) return;
@@ -519,14 +1084,34 @@ export function clearCountryBoundaryData() {
disposeObj(_hoverLines);
disposeObj(_hoverGlowLines);
disposeObj(_boundaryLines);
disposeObj(_coastlineLines);
disposeObj(_claimGroup);
disposeObj(_landMesh);
disposeObj(_tintMesh);
_landTexture?.dispose?.();
_tileCache.forEach(entry => disposeTileObject(entry.object));
_tileCache.clear();
_tileLru = [];
_inFlightTiles.clear();
_activeTileKeys.clear();
_lastTileSignature = "";
if (_tileUpdateTimer) {
clearTimeout(_tileUpdateTimer);
_tileUpdateTimer = null;
}
_hoverLines = null;
_hoverGlowLines = null;
_boundaryLines = null;
_coastlineLines = null;
_claimGroup = null;
_landMesh = null;
_tintMesh = null;
_landTexture = null;
_tileManifest = null;
_tileProvider = "pmtiles-mvt";
_boundaryProviderState = "unloaded";
_pmtilesArchive = null;
_features = [];
disposeHoverGeometryCache();
_loaded = false;

View File

@@ -36,18 +36,24 @@ const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
).normalize();
function applyEarthDayNightShader(material) {
function applyEarthDayNightShader(material, options = {}) {
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
const {
nightFloor = EARTH_MATERIAL_CONFIG.dayNight.nightFloor,
} = options;
material.onBeforeCompile = (shader) => {
_earthShaders.push(shader);
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
shader.uniforms.uNightFloor = { value: nightFloor };
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
shader.uniforms.uFeatherScale = { value: EARTH_MATERIAL_CONFIG.dayNight.featherScale };
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
shader.uniforms.uTwilightFeatherScale = {
value: EARTH_MATERIAL_CONFIG.dayNight.twilightFeatherScale,
};
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
shader.uniforms.uTwilightColor = { value: twilightColor };
shader.uniforms.uNightTintColor = { value: nightTintColor };
@@ -71,7 +77,9 @@ varying vec3 vWorldNormal;
uniform vec3 uSunDirectionWorld;
uniform float uNightFloor;
uniform float uDayBoost;
uniform float uFeatherScale;
uniform float uTwilightWidth;
uniform float uTwilightFeatherScale;
uniform float uTwilightIntensity;
uniform vec3 uTwilightColor;
uniform vec3 uNightTintColor;
@@ -83,8 +91,9 @@ uniform float uDayNightEnabled;`,
vec3 worldNormal = normalize(vWorldNormal);
vec3 sunDir = normalize(uSunDirectionWorld);
float sunFacing = dot(worldNormal, sunDir);
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
float edgeFeather = max(fwidth(sunFacing) * uFeatherScale, uTwilightWidth);
float daylight = smoothstep(-edgeFeather, edgeFeather, sunFacing);
float twilight = 1.0 - smoothstep(0.0, edgeFeather * uTwilightFeatherScale, abs(sunFacing));
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
@@ -170,10 +179,12 @@ export function createEarth(scene) {
);
const occluderMaterial = new THREE.MeshBasicMaterial({
colorWrite: false,
depthTest: false,
depthWrite: true,
side: THREE.FrontSide,
});
const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial);
occluder.renderOrder = -1;
occluder.renderOrder = 0.5;
earth.add(occluder);
// Keep the original atmosphere shells on the legacy camera-facing shader so

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