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" },