release: bump version to 0.31.3

This commit is contained in:
rayd1o
2026-04-22 03:52:09 +08:00
parent 003a46ac30
commit 437efc848c
19 changed files with 1516 additions and 321 deletions

View File

@@ -20,3 +20,5 @@
- [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` 的平滑迁移策略

View File

@@ -1 +1 @@
0.31.2
0.31.3

View File

@@ -8,6 +8,27 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.31.3] — 2026-04-22
### ✨ Highlights
- Earth 图层注册表和启动任务框架继续收口,启动顺序、启动模式、启动提示和任务注册现在都能从统一入口扩展
- 修复 Earth 普通旋转模式与巡航模式切换时的一组交互回归,同时让卫星/地形/昼夜模式的表现更稳定
### 🔧 Improvements
- 新增 [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js) 启动任务注册表,支持 `registerLayerStartupTask(id, taskFactory)`,并拆成海缆 / 卫星 / BGP 独立注册函数
- Earth 图层控制改成注册表驱动,统一承载 `startupPriority``startupMode``startupLabel``startupMessage` 与图层持久化元信息
- Earth 设置支持持久化图层开关、旋转模式、HUD 面板显示状态、地形透明度与日夜模式,并提供一键重置
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 记录图层注册表、启动任务、设置持久化与巡航适配边界
### 🐛 Fixes
- 修复普通旋转模式下点击海缆 / 卫星 / BGP 后卡片和选中表现会被异常清空的问题
- 修复巡航模式切回旋转再切回巡航后无法继续自动巡航的问题
- 修复开启地形后卫星选中反馈层被高海拔区域吞掉的问题,并恢复轨道只在地球前半侧可见
- 修复关闭日夜模式后地球照明仍沿真实昼夜切换、亮部过曝和偏色的问题,改成更中性的 inspection lighting
- 修复 toolbar 收起态仍挡住地球交互,以及首帧短暂展开闪现的问题
---
## [0.31.0] — 2026-04-21
## [0.31.2] — 2026-04-21

View File

@@ -67,6 +67,7 @@ React 路由入口:
- 旋转/缩放/布局
- HUD 面板拖拽
- 图层开关状态机
- Earth 设置读取、持久化与重置
这份文件是 Earth 前端当前最核心的 UI 控制入口。
@@ -99,6 +100,7 @@ React 路由入口:
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
- [layer-startup-tasks.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-startup-tasks.js)
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
@@ -109,6 +111,16 @@ React 路由入口:
- 面板内容
- hover/lock/selection 语义
其中 Earth 启动加载链现在也拆成了两层:
- `controls.js`
- 提供图层注册表与启动元信息
- `layer-startup-tasks.js`
- 提供图层启动任务注册表
- 通过 `registerLayerStartupTask(id, taskFactory)` 扩展启动任务
- `main.js`
- 只负责读取排序后的启动图层,再按映射执行队列
其中巡航模式现在已经拆成两层:
- `cruise-sequencer.js`
@@ -169,6 +181,57 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。
另外Earth 图层控制现在已经收成“注册表驱动”:
- 图层元数据
- `id`
- `icon`
- `label`
- `meta`
- `buttonId`
- `persist`
- `startupPriority`
- `startupMode`
- `startupLabel`
- `startupMessage`
- 图层行为
- `getVisible()`
- `setVisible(next, options)`
当前入口仍在 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)。
这意味着后续新增图层时,优先应补一条图层注册定义,而不是同时去改:
- 图层面板 HTML
- 持久化快照
- 初始化恢复
- click 绑定
这四处现在都应该由注册表派生。
其中:
- `startupPriority`
- 描述图层参与启动加载时的顺序
- `startupMode`
- `visible`
- 仅当前图层处于启用/可见状态时,才加入启动加载队列
- `preload`
- 即使当前图层未显示,也会参与启动预加载
当前 `main.js` 会通过注册表读取排序后的启动图层列表,再动态拼装启动加载队列,而不是手写一串固定步骤。像 BGP 这类需要尽早准备数据、但不一定默认显示的图层,应该优先走 `startupMode: "preload"`,而不是在启动流程里写隐式特判。
此外,启动阶段给用户看的提示文案也应尽量从注册表派生:
- `startupLabel`
- 用于描述当前启动任务的业务名称
- `startupMessage`
- 用于描述启动中的提示文案
- 可以是字符串
- 也可以是对象,用于像海缆这种“准备阶段 / 主加载阶段”两段式文案
这样后续新增会参与启动加载的图层时,顺序、模式和提示文案都在同一处定义,不需要再去 `main.js` 里补第二套常量。
### `data-status-target`
图层按钮可以通过:
@@ -182,6 +245,25 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
以后别的异步图层也可以沿用这套约定。
## 当前设置持久化
Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一负责:
- 捕获默认值
-`localStorage` 读取上次设置
- 初始化应用当前设置
- 用户变更后即时持久化
- 一键重置回默认值
当前持久化的范围是:
- 旋转模式
- HUD 面板显示/隐藏
- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP`
- 地形透明度
也就是说Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`
## 当前地形链路
真实地形首次启用会慢,原因不只是一个:

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.31.2`
- `dev` 当前开发分支历史推导到:`0.31.3`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |

View File

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

View File

@@ -538,7 +538,7 @@
.earth-settings-kicker {
color: var(--hud-text-soft);
font-size: var(--hud-panel-header-title-size);
font-size: calc(0.72rem * var(--hud-scale));
letter-spacing: 0.16em;
text-transform: uppercase;
}
@@ -548,6 +548,22 @@
flex-shrink: 0;
}
.earth-settings-reset {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding-inline: calc(10px * var(--hud-scale));
color: var(--hud-text-soft);
font-size: calc(0.68rem * var(--hud-scale));
letter-spacing: 0.06em;
text-transform: uppercase;
}
.earth-settings-reset .material-symbols-rounded {
font-size: calc(0.86rem * var(--hud-scale));
}
.earth-settings-content {
overflow-y: auto;
padding-right: 4px;
@@ -575,7 +591,7 @@
.earth-settings-section-title {
margin-bottom: 12px;
color: var(--hud-text-soft);
font-size: var(--hud-kicker-size);
font-size: calc(0.62rem * var(--hud-scale));
letter-spacing: 0.16em;
text-transform: uppercase;
}
@@ -590,9 +606,9 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 15px 16px;
border-radius: 16px;
gap: 12px;
padding: 11px 14px;
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025);
@@ -648,10 +664,10 @@
border: 0;
background: transparent;
color: var(--hud-text-soft);
padding: 8px 14px;
padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
border-radius: 999px;
font: inherit;
font-size: 0.82rem;
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
@@ -680,18 +696,23 @@
.earth-settings-slider {
flex: 1 1 auto;
width: 100%;
height: 6px;
height: calc(4px * var(--hud-scale));
appearance: none;
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
border-radius: 999px;
outline: none;
cursor: pointer;
transition: background 0.18s ease;
}
.earth-settings-slider:hover {
background: linear-gradient(90deg, rgba(152, 184, 224, 0.44), rgba(114, 155, 202, 0.64));
}
.earth-settings-slider::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
width: calc(14px * var(--hud-scale));
height: calc(14px * var(--hud-scale));
border-radius: 50%;
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
@@ -699,26 +720,62 @@
border: 1px solid rgba(222, 236, 252, 0.4);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06),
0 6px 16px rgba(0, 0, 0, 0.24);
0 4px 10px rgba(0, 0, 0, 0.24);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.earth-settings-slider:hover::-webkit-slider-thumb {
transform: scale(1.22);
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0.32) 55%, transparent 70%),
linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98));
border-color: rgba(232, 244, 255, 0.72);
box-shadow:
0 0 0 3px rgba(145, 186, 255, 0.22),
0 6px 14px rgba(0, 0, 0, 0.3);
}
.earth-settings-slider:active::-webkit-slider-thumb {
transform: scale(1.08);
box-shadow:
0 0 0 4px rgba(145, 186, 255, 0.32),
0 4px 10px rgba(0, 0, 0, 0.28);
}
.earth-settings-slider::-moz-range-thumb {
width: 18px;
height: 18px;
width: calc(14px * var(--hud-scale));
height: calc(14px * var(--hud-scale));
border-radius: 50%;
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
border: 1px solid rgba(222, 236, 252, 0.4);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.06),
0 6px 16px rgba(0, 0, 0, 0.24);
0 4px 10px rgba(0, 0, 0, 0.24);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.earth-settings-slider:hover::-moz-range-thumb {
transform: scale(1.22);
background: linear-gradient(180deg, rgba(188, 216, 252, 1), rgba(108, 155, 210, 0.98));
border-color: rgba(232, 244, 255, 0.72);
box-shadow:
0 0 0 3px rgba(145, 186, 255, 0.22),
0 6px 14px rgba(0, 0, 0, 0.3);
}
.earth-settings-slider:active::-moz-range-thumb {
transform: scale(1.08);
box-shadow:
0 0 0 4px rgba(145, 186, 255, 0.32),
0 4px 10px rgba(0, 0, 0, 0.28);
}
.earth-settings-slider-value {
flex: 0 0 auto;
min-width: 46px;
min-width: calc(34px * var(--hud-scale));
text-align: right;
color: var(--hud-text-soft);
font-size: 0.78rem;
font-size: calc(0.66rem * var(--hud-scale));
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
@@ -731,13 +788,13 @@
.earth-settings-item-title {
color: var(--hud-text);
font-size: calc(0.98rem * var(--hud-scale));
font-size: calc(0.76rem * var(--hud-scale));
font-weight: 600;
}
.earth-settings-item-subtitle {
color: var(--hud-text-muted);
font-size: 0.82rem;
font-size: calc(0.67rem * var(--hud-scale));
line-height: 1.4;
}
@@ -750,11 +807,11 @@
}
.earth-settings-link-meta .material-symbols-rounded:first-child {
font-size: 1.1rem;
font-size: calc(0.9rem * var(--hud-scale));
}
.earth-settings-link-meta .material-symbols-rounded:last-child {
font-size: 1rem;
font-size: calc(0.82rem * var(--hud-scale));
}
.earth-settings-switch {
@@ -770,8 +827,8 @@
}
.earth-settings-switch-track {
width: 48px;
height: 30px;
width: calc(38px * var(--hud-scale));
height: calc(22px * var(--hud-scale));
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(215, 229, 242, 0.12);
@@ -782,10 +839,10 @@
.earth-settings-switch-track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
top: calc(3px * var(--hud-scale));
left: calc(3px * var(--hud-scale));
width: calc(16px * var(--hud-scale));
height: calc(16px * var(--hud-scale));
border-radius: 50%;
background: #edf4fc;
box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26);
@@ -798,7 +855,7 @@
}
.earth-settings-switch input:checked + .earth-settings-switch-track::after {
transform: translateX(18px);
transform: translateX(calc(16px * var(--hud-scale)));
}
@media (max-width: 960px) {

View File

@@ -9,6 +9,7 @@
align-items: center;
justify-content: center;
z-index: 200;
pointer-events: none;
}
.earth-toolbar-group,
@@ -40,6 +41,7 @@
border: none;
box-shadow: none;
padding: 0;
pointer-events: none;
}
.earth-toolbar-cluster {
@@ -103,6 +105,14 @@
pointer-events: auto;
}
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
pointer-events: none;
}
.earth-toolbar-hub > * {
pointer-events: auto;
}
.earth-toolbar-orb > .liquid-glass-surface {
animation: floatDock 4.6s ease-in-out infinite;
animation-delay: var(--orb-delay, 0s);
@@ -120,7 +130,7 @@
border: none;
background: transparent;
color: var(--hud-text-soft);
font-size: 14px;
font-size: calc(14px * var(--toolbar-scale));
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -361,7 +371,7 @@
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate(-50%, 8px);
transform: translate(-50%, calc(8px * var(--toolbar-scale)));
}
.earth-toolbar-popover::before {
@@ -381,7 +391,7 @@
top: auto;
right: auto;
bottom: calc(100% + (12px * var(--toolbar-scale)));
transform: translate(-50%, 10px);
transform: translate(-50%, calc(10px * var(--toolbar-scale)));
display: flex;
flex-direction: column;
align-items: center;
@@ -398,16 +408,16 @@
.earth-zoom-toolbar .earth-zoom-btn,
.earth-zoom-toolbar .earth-zoom-value {
width: 42px;
min-width: 42px;
width: calc(42px * var(--toolbar-scale));
min-width: calc(42px * var(--toolbar-scale));
border-radius: 50%;
color: var(--hud-text-soft);
animation: none;
}
.earth-zoom-toolbar .earth-zoom-btn {
height: 42px;
font-size: 20px;
height: calc(42px * var(--toolbar-scale));
font-size: calc(20px * var(--toolbar-scale));
font-weight: 500;
line-height: 1;
}
@@ -416,9 +426,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
height: calc(42px * var(--toolbar-scale));
padding: 0;
font-size: 0.68rem;
font-size: calc(11px * var(--toolbar-scale));
letter-spacing: normal;
}
@@ -429,15 +439,15 @@
.earth-toolbar-btn .earth-toolbar-tooltip {
position: absolute;
bottom: 56px;
bottom: calc(56px * var(--toolbar-scale));
left: 50%;
transform: translateX(-50%);
background:
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
color: var(--hud-text);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
border-radius: calc(6px * var(--toolbar-scale));
font-size: calc(12px * var(--toolbar-scale));
white-space: nowrap;
opacity: 0;
visibility: hidden;
@@ -453,7 +463,7 @@
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
opacity: 1;
visibility: visible;
bottom: 58px;
bottom: calc(58px * var(--toolbar-scale));
}
.earth-toolbar-btn .earth-toolbar-tooltip::after {
@@ -462,6 +472,6 @@
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border: calc(6px * var(--toolbar-scale)) solid transparent;
border-top-color: rgba(18, 31, 52, 0.96);
}

View File

@@ -154,7 +154,7 @@
<div id="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<span class="icon" aria-hidden="true">
@@ -419,6 +419,10 @@
<div class="hud-panel__title-group">
<div class="earth-settings-kicker">设置</div>
</div>
<button id="settings-reset" class="earth-settings-reset hud-panel__action" type="button" aria-label="重置设置">
<span class="material-symbols-rounded">restart_alt</span>
<span>重置</span>
</button>
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
<span class="material-symbols-rounded">close</span>
</button>
@@ -456,6 +460,16 @@
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-daynight" type="checkbox" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span>

View File

@@ -1,7 +1,11 @@
import * as THREE from "three";
import * as Astronomy from "astronomy-engine";
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js";
import {
CELESTIAL_CONFIG,
EARTH_CONFIG,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const textureLoader = new THREE.TextureLoader();
@@ -21,7 +25,11 @@ let moonDirection = defaultMoonDirection.clone();
let lastUpdatedAt = 0;
let linkedSunLight = null;
let linkedBackLight = null;
let linkedAmbientLight = null;
let linkedPointLight = null;
let linkedCamera = null;
let linkedEarth = null;
let dayNightLightingEnabled = true;
let brightStarSprites = [];
let celestialRotationQuaternion = new THREE.Quaternion();
let celestialViewQuaternion = new THREE.Quaternion();
@@ -333,10 +341,71 @@ function updateSpritePositions() {
}
function updateLighting() {
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
const inspection = CELESTIAL_CONFIG.inspectionLighting;
const cameraDirection = linkedCamera
? linkedCamera.position.clone().normalize()
: defaultSunDirection.clone();
const worldUp = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
if (right.lengthSq() < 1e-6) {
right.set(1, 0, 0);
} else {
right.normalize();
}
const adjustedUp = new THREE.Vector3()
.crossVectors(cameraDirection, right)
.normalize();
const resolveInspectionDirection = (offset) =>
cameraDirection
.clone()
.multiplyScalar(offset.z)
.add(right.clone().multiplyScalar(offset.x))
.add(adjustedUp.clone().multiplyScalar(offset.y))
.normalize();
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(inspection.ambientColor);
linkedAmbientLight.intensity = inspection.ambientIntensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(inspection.keyLightColor);
linkedSunLight.intensity = inspection.keyLightIntensity;
linkedSunLight.position
.copy(resolveInspectionDirection(inspection.keyLightOffset))
.multiplyScalar(inspection.keyLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(inspection.backLightColor);
linkedBackLight.intensity = inspection.backLightIntensity;
linkedBackLight.position
.copy(resolveInspectionDirection(inspection.backLightOffset))
.multiplyScalar(inspection.backLightDistance);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(inspection.pointLightColor);
linkedPointLight.intensity = inspection.pointLightIntensity;
linkedPointLight.position
.copy(resolveInspectionDirection(inspection.pointLightOffset))
.multiplyScalar(inspection.pointLightDistance);
}
return;
}
const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()),
);
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
@@ -352,6 +421,16 @@ function updateLighting() {
.copy(physicalSunDirection)
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
linkedPointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
}
}
function computeCelestialState(date = new Date()) {
@@ -364,14 +443,24 @@ function computeCelestialState(date = new Date()) {
export function initCelestialLayer(
scene,
{ camera = null, sunLight = null, backLight = null, earth = null } = {},
{
camera = null,
sunLight = null,
backLight = null,
ambientLight = null,
pointLight = null,
earth = null,
} = {},
) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer();
linkedCamera = camera;
linkedSunLight = sunLight;
linkedBackLight = backLight;
linkedAmbientLight = ambientLight;
linkedPointLight = pointLight;
linkedEarth = earth;
celestialRoot = new THREE.Group();
@@ -457,6 +546,10 @@ export function initCelestialLayer(
export function updateCelestialLayer(date = new Date(), camera = null) {
if (!celestialRoot) return;
if (camera) {
linkedCamera = camera;
}
refreshCelestialView();
const now = date.getTime();
@@ -503,6 +596,11 @@ export function setCelestialFollow(nextFollow = {}) {
return getCelestialDebugState();
}
export function setCelestialDayNightEnabled(enabled) {
dayNightLightingEnabled = enabled;
updateLighting();
}
export function disposeCelestialLayer() {
if (celestialRoot?.parent) {
celestialRoot.parent.remove(celestialRoot);
@@ -531,7 +629,11 @@ export function disposeCelestialLayer() {
lastUpdatedAt = 0;
linkedSunLight = null;
linkedBackLight = null;
linkedAmbientLight = null;
linkedPointLight = null;
linkedCamera = null;
linkedEarth = null;
dayNightLightingEnabled = true;
sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = {

View File

@@ -92,6 +92,45 @@ export const CELESTIAL_CONFIG = {
sunLightColor: 0xfff4df,
backLightIntensity: 0.3,
backLightColor: 0x2b4c78,
inspectionLighting: {
enabled: true,
ambientIntensity: 0.64,
ambientColor: 0x707070,
keyLightIntensity: 0.92,
keyLightColor: 0xfcfcfb,
keyLightDistance: 380,
keyLightOffset: { x: 0.42, y: 0.34, z: 0.84 },
backLightIntensity: 0.26,
backLightColor: 0x8f96a0,
backLightDistance: 260,
backLightOffset: { x: -0.52, y: -0.1, z: -0.62 },
pointLightIntensity: 0.36,
pointLightColor: 0xfafcff,
pointLightDistance: 320,
pointLightOffset: { x: 0.18, y: 0.52, z: 0.62 },
},
};
export const SCENE_LIGHT_CONFIG = {
ambient: {
color: 0x404060,
intensity: 1,
},
sun: {
color: 0xffffff,
intensity: 1.2,
position: { x: 5, y: 3, z: 5 },
},
back: {
color: 0x446688,
intensity: 0.3,
position: { x: -5, y: 0, z: -5 },
},
point: {
color: 0xffffff,
intensity: 0.4,
position: { x: 10, y: 10, z: 10 },
},
};
export const TERRAIN_CONFIG = {
@@ -192,6 +231,9 @@ export const SATELLITE_CONFIG = {
initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true,
trailLength: 10,
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotSize: 4,
ringSize: 0.07,
apiPath: '/api/v1/visualization/geo/satellites',

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ export let terrain = null;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
let _earthShader = null;
let _dayNightEnabled = true;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -33,6 +34,7 @@ function applyEarthDayNightShader(material) {
shader.uniforms.uTwilightColor = { value: twilightColor };
shader.uniforms.uNightTintColor = { value: nightTintColor };
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 };
shader.vertexShader = shader.vertexShader.replace(
"#include <common>",
@@ -55,7 +57,8 @@ uniform float uTwilightWidth;
uniform float uTwilightIntensity;
uniform vec3 uTwilightColor;
uniform vec3 uNightTintColor;
uniform float uNightTintIntensity;`,
uniform float uNightTintIntensity;
uniform float uDayNightEnabled;`,
).replace(
"#include <output_fragment>",
`
@@ -65,16 +68,27 @@ uniform float uNightTintIntensity;`,
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
outgoingLight *= mix(uNightFloor, uDayBoost, daylight);
outgoingLight += uTwilightColor * twilight * uTwilightIntensity;
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition)));
float cameraBoost = mix(0.62, 1.08, nDotV);
float dn = uDayNightEnabled;
vec3 dnLight = outgoingLight;
dnLight *= mix(uNightFloor, uDayBoost, daylight);
dnLight += uTwilightColor * twilight * uTwilightIntensity;
dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
// dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable
// dn=1: full day/night solar lighting
outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn);
#include <output_fragment>
`,
);
};
material.customProgramCacheKey = () => "earth-day-night-v1";
material.customProgramCacheKey = () => "earth-day-night-v5";
material.needsUpdate = true;
}
@@ -348,6 +362,28 @@ export function setEarthSunDirection(direction) {
}
}
export function setDayNightEnabled(enabled) {
_dayNightEnabled = enabled;
if (_earthShader?.uniforms?.uDayNightEnabled) {
_earthShader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0;
}
if (_earthMaterial) {
if (enabled) {
// Restore normal Phong lighting + custom day/night shader
_earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive);
_earthMaterial.emissiveMap = null;
} else {
// Full bright: zero diffuse so directional light has no effect;
// use original color as emissive map to show texture uniformly.
_earthMaterial.color.setRGB(0, 0, 0);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissiveMap = _earthMaterial.map;
}
_earthMaterial.needsUpdate = true;
}
}
export function loadEarthTexture() {
return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; }
@@ -368,6 +404,10 @@ export function loadEarthTexture() {
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
_earthMaterial.map = texture;
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
if (!_dayNightEnabled) {
_earthMaterial.emissiveMap = texture;
}
_earthMaterial.needsUpdate = true;
resolve();
},

View File

@@ -0,0 +1,174 @@
import {
loadGeoJSONFromPath,
loadLandingPoints,
getCableLegendItems,
toggleCables,
} from "./cables.js";
import {
clearSatelliteData,
getSatelliteLegendItems,
loadSatellites,
toggleSatellites,
} from "./satellites.js";
import {
loadBGPAnomalies,
toggleBGP,
} from "./bgp.js";
/**
* Layer startup task registry.
*
* This module is the startup-task counterpart to the layer registry in controls.js:
* - controls.js owns layer metadata such as startupPriority/startupMode/startupMessage
* - this file owns the executable startup task factory for each layer id
*
* A startup task is registered via registerLayerStartupTask(id, taskFactory).
* The taskFactory receives a startup context from main.js and must return an async
* function with the signature async (layerDefinition) => void.
*
* Put a task here only when a layer needs dedicated startup loading work:
* - preloading data at boot
* - staged loading with progress/loading messages
* - post-load UI refresh or warmup
*
* Do not put plain visibility toggles or persistent UI state here; those still belong
* to the layer registry/state flow in controls.js.
*/
const startupTaskRegistry = new Map();
export function resolveStartupMessage(layer, key, fallback) {
const message = layer?.startupMessage;
if (message && typeof message === "object" && key in message) {
return message[key];
}
if (typeof message === "string" && message.trim()) {
return message;
}
return fallback;
}
export function createLayerStartupTaskMap(context) {
return Object.fromEntries(
Array.from(startupTaskRegistry.entries()).map(([id, taskFactory]) => [
id,
taskFactory(context),
]),
);
}
export function registerLayerStartupTask(id, taskFactory) {
if (typeof id !== "string" || !id.trim()) {
throw new Error("registerLayerStartupTask 需要有效的图层 id");
}
if (typeof taskFactory !== "function") {
throw new Error("registerLayerStartupTask 需要可调用的任务工厂");
}
startupTaskRegistry.set(id, taskFactory);
}
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCableStartupTask();
registerSatelliteStartupTask();
registerBGPStartupTask();
}
function registerCableStartupTask() {
registerLayerStartupTask("cables", (context) => async (layer) => {
if (!context.isCablesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "prepare", "正在加载登陆点..."),
);
await context.yieldFrame(12);
try {
await loadLandingPoints(context.scene, context.earth, { silent: true });
} catch (error) {
context.reportError("登陆点", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海缆..."),
);
await context.yieldFrame(12);
try {
await loadGeoJSONFromPath(context.scene, context.earth, { silent: true });
if (!context.isCancelled() && context.isCablesEnabled()) {
toggleCables(true);
context.updateCableToggleUi(true);
context.setLegendItems("cables", getCableLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "海缆", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerSatelliteStartupTask() {
registerLayerStartupTask("satellites", (context) => async (layer) => {
if (!context.isSatellitesEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载卫星..."),
);
await context.yieldFrame(12);
try {
clearSatelliteData();
const loadResult = await loadSatellites({
limit: context.getInitialSatelliteLoadLimit(),
});
if (!context.isCancelled() && context.isSatellitesEnabled()) {
context.updateSatelliteToggleUi(true, loadResult.count);
context.setLegendItems("satellites", getSatelliteLegendItems());
context.refreshLegend();
context.scheduleSatellitePositionWarmup(() => {
if (!context.isCancelled() && context.isSatellitesEnabled()) {
toggleSatellites(true);
}
});
if (context.shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = context.nextSatelliteHydrationToken();
context.hydrateAllSatellitesInBackground(
() =>
hydrationToken === context.getSatelliteHydrationToken() &&
!context.isCancelled() &&
context.isSatellitesEnabled(),
);
}
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "卫星", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerBGPStartupTask() {
registerLayerStartupTask("bgp", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载BGP态势..."),
);
await context.yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(context.scene, context.earth);
if (!context.isCancelled()) {
toggleBGP(context.getShowBGP());
context.updateBGPHud(bgpResult);
context.syncBGPKnownEventIds();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "BGP态势", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
registerBuiltinLayerStartupTasks();

View File

@@ -10,6 +10,7 @@ import {
BGP_CONFIG,
CRUISE_CONFIG,
ROTATION_MODE,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
@@ -36,7 +37,7 @@ import {
clearEarthTexture,
setEarthSunDirection,
} from "./earth.js";
import { registerTerrainMesh, clearTerrainData } from "./terrain.js";
import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js";
import {
initCelestialLayer,
updateCelestialLayer,
@@ -45,6 +46,7 @@ import {
getSunDirection,
setCelestialOrientation,
setCelestialFollow,
setCelestialDayNightEnabled,
} from "./celestial.js";
import {
loadGeoJSONFromPath,
@@ -129,12 +131,17 @@ import {
getAutoRotate,
getRotationMode,
getShowTerrain,
getStartupLoadLayers,
setAutoRotate,
applyImmediateView,
focusEarthView,
getZoomLevel,
teardownControls,
} from "./controls.js";
import {
createLayerStartupTaskMap,
resolveStartupMessage,
} from "./layer-startup-tasks.js";
import {
setLayerButtonState,
} from "./layer-button-state.js";
@@ -1224,10 +1231,13 @@ export function init() {
registerTerrainMesh(createTerrain(earthObj));
initCelestialLayer(scene, {
camera,
ambientLight: sceneLights?.ambientLight ?? null,
sunLight: sceneLights?.sunLight ?? null,
backLight: sceneLights?.backLight ?? null,
pointLight: sceneLights?.pointLight ?? null,
earth: earthObj,
});
setCelestialDayNightEnabled(true);
createGridLines(scene, earthObj);
createSatellites(scene, earthObj);
@@ -1258,21 +1268,43 @@ function registerGlobalApi() {
}
function addLights() {
const ambientLight = new THREE.AmbientLight(0x404060);
const ambientLight = new THREE.AmbientLight(SCENE_LIGHT_CONFIG.ambient.color);
ambientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
sunLight.position.set(5, 3, 5);
const sunLight = new THREE.DirectionalLight(
SCENE_LIGHT_CONFIG.sun.color,
SCENE_LIGHT_CONFIG.sun.intensity,
);
sunLight.position.set(
SCENE_LIGHT_CONFIG.sun.position.x,
SCENE_LIGHT_CONFIG.sun.position.y,
SCENE_LIGHT_CONFIG.sun.position.z,
);
sunLight.target.position.set(0, 0, 0);
scene.add(sunLight);
scene.add(sunLight.target);
const backLight = new THREE.DirectionalLight(0x446688, 0.3);
backLight.position.set(-5, 0, -5);
const backLight = new THREE.DirectionalLight(
SCENE_LIGHT_CONFIG.back.color,
SCENE_LIGHT_CONFIG.back.intensity,
);
backLight.position.set(
SCENE_LIGHT_CONFIG.back.position.x,
SCENE_LIGHT_CONFIG.back.position.y,
SCENE_LIGHT_CONFIG.back.position.z,
);
scene.add(backLight);
const pointLight = new THREE.PointLight(0xffffff, 0.4);
pointLight.position.set(10, 10, 10);
const pointLight = new THREE.PointLight(
SCENE_LIGHT_CONFIG.point.color,
SCENE_LIGHT_CONFIG.point.intensity,
);
pointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
scene.add(pointLight);
return {
@@ -1364,100 +1396,54 @@ async function loadData() {
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
// Step 2 — Landing points
if (cablesEnabled) {
setLoadingMessage("正在加载登陆点...");
await yieldFrame(12);
try {
await loadLandingPoints(scene, earth, { silent: true });
} catch (err) {
errors.push({ label: "登陆点", reason: err });
}
const startupLoaders = createLayerStartupTaskMap({
scene,
earth,
setLoadingMessage,
yieldFrame,
refreshLegend,
setLegendItems,
updateCableToggleUi,
updateSatelliteToggleUi,
updateBGPHud,
getShowBGP,
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
hydrateAllSatellitesInBackground,
syncBGPKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(),
isCancelled: () => loadToken !== currentLoadToken || destroyed,
isCablesEnabled: () => cablesEnabled,
isSatellitesEnabled: () => satellitesEnabled,
nextSatelliteHydrationToken: () => ++satelliteHydrationToken,
getSatelliteHydrationToken: () => satelliteHydrationToken,
reportError: (label, reason) => {
errors.push({ label, reason });
},
});
const startupLayers = getStartupLoadLayers();
const startupLoadQueue = startupLayers
.map((layer) => ({
layer,
run: startupLoaders[layer.id],
}))
.filter((entry) => typeof entry.run === "function");
for (const entry of startupLoadQueue) {
await entry.run(entry.layer);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
}
// Step 3 — Cables
if (cablesEnabled) {
setLoadingMessage("正在加载海缆...");
await yieldFrame(12);
try {
const cableCount = await loadGeoJSONFromPath(scene, earth, {
silent: true,
});
if (loadToken === currentLoadToken && cablesEnabled) {
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
}
} catch (err) {
errors.push({ label: "海缆", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
}
// Step 4 — Satellites
if (satellitesEnabled) {
setLoadingMessage("正在加载卫星...");
await yieldFrame(12);
try {
clearSatelliteData();
const loadResult = await loadSatellites({
limit: getInitialSatelliteLoadLimit(),
});
if (loadToken === currentLoadToken && satellitesEnabled) {
updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
scheduleSatellitePositionWarmup(() => {
if (
loadToken === currentLoadToken &&
satellitesEnabled &&
!destroyed
) {
toggleSatellites(true);
}
});
if (shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = ++satelliteHydrationToken;
hydrateAllSatellitesInBackground(
() =>
hydrationToken === satelliteHydrationToken &&
loadToken === currentLoadToken &&
satellitesEnabled &&
!destroyed,
);
}
}
} catch (err) {
errors.push({ label: "卫星", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
}
// Step 5 — BGP
setLoadingMessage("正在加载BGP态势...");
await yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(scene, earth);
if (loadToken === currentLoadToken) {
toggleBGP(true);
updateBGPHud(bgpResult);
ensureBGPCruiseAdapter().syncKnownEventIds();
}
} catch (err) {
errors.push({ label: "BGP态势", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
// Step 6 — Terrain (if enabled)
if (getShowTerrain()) {
setLoadingMessage("正在渲染地形...");
const terrainLayer = startupLayers.find((layer) => layer.id === "terrain");
const terrainMessage = resolveStartupMessage(
terrainLayer,
"load",
"正在渲染地形...",
);
setLoadingMessage(terrainMessage);
await yieldFrame(24);
}
@@ -1493,7 +1479,14 @@ export async function reloadData() {
await loadData();
}
export async function setCablesEnabled(enabled) {
export function getSatellitesEnabled() {
return satellitesEnabled;
}
export async function setCablesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === cablesEnabled) {
updateCableToggleUi(enabled);
return getCableLines().length;
@@ -1502,32 +1495,47 @@ export async function setCablesEnabled(enabled) {
if (!enabled) {
clearSelectionAndInfo();
disableCables();
showStatusMessage("线缆已隐藏", "info");
if (!suppressStatus) {
showStatusMessage("线缆已隐藏", "info");
}
return 0;
}
setLoadingMessage("正在加载线缆数据...");
setLoading(true);
hideError();
if (!suppressLoadingUi) {
setLoadingMessage("正在加载线缆数据...");
setLoading(true);
hideError();
}
try {
const cableCount = await ensureCablesEnabled();
showStatusMessage("线缆已显示", "info");
if (!suppressStatus) {
showStatusMessage("线缆已显示", "info");
}
return cableCount;
} catch (error) {
cablesEnabled = false;
clearCableData(getEarth());
updateCableToggleUi(false);
const message = `线缆加载失败: ${error?.message || String(error)}`;
showError(message);
showStatusMessage(message, "error");
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
setLoading(false);
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
export async function setSatellitesEnabled(enabled) {
export async function setSatellitesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === satellitesEnabled) {
updateSatelliteToggleUi(enabled);
return getSatelliteCount();
@@ -1539,24 +1547,34 @@ export async function setSatellitesEnabled(enabled) {
return 0;
}
setLoadingMessage("正在加载卫星数据...");
setLoading(true);
hideError();
if (!suppressLoadingUi) {
setLoadingMessage("正在加载卫星数据...");
setLoading(true);
hideError();
}
try {
const satelliteCount = await ensureSatellitesEnabled();
showStatusMessage("卫星已显示", "info");
if (!suppressStatus) {
showStatusMessage("卫星已显示", "info");
}
return satelliteCount;
} catch (error) {
satellitesEnabled = false;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
const message = `卫星加载失败: ${error?.message || String(error)}`;
showError(message);
showStatusMessage(message, "error");
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
setLoading(false);
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
@@ -1618,7 +1636,10 @@ function getFrontFacingCables(cableLines) {
scratchCableDirection
.subVectors(scratchCableCenter, earth.position)
.normalize();
return scratchCameraToEarth.dot(scratchCableDirection) > 0;
return (
scratchCameraToEarth.dot(scratchCableDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
@@ -1634,7 +1655,10 @@ function getFrontFacingBGPMarkers(markers) {
scratchBGPDirection
.subVectors(scratchBGPWorldPosition, earth.position)
.normalize();
return scratchCameraToEarth.dot(scratchBGPDirection) > 0;
return (
scratchCameraToEarth.dot(scratchBGPDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
@@ -1793,10 +1817,16 @@ function onMouseMove(event) {
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
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>海拔: ${coords.alt.toFixed(1)} km`,
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
);
} else {
hideTooltip();

View File

@@ -490,7 +490,8 @@ function computeSatellitePosition(satellite, time) {
}
const r = Math.sqrt(x * x + y * y + z * z);
const displayRadius = CONFIG.earthRadius * 1.05;
const displayRadius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale);
@@ -637,7 +638,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
}
function generateFallbackPosition(satellite, index, total) {
const radius = CONFIG.earthRadius + 5;
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53;
@@ -843,6 +844,10 @@ export function toggleTrails(visible) {
}
}
export function getShowTrails() {
return showTrails;
}
export function getShowSatellites() {
return showSatellites;
}
@@ -902,7 +907,10 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
.normalize();
return scratchToCamera.dot(scratchToSatellite) > 0;
return (
scratchToCamera.dot(scratchToSatellite) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
}
function createBrighterDotCanvas() {
@@ -948,6 +956,7 @@ function createRingSprite(position, isLocked = false) {
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
}
@@ -967,6 +976,7 @@ function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
}
@@ -989,6 +999,7 @@ export function showHoverRing(position, isLocked = false) {
lockedDotSprite = new THREE.Sprite(dotMaterial);
lockedDotSprite.position.copy(position);
lockedDotSprite.scale.set(4, 4, 1);
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
earthObjRef.add(lockedDotSprite);
return lockedRingSprite;
}
@@ -1199,7 +1210,8 @@ function calculatePredictedOrbit(
if (points.length < samples * 0.5) {
points.length = 0;
const radius = CONFIG.earthRadius + 5;
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
@@ -1250,9 +1262,12 @@ export function showPredictedOrbit(satellite) {
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
});
predictedOrbitLine = new THREE.Line(geometry, material);
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(predictedOrbitLine);
}

View File

@@ -18,6 +18,7 @@ let terrainLoadPromise = null;
let terrainReady = false;
let terrainFailed = false;
let terrainTileCache = new Map();
let resolvedTileCache = new Map();
let terrainVertexSamples = null;
let terrainOpacity = TERRAIN_CONFIG.opacity;
@@ -67,7 +68,9 @@ async function decodeTerrainTile(z, x, y) {
TERRAIN_CONFIG.tileSize,
TERRAIN_CONFIG.tileSize,
);
return { data, width, height };
const tileData = { data, width, height };
resolvedTileCache.set(cacheKey, tileData);
return tileData;
})();
terrainTileCache.set(cacheKey, tilePromise);
@@ -236,6 +239,7 @@ export function registerTerrainMesh(mesh) {
terrainFailed = false;
terrainLoadPromise = null;
terrainTileCache = new Map();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity;
if (terrainMesh?.material) {
terrainMesh.material.opacity = terrainOpacity;
@@ -287,9 +291,19 @@ export function clearTerrainData() {
terrainFailed = false;
terrainVertexSamples = null;
terrainTileCache = new Map();
resolvedTileCache = new Map();
terrainOpacity = TERRAIN_CONFIG.opacity;
}
export function sampleElevationAt(lat, lon) {
if (!terrainReady) return null;
const z = TERRAIN_CONFIG.baseZoom;
const { tileX, tileY, pixelX, pixelY } = latLonToTileSample(lat, lon, z, TERRAIN_CONFIG.tileSize);
const tile = resolvedTileCache.get(`${z}/${tileX}/${tileY}`);
if (!tile) return null;
return Math.max(0, decodeTerrariumHeight(tile, pixelX, pixelY));
}
export function setTerrainOpacity(nextOpacity) {
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
if (terrainMesh?.material) {

View File

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

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.31.2"
version = "0.31.3"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },