dev #11

Merged
linkong merged 19 commits from dev into main 2026-05-20 22:41:06 +00:00
46 changed files with 1589 additions and 684 deletions
Showing only changes of commit f14ff6ec0f - Show all commits

112
TODO.md
View File

@@ -1,45 +1,71 @@
# 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
- [ ] High-precision country boundaries: implement the source-faithful static vector tile pipeline described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md). Highest zoom must preserve trusted source geometry instead of smoothing or hand-drawing borders.
- [ ] 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, and the official dashed maritime claim line before implementing visual changes.
- [ ] 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.55.0
0.56.0

View File

@@ -8,6 +8,22 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [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

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

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

@@ -0,0 +1,132 @@
# Earth High Precision Boundary Tiles Plan
## Status
Planning revised after visual review. The previous hand-authored China claim-line / point-buffer approach is rejected and must not be implemented.
Implemented separately:
- 地表 hover 三模式已实现。
Still planned:
- 权威 China POV 数据包。
- OSM / coastline 高精度离线构建。
- 版本化静态矢量瓦片输出。
- 前端 bbox/tile/LRU 高精度加载器。
## Summary
Earth 国界线目标从“明显提升”升级为 **最高精度档按真实地图源一比一还原**
- 高缩放时不能使用当前 `countries-admin0.min.geojson` 这种低精度简化线。
- 最高 tile zoom 必须忠实保留选定权威矢量源的原始折点,不做视觉平滑,不做人工凭感觉补线。
- 藏南、阿克赛钦等争议陆地直接作为中国国家面的一部分表达hover 只显示普通 `中国 / CHN`,不显示特殊区域名。
- 九段线 / 十段线必须来自官方标准地图口径或经地理配准校核后的权威矢量数据;不能手工目测画线,不能把马来西亚、菲律宾等周边陆地或近岸底盘划入中国面。
方案仍采用 **离线构建 + 静态矢量瓦片**。低缩放加载简化 base line高缩放按当前视野 bbox 加载高精度 boundary tiles。第一版不使用 Redis依靠 Nginx 静态服务、浏览器 HTTP cache 和前端 LRU。
## Data Source and Policy
- 基础陆地国界使用 OSM `boundary=administrative` + `admin_level=2`,并补充高精度 coastline避免只靠粗糙国家面导致海岸线缺失。
- China POV 覆盖数据必须独立成包,构建时优先级高于 OSM 原始归属:
- 藏南 union 到中国面,同时从印度面 subtract。
- 阿克赛钦 union 到中国面,同时从相关邻接面 subtract。
- 台湾、澎湖、钓鱼岛及附属岛屿、赤尾屿、东沙、西沙、中沙、南沙等作为中国国家面/岛礁面的一部分进入 hover index。
- 岛礁很小时可有最小可交互面,但 tooltip 仍是 `中国 / CHN`,不展示“某特殊区域归属”标签。
- 九段线 / 十段线是独立 maritime claim line 图层:
- 只渲染 dashed line不参与国家陆地面。
- 不用于吞并周边国家陆地或近岸水域。
- 坐标必须来自官方标准地图、权威矢量数据,或从官方示意图配准后人工复核,不接受手工猜测坐标。
- 必须保留 OSM 数据归因:`© OpenStreetMap contributors, ODbL`
## Precision Requirements
- 最高精度档的验收口径是 **source-faithful**,不是“看起来更细”:
- 对高精度源线,最高 zoom tile 不允许 Douglas-Peucker 简化。
- 坐标量化精度至少保留到 `1e-5` 度级别,构建时不得把经纬度粗暴四舍五入到低精度。
- 球面渲染只允许 densify 长边来贴合地球曲率;不允许 CatmullRom、Bezier 或任何会改变边界走向的平滑。
- 海岸/边界红框类区域必须与源地图折线逐点对齐;若有偏差,只能追溯并替换数据源,不能靠渲染平滑掩盖。
- 低缩放允许简化,但必须有误差预算:
- base line 只服务远景识别。
- 中 zoom tile 可简化到屏幕误差低于 `0.5px`
- 最高 zoom tile 使用无简化或近零误差版本。
## Implementation Plan
### 1. Data pipeline
新增边界构建脚本,使用 `/home/ray/.local/bin/uv` 运行:
1. 读取 OSM PBF 或预处理后的 admin-0 边界 / coastline GeoJSON。
2. 读取 China POV override package。
3. 使用可靠几何库做 union / difference / validity repair禁止手写 polygon overlay。
4. 生成中国国家面时直接合并藏南、阿克赛钦和相关岛礁;从相邻国家面扣除同一区域。
5. 单独读取官方口径九段线 / 十段线矢量,生成 claim-line tiles。
6. 输出:
- 低精度 global base line。
- 无简化 high-precision hover polygon index。
- 高精度静态瓦片:`frontend/public/earth/data/boundaries/v1/{z}/{x}/{y}.geojson`
- 海上断续线瓦片:`frontend/public/earth/data/boundaries/v1/china-claims/{z}/{x}/{y}.geojson`
- manifest记录数据源、覆盖规则版本、构建时间、简化误差和 attribution。
### 2. Tile levels and size budget
- Earth zoom `< 1.6`:只显示 global base line 和低精度 claim line。
- Earth zoom `1.6-2.8`:加载 tile zoom `4-5`
- Earth zoom `2.8-4.0`:加载 tile zoom `6-7`
- Earth zoom `> 4.0`:加载 tile zoom `8-10`,使用最高精度无简化折线。
- 单 tile gzip 目标 `20-80KB`,但最高精度档优先保证几何真实性;若超限,优先提高 tile zoom 或拆 tile而不是简化真实线。
### 3. Frontend loading model
`country-boundaries.js` 拆成 base layer、tile layer 和 China claim layer
- 首屏加载 base line + hover index不阻塞 Earth 初始化。
- 高缩放时根据 camera 可见范围计算经纬 bbox再转换为 Web Mercator tile keys。
- bbox 由屏幕中心、四角和边中点 raycast 得到,并扩张 `10-20%` 作为预取范围。
- 处理反经线,必要时拆成两个 bbox。
- 视野变化请求 debounce `150-250ms`
- 拖拽/惯性旋转中不每帧请求;缩放档或 tile key 集合没变时不刷新。
- 加载当前视野 tile并预取一圈邻接 tile。
- base line 在高精度 tile 到达后降低 opacity避免双线。
### 4. Caching and memory control
- 前端维护 `tileCache``inFlightTiles` 和 LRU 使用顺序。
- tile cache 上限建议 `120-180` 个 tile超过后释放最旧 tile 的 `BufferGeometry`
- Nginx 为 `/earth/data/boundaries/` 设置长缓存:
- `Cache-Control: public, max-age=31536000, immutable`
- gzip 包含 JSON。
- tile URL 包含版本目录,例如 `v1`;数据更新时改版本目录破浏览器缓存。
- 第一版不使用 Redis。只有改成动态裁剪 API、多 POV 同 URL、或压测证明静态服务成为瓶颈时再考虑 Redis。
## Acceptance Criteria
1. 最高 zoom 的海岸线和国界线与选定源地图逐点一致,不再只是“明显提升”。
2. 红框类海岸/边界细节在最高 zoom 下不能出现肉眼可见的低精度折线、直线切边或圆滑失真。
3. 藏南、阿克赛钦 hover 命中普通 `中国 / CHN`;印度或其他邻接国家不再包含这些区域。
4. 钓鱼岛、赤尾屿、南海诸岛代表点 hover 命中普通 `中国 / CHN`
5. 九段线 / 十段线位置与权威来源一致,不压入马来西亚、菲律宾等周边陆地或错误包围近岸底盘。
6. 首屏不加载全球超高精度整包。
7. 高缩放只请求当前视野附近 tile快速旋转不会出现请求风暴。
8. 回到已访问区域命中前端 cache 或浏览器 cache。
9. 国界图层开关、hover 高亮、移动端中心国家高亮、悬停提示三模式保持可用。
## Verification
- 构建阶段:
- 几何 validity check 全通过。
- China POV 代表点测试全部返回 `CHN`
- 相邻国家代表点不得被 China override 误吞。
- 最高 zoom tile 抽样与源数据做坐标级 diff确认未简化。
- 前端阶段:
-`frontend` 运行 `/home/ray/.bun/bin/bun run build`
- 浏览器 DevTools 验证低缩放无高精度 tile 请求,高缩放请求数量受控,重复视野走 cache。
- 用截图中的红框区域、南海断续线、藏南、钓鱼岛、赤尾屿、南海诸岛做手动视觉验收。
## Sources and Assumptions
- 产品默认采用中国标准地图/公开地图合规口径;若后续支持多 POV必须通过版本化数据目录隔离不能让同一 URL 返回不同政治口径。
- 当前仓库没有足够权威和足够精细的 China POV / 九段线矢量源,因此不能直接凭现有低精度 GeoJSON 完成“一比一还原”。
- 实施前必须先引入或生成可审计的高精度源数据包;没有源数据时,只能实现加载框架,不能伪造边界。

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

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

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

@@ -205,9 +205,13 @@ 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, satellite idle breathing, real satellite altitude, track display, 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 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.
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.
## Current Terrain Pipeline

View File

@@ -163,7 +163,7 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
| 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 / 55` | Compressed offset added to `CONFIG.earthRadius` |
| 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.42 / 1.0` | Breathing animation |
@@ -233,7 +233,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

@@ -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; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..55`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. |
| 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 returns to same-sphere mode when disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
| 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

View File

@@ -323,10 +323,12 @@ Compute-center saves write to `compute_center_locations` and refresh the layer.
### 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. This setting also affects satellite trails and the predicted orbit shown after locking a satellite.
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

@@ -255,9 +255,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, 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.
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. 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.
"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.

View File

@@ -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; satellite settings can toggle real-altitude layering and track display
- 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

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

View File

@@ -421,11 +421,16 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
- HUD 面板显示/隐藏
- 图层控制开关:`地形 / 卫星 / 海缆 / BGP`
- 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示`
- 地表 hover 提示偏好:`国家 / 位置 / 完整`
- 地形透明度
也就是说Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`
卫星真实高度开关`controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型
地表 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 当成恒定纬度。
## 当前地形链路

View File

@@ -169,7 +169,7 @@
| 卫星 fallback 半径偏移 | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | 关闭真实高度或 TLE 传播失败时的旧版同层球面位置 |
| 卫星真实高度压缩尺度 | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 高度压缩映射,数值越大高轨差异越温和 |
| 卫星真实高度上限 | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | 压缩映射的高度截断,覆盖 GEO 附近高度 |
| 卫星真实高度显示偏移范围 | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 55` | 压缩后映射到 `CONFIG.earthRadius + offset` |
| 卫星真实高度显示偏移范围 | `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.42 / 1.0` | 呼吸动画 |
@@ -252,7 +252,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

@@ -32,9 +32,9 @@
| 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默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..55`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 |
| 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` 及偏移;预测轨道使用同一真实高度开关,关闭时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制闭合惯性轨道;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
## 开关联动

View File

@@ -325,10 +325,12 @@ Docs 按 Gatekeeper 权限组控制可见性:
### 为什么卫星看起来不在同一个球面上?
Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4但高度会经过压缩映射低轨卫星靠近地球高轨卫星更远同时保持在当前视图可读范围内。这个设置也会影响卫星轨迹和锁定后的预测轨道。
Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4但高度会经过压缩映射低轨卫星靠近地球高轨卫星更远同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。
如果需要旧版所有卫星位于同一显示球面的效果,在 Earth 设置里关闭“真实卫星高度”。缺失 TLE 或传播失败的卫星仍会回退到旧版固定高度,不会因为无法计算真实高度而消失。
低倾角高轨卫星应该沿赤道附近或固定经度附近分布而不是绕出接近南北向的大圈。Earth 会先把 SGP4 的惯性系位置转换成地固坐标再绘制当前点;锁定后的预测轨道则固定当前地球姿态来画一圈惯性轨道,所以应该闭合并保留正确倾角。如果以后看到详情卡倾角接近 `0°`,但预测轨道像极轨一样竖着绕,优先检查 ECI/TEME 到 ECF 的转换和预测轨道是否错误使用了逐采样 `gstime`
### 动捕调试面板为什么看不到摄像头画面?
如果输入源是 Browser Camera调试面板会显示浏览器本机摄像头实时预览并在画面上绘制骨架。如果勾选了 `只显示骨骼`,视频预览会被隐藏,只显示深色背景和红/绿骨架。

View File

@@ -258,9 +258,11 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
### 设置
设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。
“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。
“悬停提示”控制鼠标悬停地表时的 tooltip 内容:`国家` 只在陆地命中国家时显示国家信息,太平洋等海洋区域不弹出地表提示;`位置` 在陆地和海洋都显示纬度、经度和海拔;`完整` 是默认模式,陆地显示国家 + 位置,海洋显示位置。
这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。

View File

@@ -48,7 +48,7 @@
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
## 5. 找回密码

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.55.0`
- `dev` 当前开发分支历史推导到:`0.56.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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 开发入口边界 |

View File

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

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

@@ -1630,9 +1630,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));
@@ -1665,10 +1668,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));
@@ -1676,7 +1689,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);
}

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

@@ -513,7 +513,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 +529,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 +564,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 +573,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 +886,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>
@@ -1190,6 +1218,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>
@@ -1290,7 +1350,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">
@@ -1300,7 +1360,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">

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

@@ -344,7 +344,7 @@ export const SATELLITE_CONFIG = {
altitudeCompressionKm: 1200,
maxDisplayAltitudeKm: 40000,
minRealAltitudeOffset: 4,
maxRealAltitudeOffset: 55,
maxRealAltitudeOffset: 25,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotBaseSize: 2.8,
@@ -440,6 +440,8 @@ export const BGP_CONFIG = {
collectorScale: 11.5,
collectorPulseScale: 16.5,
collectorCoverageScale: 22.5,
tintNeutralColor: 0xffffff,
tintBlend: 0.72,
},
sizeStabilization: {
enabled: true,

View File

@@ -1244,6 +1244,17 @@ 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");
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
@@ -1373,6 +1384,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]");
@@ -1456,6 +1503,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setSurfaceHoverInfoMode(settings.shared.surfaceHoverInfoMode, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -2690,6 +2741,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) => {
@@ -2772,6 +2824,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", () => {
@@ -2842,6 +2902,7 @@ function setupSettingsControls() {
syncSatelliteIdleBreathingToggle();
syncSatelliteRealAltitudeToggle();
syncInteractableCompactDotsToggle();
syncSurfaceHoverInfoModeControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
@@ -3034,7 +3095,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;

View File

@@ -1276,8 +1276,8 @@ const CARD_CONFIG = {
{ key: 'footprint_model', label: '覆盖模型' },
{ key: 'inclination', label: '倾角', unit: '°' },
{ key: 'period', label: '周期', unit: '分钟' },
{ key: 'perigee', label: '近地点', unit: 'km' },
{ key: 'apogee', label: '远地点', unit: 'km' }
{ key: 'perigee', label: '近地点高度', unit: 'km' },
{ key: 'apogee', label: '远地点高度', unit: 'km' }
]
},
bgp: {

View File

@@ -12,6 +12,7 @@ import {
CRUISE_CONFIG,
ROTATION_MODE,
SCENE_LIGHT_CONFIG,
SURFACE_HOVER_INFO_MODES,
} from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
@@ -224,6 +225,7 @@ import {
setDayNightInteractable,
applyDeferredLayerVisibilitySettings,
scheduleTerrainPrefetch,
getSurfaceHoverInfoMode,
} from "./controls.js";
import {
createLayerStartupTaskMap,
@@ -276,6 +278,10 @@ import {
setMotionDebugPanelVisible,
} from "./motion-debug-panel.js";
const EARTH_RADIUS_KM = 6371;
const EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 = 398600.4418;
const SECONDS_PER_DAY = 86400;
export let scene;
export let camera;
export let renderer;
@@ -391,6 +397,10 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#desktop-news-ticker",
"#desktop-news-ticker *",
"#news-hud-panel",
"#news-hud-panel *",
"#motion-debug-panel",
"#motion-debug-panel *",
"#mobile-drawer-shell",
@@ -1402,9 +1412,10 @@ function getCableBriefHtml(cable) {
function showSatelliteInfo(props, coords) {
const meanMotion = props?.mean_motion || 0;
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
const ecc = props?.eccentricity || 0;
const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0);
const altitudeRange = calculateSatelliteAltitudeRangeKm(
props?.mean_motion,
props?.eccentricity,
);
const presentation = getSatellitePresentationInfo(props);
let footprintModel = "不适用";
@@ -1426,11 +1437,39 @@ function showSatelliteInfo(props, coords) {
footprint_model: footprintModel,
inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
period,
perigee,
apogee,
perigee: altitudeRange?.perigee ?? "-",
apogee: altitudeRange?.apogee ?? "-",
}, coords);
}
function calculateSatelliteAltitudeRangeKm(meanMotion, eccentricity = 0) {
const revolutionsPerDay = Number(meanMotion);
const ecc = Number(eccentricity);
if (
!Number.isFinite(revolutionsPerDay) ||
revolutionsPerDay <= 0 ||
!Number.isFinite(ecc) ||
ecc < 0 ||
ecc >= 1
) {
return null;
}
const meanMotionRadPerSecond =
(revolutionsPerDay * Math.PI * 2) / SECONDS_PER_DAY;
const semiMajorAxisKm = Math.cbrt(
EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 /
(meanMotionRadPerSecond * meanMotionRadPerSecond),
);
const perigeeKm = semiMajorAxisKm * (1 - ecc) - EARTH_RADIUS_KM;
const apogeeKm = semiMajorAxisKm * (1 + ecc) - EARTH_RADIUS_KM;
return {
perigee: Math.max(0, perigeeKm).toFixed(0),
apogee: Math.max(0, apogeeKm).toFixed(0),
};
}
function getSatelliteBriefHtml(props) {
const name = props?.name || "未知卫星";
const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : "";
@@ -1525,6 +1564,16 @@ function getCountryBoundaryBriefHtml(country) {
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
}
function getSurfacePositionBriefHtml(coords) {
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
return `纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`;
}
function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
@@ -4648,9 +4697,33 @@ function onMouseMove(event) {
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
const hoveredCountry = getShowCountryBoundaries()
const hoverInfoMode = getSurfaceHoverInfoMode();
const shouldShowCountry =
hoverInfoMode === SURFACE_HOVER_INFO_MODES.COUNTRY ||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
const shouldShowPosition =
hoverInfoMode === SURFACE_HOVER_INFO_MODES.POSITION ||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
const hoveredCountry = shouldShowCountry && getShowCountryBoundaries()
? updateCountryBoundaryHover(coords)
: null;
const positionHtml = shouldShowPosition
? getSurfacePositionBriefHtml(coords)
: "";
if (!shouldShowCountry) {
clearCountryBoundaryHover();
}
if (hoveredCountry && shouldShowPosition) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
`${getCountryBoundaryBriefHtml(hoveredCountry)}<br>${positionHtml}`,
);
return;
}
if (hoveredCountry) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
@@ -4659,18 +4732,17 @@ function onMouseMove(event) {
);
return;
}
clearCountryBoundaryHover();
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
);
if (shouldShowPosition) {
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
positionHtml,
);
} else {
hideTooltip();
}
} else {
clearCountryBoundaryHover();
hideTooltip();

View File

@@ -1,15 +1,19 @@
import { showStatusMessage } from "./ui.js";
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
// News aggregation now lives inside the shared media panel:
// - outer shell: #media-panel
// - this module renders into inner pane: #news-panel
// Desktop news has two surfaces:
// - a persistent top ticker
// - a center HUD that expands from the ticker
// Mobile keeps its existing drawer page.
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const FOCUS_UPDATE_INTERVAL_MS = 4000;
const DATA_REFRESH_INTERVAL_MS = 180000;
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
const REQUEST_TIMEOUT_MS = 15000;
const NEWS_HUD_MORPH_MS = 300;
const NEWS_HUD_MIN_WIDTH_PX = 420;
const NEWS_HUD_MIN_HEIGHT_PX = 360;
const NEWS_HUD_RESIZE_MARGIN_PX = 12;
let initialized = false;
let refreshPromise = null;
@@ -18,6 +22,8 @@ let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
let selectedCruiseStoryId = null;
let morphTimer = null;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
@@ -31,6 +37,11 @@ function getElements() {
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
ticker: document.getElementById("desktop-news-ticker"),
tickerRegion: document.getElementById("news-ticker-region"),
tickerTrack: document.getElementById("news-ticker-track"),
hud: document.getElementById("news-hud-panel"),
hudCloseBtn: document.getElementById("news-hud-close"),
};
}
@@ -57,6 +68,250 @@ export function updateNewsToggleUI(visible) {
void visible;
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
);
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function clearMorphTimer() {
if (!morphTimer) return;
window.clearTimeout(morphTimer);
morphTimer = null;
}
function setHudRect(hud, rect, opacity = 1) {
hud.style.left = `${rect.left}px`;
hud.style.top = `${rect.top}px`;
hud.style.width = `${rect.width}px`;
hud.style.height = `${rect.height}px`;
hud.style.transform = "none";
hud.style.opacity = String(opacity);
}
function setHudRectWithoutTransition(hud, rect, opacity = 1) {
const previousTransition = hud.style.transition;
hud.style.transition = "none";
setHudRect(hud, rect, opacity);
void hud.offsetHeight;
hud.style.transition = previousTransition;
}
function getNewsHudTargetRect(hud) {
const wasHidden = hud.classList.contains("hud-panel-hidden");
const previousVisibility = hud.style.visibility;
if (wasHidden) hud.classList.remove("hud-panel-hidden");
hud.style.visibility = "hidden";
const rect = hud.getBoundingClientRect();
hud.style.visibility = previousVisibility;
if (wasHidden) hud.classList.add("hud-panel-hidden");
return rect;
}
function finishHudOpen(hud) {
hud.classList.remove("is-morphing");
hud.style.opacity = "";
revealSelectedCruiseStory();
}
function finishHudClose(hud, ticker, restoreRect = null) {
hud.classList.add("hud-panel-hidden");
hud.classList.remove("is-morphing");
hud.style.opacity = "";
if ((hud.dataset.dragged === "true" || hud.dataset.resized === "true") && restoreRect) {
setHudRect(hud, restoreRect, 1);
hud.style.opacity = "";
} else {
hud.style.left = "";
hud.style.top = "";
hud.style.width = "";
hud.style.height = "";
hud.style.transform = "";
}
ticker?.classList.remove("is-hidden");
}
function setNewsHudOpen(open, { highlightId = null } = {}) {
const { hud, ticker } = getElements();
if (!(hud instanceof HTMLElement)) return;
clearMorphTimer();
if (highlightId) {
selectedCruiseStoryId = highlightId;
applyCruiseStorySelection();
}
const tickerRect = ticker instanceof HTMLElement
? ticker.getBoundingClientRect()
: { left: window.innerWidth / 2 - 240, top: 20, width: 480, height: 38 };
if (open) {
const targetRect = getNewsHudTargetRect(hud);
hud.classList.remove("hud-panel-hidden");
hud.classList.add("is-morphing");
setHudRectWithoutTransition(hud, tickerRect, 1);
ticker?.classList.add("is-hidden");
requestAnimationFrame(() => {
setHudRect(hud, targetRect, 1);
});
morphTimer = window.setTimeout(() => {
morphTimer = null;
finishHudOpen(hud);
}, NEWS_HUD_MORPH_MS);
} else {
const currentRect = hud.getBoundingClientRect();
hud.classList.add("is-morphing");
setHudRectWithoutTransition(hud, currentRect, 1);
requestAnimationFrame(() => {
setHudRect(hud, tickerRect, 1);
});
morphTimer = window.setTimeout(() => {
morphTimer = null;
finishHudClose(hud, ticker, currentRect);
}, NEWS_HUD_MORPH_MS);
}
ticker?.setAttribute("aria-expanded", open ? "true" : "false");
}
function openNewsHud(options = {}) {
setNewsHudOpen(true, options);
}
function closeNewsHud() {
setNewsHudOpen(false);
}
function setupNewsHudResize() {
const { hud } = getElements();
const container = document.getElementById("container");
if (!(hud instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
let resizing = false;
let activeEdge = "";
const resizeStart = {
pointerX: 0,
pointerY: 0,
width: 0,
height: 0,
left: 0,
top: 0,
};
const clearPositioningForResize = () => {
const rect = hud.getBoundingClientRect();
hud.style.left = `${rect.left}px`;
hud.style.top = `${rect.top}px`;
hud.style.width = `${rect.width}px`;
hud.style.height = `${rect.height}px`;
hud.style.transform = "none";
hud.dataset.dragged = "true";
hud.dataset.resized = "true";
};
const stopResize = () => {
resizing = false;
activeEdge = "";
hud.classList.remove("is-resizing");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!resizing) return;
const containerRect = container.getBoundingClientRect();
const hudScale = getHudScale();
const minWidth = Math.round(NEWS_HUD_MIN_WIDTH_PX * hudScale);
const minHeight = Math.round(NEWS_HUD_MIN_HEIGHT_PX * hudScale);
const dx = event.clientX - resizeStart.pointerX;
const dy = event.clientY - resizeStart.pointerY;
if (activeEdge.includes("r")) {
const maxW = containerRect.right - resizeStart.left - NEWS_HUD_RESIZE_MARGIN_PX;
hud.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
}
if (activeEdge.includes("l")) {
const newW = Math.max(minWidth, resizeStart.width - dx);
hud.style.width = `${newW}px`;
hud.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
}
if (activeEdge.includes("b")) {
const maxH = containerRect.bottom - resizeStart.top - NEWS_HUD_RESIZE_MARGIN_PX;
hud.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
}
};
hud.querySelectorAll(".earth-news-hud-edge[data-edge]").forEach((edgeEl) => {
edgeEl.addEventListener("pointerdown", (event) => {
if (document.body.classList.contains("layout-mode-mobile")) return;
if (hud.classList.contains("hud-panel-hidden")) return;
event.preventDefault();
event.stopPropagation();
clearPositioningForResize();
activeEdge = edgeEl.dataset.edge ?? "";
resizing = true;
resizeStart.pointerX = event.clientX;
resizeStart.pointerY = event.clientY;
const rect = hud.getBoundingClientRect();
resizeStart.width = rect.width;
resizeStart.height = rect.height;
resizeStart.left = rect.left;
resizeStart.top = rect.top;
hud.classList.add("is-resizing");
document.body.style.userSelect = "none";
edgeEl.setPointerCapture?.(event.pointerId);
});
edgeEl.addEventListener("pointermove", onMove);
edgeEl.addEventListener("pointerup", stopResize);
edgeEl.addEventListener("pointercancel", stopResize);
edgeEl.addEventListener("lostpointercapture", stopResize);
});
}
function escapeTickerText(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderTicker(nextPayload) {
const { ticker, tickerRegion, tickerTrack } = getElements();
if (!(ticker instanceof HTMLElement) || !(tickerTrack instanceof HTMLElement)) return;
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const focus = nextPayload?.focus || {};
if (tickerRegion instanceof HTMLElement) {
tickerRegion.textContent = (focus.region || "global").toUpperCase();
tickerRegion.style.color = focus.accent || "";
}
if (items.length === 0) {
tickerTrack.textContent = "正在准备全球态势新闻...";
tickerTrack.style.removeProperty("--news-ticker-duration");
return;
}
const visibleItems = items.slice(0, 6);
const tickerItems = [...visibleItems, ...visibleItems];
tickerTrack.innerHTML = tickerItems
.map((item) => `
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
<span>${escapeTickerText(item.title || "未命名新闻")}</span>
</span>
`)
.join("");
tickerTrack.style.setProperty("--news-ticker-duration", `${Math.max(22, visibleItems.length * 7)}s`);
}
function renderEmptyState(message) {
const { board, empty, status, openBtn } = getElements();
if (board) board.innerHTML = "";
@@ -68,6 +323,7 @@ function renderEmptyState(message) {
status.textContent = "等待聚合新闻源";
}
if (openBtn) openBtn.disabled = true;
renderTicker({ items: [], focus: payload?.focus || { region: "global" } });
}
function renderPayload(nextPayload) {
@@ -87,6 +343,8 @@ function renderPayload(nextPayload) {
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
renderTicker(nextPayload);
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
@@ -338,21 +596,24 @@ export function initNewsPanel() {
if (initialized) return;
initialized = true;
updateNewsToggleUI(isTVPanelVisible());
updateNewsToggleUI(true);
renderEmptyState("正在准备全球态势新闻聚合源...");
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
if (getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
const { ticker, hudCloseBtn } = getElements();
ticker?.addEventListener("click", (event) => {
const itemEl = event.target instanceof Element
? event.target.closest("[data-news-id]")
: null;
const highlightId = itemEl?.getAttribute("data-news-id") || null;
openNewsHud({ highlightId });
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
if (event.detail?.visible && getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
ticker?.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openNewsHud();
});
hudCloseBtn?.addEventListener("click", closeNewsHud);
setupNewsHudResize();
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);

View File

@@ -23,8 +23,6 @@ function waitForContext(context, durationMs) {
}
function getDefaultCardTarget() {
// TODO: replace the singleton card fallback with a presentation/card token check
// before BGP/News migrate here, so connectors only attach to their owning card.
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;

View File

@@ -1,7 +1,7 @@
// satellites.js - Satellite visualization module with real SGP4 positions and animations
import * as THREE from "three";
import { twoline2satrec, propagate } from "satellite.js";
import { twoline2satrec, propagate, eciToEcf, gstime } from "satellite.js";
import {
CONFIG,
DEFAULT_SATELLITE_DISPLAY_STYLE,
@@ -128,6 +128,7 @@ const FALLBACK_TRAIL_ALPHA_START = 0.2;
const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const FOOTPRINT_DIRECTION_SAMPLE_MS = 30000;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
const SATELLITE_TWINKLE_SECONDARY_WEIGHT = 0.28;
@@ -954,30 +955,84 @@ function computeSatellitePosition(satellite, time) {
return null;
}
const x = positionAndVelocity.position.x;
const y = positionAndVelocity.position.y;
const z = positionAndVelocity.position.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale);
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
gstime(time),
);
} catch (error) {
return null;
}
}
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
const earthFixedPosition = convertEciPositionToSceneVector(
positionEci,
siderealTime,
);
const x = earthFixedPosition.x;
const y = earthFixedPosition.y;
const z = earthFixedPosition.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(
positionEci.x * positionEci.x +
positionEci.y * positionEci.y +
positionEci.z * positionEci.z,
);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
return null;
}
const scale = displayRadius / sceneRadius;
return new THREE.Vector3(x * scale, y * scale, z * scale);
}
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
siderealTime,
);
} catch (error) {
return null;
}
}
function convertEciPositionToSceneVector(positionEci, siderealTime) {
const positionEcf = eciToEcf(positionEci, siderealTime);
return new THREE.Vector3(
positionEcf.x,
positionEcf.z,
-positionEcf.y,
);
}
function getCompressedRealAltitudeOffset(radiusKm) {
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
const clampedAltitudeKm = Math.min(
@@ -1952,14 +2007,15 @@ function getLockedSatelliteTrackDirection(groundNormal) {
const props = satellite?.properties;
if (!props?.norad_cat_id) return null;
const satrec = getOrBuildSatrec(props, new Date());
if (!satrec || satrec.error) return null;
const now = new Date();
const currentPosition = computeSatellitePosition(satellite, now);
const nextPosition = computeSatellitePosition(
satellite,
new Date(now.getTime() + FOOTPRINT_DIRECTION_SAMPLE_MS),
);
if (!currentPosition || !nextPosition) return null;
const propagation = propagate(satrec, new Date());
const velocity = propagation?.velocity;
if (!velocity) return null;
scratchFootprintVelocity.set(velocity.x, velocity.y, velocity.z);
scratchFootprintVelocity.subVectors(nextPosition, currentPosition);
if (!Number.isFinite(scratchFootprintVelocity.lengthSq())) return null;
scratchFootprintTangent
@@ -2592,32 +2648,65 @@ function calculatePredictedOrbit(
const points = [];
const samples = Math.ceil(periodSeconds / sampleInterval);
const now = new Date();
const fixedSiderealTime = gstime(now);
for (let i = 0; i <= samples; i++) {
const time = new Date(now.getTime() + i * sampleInterval * 1000);
const pos = computeSatellitePosition(satellite, time);
const pos = computeSatelliteInertialOrbitPosition(
satellite,
time,
fixedSiderealTime,
);
if (pos) points.push(pos);
}
if (points.length < samples * 0.5) {
points.length = 0;
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
for (let i = 0; i <= samples; i++) {
const theta = (i / samples) * Math.PI * 2;
const phi = (inclination * Math.PI) / 180;
const x =
radius * Math.sin(phi) * Math.cos(theta + (raan * Math.PI) / 180);
const y = radius * Math.cos(phi);
const z =
radius * Math.sin(phi) * Math.sin(theta + (raan * Math.PI) / 180);
points.push(new THREE.Vector3(x, y, z));
}
return calculateFallbackPredictedOrbit(satellite, samples);
}
closeOrbitLoop(points);
return points;
}
function closeOrbitLoop(points) {
if (points.length > 2) {
points.push(points[0].clone());
}
}
function calculateFallbackPredictedOrbit(satellite, samples) {
const points = [];
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const inclinationRad = THREE.MathUtils.degToRad(
satellite.properties?.inclination ?? 53,
);
const raanRad = THREE.MathUtils.degToRad(satellite.properties?.raan ?? 0);
const cosRaan = Math.cos(raanRad);
const sinRaan = Math.sin(raanRad);
const cosInclination = Math.cos(inclinationRad);
const sinInclination = Math.sin(inclinationRad);
for (let i = 0; i <= samples; i++) {
const argument = (i / samples) * Math.PI * 2;
const cosArgument = Math.cos(argument);
const sinArgument = Math.sin(argument);
const ecfX =
radius *
(cosRaan * cosArgument -
sinRaan * sinArgument * cosInclination);
const ecfY =
radius *
(sinRaan * cosArgument +
cosRaan * sinArgument * cosInclination);
const ecfZ = radius * sinArgument * sinInclination;
points.push(new THREE.Vector3(ecfX, ecfZ, -ecfY));
}
closeOrbitLoop(points);
return points;
}

View File

@@ -5,7 +5,6 @@ import { createHUDPanel } from "./hud-panels.js";
// Naming convention:
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
// - #tv-panel is the inner live tab pane
// - #news-panel is the inner aggregation-news tab pane
const TV_STREAMS_API = "/api/v1/tv/streams";
const TV_PROXY_API = "/api/v1/tv/proxy";
@@ -13,8 +12,8 @@ const TV_STATUS_MESSAGE = {
idle: "等待加载直播源",
syncing: "正在同步直播源...",
empty: "暂无可播放直播源",
iframeReady: "直播已加载",
videoReady: "视频流已加载",
iframeReady: "直播已加载",
videoReady: "直播已加载",
videoError: "当前视频流不可播放,请尝试其他频道",
externalOnly: "当前频道仅支持外部打开",
loadFailed: "电视直播源加载失败",
@@ -28,25 +27,15 @@ let hlsPlayer = null;
let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null;
let mediaPanel = null;
let activeTab = "live";
const failedSourceIds = new Set();
let probeTimer = null;
let reformCleanupTimer = null;
const tabPanelState = {
live: null,
news: null,
};
let mobileMetaCollapsed = false;
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const DEFAULT_HUD_OFFSET_PX = 20;
const MIN_NEWS_TAB_HEIGHT_PX = 280;
const PANEL_RESIZE_MARGIN_PX = 12;
const TV_PANEL_MIN_WIDTH_PX = 360;
const TV_PANEL_MIN_HEIGHT_PX = 340;
const REFORM_CLEANUP_MS = 280;
const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor";
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
@@ -66,6 +55,7 @@ function getElements() {
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
origin: document.getElementById("tv-source-origin"),
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
@@ -75,13 +65,8 @@ function getElements() {
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"),
newsTabBtn: document.getElementById("tv-tab-news"),
// Inner tab panes.
livePane: document.getElementById("tv-panel"),
newsPane: document.getElementById("news-panel"),
};
}
@@ -124,37 +109,13 @@ function setMetaCollapsed(collapsed) {
syncMetaToggleState(Boolean(collapsed));
}
function syncPanelActiveTab(tab = activeTab) {
function syncPanelActiveTab(tab = "live") {
const { panel } = getElements();
if (panel instanceof HTMLElement) {
panel.dataset.activeTab = tab;
}
}
function syncNewsDefaultMaxHeight() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
const earthStats = document.getElementById("earth-stats");
const hudOffset = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"),
);
const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX;
if (!(earthStats instanceof HTMLElement)) {
panel.style.removeProperty("--tv-news-default-max-height");
return;
}
const statsRect = earthStats.getBoundingClientRect();
const availableHeight = Math.max(
Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()),
Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom),
);
panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`);
}
function autoExpandMeta() {
if (isMobileLayout()) {
clearTimeout(metaAutoCollapseTimer);
@@ -174,82 +135,6 @@ function clearPanelPositioningForResize(panel) {
panel.dataset.dragged = "true";
}
function readPanelLayoutState(panel) {
return {
width: panel.style.width || "",
height: panel.style.height || "",
resized: panel.dataset.resized === "true",
};
}
function resetPanelLayoutState(panel) {
panel.style.width = "";
panel.style.height = "";
delete panel.dataset.resized;
}
function captureTabState(tab = activeTab) {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? isMetaCollapsed() : null,
};
}
function restoreTabState(tab, panel, container, anchor = null) {
if (!(panel instanceof HTMLElement)) return;
const snapshot = tabPanelState[tab];
if (!snapshot?.layout) {
resetPanelLayoutState(panel);
return;
}
const { layout } = snapshot;
panel.style.width = layout.width;
panel.style.height = layout.height;
if (layout.resized) {
panel.dataset.resized = "true";
} else {
delete panel.dataset.resized;
}
if (tab === "live" && snapshot.metaCollapsed !== null) {
setMetaCollapsed(snapshot.metaCollapsed);
}
requestAnimationFrame(() => {
if (anchor) {
const panelRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const targetLeft = anchor.right - containerRect.left - panelRect.width;
const targetTop = anchor.bottom - containerRect.top - panelRect.height;
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
} else {
panel.style.right = "";
panel.style.bottom = "";
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
delete panel.dataset.dragged;
}
});
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -365,14 +250,11 @@ function updateToggleButton(visible) {
const { toggleBtn } = getElements();
if (!toggleBtn) return;
const icon = toggleBtn.querySelector(".material-symbols-rounded");
const isLiveTab = activeTab === "live";
toggleBtn.classList.toggle("active", visible);
if (icon) {
icon.textContent = isLiveTab ? "live_tv" : "newspaper";
icon.textContent = "live_tv";
}
const title = visible
? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播")
: (isLiveTab ? "打开新闻直播" : "打开态势新闻");
const title = visible ? "关闭 Live 新闻" : "打开 Live 新闻";
toggleBtn.title = title;
toggleBtn.setAttribute("aria-label", title);
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
@@ -404,182 +286,16 @@ export function setTVPanelVisible(visible, options = {}) {
setPanelVisible(visible, options);
}
function clearReformState() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
panel.classList.remove("is-reforming");
panel.style.height = "";
if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") {
panel.style.top = "";
panel.style.bottom = "";
delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY];
}
if (reformCleanupTimer) {
clearTimeout(reformCleanupTimer);
reformCleanupTimer = null;
}
function setActiveTab() {
syncPanelActiveTab("live");
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: "live" },
}));
}
function animateTabReform(applyChange) {
const { panel } = getElements();
const container = document.getElementById("container");
if (!(panel instanceof HTMLElement)) {
applyChange();
return;
}
if (!(container instanceof HTMLElement)) {
applyChange();
return;
}
if (panel.dataset.resized === "true") {
applyChange();
requestAnimationFrame(() => {
clampPanelToContainer(panel, container);
});
return;
}
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
applyChange();
return;
}
clearReformState();
const reformStartRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const reformSnapshot = {
height: reformStartRect.height,
anchoredBottom: reformStartRect.bottom - containerRect.top,
shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true",
};
if (reformSnapshot.shouldRestoreDefaultAnchoring) {
panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true";
panel.style.bottom = "auto";
}
panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`;
panel.style.height = `${reformSnapshot.height}px`;
panel.classList.add("is-reforming");
void panel.offsetHeight;
applyChange();
panel.style.height = "auto";
const targetHeight = panel.getBoundingClientRect().height;
panel.style.height = `${reformSnapshot.height}px`;
void panel.offsetHeight;
const targetTop = reformSnapshot.anchoredBottom - targetHeight;
const finalizeReform = () => {
panel.removeEventListener("transitionend", handleReformTransitionEnd);
clearReformState();
};
const handleReformTransitionEnd = (event) => {
if (event.target === panel && event.propertyName === "height") {
finalizeReform();
}
};
panel.addEventListener("transitionend", handleReformTransitionEnd);
reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS);
requestAnimationFrame(() => {
panel.style.top = `${targetTop}px`;
panel.style.height = `${targetHeight}px`;
});
}
function updateTabState(target, isActive, activeClassName = "") {
if (!(target instanceof HTMLElement)) return;
target.hidden = !isActive;
if (activeClassName) {
target.classList.toggle(activeClassName, isActive);
}
}
function updateTabButtonState(button, isActive) {
if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle("media-panel-tab--active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
}
function setActiveTab(tab) {
const nextTab = tab === "news" ? "news" : "live";
if (activeTab === nextTab) return;
captureTabState(activeTab);
const { panel } = getElements();
const targetSnapshot = tabPanelState[nextTab];
const currentIsCustom =
panel instanceof HTMLElement && panel.dataset.resized === "true";
const targetIsCustom = Boolean(targetSnapshot?.layout?.resized);
const container = document.getElementById("container");
const currentAnchor =
panel instanceof HTMLElement && container instanceof HTMLElement
? (() => {
const panelRect = panel.getBoundingClientRect();
return {
right: panelRect.right,
bottom: panelRect.bottom,
};
})()
: null;
const applyTabSwitch = (restoreLayoutState = false) => {
activeTab = nextTab;
syncPanelActiveTab(nextTab);
syncNewsDefaultMaxHeight();
const {
liveTabBtn,
newsTabBtn,
liveHeaderControls,
newsHeaderControls,
livePane,
newsPane,
} = getElements();
updateTabButtonState(liveTabBtn, nextTab === "live");
updateTabButtonState(newsTabBtn, nextTab === "news");
updateTabState(liveHeaderControls, nextTab === "live");
updateTabState(newsHeaderControls, nextTab === "news");
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
if (
restoreLayoutState &&
panel instanceof HTMLElement &&
container instanceof HTMLElement
) {
restoreTabState(nextTab, panel, container, currentAnchor);
}
requestAnimationFrame(() => {
captureTabState(nextTab);
});
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: nextTab },
}));
};
if (currentIsCustom || targetIsCustom) {
applyTabSwitch(true);
return;
}
animateTabReform(() => applyTabSwitch(false));
}
export function openTVPanelTab(tab = "live") {
export function openTVPanelTab() {
setPanelVisible(true);
if (activeTab === tab) return;
setActiveTab(tab);
setActiveTab("live");
}
export function setActiveTVTab(tab = "live") {
@@ -591,7 +307,7 @@ export function isTVPanelVisible() {
}
export function getActiveTVTab() {
return activeTab;
return "live";
}
function getEmbeddedUrl(source) {
@@ -913,6 +629,21 @@ function setPanelMessage(message) {
const { status } = getElements();
if (status) {
status.textContent = message || TV_STATUS_MESSAGE.idle;
if (status.id === "tv-source-status") {
const normalized = message || TV_STATUS_MESSAGE.idle;
status.classList.toggle(
"tv-panel-tag--error",
normalized === TV_STATUS_MESSAGE.videoError || normalized === TV_STATUS_MESSAGE.loadFailed,
);
status.classList.toggle(
"tv-panel-tag--warning",
normalized === TV_STATUS_MESSAGE.syncing ||
normalized === TV_STATUS_MESSAGE.externalOnly ||
normalized.includes("重试") ||
normalized.includes("恢复") ||
normalized.includes("回退"),
);
}
}
}
@@ -967,12 +698,11 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment();
sources.forEach((source) => {
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option");
option.value = source.id;
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
option.textContent = `${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option);
});
@@ -983,7 +713,7 @@ function renderSourceOptions() {
}
function renderSource(source) {
const { title, meta, catalog, notes, iframe, video, empty } = getElements();
const { title, meta, catalog, origin, notes, iframe, video, empty } = getElements();
const embeddedUrl = getEmbeddedUrl(source);
const videoUrl = getVideoUrl(source);
const externalUrl = getExternalUrl(source);
@@ -993,6 +723,10 @@ function renderSource(source) {
if (title) {
title.textContent = source?.name || "暂无可用频道";
}
if (origin instanceof HTMLElement) {
origin.textContent = source?.collector_source ? "采集" : source ? "内置" : "待加载";
origin.title = source?.collector_source ? `采集源:${source.collector_source}` : source ? "内置源" : "待加载";
}
if (meta) {
meta.textContent = source
? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}`
@@ -1004,12 +738,7 @@ function renderSource(source) {
const latestLabel = latestUpdatedAt
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
: "尚未同步";
const sourceOriginLabel = source?.collector_source
? `采集源 ${source.collector_source}`
: source
? "内置源"
: "";
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}`;
}
if (notes) {
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
@@ -1039,9 +768,7 @@ function renderSource(source) {
hideEmptyState(empty);
setPanelMessage(
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
);
setPanelMessage("直播加载中");
updateOpenButton(source);
autoExpandMeta();
}
@@ -1117,8 +844,6 @@ export function initTVPanel() {
toggleBtn,
panel,
metaToggle,
liveTabBtn,
newsTabBtn,
} = getElements();
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
@@ -1148,23 +873,13 @@ export function initTVPanel() {
const currentlyVisible = mediaPanel?.isVisible() ?? false;
if (!currentlyVisible) {
setPanelVisible(true);
if (activeTab === "live") {
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("态势新闻窗口已打开", "info");
}
await ensureTVPanelReady();
showStatusMessage("Live 新闻窗口已打开", "info");
return;
}
const nextTab = activeTab === "live" ? "news" : "live";
setActiveTab(nextTab);
if (nextTab === "live") {
await ensureTVPanelReady();
showStatusMessage("已切换到新闻直播", "info");
return;
}
showStatusMessage("已切换到态势新闻", "info");
setPanelVisible(false);
showStatusMessage("Live 新闻窗口已关闭", "info");
});
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
@@ -1213,13 +928,6 @@ export function initTVPanel() {
});
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
});
newsTabBtn?.addEventListener("click", () => {
setActiveTab("news");
});
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((iframeEl) => {
@@ -1249,10 +957,4 @@ export function initTVPanel() {
setupResizeHandle();
syncPanelActiveTab("live");
syncNewsDefaultMaxHeight();
updateTabButtonState(liveTabBtn, true);
updateTabButtonState(newsTabBtn, false);
captureTabState("live");
window.addEventListener("resize", syncNewsDefaultMaxHeight);
}

View File

@@ -3,6 +3,7 @@
let statusTimeoutId = null;
let statusHideTimeoutId = null;
const STATUS_BASE_CLASS = "earth-status-message";
const STATUS_TICKER_STACK_CLASS = "earth-status-message--ticker-stack";
const STATUS_DISPLAY_MS = 3000;
const STATUS_FADE_MS = 280;
const GESTURE_STATUS_DISPLAY_MS = 760;
@@ -87,6 +88,52 @@ function buildStatusContent(statusEl, message, type) {
statusEl.appendChild(text);
}
function getStatusSidePlacement(statusEl) {
if (!(statusEl instanceof HTMLElement)) return false;
if (document.body.classList.contains("layout-mode-mobile")) return false;
const ticker = document.getElementById("desktop-news-ticker");
const brand = document.getElementById("brand-panel");
if (!(ticker instanceof HTMLElement) || !(brand instanceof HTMLElement)) return false;
if (ticker.classList.contains("is-hidden") || ticker.offsetParent === null) return false;
const tickerRect = ticker.getBoundingClientRect();
const brandRect = brand.getBoundingClientRect();
const statusWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
if (!statusWidth || !tickerRect.width || !brandRect.width) return false;
const rootStyle = getComputedStyle(document.documentElement);
const hudScale = Number.parseFloat(rootStyle.getPropertyValue("--hud-scale")) || 1;
const requiredGap = Math.max(10, Math.round(12 * hudScale));
const availableWidth = tickerRect.left - brandRect.right - requiredGap * 2;
return {
shouldStack: availableWidth < statusWidth,
left: Math.round(brandRect.right + requiredGap),
maxWidth: Math.max(160, Math.floor(availableWidth)),
};
}
function syncStatusPlacement(statusEl) {
if (!(statusEl instanceof HTMLElement)) return;
const placement = getStatusSidePlacement(statusEl);
const shouldStack = !placement || placement.shouldStack;
statusEl.classList.toggle(STATUS_TICKER_STACK_CLASS, shouldStack);
if (!placement || shouldStack) {
statusEl.style.left = "";
statusEl.style.maxWidth = "";
return;
}
statusEl.style.left = `${placement.left}px`;
statusEl.style.maxWidth = `${placement.maxWidth}px`;
}
function syncVisibleStatusPlacement() {
const statusEl = getElement("status-message");
if (statusEl?.classList.contains("visible")) {
syncStatusPlacement(statusEl);
}
}
function buildPersistentErrorContent(errorEl, message) {
buildStatusContent(errorEl, message, "error");
}
@@ -97,6 +144,8 @@ function hideStatusElement(statusEl, onHidden) {
if (!statusEl.classList.contains("visible")) {
setElementDisplay(statusEl, false);
statusEl.className = STATUS_BASE_CLASS;
statusEl.style.left = "";
statusEl.style.maxWidth = "";
statusEl.innerHTML = "";
}
statusHideTimeoutId = null;
@@ -123,6 +172,7 @@ function startTransientStatus(message, type = "info") {
buildStatusContent(statusEl, message, type);
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
setElementDisplay(statusEl, true, "inline-flex");
syncStatusPlacement(statusEl);
statusEl.offsetHeight;
statusEl.classList.add("visible");
@@ -159,6 +209,7 @@ export function showGestureStatusMessage(message, type = "info") {
buildStatusContent(statusEl, message, type);
statusEl.className = `${STATUS_BASE_CLASS} ${type} gesture`;
setElementDisplay(statusEl, true, "inline-flex");
syncStatusPlacement(statusEl);
statusEl.offsetHeight;
statusEl.classList.add("visible");
@@ -246,6 +297,7 @@ export function setLoading(loading) {
pendingLoadingMessage = "";
statusEl.className = `${STATUS_BASE_CLASS} loading`;
setElementDisplay(statusEl, true, "inline-flex");
syncStatusPlacement(statusEl);
statusEl.offsetHeight;
statusEl.classList.add("visible");
requestAnimationFrame(() => {
@@ -268,6 +320,8 @@ export function setLoading(loading) {
}
}
window.addEventListener("resize", syncVisibleStatusPlacement, { passive: true });
export function setLoadingMessage(title) {
const statusEl = getElement("status-message");
if (!statusEl || !statusEl.classList.contains("loading")) {
@@ -279,6 +333,7 @@ export function setLoadingMessage(title) {
textEl.textContent = title;
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
syncStatusPlacement(statusEl);
});
}
}
@@ -334,6 +389,8 @@ export function clearUiState() {
if (statusEl) {
statusEl.className = STATUS_BASE_CLASS;
setElementDisplay(statusEl, false);
statusEl.style.left = "";
statusEl.style.maxWidth = "";
statusEl.innerHTML = "";
clearLoadingWidthLock(statusEl);
}

View File

@@ -1437,7 +1437,7 @@ body {
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-radius: 14px;
border-radius: 8px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 247, 250, 0.96) 100%);
border: 1px solid rgba(5, 5, 5, 0.08);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
@@ -1593,31 +1593,33 @@ body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #ffffff;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container,
.data-source-table-region .ant-table,
.data-source-table-region .ant-table-container,
.data-source-table-region .ant-table-body {
.data-source-table-region .ant-table-container {
height: 100%;
min-height: 0;
}
/* users table: flex-fill approach so overlay x-track aligns with table bottom */
.users-table-region .ant-table-container {
.data-source-table-region .ant-table-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border-radius: inherit;
overflow: hidden;
background: #ffffff;
}
.users-table-region .ant-table-header {
.data-source-table-region .ant-table-header {
flex: 0 0 auto;
}
.users-table-region .ant-table-body {
.data-source-table-region .ant-table-body {
flex: 1 1 auto;
min-height: 0;
height: 0 !important;
@@ -1656,6 +1658,27 @@ body {
border-radius: 12px;
}
.tasks-page > .ant-row {
flex: 0 0 auto;
}
.tasks-page__table-card {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.tasks-page__table-card .ant-card-body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.data-source-drawer-collapse {
margin-bottom: 12px;
}
@@ -2074,13 +2097,20 @@ body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.system-alerts-page__table-card .ant-card-head,
.bgp-alerts-page__table-card .ant-card-head {
flex: 0 0 auto;
}
.system-alerts-page__table-card .ant-card-body,
.bgp-alerts-page__table-card .ant-card-body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;

View File

@@ -833,10 +833,12 @@ function DataSources() {
<div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
<div className="page-shell__body data-source-tabs-shell">
<Tabs
className="data-source-tabs"
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'tasks',
label: '采集任务',
@@ -1048,8 +1050,9 @@ function DataSources() {
</div>
),
},
]}
/>
]}
/>
</div>
</div>
<Modal

View File

@@ -134,46 +134,49 @@ function Tasks() {
return (
<AppLayout>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic title="今日任务" value={tasks.length} prefix={<ReloadOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="成功率"
value={successRate.toFixed(1)}
suffix="%"
valueStyle={{ color: successRate >= 90 ? '#52c41a' : '#faad14' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="成功" value={statusCounts.success} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="失败" value={statusCounts.failed} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
</Row>
<div className="page-shell tasks-page">
<Row gutter={[16, 16]}>
<Col xs={24} md={6}>
<Card>
<Statistic title="今日任务" value={tasks.length} prefix={<ReloadOutlined />} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic
title="成功率"
value={successRate.toFixed(1)}
suffix="%"
valueStyle={{ color: successRate >= 90 ? '#52c41a' : '#faad14' }}
/>
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="成功" value={statusCounts.success} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} md={6}>
<Card>
<Statistic title="失败" value={statusCounts.failed} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
</Row>
<Card
title="任务历史"
extra={
<Button type="primary" icon={<ReloadOutlined />} onClick={fetchTasks}>
</Button>
}
>
<TableScrollRegion>
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</TableScrollRegion>
</Card>
<Card
className="tasks-page__table-card"
title="任务历史"
extra={
<Button type="primary" icon={<ReloadOutlined />} onClick={fetchTasks}>
</Button>
}
>
<TableScrollRegion className="data-source-table-region">
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 480 }} tableLayout="fixed" />
</TableScrollRegion>
</Card>
</div>
</AppLayout>
)
}

View File

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

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.55.0"
version = "0.56.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },