diff --git a/VERSION b/VERSION index 620104d8..43c125a0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.1 +0.46.2 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6adbeab3..2ddd9c95 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,20 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.46.2] — 2026-04-30 + +Released: 2026-04-30 + +### 🐛 Fixes +- 修复 Earth 启动时高清材质、云图和图层可见性绕过 `startupPriority` 的问题,统一由启动队列按文档顺序加载。 +- 修复保存为关闭的高清材质/图层仍会先加载再关闭的问题,并保持海陆基座作为国界线图层的常驻底图。 +- 修复搜索跳转会误关媒体面板、船只轨迹末端不贴合当前船只、Iridium footprint 被地表层遮挡等 Earth 交互问题。 + +### 📝 Documentation +- 更新 Earth 图层顺序、样式参考、使用手册和 AIS 聚合计划,补齐中英文说明与后续接入策略。 + +--- + ## [0.46.1] — 2026-04-30 Released: 2026-04-30 diff --git a/docs/plans/README.md b/docs/plans/README.md index 1ca4f784..31c25ac5 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -26,6 +26,7 @@ - [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md) - [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md) - [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md) +- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) - [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md) - [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md) - [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) diff --git a/docs/plans/earth-interactable-layer-plan.md b/docs/plans/earth-interactable-layer-plan.md index f6940a58..29d67010 100644 --- a/docs/plans/earth-interactable-layer-plan.md +++ b/docs/plans/earth-interactable-layer-plan.md @@ -272,7 +272,8 @@ hover / locked 使用少量 overlay: ### Phase 3:迁移算力中心并评估登陆点 - 算力中心保留现有业务 icon,但接入统一 hover / locked / glow。(已完成) -- 登陆点曾接入同一套 `Points` 渲染,但 pin 类 SVG 在地球边缘会被深度测试裁切;当前保留专用 `THREE.Sprite`,并使用 canvas 生成黄色扁平球,贴到海缆层级。后续如要重新设计登陆点,需要先确认图标能在边缘视角完整显示。 +- 登陆点曾接入同一套 `Points` 渲染,但 pin 类 SVG 在地球边缘会被深度测试裁切;当前保留专用 `THREE.Sprite`,并使用 canvas 生成黄色扁平球,贴到海缆层级。 +- TODO:登陆点暂不迁移到完整 Interactable。后续若要统一交互接口,优先考虑 Sprite-backed adapter,只对齐 `getMarkers()`、`getPointerIntersections()`、`setMarkerState()`、`updateVisualState()` 等外观协议,不强行复用 `THREE.Points`、atlas 和跨图层避让。 - 检查图例、搜索和 info-card 是否只依赖业务 payload,而不是依赖渲染对象类型。 ### Phase 4:形成 Earth 图标层规范 diff --git a/docs/plans/earth-vessel-ais-aggregation-plan.md b/docs/plans/earth-vessel-ais-aggregation-plan.md new file mode 100644 index 00000000..2728ae33 --- /dev/null +++ b/docs/plans/earth-vessel-ais-aggregation-plan.md @@ -0,0 +1,261 @@ +# AIS 多源采集、冲突记录与聚合接口计划 + +**状态**:规划中 +**创建日期**:2026-04-30 +**核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成 + +## 已确认决策 + +| 项目 | 决策 | +|-----|------| +| AISStream 接入方式 | 单独实现 WebSocket 采集器,不塞进现有 BarentsWatch HTTP collector | +| 采集器职责 | 连接上游、标准化字段、写入原始观测,不直接决定最终展示值 | +| 去重合并位置 | 放在聚合服务和聚合 API 中,而不是散落在每个 collector 的保存逻辑里 | +| 冲突处理 | 先记录冲突事实和当前选择原因,后续再开放用户规则配置 | +| 默认可信度 | 同类 AIS 数据源优先按 `delivery_mode` 评估:`realtime_stream` 优于 `batch_stream`,再优于 `polling` 和 `snapshot` | +| 过期保护 | 实时流源断流超过 freshness 窗口后,不能仅凭“实时源”身份压过更新的轮询数据 | + +## 背景 + +当前 AIS 链路以 BarentsWatch 为主。它是 HTTP polling 模式,覆盖挪威附近海域,适合作为稳定的免费起点,但不适合承担全球实时船只数据的全部职责。后续接入 AISStream 后,会出现同一个 MMSI 被多个来源同时上报的情况: + +- 位置、航速、航向可能在多个来源之间存在秒级差异。 +- 船名、IMO、呼号、船型、尺寸等静态字段可能不完整,甚至互相冲突。 +- WebSocket 或其他实时流通常更接近实时,但也可能断流或批量延迟。 +- 如果每个 collector 自己做去重合并,规则会分散、不可审计,也很难让用户后续配置“某个字段信任哪个来源”。 + +因此 v1 不应让采集器直接覆盖最终船只表。更稳的方式是先保留观测事实,再由聚合接口统一给出当前展示视图。 + +## 目标架构 + +```mermaid +flowchart LR + A[BarentsWatch HTTP collector] --> D[AIS raw observations] + B[AISStream WebSocket collector] --> D + C[Custom mapped vessel_ais sources] --> D + D --> E[AIS aggregation service] + E --> F[Conflict records] + E --> G[GeoJSON vessels API] + E --> H[Vessel detail API] + I[Aggregation strategy config] --> E +``` + +### 原始观测层 + +原始观测层保存每个来源看到的事实。建议模型包含: + +| 字段 | 用途 | +|-----|------| +| `target_schema` | 例如 `vessel_ais` | +| `source` | 例如 `barentswatch_vessels`、`aisstream_vessels` | +| `entity_key` | AIS 使用 MMSI | +| `delivery_mode` | `realtime_stream`、`batch_stream`、`polling`、`snapshot` | +| `transport` | `websocket`、`sse`、`http`、`file` 等 | +| `observed_at` | 上游数据时间,优先使用 AIS 消息时间 | +| `collected_at` | 本系统接收或采集时间 | +| `normalized_payload` | 标准化后的 AIS JSON | +| `raw_payload` | 可选,保存原始或裁剪后的上游记录 | + +`delivery_mode` 和 `transport` 不应混为一谈。WebSocket 是传输方式;streaming 是交付模式。聚合可信度主要看 `delivery_mode`,`transport` 只作为辅助信息。 + +### 冲突记录层 + +聚合服务发现同一个实体、同一个字段存在多个非空不同值时,写入冲突记录。冲突记录不代表错误,只代表“有多个可用候选值”。 + +```json +{ + "target_schema": "vessel_ais", + "entity_key": "257123000", + "field": "name", + "candidates": { + "barentswatch_vessels": "OSLO TRADER", + "aisstream_vessels": "OSLO TRADER II" + }, + "selected_source": "aisstream_vessels", + "selected_value": "OSLO TRADER II", + "selected_reason": "delivery_mode_priority", + "resolved_by": "system", + "status": "open" +} +``` + +第一阶段只需要记录冲突和当前选择原因,不需要做人工逐条确认。后续 UI 的目标也不是让用户处理每条冲突,而是把冲突沉淀成字段级规则。 + +## 聚合规则 + +### 字段分类 + +| 类型 | 字段 | 默认策略 | +|-----|------|----------| +| 动态位置 | `lat`、`lon`、`sog`、`cog`、`heading`、`nav_status` | 优先最新 `observed_at`,同时间再按来源优先级 | +| 静态身份 | `name`、`callsign`、`imo`、`flag` | 非空优先,再按字段策略或来源优先级 | +| 静态规格 | `vessel_type`、`vessel_type_name`、`length`、`width`、`draught` | 非空优先;冲突时记录候选值 | +| 元信息 | `field_sources`、`conflict_count`、`selected_reasons` | 聚合接口生成,便于调试和后续 UI 展示 | + +### 默认优先级 + +默认优先级应使用两个维度: + +```yaml +delivery_mode_priority: + - realtime_stream + - batch_stream + - polling + - snapshot + +transport_priority: + - websocket + - sse + - http + - file +``` + +`delivery_mode_priority` 是主判断。比如 AISStream 如果提供实时推送,应标记为 `realtime_stream + websocket`;BarentsWatch 当前是 `polling + http`。 + +### 断流保护 + +实时流不能永久凭身份占优。聚合时需要 freshness 窗口: + +```yaml +freshness: + realtime_stream_seconds: 900 + polling_seconds: 3600 +``` + +如果 `aisstream_vessels` 最近 15 分钟没有该 MMSI 的新观测,而 BarentsWatch 轮询源有更新位置,则位置类字段应采用 BarentsWatch 的更新观测,并记录选择原因 `newest_observation` 或 `freshness_fallback`。 + +## 聚合接口 + +现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。 + +```text +GET /api/v1/visualization/geo/vessels +GET /api/v1/visualization/vessels/{mmsi} +GET /api/v1/visualization/vessels/{mmsi}/track +GET /api/v1/visualization/vessels/{mmsi}/conflicts +``` + +GeoJSON properties 建议增加: + +```json +{ + "mmsi": 257123000, + "name": "OSLO TRADER", + "lat": 59.91, + "lon": 10.73, + "received_at": "2026-04-30T10:00:00Z", + "field_sources": { + "name": "aisstream_vessels", + "lat": "aisstream_vessels", + "lon": "aisstream_vessels", + "vessel_type": "barentswatch_vessels" + }, + "selected_reasons": { + "name": "delivery_mode_priority", + "lat": "newest_observation", + "vessel_type": "non_empty_priority" + }, + "conflict_count": 2 +} +``` + +## 开放配置计划 + +### Phase 1 — 内置默认策略和只读解释 + +- 实现后端默认策略。 +- 聚合接口返回 `field_sources`、`selected_reasons`、`conflict_count`。 +- 冲突记录可查询,但不允许用户修改。 +- 保持现有前端船只图层接口形状基本兼容,新增字段只作为调试和后续 UI 输入。 + +### Phase 2 — 系统设置中的 JSON/YAML 策略配置 + +新增系统设置项,例如: + +```yaml +collector_aggregation: + vessel_ais: + source_priority: + - aisstream_vessels + - barentswatch_vessels + field_rules: + name: + mode: source_priority + vessel_type: + mode: source_priority + source_priority: + - barentswatch_vessels + - aisstream_vessels + lat: + mode: newest + lon: + mode: newest +``` + +配置校验要求: + +- 未知 source 只警告,不阻断保存,便于先配置后启用。 +- 未知 field 必须拒绝,避免拼写错误悄悄失效。 +- 动态位置字段默认不允许被固定来源永久锁死,除非显式开启高级选项。 +- 空值不覆盖非空值是全局保护,不建议开放关闭。 + +### Phase 3 — 冲突治理 UI + +基于冲突记录提供页面或 drawer: + +- 查看某个 MMSI 的冲突字段。 +- 查看每个字段的候选来源和值。 +- 查看当前选择原因。 +- 将一次人工选择保存成字段规则,而不是只处理单条冲突。 +- 支持恢复默认策略。 + +## AISStream 采集器计划 + +AISStream 采集器单独实现,建议命名为 `aisstream_vessels`。它的职责是: + +- 维护 WebSocket 连接、订阅范围和重连。 +- 将上游 AIS 消息标准化为 `vessel_ais` payload。 +- 标记 `delivery_mode = realtime_stream`,`transport = websocket`。 +- 写入原始观测层。 +- 不直接 upsert 最终展示数据。 + +配置应放入采集器设置,而不是硬编码: + +```yaml +aisstream_vessels: + api_key: "${AISSTREAM_API_KEY}" + bounding_boxes: + - [[-180, -90], [180, 90]] + message_types: + - PositionReport + - ShipStaticData +``` + +## 实施顺序 + +1. 新增原始观测模型和冲突记录模型。 +2. 实现 AIS 聚合服务,先从现有 `vessel_position` / `vessel_static` 兼容读取,再逐步切换到原始观测层。 +3. 将 `/geo/vessels` 和 `/vessels/{mmsi}` 改为走聚合服务。 +4. 改造 BarentsWatch 保存逻辑,让它写入原始观测,同时保留现有表作为兼容缓存。 +5. 实现 AISStream WebSocket collector。 +6. 接入系统设置中的聚合策略配置。 +7. 做冲突治理 UI。 + +## 测试计划 + +- 同一来源同一 `mmsi + observed_at + lat + lon` 重复记录只聚合一次。 +- 多来源同一 MMSI 的位置字段优先选择最新观测。 +- 实时流和轮询源同时间冲突时,实时流优先。 +- 实时流过期后,更新的轮询源可以接管动态字段。 +- 静态字段不会被空值覆盖。 +- 静态字段冲突会写入冲突记录。 +- 字段级配置可以覆盖默认来源优先级。 +- 聚合接口在没有冲突表时仍可返回兼容 GeoJSON。 + +## 相关文件 + +- [实时船只监控系统计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-tracking-plan.md) +- [自定义 API 数据源与 LLM 映射系统计划](/home/ray/dev/linkong/planet/docs/plans/datasource-custom-api-mapping-plan.md) +- [BarentsWatch AIS collector](/home/ray/dev/linkong/planet/backend/app/services/collectors/vessel_ais.py) +- [船只模型](/home/ray/dev/linkong/planet/backend/app/models/vessel.py) +- [可视化 API](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) diff --git a/docs/plans/earth-vessel-tracking-plan.md b/docs/plans/earth-vessel-tracking-plan.md index f38d399b..24c31dd1 100644 --- a/docs/plans/earth-vessel-tracking-plan.md +++ b/docs/plans/earth-vessel-tracking-plan.md @@ -12,7 +12,7 @@ | 船只规模 | BarentsWatch 阶段全部显示;全球数据接入后按需加船型过滤(默认 Cargo + Tanker + Passenger) | | 更新频率 | 准实时:前端 5 分钟轮询,后端 Collector 每分钟拉取写库 | | 历史轨迹 | 保留(`vessel_position` 表保留 24h,后期按需扩展) | -| 推送方式 | HTTP 轮询(不用 WebSocket);换实时数据源后再评估升级 | +| 推送方式 | 前端展示仍可先用 HTTP 拉取聚合结果;AISStream 等实时源应单独实现 WebSocket 采集器 | --- @@ -36,13 +36,19 @@ - 字段:mmsi, lat, lon, sog, cog, heading, nav_status, name, vessel_type, flag - 刷新频率:数据约 30–60s 更新一次,可随意轮询 -### TODO:付费数据源接入 +### TODO:多源 AIS 与实时流接入 +- [ ] 接入 AISStream WebSocket 采集器,作为 BarentsWatch 覆盖不足的实时补充 +- [ ] 将 BarentsWatch、AISStream、自定义 `vessel_ais` 映射源统一写入原始观测层 +- [ ] 通过聚合接口做去重、字段合并、冲突记录和默认来源选择 +- [ ] 开放字段级聚合策略配置,让用户决定不同字段优先信任哪个来源 - [ ] 评估 AISHub 订阅(全球覆盖,约 $30/月),接入全球实时流 - [ ] 评估 MarineTraffic API tier,对比 AISHub 数据质量与成本 - [ ] 实现多数据源适配器,通过 `datasource_config` 切换 - [ ] 真实高频 AIS 稳定接入后,评估将 `vessel_position` 迁移为 TimescaleDB hypertable(保留 Postgres 原生分区作为备选) +多源 AIS 的详细设计见 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)。 + --- ## 二、实施计划 @@ -151,12 +157,13 @@ GeoJSON Feature 格式: #### 1.4 更新机制 -**HTTP 轮询**(不使用 WebSocket): +**前端聚合结果拉取 + 后端实时采集**: - 前端 `setInterval(fetchVessels, 5 * 60 * 1000)` 定期拉取最新快照 -- 后端 Collector 每 60s 从 BarentsWatch 拉取并写库,`vessel_latest` 物化视图随时可查 -- WebSocket 留给告警/事件驱动场景(BGP、系统通知),不混入周期性位置刷新 -- 换用 AISHub / MarineTraffic 实时流后,届时再评估是否升级为 WebSocket delta push +- 后端 BarentsWatch collector 继续以 HTTP polling 方式采集 +- AISStream 等实时源以独立 WebSocket collector 写入原始观测层 +- 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值 +- 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源 --- diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index 2d89bbb2..7d333bbd 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -74,6 +74,8 @@ This document records the material, color, opacity, line width, radius offset, a ## Land/Ocean Base and Country Borders +The land/ocean base is an Earth base-map asset and preloads at startup; the "Border Lines" layer toggle only controls normal border lines, hover lines, and interactive hover. + | Name | Variable | Current Value | Location / Notes | | --- | --- | --- | --- | | Country border data path | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON input | @@ -89,11 +91,11 @@ This document records the material, color, opacity, line width, radius offset, a | Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line | | Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity | | Border dimmed opacity on hover | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | Normal border opacity during hover | -| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | Normal border line radius | +| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; slightly above HD texture `0.10` and below terrain base `0.16` to reduce floating | | Border line renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | Normal border line level | | Border hover color | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | Neon red-orange | | Border hover opacity | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | Hover line opacity | -| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | Hover line radius | +| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | Hover line radius; close to the surface but above normal border lines | | Border hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | Hover line level | | Border hover glow opacity | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | Glow line opacity | | Border hover glow line width | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | Glow `LineBasicMaterial.linewidth` | @@ -143,13 +145,14 @@ This document records the material, color, opacity, line width, radius offset, a | Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | Same surface height as cable lines, avoiding a floating marker | | Landing point sprite height | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` base height | | Landing point reference FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | Matches the current Earth camera FOV | -| Landing point scale minimum | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.36` | Minimum multiplier at maximum zoom; `3 * 0.36 = 1.08` | +| Landing point scale minimum | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.16` | Minimum multiplier after roughly 200% zoom, limiting high-zoom screen footprint; `3 * 0.16 = 0.48` | | Landing point scale maximum | local `LANDING_POINT_SIZE_SCALE_MAX` | `3` | Maximum multiplier at far distance; current minimum zoom reaches roughly `2.50` | | Landing point atlas size | local `LANDING_POINT_ATLAS_CELL_SIZE` | `128` | Canvas flat shaded sphere texture size | | Landing point color | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` | | Landing point opacity | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` | | Landing point renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `1` | Same level as cable lines; `depthTest: false` keeps the ball whole, while camera-to-center globe occlusion hides back-side points | | Landing point dim brightness | `landingPointVisual.dimBrightness` | `0.62` | Dim state color multiplier | +| Related landing point opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | Highlight pulse | | Dimmed landing point color | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | Dim state color; avoids dark base showing through as a dark hole | | Dimmed landing point opacity | `landingPointVisual.dimmed.opacity` | `0.78` | Dim state opacity; no longer uses low alpha blending with dark base | @@ -168,7 +171,21 @@ This document records the material, color, opacity, line width, radius offset, a | Satellite trail line width | `SATELLITE_CONFIG.trailLineWidth` | `3` | Ribbon shader uniform | | Selected ring size | `SATELLITE_CONFIG.ringSize` | `0.07` | Hover / locked ring sprite | | Satellite overlay renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | Locked ring / halo / orbit | -| Footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Footprint fill | +| Footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Starlink footprint fill and Iridium coverage ring; must stay above land / texture / terrain surface layers | + +## AIS Vessels + +| Name | Variable | Current Value | Location / Notes | +| --- | --- | --- | --- | +| Vessel radius offset | `VESSEL_CONFIG.altitudeOffset` | `0.2` | Normal marker position, close to the real terrain base layer | +| Vessel track radius offset | `VESSEL_CONFIG.track.altitudeOffset` | `0.2` | Selected vessel track line, aligned to the vessel marker radius; the frontend anchors the track endpoint to the current marker position | +| Vessel renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | Normal marker and interactive overlay | +| Vessel track renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | Below vessel markers | +| Vessel point pixel size | local `VESSEL_POINT_SIZE` | `34` | Shared size for normal markers and hover / locked overlays | +| Vessel texture canvas size | local `VESSEL_ATLAS_CELL_SIZE` | `128` | Canvas point texture | +| Course bucket count | local `VESSEL_COURSE_BINS` | `32` | Moving vessels are bucketed by COG to reduce draw calls while preserving direction | +| Vessel hover picking throttle | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking | +| Vessel screen hit radius | local `VESSEL_POINTER_RADIUS_PX` | `22` | `main.js` screen-space picking | ## Compute Centers diff --git a/docs/technical/en/earth-render-layer-order.md b/docs/technical/en/earth-render-layer-order.md index 3105c882..3f15329c 100644 --- a/docs/technical/en/earth-render-layer-order.md +++ b/docs/technical/en/earth-render-layer-order.md @@ -6,8 +6,8 @@ Note: the layer control panel order and the registration / startup load order ar | Order type | Current sequence | Notes | | --- | --- | --- | -| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Borders → Grid | Controlled by `displayOrder`, sorted by operational relevance. | -| Registration / startup load order | Grid → Borders → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. | +| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Border Lines → Grid | Controlled by `displayOrder`, sorted by operational relevance. | +| Registration / startup load order | Grid → Border Lines / Land-Ocean Base → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; the startup queue reads persisted layer visibility first, skips normal layers explicitly saved as hidden, and HD Texture does not download the texture when disabled; Border Lines are the exception: the land-ocean base always preloads, while the persisted state only controls interactive border lines and hover; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. | ## Surface Layer Stack @@ -26,7 +26,7 @@ Note: the layer control panel order and the registration / startup load order ar | 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset` | Raycast disabled | Only needs to stay above HD texture. | | 2.29 | Country border hover glow | `country-boundaries.js` | Hover radius + glow offset | `depthTest: false`, raycast disabled | Additive glow to reinforce border edge and terrain hover visibility. | | 2.3 | Country border hover line | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`, raycast disabled | Neon red-orange hover line; China and Taiwan share the same highlight group. | -| 3 | Satellite footprint fill | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested, Group renderOrder stays 0 | Footprint above country borders, below compute centers and satellites. | +| 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. | | 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. | | 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. | | 5 | Satellite background dot | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Below satellite dots. | @@ -42,7 +42,7 @@ Note: the layer control panel order and the registration / startup load order ar | HD texture on | Restores HD texture and the remembered terrain / day/night states. | | Terrain on | Displayed above HD texture, but below country border hover, footprints, satellites, and other emphasis layers. | | Cloud layer | Only controls cloud mesh visibility. | -| Country borders | Controls border line and hover line visibility; land/ocean base fill exists independently as the Earth base map. | +| Border Lines off | Hides only interactive border lines and hover, clearing hover state; the land/ocean base fill remains as the Earth base map. | ## Interaction Rules diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index badfd3dd..45de6166 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -203,7 +203,7 @@ Earth is used to observe in a single globe view: - Submarine cables and landing points - Compute centers - AIS vessels -- Country borders, grid lines, HD texture, cloud layer, terrain +- Border lines, grid lines, HD texture, cloud layer, terrain - Live news streams and situational news - Search and focused object details @@ -214,7 +214,7 @@ The right-side layer panel toggles visualization layers on or off. Common layers include: - Grid lines -- Country borders +- Border lines - HD texture - Atmospheric cloud layer - Submarine cables @@ -239,7 +239,7 @@ Current legend modes include: - Cables - Satellites -- Country borders +- Border lines - Compute centers - BGP - AIS vessels diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 3118710a..8c47186b 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -80,6 +80,8 @@ ## 海陆基座与国界 +海陆基座是 Earth 的底图资产,随启动预加载;图层面板里的“国界线”只控制普通国界线、hover 线和可交互 hover。 + | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | | 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 | @@ -149,7 +151,7 @@ | 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | 与海缆线同层贴地,避免凌空 | | 登陆点 sprite 高度 | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` 基准高度 | | 登陆点缩放参考 FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | 与当前 Earth 相机 FOV 一致 | -| 登陆点缩放下限 | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.36` | 地球放到最大时的最小倍率;`3 * 0.36 = 1.08` | +| 登陆点缩放下限 | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.16` | 地球放到 200% 之后的最小倍率,限制高倍 zoom 下的屏幕占比;`3 * 0.16 = 0.48` | | 登陆点缩放上限 | local `LANDING_POINT_SIZE_SCALE_MAX` | `3` | 远距离时的最大倍率;当前最小缩放约只能到 `2.50` | | 登陆点 atlas 尺寸 | local `LANDING_POINT_ATLAS_CELL_SIZE` | `128` | canvas 扁平立体球纹理尺寸 | | 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` | @@ -180,7 +182,7 @@ | 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit | | 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` | | 自发光选中点透明度 | inline | `0.96` | locked dot material | -| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | footprint fill | +| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Starlink footprint fill 和 Iridium coverage ring;必须高于地表 land / texture / terrain 层 | | footprint group renderOrder | inline | `0` | 避免 Group 排序盖过卫星点 | ## AIS 船只 @@ -188,7 +190,7 @@ | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | | 船只半径偏移 | `VESSEL_CONFIG.altitudeOffset` | `0.2` | 普通 marker 位置,贴近真实地形基础层 | -| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.22` | 选中船只轨迹线,略高于船只 marker 以保持可见 | +| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.2` | 选中船只轨迹线,与船只 marker 同一半径;前端会把轨迹末端锚到当前 marker 位置 | | 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay | | 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker | | 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 | diff --git a/docs/technical/zh/earth-render-layer-order.md b/docs/technical/zh/earth-render-layer-order.md index 2e62d766..e669c9b8 100644 --- a/docs/technical/zh/earth-render-layer-order.md +++ b/docs/technical/zh/earth-render-layer-order.md @@ -7,8 +7,8 @@ | 顺序类型 | 当前顺序 | 说明 | | --- | --- | --- | -| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 | -| 注册 / 启动加载顺序 | 经纬线 → 国界 → 高清材质 → 大气云图 → 海缆 → 算力中心 → 船只 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;船只和卫星默认关闭,只有可见时参与启动加载;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 | +| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界线 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 | +| 注册 / 启动加载顺序 | 经纬线 → 国界线 / 海陆基座 → 高清材质 → 大气云图 → 海缆 → 算力中心 → 船只 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;启动队列会先读取保存的图层可见状态,明确关闭的普通图层不预加载,高清材质关闭时不下载贴图;国界线图层例外,海陆基座始终预加载,保存状态只控制可交互国界线和 hover;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 | ## 地表图层栈 @@ -27,7 +27,7 @@ | 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 | | 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 | | 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 | -| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested,Group renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 | +| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 | | 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4` 和 `BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking,并参与同坐标避让 | BGP 观测站主图标与船只同层;BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 | | 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 | | 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow,交互态叠加同尺寸 glow;低于算力中心 `4.5`。 | @@ -45,7 +45,7 @@ | 高清材质 on | 恢复高清材质,并恢复记住的地形 / 昼夜状态。 | | 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 | | 大气云图 | 只控制云图 mesh 显隐。 | -| 国界 | 控制国界线和 hover 线显隐;海陆基座填充独立存在,作为 Earth 基座地图使用。 | +| 国界线 off | 只隐藏可交互国界线和 hover,高亮状态会清除;海陆基座填充仍作为 Earth 底图保留。 | ## 交互规则 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 35e26cf6..8d57a424 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -224,7 +224,7 @@ Earth 用于在一个地球视图中观察: - 卫星和轨迹 - 海缆与登陆点 - 算力中心 -- 国界、经纬线、高清材质、云图、地形 +- 国界线、经纬线、高清材质、云图、地形 - 新闻直播和态势新闻 - 搜索和聚焦对象详情 @@ -235,7 +235,7 @@ Earth 用于在一个地球视图中观察: 常见图层包括: - 经纬线 -- 国界 +- 国界线 - 高清材质 - 大气云图 - 海缆 @@ -260,7 +260,7 @@ Earth 用于在一个地球视图中观察: - 海缆 - 卫星 -- 国界 +- 国界线 - 算力中心 - BGP - AIS 船只 diff --git a/docs/version-history.md b/docs/version-history.md index e8ec64e3..26d80b95 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.46.1` +- `dev` 当前开发分支历史推导到:`0.46.2` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.46.2` | bugfix | `dev` | `pending` | 修复 Earth 启动加载顺序、图层 localStorage 恢复、国界线底图语义、媒体面板、船只轨迹和 Iridium footprint 显示问题,并补充 AIS 聚合计划 | | `0.46.1` | bugfix | `dev` | `pending` | 修复新增 Docs 技术文档未进前端白名单导致页面不可访问的问题,补齐英文文档并固化白名单/双语/裸文件标题检查 | | `0.46.0` | feature | `dev` | `pending` | Earth 新增通用 Interactable 图标层,统一船只、算力中心、BGP 事件/观测站交互图标,并优化登陆点与 toolbar 初始渲染 | | `0.45.0` | feature | `dev` | `pending` | 新增采集任务 fetching 阶段量化进度,收敛 AI Provider 运行期环境注入和 Docker build context | diff --git a/frontend/package.json b/frontend/package.json index 73363f9b..7a7ee8da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.46.1", + "version": "0.46.2", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index fab6452d..83364d10 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -28,7 +28,7 @@ const _lpCameraToPoint = new THREE.Vector3(); const LANDING_POINT_SPRITE_HEIGHT = 3; const LANDING_POINT_SPRITE_ASPECT = 1; const LANDING_POINT_SIZE_REFERENCE_FOV = 75; -const LANDING_POINT_SIZE_SCALE_MIN = 0.36; +const LANDING_POINT_SIZE_SCALE_MIN = 0.16; const LANDING_POINT_SIZE_SCALE_MAX = 3; const LANDING_POINT_ATLAS_CELL_SIZE = 128; let landingPointTexture = null; diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 9ce0bbdb..d4d7c30f 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -232,7 +232,7 @@ export const VESSEL_CONFIG = { max: 2.4, }, track: { - altitudeOffset: 0.22, + altitudeOffset: 0.2, color: 0x7dd3fc, opacity: 0.82, }, diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index cd65b760..ba2b8019 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -19,6 +19,7 @@ import { import { toggleTerrain, setDayNightEnabled, + toggleClouds, toggleGridLines, getShowGridLines, } from "./earth.js"; @@ -53,7 +54,7 @@ import { } from "./satellites.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; -import { getShowCountryBoundaries } from "./country-boundaries.js"; +import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js"; import { toggleComputeCenters, getShowComputeCenters, @@ -136,6 +137,7 @@ let focusViewAnimationToken = 0; let earthSettingsDefaults = null; let lastZoomStatusUpdateTime = 0; let earthSettingsState = null; +let deferredLayerVisibilitySettings = null; let layerRegistry = new Map(); let layerPanelInitialized = false; let layoutMode = "desktop"; @@ -269,7 +271,7 @@ function closeTransientMobileOverlays({ except = null } = {}) { setMobileDrawerOpen("layer-toggles", false); } - if (except !== "media" && isTVPanelVisible()) { + if (except !== "media" && except !== "search" && isTVPanelVisible()) { setTVPanelVisible(false); } } @@ -670,6 +672,14 @@ function shouldIncludeLayerInStartupLoad(definition) { return false; } + const persistedVisible = getPersistedLayerVisibilityOverride(definition.id); + if (typeof persistedVisible === "boolean") { + if (definition.startupMode === "preload" && definition.startupAlwaysLoad) { + return true; + } + return persistedVisible; + } + if (definition.startupMode === "preload") { return true; } @@ -677,6 +687,14 @@ function shouldIncludeLayerInStartupLoad(definition) { return Boolean(definition?.getVisible?.()); } +function getPersistedLayerVisibilityOverride(layerId) { + if (!layerId) return null; + const layerVisibility = + deferredLayerVisibilitySettings || earthSettingsState?.shared?.layerVisibility; + const persistedVisible = layerVisibility?.[layerId]; + return typeof persistedVisible === "boolean" ? persistedVisible : null; +} + function clampEarthZoomLevel(nextZoom) { const parsedZoom = Number.parseFloat(nextZoom); if (!Number.isFinite(parsedZoom)) { @@ -1127,7 +1145,7 @@ function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = tr return defaultEarthZoom; } -async function applyEarthSettings(settings) { +async function applyEarthSettings(settings, { applyLayers = true } = {}) { if (!settings) return; earthSettingsState = cloneEarthSettings(settings); @@ -1161,12 +1179,31 @@ async function applyEarthSettings(settings) { applyToCurrentView: true, }); + if (!applyLayers) { + const layerVisibility = { ...(settings.shared.layerVisibility || {}) }; + applyImmediateLayerVisibilityHints(layerVisibility); + deferredLayerVisibilitySettings = layerVisibility; + return; + } + + deferredLayerVisibilitySettings = null; await applyLayerVisibilitySettings(settings.shared.layerVisibility, { persist: false, silent: true, }); } +export async function applyDeferredLayerVisibilitySettings(options = {}) { + const layerVisibility = deferredLayerVisibilitySettings; + deferredLayerVisibilitySettings = null; + if (!layerVisibility) return; + await applyLayerVisibilitySettings(layerVisibility, { + persist: false, + silent: true, + ...options, + }); +} + function resetEarthSettings() { const defaults = cloneEarthSettings(captureEarthSettingsDefaults()); earthSettingsState = cloneEarthSettings(defaults); @@ -1307,8 +1344,15 @@ async function setCountryBoundariesLayerEnabled(button, enabled, { persist = tru } } -function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { - setHighResTextureEnabled(enabled, { suppressStatus: silent }); +async function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { + if (enabled) { + setLayerButtonState(button, { + active: false, + loading: true, + tooltip: "高清材质加载中...", + }); + } + await setHighResTextureEnabled(enabled, { suppressStatus: silent }); setLayerButtonState(button, { active: enabled, loading: false, @@ -1319,8 +1363,15 @@ function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent return enabled; } -function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { - setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent }); +async function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { + if (enabled) { + setLayerButtonState(button, { + active: false, + loading: true, + tooltip: "大气云图加载中...", + }); + } + await setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent }); setLayerButtonState(button, { active: enabled, loading: false, @@ -1449,6 +1500,44 @@ async function applyLayerVisibilitySettings(layerVisibility = {}, options = {}) } } +function applyImmediateLayerVisibilityHints(layerVisibility = {}) { + if (typeof layerVisibility.gridLines === "boolean") { + setGridLinesLayerEnabled(getLayerButton("gridLines"), layerVisibility.gridLines, { + persist: false, + silent: true, + }); + } + + if (typeof layerVisibility.countryBoundaries === "boolean") { + toggleCountryBoundaries(layerVisibility.countryBoundaries, { + showLandFill: true, + }); + setLayerButtonState(getLayerButton("countryBoundaries"), { + active: layerVisibility.countryBoundaries, + loading: false, + tooltip: layerVisibility.countryBoundaries ? "隐藏国界线" : "显示国界线", + }); + } + + if (layerVisibility.earthHighResTexture === false) { + void setHighResTextureEnabled(false, { suppressStatus: true }); + setLayerButtonState(getLayerButton("earthHighResTexture"), { + active: false, + loading: false, + tooltip: "显示高清材质", + }); + } + + if (typeof layerVisibility.atmosphereClouds === "boolean") { + toggleClouds(layerVisibility.atmosphereClouds); + setLayerButtonState(getLayerButton("atmosphereClouds"), { + active: layerVisibility.atmosphereClouds, + loading: false, + tooltip: layerVisibility.atmosphereClouds ? "隐藏大气云图" : "显示大气云图", + }); + } +} + function getBuiltinLayerDefinitions() { return [ { @@ -1472,13 +1561,14 @@ function getBuiltinLayerDefinitions() { id: "countryBoundaries", buttonId: "toggle-country-boundaries", icon: "public", - label: "国界", + label: "国界线", meta: "Country Borders", keywords: "国界 国家 borders countries boundary", defaultActive: true, displayOrder: 90, startupPriority: 20, startupMode: "preload", + startupAlwaysLoad: true, startupLabel: "海陆基座", startupMessage: "正在加载海陆基座...", getVisible: () => getShowCountryBoundaries(), @@ -2268,7 +2358,7 @@ function setupSettingsControls() { }); captureEarthSettingsDefaults(); - settingsApplyPromise = applyEarthSettings(loadEarthSettings()); + settingsApplyPromise = applyEarthSettings(loadEarthSettings(), { applyLayers: false }); syncAllHudPanelToggles(); syncRotationModeButtons(); syncCruiseModuleControls(); diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js index c2c0badf..88cdd05e 100644 --- a/frontend/public/earth/js/earth.js +++ b/frontend/public/earth/js/earth.js @@ -25,8 +25,11 @@ let _earthTextureOverlayMaterial = null; let _earthShaders = []; let _dayNightEnabled = true; let _loadedTexture = null; +let _textureLoadPromise = null; let _textureVisible = true; let _earthRimGlow = null; +let _cloudTexture = null; +let _cloudTextureLoadPromise = null; const _earthSunDirection = new THREE.Vector3( EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y, @@ -301,28 +304,16 @@ export function createClouds(scene, earthObj) { clouds = new THREE.Mesh(geometry, material); clouds.name = "earth-atmosphere-clouds"; - clouds.visible = showClouds; + clouds.visible = false; earthObj.add(clouds); - textureLoader.load( - CLOUD_LAYER_CONFIG.textureUrl, - function(texture) { - material.map = texture; - material.needsUpdate = true; - }, - undefined, - function(err) { - console.log('云层纹理加载失败'); - } - ); - return clouds; } export function toggleClouds(visible) { showClouds = Boolean(visible); if (clouds) { - clouds.visible = showClouds; + clouds.visible = showClouds && Boolean(clouds.material?.map); } } @@ -330,6 +321,39 @@ export function getShowClouds() { return showClouds; } +export function loadCloudTexture() { + if (_cloudTexture) return Promise.resolve(_cloudTexture); + if (_cloudTextureLoadPromise) return _cloudTextureLoadPromise; + + _cloudTextureLoadPromise = new Promise((resolve, reject) => { + if (!clouds?.material) { + resolve(null); + return; + } + + textureLoader.load( + CLOUD_LAYER_CONFIG.textureUrl, + (texture) => { + _cloudTexture = texture; + clouds.material.map = texture; + clouds.material.needsUpdate = true; + clouds.visible = showClouds; + resolve(texture); + }, + undefined, + (error) => { + console.warn("云层纹理加载失败"); + reject(error); + }, + ); + }); + + _cloudTextureLoadPromise.finally(() => { + _cloudTextureLoadPromise = null; + }); + return _cloudTextureLoadPromise; +} + export function createTerrain(earthObj) { const geometry = new THREE.SphereGeometry( CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset, @@ -478,6 +502,7 @@ export function getClouds() { export function clearEarthTexture() { _loadedTexture = null; + _textureLoadPromise = null; if (_earthTextureOverlayMaterial) { _earthTextureOverlayMaterial.map = null; _earthTextureOverlayMaterial.needsUpdate = true; @@ -522,7 +547,10 @@ export function setDayNightEnabled(enabled) { } export function loadEarthTexture() { - return new Promise((resolve) => { + if (_loadedTexture) return Promise.resolve(_loadedTexture); + if (_textureLoadPromise) return _textureLoadPromise; + + _textureLoadPromise = new Promise((resolve) => { if (!_earthTextureOverlayMaterial) { resolve(); return; } const urls = EARTH_MATERIAL_CONFIG.textureUrls; @@ -549,7 +577,7 @@ export function loadEarthTexture() { if (_earthRimGlow) { _earthRimGlow.visible = !_textureVisible; } - resolve(); + resolve(texture); }, null, () => tryLoad(index + 1), @@ -557,6 +585,11 @@ export function loadEarthTexture() { }; tryLoad(0); }); + + _textureLoadPromise.finally(() => { + _textureLoadPromise = null; + }); + return _textureLoadPromise; } export function setEarthTextureVisible(visible) { diff --git a/frontend/public/earth/js/layer-startup-tasks.js b/frontend/public/earth/js/layer-startup-tasks.js index c67a7a9a..baefc430 100644 --- a/frontend/public/earth/js/layer-startup-tasks.js +++ b/frontend/public/earth/js/layer-startup-tasks.js @@ -23,6 +23,10 @@ import { loadVessels, toggleVessels, } from "./vessels.js"; +import { + loadCloudTexture, + loadEarthTexture, +} from "./earth.js"; import { getCountryBoundaryLegendItems, loadCountryBoundaries, @@ -83,6 +87,8 @@ export function registerLayerStartupTask(id, taskFactory) { function registerBuiltinLayerStartupTasks() { startupTaskRegistry.clear(); registerCountryBoundaryStartupTask(); + registerEarthTextureStartupTask(); + registerCloudStartupTask(); registerCableStartupTask(); registerComputeCenterStartupTask(); registerVesselStartupTask(); @@ -210,6 +216,42 @@ function registerBGPStartupTask() { }); } +function registerEarthTextureStartupTask() { + registerLayerStartupTask("earthHighResTexture", (context) => async (layer) => { + if (!context.isEarthTextureVisible()) return; + + context.setLoadingMessage( + resolveStartupMessage(layer, "load", "正在加载地球纹理..."), + ); + await context.yieldFrame(12); + try { + await loadEarthTexture(); + } catch (error) { + console.warn("地球纹理加载失败:", error); + } + if (context.isCancelled()) return; + await context.yieldFrame(16); + }); +} + +function registerCloudStartupTask() { + registerLayerStartupTask("atmosphereClouds", (context) => async (layer) => { + if (!context.isCloudsEnabled()) return; + + context.setLoadingMessage( + resolveStartupMessage(layer, "load", "正在加载大气云图..."), + ); + await context.yieldFrame(12); + try { + await loadCloudTexture(); + } catch (error) { + context.reportError(layer?.startupLabel || layer?.label || "大气云图", error); + } + if (context.isCancelled()) return; + await context.yieldFrame(16); + }); +} + function registerCountryBoundaryStartupTask() { registerLayerStartupTask("countryBoundaries", (context) => async (layer) => { context.setLoadingMessage( diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index ace44dcd..d03e023a 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -37,6 +37,7 @@ import { createGridLines, getEarth, getEarthSurfacePickTarget, + loadCloudTexture, loadEarthTexture, clearEarthTexture, setEarthSunDirection, @@ -203,6 +204,7 @@ import { setDayNightEnabledExternal, setTerrainLayerInteractable, setDayNightInteractable, + applyDeferredLayerVisibilitySettings, } from "./controls.js"; import { createLayerStartupTaskMap, @@ -1470,23 +1472,7 @@ function formatBGPStatusFromSummary(summary) { return "当前无活跃事件"; } -function applyEarthStatsSummary(summary) { - if (!summary) return; - updateEarthStats({ - cableCount: `${summary.cableCount}个`, - landingPointCount: `${summary.landingPointCount}个`, - satelliteCount: `${summary.satelliteCount} 颗`, - computeCenterCount: `${summary.computeCenterCount} 个`, - vesselCount: `${summary.vesselCount} 艘`, - bgpAnomalyCount: `${summary.bgpEventCount} 起`, - bgpCollectorCount: `${summary.bgpCollectorCount} 个`, - bgpStatusSummary: formatBGPStatusFromSummary(summary), - terrainOn: getShowTerrain(), - textureQuality: "8K 卫星图", - }); -} - -async function loadEarthStatsSummary() { +async function loadEarthStatsSummary({ shouldApply = () => true } = {}) { try { const response = await fetch(PATHS.earthSummaryApi); if (!response.ok) { @@ -1505,7 +1491,9 @@ async function loadEarthStatsSummary() { bgpAnomalyCount: toCount(stats.bgp_anomaly_count), bgpCollectorCount: toCount(stats.bgp_collector_count), }; - applyEarthStatsSummary(earthStatsSummary); + if (shouldApply()) { + updateStatsSummary(); + } } catch (error) { console.warn("全球态势聚合统计加载失败:", error); } @@ -2594,24 +2582,14 @@ async function loadData() { await yieldFrame(18); if (loadToken !== currentLoadToken) { isDataLoading = false; return; } - setLoadingMessage("正在读取全球态势统计..."); - await loadEarthStatsSummary(); - if (loadToken !== currentLoadToken) { isDataLoading = false; return; } - await yieldFrame(12); + loadEarthStatsSummary({ + shouldApply: () => loadToken === currentLoadToken && !destroyed, + }).catch((error) => { + console.warn("后台刷新全球态势统计失败:", error); + }); const errors = []; - // Step 1 — Earth texture - setLoadingMessage("正在加载地球纹理..."); - await yieldFrame(12); - try { - await loadEarthTexture(); - } catch (err) { - // texture failure is non-fatal - } - if (loadToken !== currentLoadToken) { isDataLoading = false; return; } - await yieldFrame(16); - const startupLoaders = createLayerStartupTaskMap({ scene, earth, @@ -2630,6 +2608,7 @@ async function loadData() { getShowCountryBoundaries, getShowBGP, isEarthTextureVisible: () => getEarthTextureVisible(), + isCloudsEnabled: () => getShowClouds(), getInitialSatelliteLoadLimit, shouldHydrateFullSatelliteSet, scheduleSatellitePositionWarmup, @@ -2704,6 +2683,10 @@ async function loadData() { hideError(); queueStatusMessage("数据已加载", "success"); } + + applyDeferredLayerVisibilitySettings().catch((error) => { + console.warn("恢复 Earth 图层可见性失败:", error); + }); } const POSITION_UPDATE_FORCE_DELTA = 250; @@ -2827,8 +2810,17 @@ export async function setCountryBoundariesEnabled( let _dayNightBeforeTextureOff = null; let _terrainBeforeTextureOff = null; -export function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) { +export async function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) { setEarthTextureVisible(enabled); + if (enabled) { + try { + await loadEarthTexture(); + setEarthTextureVisible(true); + } catch (error) { + console.warn("高清材质加载失败:", error); + setEarthTextureVisible(false); + } + } setSurfaceTintEnabled(!enabled); setLandFillEnabled(true); setLandFillSuppressed(false); @@ -2863,8 +2855,17 @@ export function getHighResTextureEnabled() { return getEarthTextureVisible(); } -export function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) { +export async function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) { toggleClouds(enabled); + if (enabled) { + try { + await loadCloudTexture(); + toggleClouds(true); + } catch (error) { + console.warn("大气云图加载失败:", error); + toggleClouds(false); + } + } if (!suppressStatus) { showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info"); } diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index f2ad2beb..66c63dce 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -2139,7 +2139,7 @@ function showIridiumReservedStyle(position) { lockedIridiumFootprintMesh = createIridiumFootprintAdapter({ earthObj: earthObjRef, earthRadiusWorld: CONFIG.earthRadius, - renderOrder: 0, + renderOrder: GROUND_FOOTPRINT_RENDER_ORDER, }); updateIridiumReservedStyle(position); } diff --git a/frontend/public/earth/js/vessels.js b/frontend/public/earth/js/vessels.js index 5325982b..3f68681c 100644 --- a/frontend/public/earth/js/vessels.js +++ b/frontend/public/earth/js/vessels.js @@ -11,6 +11,7 @@ const VESSEL_RENDER_ORDER = 4.4; const VESSEL_POINT_SIZE = 34; const VESSEL_ATLAS_CELL_SIZE = 128; const VESSEL_COURSE_BINS = 32; +const VESSEL_TRACK_ENDPOINT_EPSILON = 0.001; function normalizeVesselType(value, code) { const type = String(value || "").trim().toLowerCase(); @@ -72,6 +73,26 @@ function getCourseBin(marker) { return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS; } +function buildTrackPoint(lon, lat) { + const latitude = Number(lat); + const longitude = Number(lon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return latLonToVector3( + latitude, + longitude, + CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset, + ); +} + +function appendCurrentMarkerTrackPoint(points, marker) { + if (!(marker?.position instanceof THREE.Vector3)) return; + const markerPosition = marker.position.clone(); + const lastPoint = points[points.length - 1]; + if (!lastPoint || lastPoint.distanceToSquared(markerPosition) > VESSEL_TRACK_ENDPOINT_EPSILON) { + points.push(markerPosition); + } +} + const vesselIconLayer = createInteractableLayer({ id: "vessels", objectType: "vessel", @@ -207,14 +228,15 @@ export async function showVesselTrack(marker, earth) { if (coordinates.length < 2) return null; const points = coordinates - .map(([lon, lat]) => - latLonToVector3( - Number(lat), - Number(lon), - CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset, - ), - ) - .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z)); + .map(([lon, lat]) => buildTrackPoint(lon, lat)) + .filter( + (point) => + point && + Number.isFinite(point.x) && + Number.isFinite(point.y) && + Number.isFinite(point.z), + ); + appendCurrentMarkerTrackPoint(points, marker); if (points.length < 2) return null; const geometry = new THREE.BufferGeometry().setFromPoints(points); diff --git a/pyproject.toml b/pyproject.toml index 50e7d9c0..adbea373 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.46.1" +version = "0.46.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 89cc6eea..4c0cb57a 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.46.1" +version = "0.46.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" },