diff --git a/VERSION b/VERSION
index 4ef2eb08..9b0025a7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.39.0
+0.40.0
diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py
index 66515a42..89996f2b 100644
--- a/backend/app/api/v1/visualization.py
+++ b/backend/app/api/v1/visualization.py
@@ -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)
diff --git a/backend/app/services/collectors/celestrak.py b/backend/app/services/collectors/celestrak.py
index 6c82d4a2..49038b42 100644
--- a/backend/app/services/collectors/celestrak.py
+++ b/backend/app/services/collectors/celestrak.py
@@ -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"),
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 26e96815..48d4908d 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -10,6 +10,25 @@ This project follows the repository versioning rule:
## [0.39.0] — 2026-04-24
+## [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 新闻/态势细节交互继续补稳
diff --git a/docs/technical/README.md b/docs/technical/README.md
index a8a585de..d846d9d7 100644
--- a/docs/technical/README.md
+++ b/docs/technical/README.md
@@ -11,6 +11,7 @@
- 前端上下文
- Earth 前端结构
+- Earth 卫星 footprint 策略
- 后端运行控制
- collector 现状
- 采集格式约定
diff --git a/docs/technical/earth-satellite-footprint-policy.md b/docs/technical/earth-satellite-footprint-policy.md
new file mode 100644
index 00000000..87891caa
--- /dev/null
+++ b/docs/technical/earth-satellite-footprint-policy.md
@@ -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
diff --git a/docs/version-history.md b/docs/version-history.md
index 23c66c72..d4a794d3 100644
--- a/docs/version-history.md
+++ b/docs/version-history.md
@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
-- `dev` 当前开发分支历史推导到:`0.39.0`
+- `dev` 当前开发分支历史推导到:`0.40.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
+| `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、移动端抽屉与设置持久化流 |
diff --git a/frontend/package.json b/frontend/package.json
index 14c29d70..faaeadf0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
- "version": "0.39.0",
+ "version": "0.40.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {
diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html
index 513d9756..fa1088d8 100644
--- a/frontend/public/earth/index.html
+++ b/frontend/public/earth/index.html
@@ -687,6 +687,19 @@
+
+
卫星
+
+
+ 卫星显示风格
+ 可选自身发光或真实地表覆盖两种选中表现
+
+
+
+
+
+
+
+
+
+ 卫星显示风格
+ 选择卫星锁定态使用自身发光,还是强调真实地表覆盖范围。
+
+
+
+
+
+
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
index 9edb4c07..6b023d33 100644
--- a/frontend/public/earth/js/constants.js
+++ b/frontend/public/earth/js/constants.js
@@ -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,
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index 32bfc6ad..59ec0402 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -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);
}
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
index f6433d8b..6ff81e6c 100644
--- a/frontend/public/earth/js/info-card.js
+++ b/frontend/public/earth/js/info-card.js
@@ -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' },
diff --git a/frontend/public/earth/js/iridium-footprint-adapter.js b/frontend/public/earth/js/iridium-footprint-adapter.js
new file mode 100644
index 00000000..531546fb
--- /dev/null
+++ b/frontend/public/earth/js/iridium-footprint-adapter.js
@@ -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);
+}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 69857d3b..096252c0 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -80,6 +80,7 @@ import {
getSatelliteCount,
selectSatellite,
getSatellitePoints,
+ getSatellitePresentationInfo,
setSatelliteRingState,
updateLockedRingPosition,
updateHoverRingPosition,
@@ -93,6 +94,7 @@ import {
updateBreathingPhase,
isSatelliteFrontFacing,
setSatelliteCamera,
+ setSatelliteSunDirection,
setLockedSatelliteIndex,
resetSatelliteState,
clearSatelliteData,
@@ -576,6 +578,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 +593,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,
@@ -1050,7 +1064,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 },
});
@@ -3115,7 +3131,9 @@ function animate() {
updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
- setEarthSunDirection(getSunDirection());
+ const currentSunDirection = getSunDirection();
+ setEarthSunDirection(currentSunDirection);
+ setSatelliteSunDirection(currentSunDirection);
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (
diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js
index 7109d715..bf8edd7c 100644
--- a/frontend/public/earth/js/satellites.js
+++ b/frontend/public/earth/js/satellites.js
@@ -2,8 +2,18 @@
import * as THREE from "three";
import { twoline2satrec, propagate } from "satellite.js";
-import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
+import {
+ CONFIG,
+ DEFAULT_SATELLITE_DISPLAY_STYLE,
+ SATELLITE_CONFIG,
+ SATELLITE_DISPLAY_STYLES,
+} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
+import {
+ createIridiumFootprintAdapter,
+ disposeIridiumFootprintAdapter,
+ updateIridiumFootprintAdapter,
+} from "./iridium-footprint-adapter.js";
let satellitePoints = null;
let satelliteBackdropPoints = null;
@@ -16,6 +26,9 @@ let satellitePositions = [];
let hoverRingSprite = null;
let lockedRingSprite = null;
let lockedDotSprite = null;
+let lockedHaloMesh = null;
+let lockedGroundFootprintMesh = null;
+let lockedIridiumFootprintMesh = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
let highlightedSatelliteIndices = null;
@@ -27,6 +40,30 @@ let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
+let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
+
+const SATELLITE_FOOTPRINT_POLICIES = Object.freeze({
+ NONE: "none",
+ STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
+ IRIDIUM_COVERAGE_RING: "iridium_coverage_ring",
+});
+
+const SATELLITE_PRESENTATION_MODES = Object.freeze({
+ SELF_GLOW: "self_glow",
+ STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
+ IRIDIUM_SPOT_BEAMS: "iridium_spot_beams",
+});
+
+const SATELLITE_CONSTELLATION_LABELS = Object.freeze({
+ starlink: "Starlink",
+ "iridium-next": "Iridium NEXT",
+ "gps-ops": "GPS",
+ galileo: "Galileo",
+ glonass: "GLONASS",
+ beidou: "北斗",
+ geo: "GEO",
+ leo: "LEO",
+});
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
@@ -35,10 +72,39 @@ const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
const DIMMED_SATELLITE_BACKDROP_OPACITY = 0.1;
+const LOCKED_HALO_CORE_RADIUS = 1;
+const LOCKED_HALO_CORE_SEGMENTS = 48;
+const LOCKED_HALO_RADIUS = 1;
+const LOCKED_HALO_BASE_OPACITY = 0.54;
+const LOCKED_HALO_OFFSET = 0.0014;
+const LOCKED_HALO_CORE_PIXEL_RADIUS = 8;
+const LOCKED_HALO_PIXEL_RADIUS = 24;
+const EARTH_RADIUS_KM = 6378.137;
+const GROUND_FOOTPRINT_MIN_ELEVATION_DEG = 25;
+const GROUND_FOOTPRINT_RADIUS_OFFSET = 0.72;
+const GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM = 550;
+const GROUND_FOOTPRINT_GRID_X = 260;
+const GROUND_FOOTPRINT_GRID_Y = 170;
+const GROUND_FOOTPRINT_SURFACE_SCALE = 1.003;
+const GROUND_FOOTPRINT_SERVICE_RADIUS_FACTOR = 0.5;
+const GROUND_FOOTPRINT_LOW_LAT_AXIS_RATIO = 1.18;
+const GROUND_FOOTPRINT_HIGH_LAT_AXIS_RATIO = 1.04;
+const GROUND_FOOTPRINT_LATITUDE_BLEND_DEG = 65;
+const GROUND_FOOTPRINT_GAP_CENTER_MIN_RATIO = 0.04;
+const GROUND_FOOTPRINT_GAP_CENTER_MAX_RATIO = 0.82;
+const GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM = 60;
+const GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM = 120;
+const GROUND_FOOTPRINT_GAP_LENGTH_RATIO = 1.08;
const scratchWorldSatellitePosition = new THREE.Vector3();
const scratchToCamera = new THREE.Vector3();
const scratchToSatellite = new THREE.Vector3();
+const scratchFootprintTrack = new THREE.Vector3();
+const scratchFootprintLateral = new THREE.Vector3();
+const scratchFootprintReference = new THREE.Vector3();
+const scratchFootprintVelocity = new THREE.Vector3();
+const scratchFootprintTangent = new THREE.Vector3();
+const satelliteSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
export let breathingPhase = 0;
@@ -185,6 +251,20 @@ function disposeObject3D(object, parent = earthObjRef) {
}
}
+function disposeObjectTree(object, parent = earthObjRef) {
+ if (!object) return;
+ object.traverse((child) => {
+ if (child === object) return;
+ if (child.geometry) {
+ child.geometry.dispose();
+ }
+ if (child.material) {
+ disposeMaterial(child.material);
+ }
+ });
+ disposeObject3D(object, parent);
+}
+
function createDotTexture() {
const canvas = document.createElement("canvas");
canvas.width = DOT_TEXTURE_SIZE;
@@ -888,6 +968,19 @@ export function setSatelliteCamera(camera) {
cameraRef = camera;
}
+export function setSatelliteSunDirection(direction) {
+ if (!direction) return;
+ satelliteSunDirection.copy(direction).normalize();
+ if (lockedGroundFootprintMesh) {
+ const fillMesh = lockedGroundFootprintMesh.getObjectByName("footprint-fill");
+ if (fillMesh?.material?.uniforms?.uSunDirectionWorld) {
+ fillMesh.material.uniforms.uSunDirectionWorld.value.copy(
+ satelliteSunDirection,
+ );
+ }
+ }
+}
+
export function setLockedSatelliteIndex(index) {
lockedSatelliteIndex = index;
}
@@ -896,6 +989,152 @@ export function setHoveredSatelliteIndex(index) {
hoveredSatelliteIndex = index;
}
+function normalizeSatelliteDisplayStyle(nextStyle) {
+ return Object.values(SATELLITE_DISPLAY_STYLES).includes(nextStyle)
+ ? nextStyle
+ : DEFAULT_SATELLITE_DISPLAY_STYLE;
+}
+
+function normalizeSatelliteConstellationGroup(rawGroup) {
+ const normalized = String(rawGroup || "")
+ .trim()
+ .toLowerCase();
+ return normalized || null;
+}
+
+function inferSatelliteConstellationGroup(props = {}) {
+ const explicitGroup = normalizeSatelliteConstellationGroup(
+ props.constellation_group,
+ );
+ if (explicitGroup) {
+ return explicitGroup;
+ }
+
+ const normalizedName = String(props.name || "")
+ .trim()
+ .toUpperCase();
+ if (normalizedName.startsWith("STARLINK")) {
+ return "starlink";
+ }
+ if (normalizedName.startsWith("IRIDIUM")) {
+ return "iridium-next";
+ }
+
+ return null;
+}
+
+function getSatelliteFootprintPolicy(props = {}) {
+ const explicitPolicy = String(props.footprint_policy || "")
+ .trim()
+ .toLowerCase();
+ if (Object.values(SATELLITE_FOOTPRINT_POLICIES).includes(explicitPolicy)) {
+ return explicitPolicy;
+ }
+
+ const constellationGroup = inferSatelliteConstellationGroup(props);
+ if (constellationGroup === "starlink") {
+ return SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT;
+ }
+ if (constellationGroup === "iridium-next") {
+ return SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING;
+ }
+
+ return SATELLITE_FOOTPRINT_POLICIES.NONE;
+}
+
+function getSatelliteConstellationLabel(props = {}) {
+ const constellationGroup = inferSatelliteConstellationGroup(props);
+ if (!constellationGroup) return "未分类";
+ return (
+ SATELLITE_CONSTELLATION_LABELS[constellationGroup] ||
+ constellationGroup
+ );
+}
+
+function getSatellitePresentationMode(props = {}) {
+ if (satelliteDisplayStyle !== SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT) {
+ return SATELLITE_PRESENTATION_MODES.SELF_GLOW;
+ }
+
+ const footprintPolicy = getSatelliteFootprintPolicy(props);
+ if (
+ footprintPolicy ===
+ SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT
+ ) {
+ return SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT;
+ }
+ if (footprintPolicy === SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING) {
+ return SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS;
+ }
+ return SATELLITE_PRESENTATION_MODES.SELF_GLOW;
+}
+
+function getSatelliteFootprintCapabilityLabel(props = {}) {
+ const footprintPolicy = getSatelliteFootprintPolicy(props);
+ switch (footprintPolicy) {
+ case SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT:
+ return "支持 Starlink 地表覆盖";
+ case SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING:
+ return "支持 Iridium 外圈覆盖";
+ default:
+ return "默认不显示 footprint";
+ }
+}
+
+function getSatellitePresentationModeLabel(mode) {
+ switch (mode) {
+ case SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT:
+ return "真实地表覆盖(Starlink)";
+ case SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS:
+ return "真实地表覆盖(Iridium 外圈)";
+ default:
+ return "自身发光";
+ }
+}
+
+export function getSatellitePresentationInfo(props = {}) {
+ const footprintPolicy = getSatelliteFootprintPolicy(props);
+ const presentationMode = getSatellitePresentationMode(props);
+
+ return {
+ constellationGroup: inferSatelliteConstellationGroup(props),
+ constellationLabel: getSatelliteConstellationLabel(props),
+ footprintPolicy,
+ footprintCapabilityLabel: getSatelliteFootprintCapabilityLabel(props),
+ presentationMode,
+ presentationModeLabel: getSatellitePresentationModeLabel(
+ presentationMode,
+ ),
+ };
+}
+
+function getLockedSatelliteProperties() {
+ if (lockedSatelliteIndex === null) return null;
+ return satelliteData[lockedSatelliteIndex]?.properties || null;
+}
+
+export function getSatelliteDisplayStyle() {
+ return satelliteDisplayStyle;
+}
+
+export function setSatelliteDisplayStyle(nextStyle) {
+ const normalizedStyle = normalizeSatelliteDisplayStyle(nextStyle);
+ if (normalizedStyle === satelliteDisplayStyle) return satelliteDisplayStyle;
+
+ satelliteDisplayStyle = normalizedStyle;
+
+ if (
+ lockedSatelliteIndex !== null &&
+ satellitePositions?.[lockedSatelliteIndex]?.current
+ ) {
+ showHoverRing(satellitePositions[lockedSatelliteIndex].current, true);
+ } else {
+ clearLockedSatelliteStyleVisuals();
+ }
+
+ return satelliteDisplayStyle;
+}
+
export function isSatelliteFrontFacing(index, camera = cameraRef) {
if (!earthObjRef || !camera) return true;
if (!satellitePositions || !satellitePositions[index]) return true;
@@ -917,28 +1156,630 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
);
}
-function createBrighterDotCanvas() {
- const size = DOT_TEXTURE_SIZE * 2;
- const canvas = document.createElement("canvas");
- canvas.width = size;
- canvas.height = size;
- const ctx = canvas.getContext("2d");
- const center = size / 2;
- const gradient = ctx.createRadialGradient(
- center,
- center,
- 0,
- center,
- center,
- center,
+function createLockedHaloMaterial() {
+ return new THREE.ShaderMaterial({
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ side: THREE.DoubleSide,
+ uniforms: {
+ uColor: { value: new THREE.Color(0xffbf47) },
+ uOpacity: { value: LOCKED_HALO_BASE_OPACITY },
+ },
+ 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 centeredUv = vUv - vec2(0.5);
+ float distanceToCenter = length(centeredUv) * 2.0;
+ float outerFade = 1.0 - smoothstep(0.34, 1.0, distanceToCenter);
+ float innerFade = smoothstep(0.18, 0.48, distanceToCenter);
+ float alpha = outerFade * innerFade * uOpacity;
+ if (alpha <= 0.001) discard;
+ gl_FragColor = vec4(uColor, alpha);
+ }
+ `,
+ });
+}
+
+function createGroundFootprintMaterial() {
+ return new THREE.ShaderMaterial({
+ transparent: true,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ polygonOffset: true,
+ polygonOffsetFactor: -2,
+ polygonOffsetUnits: -2,
+ uniforms: {
+ uColor: { value: new THREE.Color(0xffffff) },
+ uOpacity: { value: 0.46 },
+ uMajorKm: { value: 1000 },
+ uMinorKm: { value: 700 },
+ uGapCenterNorthKm: { value: 0 },
+ uGapLengthKm: { value: 1000 },
+ uGapWidthCenterKm: { value: GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM },
+ uGapWidthEdgeKm: { value: GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM },
+ uEastAlongDot: { value: 1 },
+ uEastCrossDot: { value: 0 },
+ uNorthAlongDot: { value: 0 },
+ uNorthCrossDot: { value: 1 },
+ uSoftOuterStart: { value: 0.0 },
+ uSoftOuterEnd: { value: 1.0 },
+ uGapSoftnessKm: { value: 6 },
+ uGlowMode: { value: 0 },
+ uSunDirectionWorld: { value: satelliteSunDirection.clone() },
+ uDayVisibilityBoost: { value: 1.48 },
+ },
+ vertexShader: `
+ varying vec3 vWorldPosition;
+ varying vec3 vWorldNormal;
+ varying vec2 vUv;
+
+ void main() {
+ vUv = uv;
+ vec4 worldPosition = modelMatrix * vec4(position, 1.0);
+ vWorldPosition = worldPosition.xyz;
+ vWorldNormal = normalize(mat3(modelMatrix) * normal);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+ fragmentShader: `
+ uniform vec3 uColor;
+ uniform float uOpacity;
+ uniform float uMajorKm;
+ uniform float uMinorKm;
+ uniform float uGapCenterNorthKm;
+ uniform float uGapLengthKm;
+ uniform float uGapWidthCenterKm;
+ uniform float uGapWidthEdgeKm;
+ uniform float uEastAlongDot;
+ uniform float uEastCrossDot;
+ uniform float uNorthAlongDot;
+ uniform float uNorthCrossDot;
+ uniform float uSoftOuterStart;
+ uniform float uSoftOuterEnd;
+ uniform float uGapSoftnessKm;
+ uniform float uGlowMode;
+ uniform vec3 uSunDirectionWorld;
+ uniform float uDayVisibilityBoost;
+ varying vec3 vWorldPosition;
+ varying vec3 vWorldNormal;
+ varying vec2 vUv;
+
+ float bowtieHalfWidth(float xEast) {
+ float t = clamp(abs(xEast) / max(uGapLengthKm, 1.0), 0.0, 1.0);
+ return mix(uGapWidthCenterKm, uGapWidthEdgeKm, pow(t, 1.7));
+ }
+
+ void main() {
+ vec2 p = vUv * 2.0 - 1.0;
+ float ellipseMetric = dot(p, p);
+ float centerGlow = exp(-ellipseMetric * 0.5);
+ float edgeFade = 1.0 - smoothstep(0.28, 1.0, ellipseMetric);
+ float outerAlpha = centerGlow * pow(max(edgeFade, 0.0), 1.45);
+ if (outerAlpha <= 0.001) discard;
+
+ float alongKm = p.x * uMajorKm;
+ float crossKm = p.y * uMinorKm;
+ float xEast = alongKm * uEastAlongDot + crossKm * uEastCrossDot;
+ float yNorth = alongKm * uNorthAlongDot + crossKm * uNorthCrossDot;
+ float gapMask = 1.0;
+ if (abs(xEast) <= uGapLengthKm) {
+ float gapHalfWidth = bowtieHalfWidth(xEast);
+ float distToGap = abs(yNorth - uGapCenterNorthKm) - gapHalfWidth;
+ gapMask = smoothstep(-uGapSoftnessKm, uGapSoftnessKm, distToGap);
+ }
+
+ float alpha = outerAlpha * gapMask * uOpacity;
+ if (uGlowMode > 0.5) {
+ alpha *= 0.92;
+ } else {
+ alpha *= 1.34;
+ }
+
+ vec3 worldNormal = normalize(vWorldPosition);
+ vec3 sunDir = normalize(uSunDirectionWorld);
+ float sunFacing = dot(worldNormal, sunDir);
+ float daylight = clamp(sunFacing * 0.5 + 0.5, 0.0, 1.0);
+ alpha *= mix(1.0, uDayVisibilityBoost, daylight);
+
+ vec3 nightColor = vec3(0.24, 0.56, 1.0);
+ vec3 dayColor = vec3(1.0, 0.72, 0.08);
+ vec3 finalColor = mix(nightColor, dayColor, daylight);
+
+ if (alpha <= 0.001) discard;
+ gl_FragColor = vec4(finalColor, alpha);
+ }
+ `,
+ });
+}
+
+function smoothstep(edge0, edge1, x) {
+ const t = THREE.MathUtils.clamp((x - edge0) / (edge1 - edge0), 0, 1);
+ return t * t * (3 - 2 * t);
+}
+
+function centralAngleForMinElevation(heightKm, elevationDeg) {
+ const elevationRad = THREE.MathUtils.degToRad(elevationDeg);
+ const orbitalRadiusKm = EARTH_RADIUS_KM + heightKm;
+ let low = 0;
+ let high = Math.acos(EARTH_RADIUS_KM / orbitalRadiusKm) - 1e-5;
+
+ function elevationAt(gamma) {
+ const ground = new THREE.Vector3(
+ EARTH_RADIUS_KM * Math.cos(gamma),
+ EARTH_RADIUS_KM * Math.sin(gamma),
+ 0,
+ );
+ const satellite = new THREE.Vector3(orbitalRadiusKm, 0, 0);
+ const surfaceNormal = ground.clone().normalize();
+ const toSatellite = satellite.clone().sub(ground).normalize();
+ return Math.asin(
+ THREE.MathUtils.clamp(surfaceNormal.dot(toSatellite), -1, 1),
+ );
+ }
+
+ for (let iteration = 0; iteration < 48; iteration += 1) {
+ const mid = (low + high) * 0.5;
+ if (elevationAt(mid) > elevationRad) {
+ low = mid;
+ } else {
+ high = mid;
+ }
+ }
+
+ return low;
+}
+
+function clearLockedSatelliteStyleVisuals() {
+ if (lockedDotSprite) {
+ disposeObject3D(lockedDotSprite, sceneRef);
+ lockedDotSprite = null;
+ }
+ if (lockedHaloMesh) {
+ disposeObject3D(lockedHaloMesh, sceneRef);
+ lockedHaloMesh = null;
+ }
+ if (lockedGroundFootprintMesh) {
+ disposeObjectTree(lockedGroundFootprintMesh);
+ lockedGroundFootprintMesh = null;
+ }
+ if (lockedIridiumFootprintMesh) {
+ disposeIridiumFootprintAdapter(lockedIridiumFootprintMesh, earthObjRef);
+ lockedIridiumFootprintMesh = null;
+ }
+}
+
+function updateLockedDotWorldTransform(position) {
+ if (!lockedDotSprite || !position || !earthObjRef) return;
+ const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
+ lockedDotSprite.position.copy(worldPosition);
+ if (cameraRef) {
+ lockedDotSprite.quaternion.copy(cameraRef.quaternion);
+ }
+ const viewportHeight = window.innerHeight || 1080;
+ const distanceToCamera = cameraRef
+ ? Math.max(cameraRef.position.distanceTo(worldPosition), 1)
+ : CONFIG.defaultCameraZ;
+ const verticalFovRad = cameraRef?.isPerspectiveCamera
+ ? THREE.MathUtils.degToRad(cameraRef.fov)
+ : THREE.MathUtils.degToRad(45);
+ const worldUnitsPerPixel =
+ (2 * Math.tan(verticalFovRad / 2) * distanceToCamera) / viewportHeight;
+ const coreRadiusWorld = worldUnitsPerPixel * LOCKED_HALO_CORE_PIXEL_RADIUS;
+ lockedDotSprite.scale.set(coreRadiusWorld, coreRadiusWorld, 1);
+}
+
+function updateLockedHaloWorldTransform(position) {
+ if (!position || !earthObjRef || !lockedHaloMesh) return;
+ const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
+ const viewDirection = cameraRef
+ ? scratchToCamera.subVectors(cameraRef.position, worldPosition).normalize()
+ : null;
+ lockedHaloMesh.position.copy(worldPosition);
+ if (viewDirection) {
+ lockedHaloMesh.position.addScaledVector(viewDirection, -LOCKED_HALO_OFFSET);
+ }
+ if (cameraRef) {
+ lockedHaloMesh.quaternion.copy(cameraRef.quaternion);
+ }
+ const viewportHeight = window.innerHeight || 1080;
+ const distanceToCamera = cameraRef
+ ? Math.max(cameraRef.position.distanceTo(worldPosition), 1)
+ : CONFIG.defaultCameraZ;
+ const verticalFovRad = cameraRef?.isPerspectiveCamera
+ ? THREE.MathUtils.degToRad(cameraRef.fov)
+ : THREE.MathUtils.degToRad(45);
+ const worldUnitsPerPixel =
+ (2 * Math.tan(verticalFovRad / 2) * distanceToCamera) / viewportHeight;
+ const haloRadiusWorld = worldUnitsPerPixel * LOCKED_HALO_PIXEL_RADIUS;
+ lockedHaloMesh.scale.set(haloRadiusWorld, haloRadiusWorld, 1);
+}
+
+function estimateLockedSatelliteAltitudeKm() {
+ if (lockedSatelliteIndex === null) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
+ const satellite = satelliteData[lockedSatelliteIndex];
+ const props = satellite?.properties;
+ if (!props?.norad_cat_id) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
+
+ const satrec = getOrBuildSatrec(props, new Date());
+ if (!satrec || satrec.error) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
+
+ const propagation = propagate(satrec, new Date());
+ const rawPosition = propagation?.position;
+ if (!rawPosition) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
+
+ const radiusKm = Math.sqrt(
+ rawPosition.x * rawPosition.x +
+ rawPosition.y * rawPosition.y +
+ rawPosition.z * rawPosition.z,
);
- gradient.addColorStop(0, "rgba(255, 255, 200, 1)");
- gradient.addColorStop(0.3, "rgba(255, 220, 100, 0.9)");
- gradient.addColorStop(0.7, "rgba(255, 180, 50, 0.5)");
- gradient.addColorStop(1, "rgba(255, 150, 0, 0)");
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, size, size);
- return canvas;
+ if (!Number.isFinite(radiusKm)) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
+
+ return Math.max(0, radiusKm - EARTH_RADIUS_KM);
+}
+
+function estimateGroundCoverageAngleRad(altitudeKm) {
+ return Math.max(
+ 0.03,
+ centralAngleForMinElevation(
+ Math.max(altitudeKm, 10),
+ GROUND_FOOTPRINT_MIN_ELEVATION_DEG,
+ ),
+ );
+}
+
+function getLockedSatelliteTrackDirection(groundNormal) {
+ if (lockedSatelliteIndex === null) return null;
+ const satellite = satelliteData[lockedSatelliteIndex];
+ const props = satellite?.properties;
+ if (!props?.norad_cat_id) return null;
+
+ const satrec = getOrBuildSatrec(props, new Date());
+ if (!satrec || satrec.error) return null;
+
+ const propagation = propagate(satrec, new Date());
+ const velocity = propagation?.velocity;
+ if (!velocity) return null;
+
+ scratchFootprintVelocity.set(velocity.x, velocity.y, velocity.z);
+ if (!Number.isFinite(scratchFootprintVelocity.lengthSq())) return null;
+
+ scratchFootprintTangent
+ .copy(scratchFootprintVelocity)
+ .projectOnPlane(groundNormal);
+
+ if (scratchFootprintTangent.lengthSq() <= 1e-6) {
+ return null;
+ }
+
+ return scratchFootprintTangent.normalize().clone();
+}
+
+function buildSurfaceFrame(position) {
+ const centerNormal = position.clone().normalize();
+ const alongTrack =
+ getLockedSatelliteTrackDirection(centerNormal) ||
+ scratchFootprintTrack.set(0, 1, 0).projectOnPlane(centerNormal).normalize();
+
+ if (alongTrack.lengthSq() <= 1e-6) {
+ alongTrack.copy(scratchFootprintReference.set(1, 0, 0));
+ }
+
+ const crossTrack = scratchFootprintLateral
+ .crossVectors(centerNormal, alongTrack)
+ .normalize()
+ .clone();
+
+ return {
+ centerNormal,
+ alongTrack: alongTrack.clone(),
+ crossTrack,
+ };
+}
+
+function projectFootprintOffsetToSurface(
+ centerNormal,
+ alongTrack,
+ crossTrack,
+ alongKm,
+ crossKm,
+ surfaceRadius,
+) {
+ const worldUnitsPerKm = CONFIG.earthRadius / EARTH_RADIUS_KM;
+ return centerNormal
+ .clone()
+ .multiplyScalar(CONFIG.earthRadius)
+ .addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
+ .addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
+ .normalize()
+ .multiplyScalar(surfaceRadius);
+}
+
+function buildGroundFootprintGeometry(position) {
+ const altitudeKm = estimateLockedSatelliteAltitudeKm();
+ const coverageRadiusKm =
+ EARTH_RADIUS_KM *
+ estimateGroundCoverageAngleRad(altitudeKm) *
+ GROUND_FOOTPRINT_SERVICE_RADIUS_FACTOR;
+ const surfaceRadius =
+ CONFIG.earthRadius * GROUND_FOOTPRINT_SURFACE_SCALE +
+ GROUND_FOOTPRINT_RADIUS_OFFSET;
+ const { centerNormal, alongTrack, crossTrack } = buildSurfaceFrame(position);
+
+ const absLatitudeDeg = Math.abs(
+ THREE.MathUtils.radToDeg(Math.asin(centerNormal.y)),
+ );
+ const latitudeBlend = THREE.MathUtils.clamp(
+ absLatitudeDeg / GROUND_FOOTPRINT_LATITUDE_BLEND_DEG,
+ 0,
+ 1,
+ );
+ const axisRatio = THREE.MathUtils.lerp(
+ GROUND_FOOTPRINT_LOW_LAT_AXIS_RATIO,
+ GROUND_FOOTPRINT_HIGH_LAT_AXIS_RATIO,
+ latitudeBlend,
+ );
+ const majorKm = coverageRadiusKm * axisRatio;
+ const minorKm = coverageRadiusKm / axisRatio;
+
+ const worldNorth = new THREE.Vector3(0, 1, 0);
+ let east = scratchFootprintReference
+ .crossVectors(worldNorth, centerNormal)
+ .normalize()
+ .clone();
+ if (east.lengthSq() < 1e-6) {
+ east = alongTrack.clone();
+ }
+ const north = new THREE.Vector3().crossVectors(centerNormal, east).normalize();
+
+ const latitudeSign = centerNormal.y >= 0 ? 1 : -1;
+ const gapCenterNorthKm =
+ latitudeSign *
+ THREE.MathUtils.lerp(
+ GROUND_FOOTPRINT_GAP_CENTER_MIN_RATIO * minorKm,
+ GROUND_FOOTPRINT_GAP_CENTER_MAX_RATIO * minorKm,
+ smoothstep(0.06, 0.95, latitudeBlend),
+ );
+ const exclusionLengthKm = GROUND_FOOTPRINT_GAP_LENGTH_RATIO * majorKm;
+
+ function isInsideEllipse(alongKm, crossKm) {
+ return (
+ (alongKm * alongKm) / (majorKm * majorKm) +
+ (crossKm * crossKm) / (minorKm * minorKm) <=
+ 1
+ );
+ }
+
+ function toEastNorth(alongKm, crossKm) {
+ const offset = alongTrack
+ .clone()
+ .multiplyScalar(alongKm)
+ .addScaledVector(crossTrack, crossKm);
+ return {
+ xEast: offset.dot(east),
+ yNorth: offset.dot(north),
+ };
+ }
+
+ function fromEastNorth(xEast, yNorth) {
+ const offset = east
+ .clone()
+ .multiplyScalar(xEast)
+ .addScaledVector(north, yNorth);
+ return {
+ alongKm: offset.dot(alongTrack),
+ crossKm: offset.dot(crossTrack),
+ };
+ }
+
+ function bowtieHalfWidth(xEast) {
+ const t = THREE.MathUtils.clamp(
+ Math.abs(xEast) / Math.max(exclusionLengthKm, 1),
+ 0,
+ 1,
+ );
+ return THREE.MathUtils.lerp(
+ GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM,
+ GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM,
+ Math.pow(t, 1.7),
+ );
+ }
+
+ const vertices = [];
+ const indices = [];
+ const indexMap = [];
+
+ for (let iy = 0; iy <= GROUND_FOOTPRINT_GRID_Y; iy += 1) {
+ const row = [];
+ const crossKm = THREE.MathUtils.lerp(
+ -minorKm,
+ minorKm,
+ iy / GROUND_FOOTPRINT_GRID_Y,
+ );
+ for (let ix = 0; ix <= GROUND_FOOTPRINT_GRID_X; ix += 1) {
+ const alongKm = THREE.MathUtils.lerp(
+ -majorKm,
+ majorKm,
+ ix / GROUND_FOOTPRINT_GRID_X,
+ );
+ if (!isInsideEllipse(alongKm, crossKm)) {
+ row.push(-1);
+ continue;
+ }
+
+ const point = projectFootprintOffsetToSurface(
+ centerNormal,
+ alongTrack,
+ crossTrack,
+ alongKm,
+ crossKm,
+ surfaceRadius,
+ );
+ row.push(vertices.length / 3);
+ vertices.push(point.x, point.y, point.z);
+ }
+ indexMap.push(row);
+ }
+
+ for (let iy = 0; iy < GROUND_FOOTPRINT_GRID_Y; iy += 1) {
+ for (let ix = 0; ix < GROUND_FOOTPRINT_GRID_X; ix += 1) {
+ const a = indexMap[iy][ix];
+ const b = indexMap[iy][ix + 1];
+ const c = indexMap[iy + 1][ix];
+ const d = indexMap[iy + 1][ix + 1];
+ if (a < 0 || b < 0 || c < 0 || d < 0) continue;
+ indices.push(a, c, b);
+ indices.push(b, c, d);
+ }
+ }
+
+ const fillGeometry = new THREE.BufferGeometry();
+ fillGeometry.setAttribute(
+ "position",
+ new THREE.Float32BufferAttribute(vertices, 3),
+ );
+ const uvs = [];
+ for (let iy = 0; iy <= GROUND_FOOTPRINT_GRID_Y; iy += 1) {
+ const crossKm = THREE.MathUtils.lerp(
+ -minorKm,
+ minorKm,
+ iy / GROUND_FOOTPRINT_GRID_Y,
+ );
+ for (let ix = 0; ix <= GROUND_FOOTPRINT_GRID_X; ix += 1) {
+ const alongKm = THREE.MathUtils.lerp(
+ -majorKm,
+ majorKm,
+ ix / GROUND_FOOTPRINT_GRID_X,
+ );
+ if (!isInsideEllipse(alongKm, crossKm)) continue;
+ uvs.push(
+ THREE.MathUtils.mapLinear(alongKm, -majorKm, majorKm, 0, 1),
+ THREE.MathUtils.mapLinear(crossKm, -minorKm, minorKm, 0, 1),
+ );
+ }
+ }
+ fillGeometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
+ fillGeometry.setIndex(indices);
+ fillGeometry.computeVertexNormals();
+
+ const basisToEastNorth = {
+ eastAlongDot: alongTrack.dot(east),
+ eastCrossDot: crossTrack.dot(east),
+ northAlongDot: alongTrack.dot(north),
+ northCrossDot: crossTrack.dot(north),
+ };
+
+ return {
+ fillGeometry,
+ majorKm,
+ minorKm,
+ gapCenterNorthKm,
+ exclusionLengthKm,
+ basisToEastNorth,
+ };
+}
+
+function updateGroundFootprintTransform(position) {
+ if (!lockedGroundFootprintMesh || !position || !earthObjRef) return;
+ const geometrySet = buildGroundFootprintGeometry(position);
+ if (!geometrySet) return;
+
+ const fillMesh = lockedGroundFootprintMesh.getObjectByName("footprint-fill");
+
+ if (fillMesh?.geometry) fillMesh.geometry.dispose();
+
+ if (fillMesh) {
+ fillMesh.geometry = geometrySet.fillGeometry;
+ if (fillMesh.material?.uniforms) {
+ fillMesh.material.uniforms.uMajorKm.value = geometrySet.majorKm;
+ fillMesh.material.uniforms.uMinorKm.value = geometrySet.minorKm;
+ fillMesh.material.uniforms.uGapCenterNorthKm.value =
+ geometrySet.gapCenterNorthKm;
+ fillMesh.material.uniforms.uGapLengthKm.value =
+ geometrySet.exclusionLengthKm;
+ fillMesh.material.uniforms.uEastAlongDot.value =
+ geometrySet.basisToEastNorth.eastAlongDot;
+ fillMesh.material.uniforms.uEastCrossDot.value =
+ geometrySet.basisToEastNorth.eastCrossDot;
+ fillMesh.material.uniforms.uNorthAlongDot.value =
+ geometrySet.basisToEastNorth.northAlongDot;
+ fillMesh.material.uniforms.uNorthCrossDot.value =
+ geometrySet.basisToEastNorth.northCrossDot;
+ }
+ }
+}
+
+function showSelfGlowStyle(position) {
+ const dotGeometry = new THREE.CircleGeometry(
+ LOCKED_HALO_CORE_RADIUS,
+ LOCKED_HALO_CORE_SEGMENTS,
+ );
+ const dotMaterial = new THREE.MeshBasicMaterial({
+ color: 0xffd25a,
+ transparent: true,
+ opacity: 0.96,
+ depthTest: false,
+ depthWrite: false,
+ side: THREE.DoubleSide,
+ });
+ lockedDotSprite = new THREE.Mesh(dotGeometry, dotMaterial);
+ lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 2;
+ updateLockedDotWorldTransform(position);
+ sceneRef?.add(lockedDotSprite);
+
+ lockedHaloMesh = new THREE.Mesh(
+ new THREE.CircleGeometry(LOCKED_HALO_RADIUS, 64),
+ createLockedHaloMaterial(),
+ );
+ lockedHaloMesh.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
+ sceneRef?.add(lockedHaloMesh);
+ updateLockedHaloWorldTransform(position);
+}
+
+function showGroundFootprintStyle(position) {
+ if (!earthObjRef) return;
+ lockedGroundFootprintMesh = new THREE.Group();
+ lockedGroundFootprintMesh.renderOrder = SATELLITE_CONFIG.overlayRenderOrder - 1;
+ const fill = new THREE.Mesh(
+ new THREE.BufferGeometry(),
+ createGroundFootprintMaterial(),
+ );
+ fill.name = "footprint-fill";
+ lockedGroundFootprintMesh.add(fill);
+ earthObjRef.add(lockedGroundFootprintMesh);
+ updateGroundFootprintTransform(position);
+}
+
+function showIridiumReservedStyle(position) {
+ if (!earthObjRef || !position) return;
+ lockedIridiumFootprintMesh = createIridiumFootprintAdapter({
+ earthObj: earthObjRef,
+ earthRadiusWorld: CONFIG.earthRadius,
+ renderOrder: SATELLITE_CONFIG.overlayRenderOrder - 1,
+ });
+ updateIridiumReservedStyle(position);
+}
+
+function updateIridiumReservedStyle(position) {
+ if (!lockedIridiumFootprintMesh || !position) return;
+ const { alongTrack, crossTrack } = buildSurfaceFrame(position);
+ updateIridiumFootprintAdapter(lockedIridiumFootprintMesh, {
+ position,
+ alongTrack,
+ crossTrack,
+ altitudeKm: estimateLockedSatelliteAltitudeKm(),
+ });
}
function createRingSprite(position, isLocked = false) {
@@ -991,20 +1832,21 @@ export function showHoverRing(position, isLocked = false) {
if (isLocked) {
hideLockedRing();
lockedRingSprite = createRingSprite(position, true);
-
- const dotCanvas = createBrighterDotCanvas();
- const dotTexture = new THREE.CanvasTexture(dotCanvas);
- const dotMaterial = new THREE.SpriteMaterial({
- map: dotTexture,
- transparent: true,
- opacity: 1.0,
- depthTest: 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);
+ const presentationMode = getSatellitePresentationMode(
+ getLockedSatelliteProperties() || {},
+ );
+ if (
+ presentationMode ===
+ SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT
+ ) {
+ showGroundFootprintStyle(position);
+ } else if (
+ presentationMode === SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS
+ ) {
+ showIridiumReservedStyle(position);
+ } else {
+ showSelfGlowStyle(position);
+ }
return lockedRingSprite;
}
@@ -1025,15 +1867,21 @@ export function hideLockedRing() {
disposeObject3D(lockedRingSprite);
lockedRingSprite = null;
}
- if (lockedDotSprite) {
- disposeObject3D(lockedDotSprite);
- lockedDotSprite = null;
- }
+ clearLockedSatelliteStyleVisuals();
}
export function updateLockedRingPosition(position) {
if (!position) return;
- if (!lockedRingSprite || !lockedDotSprite) {
+ const presentationMode = getSatellitePresentationMode(
+ getLockedSatelliteProperties() || {},
+ );
+ const hasStyleVisual =
+ presentationMode === SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT
+ ? Boolean(lockedGroundFootprintMesh)
+ : presentationMode === SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS
+ ? Boolean(lockedIridiumFootprintMesh)
+ : Boolean(lockedDotSprite && lockedHaloMesh);
+ if (!lockedRingSprite || !hasStyleVisual) {
showHoverRing(position, true);
}
if (lockedRingSprite) {
@@ -1055,17 +1903,34 @@ export function updateLockedRingPosition(position) {
}
if (lockedDotSprite) {
- lockedDotSprite.position.copy(position);
+ updateLockedDotWorldTransform(position);
const dotPulse = getBreathingPulse(breathingPhase);
- const dotBreathScale =
- 1 +
- (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
- lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
+ const dotBreathScale = 1 + (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
+ lockedDotSprite.scale.multiplyScalar(dotBreathScale);
lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin +
dotPulse *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
}
+
+ if (lockedHaloMesh) {
+ updateLockedHaloWorldTransform(position);
+ const haloPulse = getBreathingPulse(breathingPhase);
+ const pulseScale =
+ 1 +
+ (haloPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude * 0.32;
+ lockedHaloMesh.scale.multiplyScalar(pulseScale);
+ lockedHaloMesh.material.uniforms.uOpacity.value =
+ LOCKED_HALO_BASE_OPACITY * (0.9 + haloPulse * 0.16);
+ }
+
+ if (lockedGroundFootprintMesh) {
+ updateGroundFootprintTransform(position);
+ }
+
+ if (lockedIridiumFootprintMesh) {
+ updateIridiumReservedStyle(position);
+ }
}
export function updateHoverRingPosition(position) {
diff --git a/pyproject.toml b/pyproject.toml
index a5702e77..45740ddc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planet"
-version = "0.39.0"
+version = "0.40.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
diff --git a/uv.lock b/uv.lock
index 18d04871..49cab99c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
-version = "0.39.0"
+version = "0.40.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },