Compare commits

..

4 Commits

Author SHA1 Message Date
rayd1o
229be0bced release: bump version to 0.40.3 2026-04-25 23:02:22 +08:00
linkong
50a417ca83 release: bump version to 0.40.2 2026-04-24 17:50:43 +08:00
linkong
e9464a9833 release: bump version to 0.40.1 2026-04-24 17:28:03 +08:00
linkong
86807f6af6 release: bump version to 0.40.0 2026-04-24 15:41:42 +08:00
17 changed files with 1799 additions and 97 deletions

View File

@@ -1 +1 @@
0.39.0
0.40.3

View File

@@ -184,6 +184,12 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
mean_motion=metadata.get("mean_motion"),
)
constellation_group = _normalize_satellite_constellation_group(
metadata.get("constellation_group"),
record.name,
)
footprint_policy = _get_satellite_footprint_policy(constellation_group)
features.append(
{
"type": "Feature",
@@ -193,6 +199,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
"id": record.id,
"norad_cat_id": norad_id,
"name": record.name,
"constellation_group": constellation_group,
"footprint_policy": footprint_policy,
"international_designator": metadata.get("international_designator"),
"epoch": metadata.get("epoch"),
"inclination": metadata.get("inclination"),
@@ -213,6 +221,31 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
return {"type": "FeatureCollection", "features": features}
def _normalize_satellite_constellation_group(
raw_group: Any,
name: Optional[str],
) -> Optional[str]:
normalized_group = str(raw_group or "").strip().lower()
if normalized_group:
return normalized_group
normalized_name = str(name or "").strip().upper()
if normalized_name.startswith("STARLINK"):
return "starlink"
if normalized_name.startswith("IRIDIUM"):
return "iridium-next"
return None
def _get_satellite_footprint_policy(constellation_group: Optional[str]) -> str:
if constellation_group == "starlink":
return "starlink_ground_footprint"
if constellation_group == "iridium-next":
return "iridium_coverage_ring"
return "none"
def _current_collected_data_stmt(source: str):
return (
select(CollectedData)

View File

@@ -46,6 +46,9 @@ class CelesTrakTLECollector(BaseCollector):
if response.status_code == 200:
data = response.json()
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
item["_celestrak_group"] = group
all_satellites.extend(data)
print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
except Exception as e:
@@ -78,6 +81,7 @@ class CelesTrakTLECollector(BaseCollector):
"name": item.get("OBJECT_NAME", "Unknown"),
"reference_date": item.get("EPOCH", ""),
"metadata": {
"constellation_group": item.get("_celestrak_group"),
"norad_cat_id": item.get("NORAD_CAT_ID"),
"international_designator": item.get("OBJECT_ID"),
"epoch": item.get("EPOCH"),

View File

@@ -10,6 +10,53 @@ This project follows the repository versioning rule:
## [0.39.0] — 2026-04-24
## [0.40.3] — 2026-04-25
### 🔧 Improvements
- 卫星点云升级为自定义 ShaderMaterial支持 per-point alpha 控制,锁定/悬停卫星从点云中精确隐藏
- 修复锁定环与自发光选中标记的 depthTest 错误false → true消除远端渲染穿透 artifact
- 新增锁定环悬停态缩放与线宽LOCKED_RING_HOVER_SCALE / LOCKED_RING_HOVER_LINE_WIDTH
- 修复 updateLockedDotWorldTransform / updateLockedHaloWorldTransform 未强制刷新 matrixWorld 导致的位置漂移
---
## [0.40.2] — 2026-04-24
### 🔧 Improvements
- 卫星点大小随镜头缩放动态调整,拉近变大、拉远变小,响应与相机距离线性对应
- 调小卫星点默认基础尺寸dotSize 2.8),缩放范围更合理
---
## [0.40.1] — 2026-04-24
### 🔧 Improvements
- 卫星选中标记lockedring / lockeddot / 光晕)颜色统一跟随图例轨道倾角分类配色
- 修复 Starlink footprint 在特定视角下遮蔽卫星点的渲染顺序问题Group renderOrder 影响子 Mesh 排序)
- footprint 材质改为 `depthTest: false` + 相机朝向 limbFade替代 polygonOffset 深度竞争方案
- 修复选中海缆时误触发附近卫星高亮(该行为属于 BGP 事件点逻辑,不应用于海缆)
---
## [0.40.0] — 2026-04-24
### ✨ Highlights
- Earth 卫星 footprint 正式按星座能力分层Starlink 保留专用地表覆盖Iridium 改为独立外圈覆盖表达,其它非 Starlink 星座不再误用同一套 footprint
- Earth 卫星详情卡补齐覆盖能力与当前显示说明,用户现在可以直接看见每颗卫星为什么显示 footprint、为何回退为自身发光
### 🔧 Improvements
- 后端可视化接口新增并透传 `constellation_group``footprint_policy`,前端据此执行 capability-gated footprint renderer
- 新增 Iridium 独立 coverage ring adapter并继续保留 Starlink 专用 footprint 调校与昼夜可读性增强
- 新增 Earth 卫星 footprint 策略技术文档,明确 GNSS、generic LEO、GEO 与 Iridium 的显示边界
### 🐛 Fixes
- 修复前后端对 Iridium footprint policy 命名不一致,导致策略分发语义含混的问题
- 清理 Starlink footprint 渲染中的未使用常量与过时命名,减少后续继续调校时的歧义
---
## [0.39.0] — 2026-04-24
### ✨ Highlights
- 后端正式落下统一结构化日志地基:请求上下文、事件名、脱敏与持久化链路开始收口为可扩展的企业级日志体系
- 系统日志页重构为真正的日志工作台:顶部筛选更紧凑,终端日志区成为主视觉,移动端 Earth 新闻/态势细节交互继续补稳

View File

@@ -11,6 +11,7 @@
- 前端上下文
- Earth 前端结构
- Earth 卫星 footprint 策略
- 后端运行控制
- collector 现状
- 采集格式约定

View File

@@ -0,0 +1,198 @@
# Earth Satellite Footprint Policy
本文件记录 Earth 卫星图层当前关于 `footprint` 的产品边界、资料依据和已落地实现,目标是避免把 Starlink 这套专用地表覆盖模型误用到其它星座上。
相关上下文:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/backend-collectors.md)
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
## 当前目标
- 明确哪些非 Starlink 卫星不该显示贴地 footprint
- 明确哪些星座未来可以有独立 footprint但不能复用 Starlink bowtie / GSO-gap 模型
- 把这条策略沉淀成可执行实现边界,而不是继续散落在视觉参数里
## 本地实际类别
当前 CelesTrak 卫星分组在 [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py) 中包括:
- `starlink`
- `gps-ops`
- `galileo`
- `glonass`
- `beidou`
- `leo`
- `geo`
- `iridium-next`
其中非 Starlink 类别是:
- `gps-ops`
- `galileo`
- `glonass`
- `beidou`
- `leo`
- `geo`
- `iridium-next`
## 资料结论
### 1. GNSS / RNSS: `gps-ops`, `galileo`, `glonass`, `beidou`
默认不要画局部地表 footprint。
原因:
- 公开资料强调的是 `Earth-pointing``Earth coverage``continuous global coverage`
- 这类系统的公开语义是全球导航 / 授时覆盖,不是 Starlink 那种面向终端业务的局部 spot footprint
更合适的表示:
- 默认只显示卫星本体和轨道
- 如果后续要强调“服务可达性”,只能做很弱的 global coverage 语义,不应画贴地局部光斑
资料:
- [GPS III EC Antenna Patterns](https://www.navcen.uscg.gov/sites/default/files/pdf/gps/GPS_ZIP/GPS_III_EC_Antenna_Patterns_SVN_74_75_76_77_78.pdf)
- [ESA Galileo satellites](https://www.esa.int/Applications/Satellite_navigation/Galileo/Galileo_satellites)
- [Navipedia Galileo General Introduction](https://gssc.esa.int/navipedia/index.php/Galileo_General_Introduction)
- [BeiDou official overview](https://www.beidou.gov.cn/xt/gfxz/201812/P020190117356387956569.pdf)
- [GPS.gov GNSS overview](https://www.gps.gov/systems/gnss/)
### 2. `iridium-next`
可以有 footprint但不能复用 Starlink 的单一 bowtie footprint。
原因:
- Iridium NEXT 公开资料强调的是固定多 spot beam 体系
- 公开示例里常见的是 `48 fixed spot beams in 4 tiers`
- 这和 Starlink 当前这套“单星、单主 footprint、带 GSO 缺口”的业务可视化不是同一个问题
更合适的表示:
- 默认:仍然不画 Starlink 式地表 footprint
- 后续如果要做:单独接入 Iridium 多波束适配层
- 在视觉上更接近多束 cluster / 蜂窝 / 分层束,而不是单个 bowtie 光斑
资料:
- [Iridium Satellite Spot Beam Coverage on the US](https://www.mathworks.com/help/phased/ug/iridium-satellite-spot-beam-coverage-on-the-us-1.html)
### 3. `geo`
默认不要画统一 footprint。
原因:
- GEO 通信星公开上可能是 global beam、zone beam、spot beam、steerable spot beam
- 没有 operator / payload / beam contour 元数据时,统一画一个 footprint 很容易错
更合适的表示:
- 默认只显示 GEO belt 和卫星驻点语义
- 只有拿到 beam contour / operator metadata 时才允许画 footprint
资料:
- [ITU Handbook on Satellite](https://www.itu.int/dms_pub/itu-r/opb/hdb/R-HDB-42-2002-PDF-E.pdf)
### 4. `leo`generic
默认不要画 footprint。
原因:
- `leo` 组过于混杂,可能同时包含通信、遥感、试验、观测等不同任务
- 没有 mission / payload / antenna pattern 元数据时,无法判断是否存在可视化意义上的服务覆盖面
更合适的表示:
- 默认只显示卫星和轨道
- 后续如果按 operator / mission subtype 细分,再决定是否引入独立 coverage mode
## 产品策略
当前统一策略如下:
- `Starlink`
- 保留当前专用 `ground_footprint` 逻辑
- `Iridium NEXT`
- 预留独立适配层
- 当前不复用 Starlink footprint
- `GPS / Galileo / GLONASS / BeiDou`
- 不显示贴地 footprint
- `GEO`
- 无 beam metadata 不显示 footprint
- `generic LEO`
- 无 mission metadata 不显示 footprint
## 已落地实现
本次实现只做最小可执行版本,不改现有 Starlink 视觉参数:
1. 后端把星座分组和 footprint 策略提示透给前端
- CelesTrak collector 会把 `GROUP` 记入 `metadata.constellation_group`
- Visualization API 会输出:
- `properties.constellation_group`
- `properties.footprint_policy`
当前策略值:
- `starlink_ground_footprint`
- `iridium_coverage_ring`
- `none`
对应代码:
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
2. 前端把 footprint 变成 capability-gated renderer
- `ground_footprint` 只有在 `footprint_policy === starlink_ground_footprint` 时才真正启用
- `iridium-next` 不再回退成占位分支,而是走独立的 Iridium coverage ring adapter
- 其它非 Starlink 即使用户全局选择了 `ground_footprint`,也会自动回退到 `self_glow`
对应代码:
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [frontend/public/earth/js/iridium-footprint-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/iridium-footprint-adapter.js)
3. 卫星信息卡显示 capability而不是只显示轨道参数
- 卫星详情现在会明确显示:
- `星座/分组`
- `覆盖能力`
- `当前显示`
- `覆盖模型`
- 这样用户能直接看到:
- 当前卫星是否支持 footprint
- 当前显示是不是因为 capability gating 被回退
- Iridium 和 Starlink 使用的不是同一种模型
对应代码:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
## 当前实现边界
这条边界需要继续保持:
- `Starlink` 的 footprint 参数和 shader 逻辑只服务于 Starlink
- 非 Starlink 的能力判断属于“策略层 / 适配层”
- 不要把不同星座的覆盖模型再混写进同一套参数里
- `iridium-next` 已经切成独立 adapter应继续沿这条边界演进而不是给现有 Starlink bowtie 增加更多 if/else
## 后续建议
如果继续往前做,推荐顺序是:
1.`iridium-next` 新建独立 footprint adapter
2. 在 UI 上补一个只读提示,让用户知道当前卫星是否支持 footprint
3. 如果未来拿到 GEO beam contour / operator metadata再为 GEO 开 operator-specific footprint

View File

@@ -16,12 +16,16 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.39.0`
- `dev` 当前开发分支历史推导到:`0.40.3`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial修复锁定环 depthTest 与位置漂移,新增悬停态缩放 |
| `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 |
| `0.40.1` | improvement | `dev` | `pending` | 卫星选中标记配色跟随图例,修复 footprint 遮蔽卫星渲染问题,修复选中海缆误触发卫星高亮 |
| `0.40.0` | feature | `dev` | `pending` | Earth 卫星 footprint 按星座能力分层Iridium 独立 coverage ring 落地,卫星详情卡补齐覆盖能力与当前显示说明 |
| `0.39.0` | feature | `dev` | `pending` | 后端统一结构化日志地基落地,系统日志页重构为紧凑日志工作台,并修复 Earth 移动端态势抽屉与新闻详情同步问题 |
| `0.38.0` | feature | `dev` | `pending` | Earth 新闻接入通用巡航与专用卡片链路,系统日志页升级为结构化时间/级别过滤与真正字符串检索 |
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |

View File

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

View File

@@ -687,6 +687,19 @@
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">卫星</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星显示风格</span>
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="self_glow" aria-pressed="true">自身发光</button>
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="ground_footprint" aria-pressed="false">真实地表覆盖</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
<label class="earth-mobile-settings-card">
@@ -875,6 +888,30 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星显示风格</span>
<span class="earth-settings-item-subtitle">选择卫星锁定态使用自身发光,还是强调真实地表覆盖范围。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-satellite-display-style="self_glow"
aria-pressed="true"
>
自身发光
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-satellite-display-style="ground_footprint"
aria-pressed="false"
>
真实地表覆盖
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">

View File

@@ -25,6 +25,14 @@ export const CRUISE_MODULES = {
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
export const SATELLITE_DISPLAY_STYLES = {
SELF_GLOW: "self_glow",
GROUND_FOOTPRINT: "ground_footprint",
};
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
SATELLITE_DISPLAY_STYLES.SELF_GLOW;
export const CRUISE_CONFIG = {
dwellMs: 7_000,
focusDurationMs: 1_400,
@@ -275,7 +283,9 @@ export const SATELLITE_CONFIG = {
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotSize: 4,
dotBaseSize: 2.8,
dotBackdropScale: 1.28,
dotZoomScalePower: 1,
ringSize: 0.07,
apiPath: '/api/v1/visualization/geo/satellites',
breathingSpeed: 0.08,

View File

@@ -4,9 +4,11 @@ import * as THREE from "three";
import {
CONFIG,
CRUISE_MODULES,
DEFAULT_SATELLITE_DISPLAY_STYLE,
DEFAULT_CRUISE_MODULES,
EARTH_CONFIG,
ROTATION_MODE,
SATELLITE_DISPLAY_STYLES,
} from "./constants.js";
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
import {
@@ -34,6 +36,8 @@ import {
toggleTrails,
getShowTrails,
getSatelliteCount,
getSatelliteDisplayStyle,
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
@@ -116,6 +120,9 @@ let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
function detectLayoutMode() {
const width = window.innerWidth;
@@ -641,6 +648,7 @@ function getCurrentSharedSettingsSnapshot() {
return {
rotationMode,
cruiseModules: getCruiseModules(),
satelliteDisplayStyle: getSatelliteDisplayStyle(),
layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
),
@@ -676,6 +684,8 @@ function cloneEarthSettings(settings) {
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
satelliteDisplayStyle:
settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE,
terrainOpacity: settings.shared.terrainOpacity,
dayNightEnabled: settings.shared.dayNightEnabled,
defaultEarthZoom: settings.shared.defaultEarthZoom,
@@ -755,6 +765,11 @@ function normalizeEarthSettings(rawSettings, defaults) {
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
),
);
const nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
sharedSettings?.satelliteDisplayStyle,
)
? sharedSettings.satelliteDisplayStyle
: defaults.shared.satelliteDisplayStyle;
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? sharedSettings.dayNightEnabled
@@ -770,6 +785,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
cruiseModules: nextCruiseModules.length > 0
? nextCruiseModules
: [...DEFAULT_CRUISE_MODULES],
satelliteDisplayStyle: nextSatelliteDisplayStyle,
layerVisibility: normalizedLayerVisibility,
terrainOpacity: Number.isFinite(nextTerrainOpacity)
? nextTerrainOpacity
@@ -883,6 +899,17 @@ function syncCruiseModuleControls() {
});
}
function syncSatelliteDisplayStyleControls() {
const activeStyle = getSatelliteDisplayStyle();
document.querySelectorAll("[data-satellite-display-style]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const styleId = button.dataset.satelliteDisplayStyle || "";
const active = styleId === activeStyle;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
@@ -925,6 +952,42 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
return normalizedModules;
}
export function setSatelliteDisplayStyle(
nextStyle,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(nextStyle)
? nextStyle
: DEFAULT_SATELLITE_DISPLAY_STYLE;
const previousStyle = getSatelliteDisplayStyle();
if (normalizedStyle === previousStyle) {
syncSatelliteDisplayStyleControls();
return normalizedStyle;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle;
applySatelliteDisplayStyle(normalizedStyle);
syncSatelliteDisplayStyleControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const nextLabel =
normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT
? "真实地表覆盖"
: "自身发光";
showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info");
}
return normalizedStyle;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -988,6 +1051,10 @@ async function applyEarthSettings(settings) {
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true });
setSatelliteDisplayStyle(settings.shared.satelliteDisplayStyle, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -1797,6 +1864,7 @@ function setupSettingsControls() {
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
@@ -1869,6 +1937,16 @@ function setupSettingsControls() {
});
});
satelliteDisplayStyleButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const nextStyle = target.dataset.satelliteDisplayStyle;
if (!nextStyle) return;
setSatelliteDisplayStyle(nextStyle);
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
@@ -1886,6 +1964,7 @@ function setupSettingsControls() {
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
syncSatelliteDisplayStyleControls();
syncDayNightToggle(dayNightEnabled);
}

View File

@@ -437,6 +437,10 @@ const CARD_CONFIG = {
fields: [
{ key: 'name', label: '名称' },
{ key: 'norad_id', label: 'NORAD ID' },
{ key: 'constellation', label: '星座/分组' },
{ key: 'footprint_capability', label: '覆盖能力' },
{ key: 'current_display', label: '当前显示' },
{ key: 'footprint_model', label: '覆盖模型' },
{ key: 'inclination', label: '倾角', unit: '°' },
{ key: 'period', label: '周期', unit: '分钟' },
{ key: 'perigee', label: '近地点', unit: 'km' },

View File

@@ -0,0 +1,167 @@
import * as THREE from "three";
const EARTH_RADIUS_KM = 6378.137;
const SURFACE_SCALE = 1.003;
const SURFACE_OFFSET = 0.72;
const CLUSTER_DIAMETER_KM_APPROX = 4500;
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
const SURFACE_AXIS = new THREE.Vector3(0, 0, 1);
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
material.dispose();
}
function disposeObjectTree(object) {
if (!object) return;
object.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
disposeMaterial(child.material);
}
});
}
function createIridiumClusterMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3,
blending: THREE.AdditiveBlending,
uniforms: {
uColor: { value: new THREE.Color(0x5faeff) },
uOpacity: { value: 0.24 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
void main() {
vec2 p = vUv * 2.0 - 1.0;
float ellipseMetric = p.x * p.x * 0.82 + p.y * p.y * 1.06;
float alpha = exp(-ellipseMetric * 1.05) * (1.0 - smoothstep(0.86, 1.24, ellipseMetric));
alpha *= uOpacity;
if (alpha <= 0.001) discard;
gl_FragColor = vec4(uColor, alpha);
}
`,
});
}
function projectOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
alongKm,
crossKm,
earthRadiusWorld,
) {
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
const surfaceRadius = earthRadiusWorld * SURFACE_SCALE + SURFACE_OFFSET;
return centerNormal
.clone()
.multiplyScalar(earthRadiusWorld)
.addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
.addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
.normalize()
.multiplyScalar(surfaceRadius);
}
function computeClusterRadiusKm(altitudeKm) {
const altitudeScale = THREE.MathUtils.clamp(
(Number(altitudeKm) || 780) / 780,
0.88,
1.18,
);
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
}
export function createIridiumFootprintAdapter({
earthObj,
earthRadiusWorld,
renderOrder,
}) {
if (!earthObj) return null;
const group = new THREE.Group();
group.name = "iridium-footprint-overlay";
group.renderOrder = renderOrder;
group.userData = {
earthRadiusWorld,
clusterGlow: null,
};
const clusterGlow = new THREE.Mesh(
new THREE.CircleGeometry(1, 72),
createIridiumClusterMaterial(),
);
clusterGlow.name = "iridium-cluster-glow";
clusterGlow.renderOrder = renderOrder - 1;
group.add(clusterGlow);
group.userData.clusterGlow = clusterGlow;
earthObj.add(group);
return group;
}
export function updateIridiumFootprintAdapter(
group,
{ position, alongTrack, crossTrack, altitudeKm },
) {
if (!group || !position || !alongTrack || !crossTrack) return;
const earthRadiusWorld =
group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
const centerNormal = position.clone().normalize();
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
const clusterGlow = group.userData?.clusterGlow || null;
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
if (clusterGlow) {
const clusterCenter = projectOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
0,
0,
earthRadiusWorld,
);
const clusterNormal = clusterCenter.clone().normalize();
clusterGlow.position.copy(clusterCenter);
clusterGlow.quaternion.setFromUnitVectors(SURFACE_AXIS, clusterNormal);
clusterGlow.scale.set(
clusterRadiusKm * worldUnitsPerKm * 1.18,
clusterRadiusKm * worldUnitsPerKm * 0.96,
1,
);
}
}
export function disposeIridiumFootprintAdapter(group, earthObj) {
if (!group) return;
if (earthObj) {
earthObj.remove(group);
} else if (group.parent) {
group.parent.remove(group);
}
disposeObjectTree(group);
}

View File

@@ -80,6 +80,7 @@ import {
getSatelliteCount,
selectSatellite,
getSatellitePoints,
getSatellitePresentationInfo,
setSatelliteRingState,
updateLockedRingPosition,
updateHoverRingPosition,
@@ -91,9 +92,12 @@ import {
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
updateSatellitePointSize,
isSatelliteFrontFacing,
setSatelliteCamera,
setSatelliteSunDirection,
setLockedSatelliteIndex,
setHoveredSatelliteIndex,
resetSatelliteState,
clearSatelliteData,
} from "./satellites.js";
@@ -480,6 +484,7 @@ function clearTransientHoverState() {
}
hoveredSatellite = null;
hoveredSatelliteIndex = null;
setHoveredSatelliteIndex(null);
}
function applyBGPHoverState(marker) {
@@ -576,6 +581,14 @@ function showSatelliteInfo(props, coords) {
const ecc = props?.eccentricity || 0;
const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0);
const presentation = getSatellitePresentationInfo(props);
let footprintModel = "不适用";
if (presentation.footprintPolicy === "starlink_ground_footprint") {
footprintModel = "Starlink 单星地表覆盖";
} else if (presentation.footprintPolicy === "iridium_coverage_ring") {
footprintModel = "Iridium 外圈半透明覆盖";
}
setSelectedSatelliteLegend(props);
setLegendItems("satellites", getSatelliteLegendItems());
@@ -583,6 +596,10 @@ function showSatelliteInfo(props, coords) {
showInfoCard("satellite", {
name: props?.name || "-",
norad_id: props?.norad_cat_id,
constellation: presentation.constellationLabel,
footprint_capability: presentation.footprintCapabilityLabel,
current_display: presentation.presentationModeLabel,
footprint_model: footprintModel,
inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
period,
perigee,
@@ -919,6 +936,9 @@ async function focusSearchSatellite(index) {
const satPositions = getSatellitePositions();
if (satPositions?.[index]) {
setSatelliteRingState(index, "locked", satPositions[index].current);
if (hoveredSatelliteIndex === index) {
setHoveredSatelliteIndex(index);
}
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
@@ -1050,7 +1070,9 @@ function resolveEarthSearchResults(query) {
icon: "satellite_alt",
typeLabel: "卫星",
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
subtitle: props?.norad_cat_id
? `NORAD ${props.norad_cat_id} · ${getSatellitePresentationInfo(props).constellationLabel}`
: `${getSatellitePresentationInfo(props).constellationLabel} · 在轨卫星`,
score,
entity: { index },
});
@@ -2647,6 +2669,7 @@ function onMouseMove(event) {
);
}
}
setHoveredSatelliteIndex(hoveredSatelliteIndex);
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
objectTooltipShown = true;
} else if (lockedObjectType === "bgp" && lockedObject) {
@@ -2946,19 +2969,6 @@ function onClick(event) {
lockedObject = clickedCable;
lockedObjectType = "cable";
setAutoRotate(false);
{
const cableLandingRegions = getLandingPoints()
.filter((lp) => lp.userData.cableNames?.includes(clickedCable.userData.name))
.map((lp) => {
const { lat, lon } = vector3ToLatLon(lp.position);
return { latitude: lat, longitude: lon };
});
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
cableLandingRegions,
{ limit: 6, maxAngleDeg: 20 },
);
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
}
handleCableClick(clickedCable);
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
return;
@@ -3013,6 +3023,9 @@ function onClick(event) {
"locked",
satPositions[selectedIndex].current,
);
if (hoveredSatelliteIndex === selectedIndex) {
setHoveredSatelliteIndex(selectedIndex);
}
}
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
@@ -3113,9 +3126,12 @@ function animate() {
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateSatellitePointSize();
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
setEarthSunDirection(getSunDirection());
const currentSunDirection = getSunDirection();
setEarthSunDirection(currentSunDirection);
setSatelliteSunDirection(currentSunDirection);
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (

File diff suppressed because it is too large Load Diff

View File

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

2
uv.lock generated
View File

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