Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea99a9529 | ||
|
|
f9c1334365 | ||
|
|
5f47ec1659 | ||
|
|
229be0bced |
@@ -8,8 +8,54 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.41.0] — 2026-04-27
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统完成地表到天空的注册顺序与关注优先的面板顺序拆分,支持基座海陆色块、国界、高清材质、云图、地形、算力、BGP、卫星、轨迹与海缆的稳定层级
|
||||
- 国界层新增真实行政区轮廓交互与中国/台湾联动高亮,修复高清材质、地形、footprint、卫星与经纬线之间的遮挡和 hover 竞争
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增无轮廓基座地图,所有图层关闭时仍保留 `#010609` 海洋与 `#080f1b` 陆地色块
|
||||
- 将大气云图抽象为独立图层并接入桌面/移动端图层开关、持久化状态与启动同步
|
||||
- 高清材质改为独立纹理覆盖层,地形显示在高清材质上方,并在高清材质关闭/恢复时保持原地形开关意图
|
||||
- 补充 Earth 渲染层级与图层样式文档,记录正式图层名、变量名、材质颜色、线宽与 renderOrder
|
||||
|
||||
---
|
||||
|
||||
## [0.40.5] — 2026-04-26
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星拖尾改用 Instanced screen-space ribbon,单 draw call 渲染所有轨迹段,支持像素级宽度控制
|
||||
- Iridium 地面覆盖重写为球面投影径向网格,修复填充光晕不可见问题;新增外圈 LineLoop
|
||||
- 搜索面板打开时改用双 rAF 延迟聚焦输入框,确保 CSS 过渡完成后焦点可靠触发
|
||||
- 代码清理:提取 `IRIDIUM_OVERLAY_COLOR`、`IRIDIUM_REFERENCE_ALTITUDE_KM` 常量,消除重复三角函数调用
|
||||
|
||||
---
|
||||
|
||||
## [0.39.0] — 2026-04-24
|
||||
|
||||
## [0.40.4] — 2026-04-26
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增页面可见性恢复处理,页面从后台切回前台时主动刷新卫星位置,避免累积后台时间在下一帧一次性回放
|
||||
- 抽出卫星轨迹状态与轨迹几何清理 helper,统一后台恢复与清空数据时的轨迹重置路径
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复页面在后台停留较久后恢复前台时,卫星轨迹因超大 `deltaTime` 突然跳变、拖尾异常拉长的问题
|
||||
- 修复后台恢复后首帧仍沿用旧轨迹缓存,导致轨迹与当前卫星位置短时错位的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.40.3] — 2026-04-25
|
||||
|
||||
### 🔧 Improvements
|
||||
- 卫星点云升级为自定义 ShaderMaterial,支持 per-point alpha 控制,锁定/悬停卫星从点云中精确隐藏
|
||||
- 修复锁定环与自发光选中标记的 depthTest 错误(false → true),消除远端渲染穿透 artifact
|
||||
- 新增锁定环悬停态缩放与线宽(LOCKED_RING_HOVER_SCALE / LOCKED_RING_HOVER_LINE_WIDTH)
|
||||
- 修复 updateLockedDotWorldTransform / updateLockedHaloWorldTransform 未强制刷新 matrixWorld 导致的位置漂移
|
||||
|
||||
---
|
||||
|
||||
## [0.40.2] — 2026-04-24
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||
- [earth-country-boundary-overlay-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-country-boundary-overlay-plan.md)
|
||||
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
|
||||
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
|
||||
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
- 前端上下文
|
||||
- Earth 前端结构
|
||||
- Earth 卫星 footprint 策略
|
||||
- Earth 渲染图层顺序
|
||||
- Earth 图层样式属性索引
|
||||
- 后端运行控制
|
||||
- collector 现状
|
||||
- 采集格式约定
|
||||
|
||||
229
docs/technical/earth-layer-style-reference.md
Normal file
229
docs/technical/earth-layer-style-reference.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Earth 图层样式属性索引
|
||||
|
||||
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
|
||||
`renderOrder` 等样式属性。层级关系请配合
|
||||
[earth-render-layer-order.md](/home/ray/dev/linkong/planet/docs/technical/earth-render-layer-order.md)
|
||||
查看。
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 类别 | 约定 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 全局配置对象 | `*_CONFIG` | `COUNTRY_BOUNDARY_CONFIG` |
|
||||
| 图层半径偏移 | `*AltitudeOffset` / `radiusOffset` | `lineAltitudeOffset`, `GRID_CONFIG.radiusOffset` |
|
||||
| 透明度 | `*Opacity` | `hoverLineOpacity` |
|
||||
| 渲染顺序 | `*RenderOrder` | `textureOverlayRenderOrder` |
|
||||
| 颜色 | `*Color`,十六进制数字或 CSS 色值 | `lineColor`, `colors.supercomputer` |
|
||||
| 线宽 | `lineWidth` / `*LineWidth` | `GRID_CONFIG.lineWidth` |
|
||||
|
||||
## Earth 基座与高清材质
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Earth 基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| Earth 基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| Earth 基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | 独立高清材质球半径 |
|
||||
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
|
||||
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| 高清材质颜色乘色 | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
|
||||
|
||||
## Earth 遮挡与昼夜
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 遮挡球半径系数 | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | 深度遮挡球半径 |
|
||||
| 遮挡球分段 | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | 遮挡球几何分段 |
|
||||
| 遮挡球 renderOrder | inline | `-1` | `occluder.renderOrder` |
|
||||
| 昼夜太阳方向 | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | 自定义 day/night shader |
|
||||
| 夜侧最低亮度 | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.32` | shader uniform |
|
||||
| 日侧增强 | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `0.94` | shader uniform |
|
||||
| 暮光宽度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightWidth` | `0.24` | shader uniform |
|
||||
| 暮光强度 | `EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity` | `0.14` | shader uniform |
|
||||
| 暮光颜色 | `EARTH_MATERIAL_CONFIG.dayNight.twilightColor` | `0x4ea0ff` | shader uniform |
|
||||
| 夜侧 tint 颜色 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintColor` | `0x0b1830` | shader uniform |
|
||||
| 夜侧 tint 强度 | `EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity` | `0.05` | shader uniform |
|
||||
|
||||
## 大气辉光与云图
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 内层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
|
||||
| 内层大气分段 | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
|
||||
| 内层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | shader RGB |
|
||||
| 内层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | shader rim |
|
||||
| 内层大气强度 | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | shader alpha multiplier |
|
||||
| 外层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.016` | `atmosOuterGeo` |
|
||||
| 外层大气分段 | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
|
||||
| 外层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | shader RGB |
|
||||
| 外层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `5.0` | shader rim |
|
||||
| 外层大气强度 | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.02` | shader alpha multiplier |
|
||||
| 大气辉光 blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
|
||||
| 大气辉光 renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
|
||||
| 云图半径偏移 | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | 云层球半径 |
|
||||
| 云图分段 | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | 云层球几何分段 |
|
||||
| 云图透明度 | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
|
||||
| 云图贴图 | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | 云层贴图 |
|
||||
| 云图 blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
|
||||
|
||||
## 海陆基座与国界
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
|
||||
| 海洋填充色 | local `OCEAN_HEX` | `0x010609` | 海陆基座 canvas 背景 |
|
||||
| 陆地填充色 | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | 海陆基座 canvas 陆地 |
|
||||
| 海陆基座透明度 | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
|
||||
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` 半径 |
|
||||
| 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 |
|
||||
| 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint |
|
||||
| 国界 tint 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` 半径 |
|
||||
| 国界 tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
|
||||
| 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 |
|
||||
| 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity |
|
||||
| 国界线 hover 时压暗透明度 | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | hover 时普通国界线 opacity |
|
||||
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | 普通国界线半径 |
|
||||
| 国界线 renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | 普通国界线层级 |
|
||||
| 国界 hover 颜色 | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | 霓虹红橘 |
|
||||
| 国界 hover 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | hover 实线 opacity |
|
||||
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | hover 实线半径 |
|
||||
| 国界 hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | hover 实线层级 |
|
||||
| 国界 hover glow 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | glow 线 opacity |
|
||||
| 国界 hover glow 线宽 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | glow `LineBasicMaterial.linewidth` |
|
||||
| 国界 hover glow 层级偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | glow renderOrder = `2.29` |
|
||||
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | glow 半径 = hover 半径 + 0.04 |
|
||||
|
||||
## 真实地形
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 地形 tile size | `TERRAIN_CONFIG.tileSize` | `256` | Terrarium tile 读取 |
|
||||
| 地形 base zoom | `TERRAIN_CONFIG.baseZoom` | `4` | 地形采样 zoom |
|
||||
| 地形几何分段 | `geometryWidthSegments / geometryHeightSegments` | `320 / 320` | 地形球几何 |
|
||||
| 地形基准半径偏移 | `TERRAIN_CONFIG.baseRadiusOffset` | `0.16` | 地形压过高清材质 |
|
||||
| 地形夸张系数 | `TERRAIN_CONFIG.exaggeration` | `34` | 海拔转世界单位 |
|
||||
| 地形陆地淡入高度 | `TERRAIN_CONFIG.landRevealFadeMeters` | `220` | 顶点 alpha |
|
||||
| 地形透明度 | `TERRAIN_CONFIG.opacity` | `0.62` | `MeshPhongMaterial.opacity` |
|
||||
| 地形颜色 | `TERRAIN_CONFIG.color` | `0x7f9d7f` | `MeshPhongMaterial.color` |
|
||||
| 地形 emissive | `TERRAIN_CONFIG.emissive` | `0x061008` | `MeshPhongMaterial.emissive` |
|
||||
| 地形 specular | `TERRAIN_CONFIG.specular` | `0x233126` | `MeshPhongMaterial.specular` |
|
||||
| 地形 shininess | `TERRAIN_CONFIG.shininess` | `10` | `MeshPhongMaterial.shininess` |
|
||||
| 地形 renderOrder | inline | `1.2` | `terrain.renderOrder` |
|
||||
| 地形 polygonOffset | inline | `factor -1`, `units -1` | 降低贴近球面时的闪烁 |
|
||||
|
||||
## 经纬线
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 经纬线半径偏移 | `GRID_CONFIG.radiusOffset` | `0.14` | 经纬线球面半径 |
|
||||
| 经纬线颜色 | `GRID_CONFIG.color` | `0xc0e0ff` | `LineBasicMaterial.color` |
|
||||
| 经纬线透明度 | `GRID_CONFIG.opacity` | `0.08` | `LineBasicMaterial.opacity` |
|
||||
| 经纬线线宽 | `GRID_CONFIG.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 经纬线 renderOrder | `GRID_CONFIG.renderOrder` | `2.05` | 经纬线层级 |
|
||||
| 纬线间隔 | `GRID_CONFIG.latitudeStep` | `15` | 纬线生成步长 |
|
||||
| 经线间隔 | `GRID_CONFIG.longitudeStep` | `30` | 经线生成步长 |
|
||||
| 线段采样步长 | `GRID_CONFIG.segmentStep` | `5` | 经纬线采样步长 |
|
||||
|
||||
## 海缆与登陆点
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |
|
||||
| 海缆半径偏移 | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | 海缆线半径 |
|
||||
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
|
||||
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
|
||||
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.1` | 登陆点球位置 |
|
||||
| 登陆点半径 | `CABLE_CONFIG.landingPoint.radius` | `0.4` | 登陆点球几何 |
|
||||
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `2.5` | 登陆点缩放 |
|
||||
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `MeshStandardMaterial.color` |
|
||||
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | `MeshStandardMaterial.emissive` |
|
||||
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | `emissiveIntensity` |
|
||||
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `MeshStandardMaterial.opacity` |
|
||||
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
|
||||
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.3` | dim 状态 |
|
||||
|
||||
## 卫星、轨迹和 footprint
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 卫星显示半径偏移 | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | 卫星点位置 |
|
||||
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size |
|
||||
| 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 |
|
||||
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | 呼吸动画 |
|
||||
| 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 |
|
||||
| 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor |
|
||||
| 卫星背景点透明度 | inline | `0.42` | backdrop point opacity |
|
||||
| 卫星点透明度 | inline | `0.9` | point material opacity |
|
||||
| 卫星背景点 renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
|
||||
| 卫星点 renderOrder | inline | `6` | `satellitePoints.renderOrder` |
|
||||
| 卫星轨迹长度 | `SATELLITE_CONFIG.trailLength` | `10` | trail buffer |
|
||||
| 卫星轨迹线宽 | `SATELLITE_CONFIG.trailLineWidth` | `3` | ribbon shader uniform |
|
||||
| 选中 ring 大小 | `SATELLITE_CONFIG.ringSize` | `0.07` | hover / locked ring sprite |
|
||||
| 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit |
|
||||
| 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` |
|
||||
| 自发光选中点透明度 | inline | `0.96` | locked dot material |
|
||||
| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | footprint fill |
|
||||
| footprint group renderOrder | inline | `0` | 避免 Group 排序盖过卫星点 |
|
||||
|
||||
## 算力中心
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 |
|
||||
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
|
||||
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker |
|
||||
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
|
||||
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 |
|
||||
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 |
|
||||
| dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 |
|
||||
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture |
|
||||
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture |
|
||||
| 关联颜色 | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | 关联态 |
|
||||
| 算力中心 renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | 地表设施低于卫星点 |
|
||||
|
||||
## BGP 观测
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `2.1` | anomaly marker |
|
||||
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker |
|
||||
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite |
|
||||
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane |
|
||||
| hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 |
|
||||
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite |
|
||||
| hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 |
|
||||
| dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 |
|
||||
| collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 |
|
||||
| critical 颜色 | `BGP_CONFIG.severityColors.critical` | `0xff4d4f` | 严重事件 |
|
||||
| high 颜色 | `BGP_CONFIG.severityColors.high` | `0xff9f43` | 高危事件 |
|
||||
| medium 颜色 | `BGP_CONFIG.severityColors.medium` | `0xffd166` | 中危事件 |
|
||||
| low 颜色 | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | 低危事件 |
|
||||
| collector 基础色 | `BGP_CONFIG.collectorColor` | `0x6db7ff` | collector 默认色 |
|
||||
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
|
||||
| BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring |
|
||||
| BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring |
|
||||
| collector marker renderOrder | inline | `3` | `marker.renderOrder` |
|
||||
| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` |
|
||||
|
||||
## 天体与星空
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 天球半径 | `CELESTIAL_CONFIG.skyRadius` | `2600` | 天体背景 |
|
||||
| 天球透明度 | `CELESTIAL_CONFIG.skyOpacity` | `1` | 背景材质 |
|
||||
| 太阳距离 / 缩放 | `sunDistance / sunScale` | `2150 / 78` | 太阳 sprite |
|
||||
| 月亮距离 / 缩放 | `moonDistance / moonScale` | `2050 / 38` | 月亮 sprite |
|
||||
| 太阳 halo 缩放 | `CELESTIAL_CONFIG.sunHaloScale` | `136` | 太阳 halo |
|
||||
| 月亮 halo 缩放 | `CELESTIAL_CONFIG.moonHaloScale` | `62` | 月亮 halo |
|
||||
| 太阳光颜色 / 强度 | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | scene light |
|
||||
| 背光颜色 / 强度 | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | scene light |
|
||||
| 星空点数量 | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
|
||||
| 星空半径范围 | `minRadius + radiusJitter` | `800 + 200` | 随机分布 |
|
||||
| 星空点颜色 | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
|
||||
| 星空点大小 | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |
|
||||
|
||||
57
docs/technical/earth-render-layer-order.md
Normal file
57
docs/technical/earth-render-layer-order.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Earth 渲染图层顺序
|
||||
|
||||
本文记录当前 Earth 渲染器的图层顺序和每层意图。后续调整
|
||||
`renderOrder`、半径偏移、深度策略或指针交互时,需要同步更新这里。
|
||||
|
||||
注意:图层控制面板顺序和注册 / 启动加载顺序是两套语义。
|
||||
|
||||
| 顺序类型 | 当前顺序 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → BGP → 地形 → 高清材质 → 大气云图 → 国界 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
|
||||
| 注册 / 启动加载顺序 | 经纬线 → 国界 → 高清材质 → 大气云图 → 海缆 → 算力中心 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
|
||||
|
||||
## 地表图层栈
|
||||
|
||||
| 顺序 | 图层 | 来源 | 渲染 / 半径策略 | 深度 / 交互策略 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有 Earth 内容之后。 |
|
||||
| -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 | `cables.js` | `CABLE_CONFIG.line.renderOrder` | 海缆拾取路径 | 保持现有海缆层级。 |
|
||||
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
|
||||
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset` | 禁用 raycast | 只保证压过高清材质。 |
|
||||
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
|
||||
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested,Group renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
|
||||
| 3-5 | BGP 标记和覆盖层 | `bgp.js` | 各 marker 自身 renderOrder | BGP 拾取路径 | 保持现有 BGP 视觉层级。 |
|
||||
| 4.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 |
|
||||
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
|
||||
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
|
||||
|
||||
## 开关联动
|
||||
|
||||
| 开关 | 行为 |
|
||||
| --- | --- |
|
||||
| 高清材质 off | 隐藏高清材质,启用国界 tint / 基座表面,禁用地形和昼夜开关交互,并记住地形和昼夜之前状态。 |
|
||||
| 高清材质 on | 恢复高清材质,并恢复记住的地形 / 昼夜状态。 |
|
||||
| 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 |
|
||||
| 大气云图 | 只控制云图 mesh 显隐。 |
|
||||
| 国界 | 控制国界线和 hover 线显隐;海陆基座填充独立存在,作为 Earth 基座地图使用。 |
|
||||
|
||||
## 交互规则
|
||||
|
||||
| 交互 | 当前规则 |
|
||||
| --- | --- |
|
||||
| Earth 坐标 hover | 高清材质可见时使用高清材质 overlay 作为地表拾取目标,否则使用 Earth 基座球。 |
|
||||
| 国界 hover | 先把地表拾取坐标转成经纬度,再用 GeoJSON 点面判断;国界 hover 线本身不接收 raycast。 |
|
||||
| 国界 hover 视觉 | hover 时压暗普通国界线,并绘制无深度测试的光晕和实线。 |
|
||||
| 中国 / 台湾 hover | `CHN` 和 `TWN` 被归到同一个 hover 高亮组;tooltip 仍显示鼠标实际命中的 feature。 |
|
||||
| 地形 | 只作为视觉层参与,`terrain.raycast` 已禁用。 |
|
||||
| 卫星 | 使用屏幕空间卫星拾取,避免 footprint 或地表层挡住卫星点击。 |
|
||||
@@ -16,12 +16,16 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.40.2`
|
||||
- `dev` 当前开发分支历史推导到:`0.41.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.41.0` | feature | `dev` | `pending` | Earth 图层顺序拆分、基座海陆色块、国界交互、高清材质/云图/地形层级与样式文档落地 |
|
||||
| `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 |
|
||||
| `0.40.4` | bugfix | `dev` | `pending` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 |
|
||||
| `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial,修复锁定环 depthTest 与位置漂移,新增悬停态缩放 |
|
||||
| `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 |
|
||||
| `0.40.1` | improvement | `dev` | `pending` | 卫星选中标记配色跟随图例,修复 footprint 遮蔽卫星渲染问题,修复选中海缆误触发卫星高亮 |
|
||||
| `0.40.0` | feature | `dev` | `pending` | Earth 卫星 footprint 按星座能力分层,Iridium 独立 coverage ring 落地,卫星详情卡补齐覆盖能力与当前显示说明 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.40.2",
|
||||
"version": "0.41.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -827,6 +827,12 @@
|
||||
rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-mobile-layer-card.is-disabled,
|
||||
.earth-mobile-layer-card:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.earth-mobile-layer-card-icon {
|
||||
font-size: 22px;
|
||||
color: var(--hud-accent-strong);
|
||||
@@ -1401,6 +1407,12 @@
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
label.is-disabled.earth-mobile-settings-card {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2478,6 +2490,12 @@
|
||||
transform: translateX(calc(16px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
.earth-settings-item.is-disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.earth-settings-sheet {
|
||||
top: 24px;
|
||||
|
||||
@@ -271,6 +271,16 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-label,
|
||||
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.layer-row-toggle-track::after {
|
||||
content: "";
|
||||
|
||||
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
File diff suppressed because one or more lines are too long
@@ -108,33 +108,13 @@
|
||||
|
||||
<!-- Layer rows -->
|
||||
<div class="layer-panel-list" id="layer-panel-list">
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">经纬线</span>
|
||||
<span class="layer-row-meta">Graticule</span>
|
||||
</div>
|
||||
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -148,13 +128,13 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -178,6 +158,56 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="高清材质 纹理 texture hd earth">
|
||||
<span class="material-symbols-rounded layer-row-icon">globe</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">高清材质</span>
|
||||
<span class="layer-row-meta">High-Res Texture</span>
|
||||
</div>
|
||||
<button id="toggle-earth-high-res-texture" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换高清材质显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="大气 云图 云层 clouds atmosphere">
|
||||
<span class="material-symbols-rounded layer-row-icon">cloud</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">大气云图</span>
|
||||
<span class="layer-row-meta">Cloud Layer</span>
|
||||
</div>
|
||||
<button id="toggle-atmosphere-clouds" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换大气云图显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="国界 国家 borders countries boundary">
|
||||
<span class="material-symbols-rounded layer-row-icon">public</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">国界</span>
|
||||
<span class="layer-row-meta">Country Borders</span>
|
||||
</div>
|
||||
<button id="toggle-country-boundaries" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换国界显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">经纬线</span>
|
||||
<span class="layer-row-meta">Graticule</span>
|
||||
</div>
|
||||
<button id="toggle-grid-lines" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换经纬线显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty search state -->
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const computeCenterGroup = new THREE.Group();
|
||||
const computeCenterMarkers = [];
|
||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||
const textureCache = new Map();
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
@@ -196,7 +197,7 @@ function createComputeCenterMarker(markerData) {
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(baseScale);
|
||||
marker.renderOrder = 8;
|
||||
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
|
||||
marker.visible = showComputeCenters;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
|
||||
@@ -155,7 +155,7 @@ export const TERRAIN_CONFIG = {
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
baseRadiusOffset: 0.16,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
@@ -168,6 +168,32 @@ export const TERRAIN_CONFIG = {
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
dataPath: "/earth/data/countries-admin0.min.geojson",
|
||||
lineAltitudeOffset: 0.24,
|
||||
hoverAltitudeOffset: 0.32,
|
||||
lineColor: 0x7fc7ff,
|
||||
lineOpacity: 0.58,
|
||||
lineRenderOrder: 2.2,
|
||||
dimmedLineOpacity: 0.18,
|
||||
hoverLineColor: 0xff3b1f,
|
||||
hoverLineOpacity: 1.0,
|
||||
hoverLineRenderOrder: 2.3,
|
||||
hoverGlowOpacity: 0.38,
|
||||
hoverGlowLineWidth: 3,
|
||||
hoverGlowRenderOrderOffset: 0.01,
|
||||
hoverGlowRadiusOffset: 0.04,
|
||||
tintAltitudeOffset: 0.04,
|
||||
tintColor: 0x0b1830,
|
||||
tintRenderOrder: 0.2,
|
||||
landColor: 0x080f1b,
|
||||
landOpacity: 1.0,
|
||||
landAltitudeOffset: 0.08,
|
||||
landRenderOrder: 0.86,
|
||||
landMaskWidth: 2048,
|
||||
landMaskHeight: 1024,
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
@@ -277,13 +303,16 @@ export const CABLE_STATE = {
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
initialLoadCount: 2400,
|
||||
hydrateFullAfterInitialLoad: true,
|
||||
initialLoadCount: null,
|
||||
hydrateFullAfterInitialLoad: false,
|
||||
trailLength: 10,
|
||||
trailLineWidth: 3,
|
||||
displayAltitudeOffset: 8,
|
||||
frontFacingDotThreshold: 0.015,
|
||||
overlayRenderOrder: 12,
|
||||
dotSize: 2.8,
|
||||
dotBaseSize: 2.8,
|
||||
dotBackdropScale: 1.28,
|
||||
dotZoomScalePower: 1,
|
||||
ringSize: 0.07,
|
||||
apiPath: '/api/v1/visualization/geo/satellites',
|
||||
breathingSpeed: 0.08,
|
||||
@@ -390,19 +419,43 @@ export const PREDICTED_ORBIT_CONFIG = {
|
||||
};
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
latitudeStep: 10,
|
||||
radiusOffset: 0.14,
|
||||
color: 0xc0e0ff,
|
||||
opacity: 0.08,
|
||||
lineWidth: 1,
|
||||
renderOrder: 2.05,
|
||||
latitudeStep: 15,
|
||||
longitudeStep: 30,
|
||||
gridStep: 5
|
||||
segmentStep: 5,
|
||||
};
|
||||
|
||||
export const CLOUD_LAYER_CONFIG = {
|
||||
radiusOffset: 3,
|
||||
widthSegments: 64,
|
||||
heightSegments: 64,
|
||||
opacity: 0.15,
|
||||
textureUrl: "./assets/earth_clouds_1024.png",
|
||||
};
|
||||
|
||||
export const STARFIELD_CONFIG = {
|
||||
count: 8000,
|
||||
minRadius: 800,
|
||||
radiusJitter: 200,
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
};
|
||||
|
||||
export const EARTH_MATERIAL_CONFIG = {
|
||||
// Diffuse color multiplies with texture — pure white = full saturation,
|
||||
// slightly grey-blue pulls perceived saturation down without a custom shader.
|
||||
color: 0xcdd8e6,
|
||||
// Base sphere sits below the country fill and high-res texture overlays.
|
||||
// Keep it dark so a delayed overlay never flashes or reads as a white layer.
|
||||
color: 0x010609,
|
||||
specular: 0x1a2d45,
|
||||
shininess: 12,
|
||||
emissive: 0x050a12,
|
||||
opacity: 0.96,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: 0.1,
|
||||
textureOverlayOpacity: 0.88,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
|
||||
// Depth-mask occluder keeps far-side objects hidden behind the earth
|
||||
occluderRadiusFactor: 0.999,
|
||||
|
||||
341
frontend/public/earth/js/controls.js
vendored
341
frontend/public/earth/js/controls.js
vendored
@@ -29,6 +29,11 @@ import {
|
||||
clearLockedObject,
|
||||
clearLockedObjectAndInfo,
|
||||
setCablesEnabled,
|
||||
setCountryBoundariesEnabled,
|
||||
setHighResTextureEnabled,
|
||||
getHighResTextureEnabled,
|
||||
setAtmosphereCloudsEnabled,
|
||||
getAtmosphereCloudsEnabled,
|
||||
setSatellitesEnabled,
|
||||
getSatellitesEnabled,
|
||||
} from "./main.js";
|
||||
@@ -41,6 +46,7 @@ import {
|
||||
} from "./satellites.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { getShowCountryBoundaries } from "./country-boundaries.js";
|
||||
import {
|
||||
toggleComputeCenters,
|
||||
getShowComputeCenters,
|
||||
@@ -73,6 +79,7 @@ export let rotationMode = ROTATION_MODE.ROTATE;
|
||||
let dayNightEnabled = true;
|
||||
let defaultEarthZoom = CONFIG.defaultViewZoom;
|
||||
let activeCamera = null;
|
||||
let settingsApplyPromise = Promise.resolve();
|
||||
|
||||
let earthObj = null;
|
||||
let listeners = [];
|
||||
@@ -331,9 +338,31 @@ function getMobileLayerButtons(layerId) {
|
||||
).filter((button) => button instanceof HTMLButtonElement);
|
||||
}
|
||||
|
||||
function getLayerDisabledState(layerId) {
|
||||
if (layerId === "trails" && !getSatellitesEnabled()) {
|
||||
return {
|
||||
disabled: true,
|
||||
statusText: "不可用",
|
||||
tooltip: "卫星关闭时不可用",
|
||||
};
|
||||
}
|
||||
if (layerId === "terrain" && !getHighResTextureEnabled()) {
|
||||
return {
|
||||
disabled: true,
|
||||
statusText: "不可用",
|
||||
tooltip: "高清材质关闭时不可用",
|
||||
};
|
||||
}
|
||||
return {
|
||||
disabled: false,
|
||||
statusText: null,
|
||||
tooltip: null,
|
||||
};
|
||||
}
|
||||
|
||||
function syncMobileLayerCards() {
|
||||
const summary = document.getElementById("mobile-layer-summary");
|
||||
const definitions = getSortedLayerDefinitions();
|
||||
const definitions = getDisplayLayerDefinitions();
|
||||
let activeCount = 0;
|
||||
|
||||
definitions.forEach((definition) => {
|
||||
@@ -342,11 +371,19 @@ function syncMobileLayerCards() {
|
||||
activeCount += 1;
|
||||
}
|
||||
getMobileLayerButtons(definition.id).forEach((button) => {
|
||||
const disabledState = getLayerDisabledState(definition.id);
|
||||
button.classList.toggle("is-active", visible);
|
||||
button.classList.toggle("is-disabled", disabledState.disabled);
|
||||
button.disabled = disabledState.disabled;
|
||||
button.setAttribute("aria-checked", visible ? "true" : "false");
|
||||
if (disabledState.tooltip) {
|
||||
button.title = disabledState.tooltip;
|
||||
} else {
|
||||
button.removeAttribute("title");
|
||||
}
|
||||
const status = button.querySelector("[data-mobile-layer-status]");
|
||||
if (status) {
|
||||
status.textContent = visible ? "开启" : "关闭";
|
||||
status.textContent = disabledState.statusText || (visible ? "开启" : "关闭");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -360,7 +397,7 @@ function renderMobileLayerCards() {
|
||||
const list = document.getElementById("mobile-layer-list");
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
|
||||
const definitions = getSortedLayerDefinitions();
|
||||
const definitions = getDisplayLayerDefinitions();
|
||||
list.innerHTML = definitions
|
||||
.map((definition) => `
|
||||
<button
|
||||
@@ -384,6 +421,7 @@ function renderMobileLayerCards() {
|
||||
bindListener(button, "click", async (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLButtonElement)) return;
|
||||
if (target.disabled || target.classList.contains("is-disabled")) return;
|
||||
const layerId = target.dataset.mobileLayerButton;
|
||||
const definition = layerId ? getLayerDefinition(layerId) : null;
|
||||
if (!definition) return;
|
||||
@@ -594,6 +632,21 @@ function getSortedLayerDefinitions({ includeUnprioritized = true } = {}) {
|
||||
.sort(compareLayerDefinitionsByStartupPriority);
|
||||
}
|
||||
|
||||
function getDisplayLayerDefinitions() {
|
||||
return Array.from(layerRegistry.values()).sort((left, right) => {
|
||||
const leftOrder = Number.isFinite(left?.displayOrder)
|
||||
? left.displayOrder
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const rightOrder = Number.isFinite(right?.displayOrder)
|
||||
? right.displayOrder
|
||||
: Number.POSITIVE_INFINITY;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left?.id || "").localeCompare(String(right?.id || ""));
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIncludeLayerInStartupLoad(definition) {
|
||||
if (!Number.isFinite(definition?.startupPriority)) {
|
||||
return false;
|
||||
@@ -658,13 +711,22 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultLayerVisibilitySnapshot() {
|
||||
return Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.defaultActive)]),
|
||||
);
|
||||
}
|
||||
|
||||
function captureEarthSettingsDefaults() {
|
||||
if (!earthSettingsDefaults) {
|
||||
const panelVisibility = getCurrentPanelVisibilitySnapshot();
|
||||
const shared = getCurrentSharedSettingsSnapshot();
|
||||
earthSettingsDefaults = {
|
||||
version: 2,
|
||||
shared,
|
||||
shared: {
|
||||
...shared,
|
||||
layerVisibility: getDefaultLayerVisibilitySnapshot(),
|
||||
},
|
||||
views: {
|
||||
desktop: {
|
||||
panelVisibility: { ...panelVisibility },
|
||||
@@ -680,7 +742,7 @@ function captureEarthSettingsDefaults() {
|
||||
|
||||
function cloneEarthSettings(settings) {
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
shared: {
|
||||
rotationMode: settings.shared.rotationMode,
|
||||
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
|
||||
@@ -753,6 +815,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
}
|
||||
});
|
||||
|
||||
if ((rawSettings?.version || 0) < 3 && inputLayerVisibility.gridLines === true) {
|
||||
normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines;
|
||||
}
|
||||
|
||||
const nextRotationMode =
|
||||
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
|
||||
? ROTATION_MODE.CRUISE
|
||||
@@ -779,7 +845,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
shared: {
|
||||
rotationMode: nextRotationMode,
|
||||
cruiseModules: nextCruiseModules.length > 0
|
||||
@@ -805,7 +871,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
}
|
||||
|
||||
function getPersistedLayers() {
|
||||
return getSortedLayerDefinitions().filter((layer) => layer.persist !== false);
|
||||
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
|
||||
}
|
||||
|
||||
function getLayerDefinition(layerId) {
|
||||
@@ -1149,6 +1215,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
} else if (enabled) {
|
||||
setEarthStatValue("satellite-count", `${getSatelliteCount()} 颗`);
|
||||
}
|
||||
syncTrailsAvailability();
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
@@ -1179,6 +1246,61 @@ function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = fa
|
||||
return enabled;
|
||||
}
|
||||
|
||||
async function setCountryBoundariesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
try {
|
||||
if (enabled) {
|
||||
setLayerButtonState(button, {
|
||||
active: false,
|
||||
loading: true,
|
||||
tooltip: "国界加载中...",
|
||||
});
|
||||
}
|
||||
await setCountryBoundariesEnabled(enabled, { suppressStatus: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏国界" : "显示国界",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
console.error("切换国界显示失败:", error);
|
||||
setLayerButtonState(button, {
|
||||
active: false,
|
||||
loading: false,
|
||||
tooltip: "显示国界",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
setHighResTextureEnabled(enabled, { suppressStatus: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏高清材质" : "显示高清材质",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏大气云图" : "显示大气云图",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
toggleBGP(enabled);
|
||||
@@ -1216,9 +1338,11 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
|
||||
|
||||
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
toggleTrails(enabled);
|
||||
const disabledState = getLayerDisabledState("trails");
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
|
||||
disabled: disabledState.disabled,
|
||||
tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"),
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
@@ -1228,6 +1352,17 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function syncTrailsAvailability() {
|
||||
const trailsEnabled = getShowTrails();
|
||||
const disabledState = getLayerDisabledState("trails");
|
||||
setLayerButtonState(getLayerButton("trails"), {
|
||||
active: trailsEnabled,
|
||||
disabled: disabledState.disabled,
|
||||
tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"),
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
}
|
||||
|
||||
async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
try {
|
||||
@@ -1253,23 +1388,6 @@ async function applyLayerVisibilitySettings(layerVisibility = {}, options = {})
|
||||
|
||||
function getBuiltinLayerDefinitions() {
|
||||
return [
|
||||
{
|
||||
id: "terrain",
|
||||
buttonId: "toggle-terrain",
|
||||
icon: "landscape",
|
||||
label: "地形",
|
||||
meta: "Terrain",
|
||||
keywords: "地形 terrain",
|
||||
defaultActive: false,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "地形",
|
||||
startupMessage: "正在渲染地形...",
|
||||
statusTarget: "terrain-status",
|
||||
getVisible: () => showTerrain,
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTerrainEnabled(getLayerButton("terrain"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "gridLines",
|
||||
buttonId: "toggle-grid-lines",
|
||||
@@ -1277,8 +1395,9 @@ function getBuiltinLayerDefinitions() {
|
||||
label: "经纬线",
|
||||
meta: "Graticule",
|
||||
keywords: "经纬线 graticule 经纬 latitude longitude",
|
||||
defaultActive: true,
|
||||
startupPriority: null,
|
||||
defaultActive: false,
|
||||
displayOrder: 100,
|
||||
startupPriority: 10,
|
||||
startupMode: "visible",
|
||||
startupLabel: "经纬线",
|
||||
startupMessage: "",
|
||||
@@ -1287,36 +1406,55 @@ function getBuiltinLayerDefinitions() {
|
||||
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "satellites",
|
||||
buttonId: "toggle-satellites",
|
||||
icon: "satellite_alt",
|
||||
label: "卫星",
|
||||
meta: "Satellites",
|
||||
keywords: "卫星 satellites",
|
||||
defaultActive: false,
|
||||
startupPriority: 30,
|
||||
startupMode: "visible",
|
||||
startupLabel: "卫星",
|
||||
startupMessage: "正在加载卫星...",
|
||||
getVisible: () => getSatellitesEnabled(),
|
||||
id: "countryBoundaries",
|
||||
buttonId: "toggle-country-boundaries",
|
||||
icon: "public",
|
||||
label: "国界",
|
||||
meta: "Country Borders",
|
||||
keywords: "国界 国家 borders countries boundary",
|
||||
defaultActive: true,
|
||||
displayOrder: 90,
|
||||
startupPriority: 20,
|
||||
startupMode: "preload",
|
||||
startupLabel: "海陆基座",
|
||||
startupMessage: "正在加载海陆基座...",
|
||||
getVisible: () => getShowCountryBoundaries(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
|
||||
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "trails",
|
||||
buttonId: "toggle-trails",
|
||||
icon: "timeline",
|
||||
label: "轨迹",
|
||||
meta: "Trails",
|
||||
keywords: "轨迹 trails",
|
||||
id: "earthHighResTexture",
|
||||
buttonId: "toggle-earth-high-res-texture",
|
||||
icon: "globe",
|
||||
label: "高清材质",
|
||||
meta: "High-Res Texture",
|
||||
keywords: "高清 材质 纹理 texture hd 地表 earth",
|
||||
defaultActive: true,
|
||||
startupPriority: null,
|
||||
displayOrder: 70,
|
||||
startupPriority: 30,
|
||||
startupMode: "visible",
|
||||
startupLabel: "轨迹",
|
||||
startupMessage: "",
|
||||
getVisible: () => getShowTrails(),
|
||||
startupLabel: "高清材质",
|
||||
startupMessage: "正在启用高清材质...",
|
||||
getVisible: () => getHighResTextureEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
|
||||
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "atmosphereClouds",
|
||||
buttonId: "toggle-atmosphere-clouds",
|
||||
icon: "cloud",
|
||||
label: "大气云图",
|
||||
meta: "Cloud Layer",
|
||||
keywords: "大气 云图 云层 clouds atmosphere",
|
||||
defaultActive: true,
|
||||
displayOrder: 80,
|
||||
startupPriority: 40,
|
||||
startupMode: "visible",
|
||||
startupLabel: "大气云图",
|
||||
startupMessage: "",
|
||||
getVisible: () => getAtmosphereCloudsEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setAtmosphereCloudsLayerEnabled(getLayerButton("atmosphereClouds"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "cables",
|
||||
@@ -1326,7 +1464,8 @@ function getBuiltinLayerDefinitions() {
|
||||
meta: "Subsea Cables",
|
||||
keywords: "海缆 subsea cables",
|
||||
defaultActive: true,
|
||||
startupPriority: 20,
|
||||
displayOrder: 10,
|
||||
startupPriority: 50,
|
||||
startupMode: "visible",
|
||||
startupLabel: "海缆",
|
||||
startupMessage: {
|
||||
@@ -1345,7 +1484,8 @@ function getBuiltinLayerDefinitions() {
|
||||
meta: "Compute Centers",
|
||||
keywords: "算力中心 compute centers gpu 超算",
|
||||
defaultActive: true,
|
||||
startupPriority: 35,
|
||||
displayOrder: 40,
|
||||
startupPriority: 60,
|
||||
startupMode: "preload",
|
||||
startupLabel: "算力中心",
|
||||
startupMessage: "正在加载算力中心...",
|
||||
@@ -1361,7 +1501,8 @@ function getBuiltinLayerDefinitions() {
|
||||
meta: "Routing Signals",
|
||||
keywords: "bgp观测 routing signals",
|
||||
defaultActive: true,
|
||||
startupPriority: 40,
|
||||
displayOrder: 50,
|
||||
startupPriority: 70,
|
||||
startupMode: "preload",
|
||||
startupLabel: "BGP态势",
|
||||
startupMessage: "正在加载BGP态势...",
|
||||
@@ -1369,6 +1510,58 @@ function getBuiltinLayerDefinitions() {
|
||||
setVisible: (visible, options = {}) =>
|
||||
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "satellites",
|
||||
buttonId: "toggle-satellites",
|
||||
icon: "satellite_alt",
|
||||
label: "卫星",
|
||||
meta: "Satellites",
|
||||
keywords: "卫星 satellites",
|
||||
defaultActive: false,
|
||||
displayOrder: 30,
|
||||
startupPriority: 80,
|
||||
startupMode: "visible",
|
||||
startupLabel: "卫星",
|
||||
startupMessage: "正在加载卫星...",
|
||||
getVisible: () => getSatellitesEnabled(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "trails",
|
||||
buttonId: "toggle-trails",
|
||||
icon: "timeline",
|
||||
label: "轨迹",
|
||||
meta: "Trails",
|
||||
keywords: "轨迹 trails",
|
||||
defaultActive: true,
|
||||
displayOrder: 20,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "轨迹",
|
||||
startupMessage: "",
|
||||
getVisible: () => getShowTrails(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "terrain",
|
||||
buttonId: "toggle-terrain",
|
||||
icon: "landscape",
|
||||
label: "地形",
|
||||
meta: "Terrain",
|
||||
keywords: "地形 terrain",
|
||||
defaultActive: false,
|
||||
displayOrder: 60,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "地形",
|
||||
startupMessage: "正在渲染地形...",
|
||||
statusTarget: "terrain-status",
|
||||
getVisible: () => showTerrain,
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTerrainEnabled(getLayerButton("terrain"), visible, options),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1430,6 +1623,7 @@ function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) {
|
||||
function registerLayerDefinition(definition, options = {}) {
|
||||
const normalizedDefinition = {
|
||||
persist: true,
|
||||
displayOrder: null,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "",
|
||||
@@ -1818,6 +2012,31 @@ function applyDayNightEnabled(enabled, { persist = true } = {}) {
|
||||
if (persist) persistEarthSettings();
|
||||
}
|
||||
|
||||
export function setDayNightEnabledExternal(enabled, { persist = true } = {}) {
|
||||
applyDayNightEnabled(enabled, { persist });
|
||||
}
|
||||
|
||||
export function getDayNightEnabled() {
|
||||
return dayNightEnabled;
|
||||
}
|
||||
|
||||
export function setTerrainLayerInteractable(enabled) {
|
||||
const button = getLayerButton("terrain");
|
||||
setLayerButtonState(button, {
|
||||
disabled: !enabled,
|
||||
tooltip: enabled ? null : "高清材质关闭时不可用",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
}
|
||||
|
||||
export function setDayNightInteractable(enabled) {
|
||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
|
||||
input.disabled = !enabled;
|
||||
const label = input.closest("label");
|
||||
if (label) label.classList.toggle("is-disabled", !enabled);
|
||||
});
|
||||
}
|
||||
|
||||
function setupSettingsControls() {
|
||||
const settingsTrigger = document.getElementById("settings-trigger");
|
||||
const settingsClose = document.getElementById("settings-close");
|
||||
@@ -1960,7 +2179,7 @@ function setupSettingsControls() {
|
||||
});
|
||||
|
||||
captureEarthSettingsDefaults();
|
||||
applyEarthSettings(loadEarthSettings());
|
||||
settingsApplyPromise = applyEarthSettings(loadEarthSettings());
|
||||
syncAllHudPanelToggles();
|
||||
syncRotationModeButtons();
|
||||
syncCruiseModuleControls();
|
||||
@@ -2283,7 +2502,7 @@ function resetCleanup() {
|
||||
listeners = [];
|
||||
}
|
||||
|
||||
export function setupControls(camera, renderer, scene, earth) {
|
||||
export async function setupControls(camera, renderer, scene, earth) {
|
||||
resetCleanup();
|
||||
activeCamera = camera;
|
||||
earthObj = earth;
|
||||
@@ -2292,6 +2511,8 @@ export function setupControls(camera, renderer, scene, earth) {
|
||||
setupWheelZoom(camera, renderer);
|
||||
setupRotateControls(camera, earth);
|
||||
setupTerrainControls();
|
||||
await settingsApplyPromise;
|
||||
syncTrailsAvailability();
|
||||
setupLiquidGlassInteractions();
|
||||
setupToolbarHubCluster();
|
||||
setupKeyboardControls();
|
||||
@@ -2622,7 +2843,11 @@ function bindLayerButton(row, definition) {
|
||||
if (button.dataset.layerBound === "true") return;
|
||||
|
||||
bindListener(button, "click", async function () {
|
||||
if (this.classList.contains("is-loading")) {
|
||||
if (
|
||||
this.disabled ||
|
||||
this.classList.contains("is-loading") ||
|
||||
this.classList.contains("is-disabled")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
@@ -2638,6 +2863,7 @@ export function registerLayer({
|
||||
keywords = "",
|
||||
defaultActive = false,
|
||||
persist = true,
|
||||
displayOrder = null,
|
||||
startupPriority = null,
|
||||
startupMode = "visible",
|
||||
startupLabel = "",
|
||||
@@ -2658,6 +2884,7 @@ export function registerLayer({
|
||||
keywords,
|
||||
defaultActive,
|
||||
persist,
|
||||
displayOrder,
|
||||
startupPriority,
|
||||
startupMode,
|
||||
startupLabel,
|
||||
|
||||
500
frontend/public/earth/js/country-boundaries.js
Normal file
500
frontend/public/earth/js/country-boundaries.js
Normal file
@@ -0,0 +1,500 @@
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||||
let _earthObj = null;
|
||||
let _features = [];
|
||||
let _landMesh = null;
|
||||
let _tintMesh = null;
|
||||
let _boundaryLines = null;
|
||||
let _hoverGlowLines = null;
|
||||
let _hoverLines = null;
|
||||
let _hoveredFeature = null;
|
||||
let _hoveredGroupKey = null;
|
||||
let _visible = false;
|
||||
let _landFillEnabled = true;
|
||||
let _landFillSuppressed = false;
|
||||
let _tintEnabled = false;
|
||||
let _loaded = false;
|
||||
let _loadPromise = null;
|
||||
|
||||
const OCEAN_HEX = 0x010609;
|
||||
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
|
||||
|
||||
function hexToStyle(hex) {
|
||||
return `#${hex.toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
return [
|
||||
(hex >> 16) & 255,
|
||||
(hex >> 8) & 255,
|
||||
hex & 255,
|
||||
];
|
||||
}
|
||||
|
||||
function buildLandTexture(features) {
|
||||
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
||||
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const oceanRgb = hexToRgb(OCEAN_HEX);
|
||||
|
||||
if (!ctx) {
|
||||
const oceanData = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < oceanData.length; i += 4) {
|
||||
oceanData[i] = oceanRgb[0];
|
||||
oceanData[i + 1] = oceanRgb[1];
|
||||
oceanData[i + 2] = oceanRgb[2];
|
||||
oceanData[i + 3] = 255;
|
||||
}
|
||||
const fallbackTexture = new THREE.DataTexture(
|
||||
oceanData,
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
fallbackTexture.needsUpdate = true;
|
||||
return fallbackTexture;
|
||||
}
|
||||
|
||||
// Ocean background
|
||||
ctx.fillStyle = hexToStyle(OCEAN_HEX);
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Land polygons using evenodd fill rule so holes (lakes, islands) work correctly
|
||||
ctx.fillStyle = hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor);
|
||||
|
||||
for (const feat of features) {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) continue;
|
||||
const polys =
|
||||
geom.type === "Polygon" ? [geom.coordinates] :
|
||||
geom.type === "MultiPolygon" ? geom.coordinates : null;
|
||||
if (!polys) continue;
|
||||
|
||||
for (const rings of polys) {
|
||||
ctx.beginPath();
|
||||
for (const ring of rings) {
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
// equirectangular: x = (lon+180)/360*width, y = (90-lat)/180*height
|
||||
const px = ((ring[i][0] + 180) / 360) * width;
|
||||
const py = ((90 - ring[i][1]) / 180) * height;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.fill("evenodd");
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const tex = new THREE.DataTexture(
|
||||
new Uint8Array(imageData.data),
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
tex.flipY = true;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function makeLandMesh(tex) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 128, 64);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
map: tex,
|
||||
transparent: COUNTRY_BOUNDARY_CONFIG.landOpacity < 1,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.landOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-land-ocean";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
|
||||
mesh.visible = false;
|
||||
mesh.raycast = () => {};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function makeTintMesh() {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 64, 32);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-tint";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.tintRenderOrder;
|
||||
mesh.visible = false;
|
||||
mesh.raycast = () => {};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// ─── Boundary line geometry ────────────────────────────────────────────────────
|
||||
|
||||
function ringToSegments(ring, radius, out) {
|
||||
const n = ring.length;
|
||||
if (n < 2) return;
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
out.push(latLonToVector3(ring[i][1], ring[i][0], radius));
|
||||
out.push(latLonToVector3(ring[i+1][1], ring[i+1][0], radius));
|
||||
}
|
||||
}
|
||||
|
||||
function featureToSegments(geom, radius) {
|
||||
const pts = [];
|
||||
if (!geom) return pts;
|
||||
if (geom.type === "Polygon") {
|
||||
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
|
||||
} else if (geom.type === "MultiPolygon") {
|
||||
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function buildBoundaryLines(features) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const all = [];
|
||||
for (const feat of features) {
|
||||
const pts = featureToSegments(feat.geometry, r);
|
||||
all.push(...pts);
|
||||
}
|
||||
|
||||
const geo = all.length > 0
|
||||
? new THREE.BufferGeometry().setFromPoints(all)
|
||||
: new THREE.BufferGeometry();
|
||||
const lines = new THREE.LineSegments(geo, mat);
|
||||
lines.name = "country-boundary-all";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildHoverLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity < 1,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-hover";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildHoverGlowLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
linewidth: COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-hover-glow";
|
||||
lines.renderOrder =
|
||||
COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder -
|
||||
COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function setBoundaryLinesDimmed(dimmed) {
|
||||
if (!_boundaryLines?.material) return;
|
||||
_boundaryLines.material.opacity = dimmed
|
||||
? COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity
|
||||
: COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
||||
_boundaryLines.material.needsUpdate = true;
|
||||
}
|
||||
|
||||
function clearHoverLineGeometries() {
|
||||
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
|
||||
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
|
||||
}
|
||||
|
||||
function featureListToSegments(features, radius) {
|
||||
return features.flatMap(f => featureToSegments(f.geometry, radius));
|
||||
}
|
||||
|
||||
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
|
||||
|
||||
function pointInRing(lat, lon, ring) {
|
||||
let inside = false;
|
||||
const n = ring.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = ring[i][0], yi = ring[i][1];
|
||||
const xj = ring[j][0], yj = ring[j][1];
|
||||
if ((yi > lat) !== (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function featureContains(lat, lon, feat) {
|
||||
const geom = feat.geometry;
|
||||
if (!geom) return false;
|
||||
if (geom.type === "Polygon") {
|
||||
if (!pointInRing(lat, lon, geom.coordinates[0])) return false;
|
||||
return geom.coordinates.slice(1).every(h => !pointInRing(lat, lon, h));
|
||||
}
|
||||
if (geom.type === "MultiPolygon") {
|
||||
return geom.coordinates.some(poly =>
|
||||
pointInRing(lat, lon, poly[0]) &&
|
||||
poly.slice(1).every(h => !pointInRing(lat, lon, h))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function makeCountryInfo(feat) {
|
||||
if (!feat) return null;
|
||||
const p = feat.properties || {};
|
||||
return {
|
||||
name: p.NAME_EN || p.NAME || p.ADMIN || "",
|
||||
nameZh: p.NAME_ZH || null,
|
||||
isoA3: p.ISO_A3 || p.ADM0_A3 || null,
|
||||
isoA2: p.ISO_A2 || null,
|
||||
continent: p.CONTINENT || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCountryHighlightGroupKey(feat) {
|
||||
const p = feat?.properties || {};
|
||||
const isoA3 = p.ISO_A3 || p.ADM0_A3 || "";
|
||||
if (isoA3 === "CHN" || isoA3 === "TWN") {
|
||||
return "CHN_TWN";
|
||||
}
|
||||
return isoA3 || p.ISO_A2 || p.NAME_EN || p.NAME || p.ADMIN || null;
|
||||
}
|
||||
|
||||
function getHighlightFeatures(feat) {
|
||||
const groupKey = getCountryHighlightGroupKey(feat);
|
||||
if (!groupKey) return feat ? [feat] : [];
|
||||
return _features.filter(f => getCountryHighlightGroupKey(f) === groupKey);
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Called during init (before data load). Creates the placeholder tint sphere. */
|
||||
export function createCountryBoundaryLayer(earthObj) {
|
||||
_earthObj = earthObj;
|
||||
_tintMesh = makeTintMesh();
|
||||
_earthObj.add(_tintMesh);
|
||||
}
|
||||
|
||||
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
|
||||
export async function loadCountryBoundaries() {
|
||||
if (_loaded) return _features.length;
|
||||
if (_loadPromise) return _loadPromise;
|
||||
|
||||
_loadPromise = (async () => {
|
||||
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
|
||||
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
|
||||
const geojson = await resp.json();
|
||||
_features = (geojson.features || []).filter(f => f.geometry);
|
||||
|
||||
const tex = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(tex);
|
||||
_earthObj.add(_landMesh);
|
||||
|
||||
_boundaryLines = buildBoundaryLines(_features);
|
||||
_earthObj.add(_boundaryLines);
|
||||
|
||||
_hoverGlowLines = buildHoverGlowLines();
|
||||
_earthObj.add(_hoverGlowLines);
|
||||
|
||||
_hoverLines = buildHoverLines();
|
||||
_earthObj.add(_hoverLines);
|
||||
|
||||
_loaded = true;
|
||||
return _features.length;
|
||||
})();
|
||||
|
||||
return _loadPromise;
|
||||
}
|
||||
|
||||
/** Load if not yet loaded, then return feature count. */
|
||||
export async function ensureCountryBoundariesReady() {
|
||||
if (!_loaded) await loadCountryBoundaries();
|
||||
return _features.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the country boundary lines.
|
||||
* The land/ocean fill is the base earth map and stays independent from this
|
||||
* line visibility switch.
|
||||
* @param {boolean} visible
|
||||
* @param {{ showTint?: boolean, showLandFill?: boolean, suppressLandFill?: boolean }} [opts]
|
||||
* showLandFill – whether to show the base land/ocean fill.
|
||||
* Defaults to the current stored value so callers that only
|
||||
* care about visibility don't need to repeat it.
|
||||
* suppressLandFill – temporarily keep the fill below the high-res texture
|
||||
* without changing the layer's own fill state.
|
||||
*/
|
||||
export function toggleCountryBoundaries(
|
||||
visible,
|
||||
{ showTint = false, showLandFill = null, suppressLandFill = null } = {},
|
||||
) {
|
||||
_visible = Boolean(visible);
|
||||
|
||||
if (showLandFill !== null) _landFillEnabled = Boolean(showLandFill);
|
||||
if (suppressLandFill !== null) _landFillSuppressed = Boolean(suppressLandFill);
|
||||
|
||||
if (_landMesh) {
|
||||
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
if (_boundaryLines) _boundaryLines.visible = _visible;
|
||||
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
|
||||
if (_hoverLines) _hoverLines.visible = _visible;
|
||||
|
||||
if (!_visible) {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
}
|
||||
|
||||
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the land/ocean canvas fill independently of boundary lines.
|
||||
*/
|
||||
export function setLandFillEnabled(enabled) {
|
||||
_landFillEnabled = Boolean(enabled);
|
||||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
|
||||
export function setLandFillSuppressed(enabled) {
|
||||
_landFillSuppressed = Boolean(enabled);
|
||||
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable / disable the solid dark tint overlay (used when high-res texture is off).
|
||||
*/
|
||||
export function setSurfaceTintEnabled(enabled) {
|
||||
_tintEnabled = Boolean(enabled);
|
||||
if (_tintMesh) _tintMesh.visible = _visible && _tintEnabled;
|
||||
}
|
||||
|
||||
export function getShowCountryBoundaries() {
|
||||
return _visible;
|
||||
}
|
||||
|
||||
/** Clear the hover highlight without hiding the full layer. */
|
||||
export function clearCountryBoundaryHover() {
|
||||
if (!_hoveredFeature) return;
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hover highlight for the given lat/lon coords.
|
||||
* Returns a country-info object when hovering over land, or null over ocean.
|
||||
*/
|
||||
export function updateCountryBoundaryHover(coords) {
|
||||
if (!_loaded || !_visible) return null;
|
||||
const { lat, lon } = coords;
|
||||
|
||||
const found = _features.find(f => featureContains(lat, lon, f)) || null;
|
||||
const groupKey = getCountryHighlightGroupKey(found);
|
||||
|
||||
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
|
||||
_hoveredFeature = found;
|
||||
_hoveredGroupKey = groupKey;
|
||||
if (_hoverLines) {
|
||||
if (!found) {
|
||||
setBoundaryLinesDimmed(false);
|
||||
clearHoverLineGeometries();
|
||||
} else {
|
||||
setBoundaryLinesDimmed(true);
|
||||
const highlightFeatures = getHighlightFeatures(found);
|
||||
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
|
||||
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
||||
if (_hoverGlowLines) {
|
||||
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
|
||||
_hoverGlowLines.geometry.setFromPoints(glowPts);
|
||||
}
|
||||
const corePts = featureListToSegments(highlightFeatures, coreRadius);
|
||||
_hoverLines.geometry.setFromPoints(corePts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found ? makeCountryInfo(found) : null;
|
||||
}
|
||||
|
||||
/** Dispose all Three.js objects and reset state. */
|
||||
export function clearCountryBoundaryData() {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
|
||||
function disposeObj(obj) {
|
||||
if (!obj) return;
|
||||
if (_earthObj) _earthObj.remove(obj);
|
||||
obj.geometry?.dispose();
|
||||
if (obj.material) {
|
||||
if (obj.material.map) obj.material.map.dispose();
|
||||
obj.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
disposeObj(_hoverLines);
|
||||
disposeObj(_hoverGlowLines);
|
||||
disposeObj(_boundaryLines);
|
||||
disposeObj(_landMesh);
|
||||
disposeObj(_tintMesh);
|
||||
|
||||
_hoverLines = null;
|
||||
_hoverGlowLines = null;
|
||||
_boundaryLines = null;
|
||||
_landMesh = null;
|
||||
_tintMesh = null;
|
||||
_features = [];
|
||||
_loaded = false;
|
||||
_loadPromise = null;
|
||||
_visible = false;
|
||||
_landFillEnabled = true;
|
||||
_landFillSuppressed = false;
|
||||
_tintEnabled = false;
|
||||
}
|
||||
|
||||
export function getCountryBoundaryLegendItems() {
|
||||
return [
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
|
||||
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
|
||||
];
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import {
|
||||
CLOUD_LAYER_CONFIG,
|
||||
CONFIG,
|
||||
EARTH_CONFIG,
|
||||
EARTH_MATERIAL_CONFIG,
|
||||
GRID_CONFIG,
|
||||
STARFIELD_CONFIG,
|
||||
TERRAIN_CONFIG,
|
||||
} from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
export let clouds = null;
|
||||
export let terrain = null;
|
||||
let showGridLines = true;
|
||||
let showGridLines = false;
|
||||
let showClouds = true;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
let _earthTextureOverlay = null;
|
||||
let _earthTextureOverlayMaterial = null;
|
||||
let _earthShader = null;
|
||||
let _dayNightEnabled = true;
|
||||
let _loadedTexture = null;
|
||||
let _textureVisible = true;
|
||||
const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
||||
@@ -103,7 +116,7 @@ export function createEarth(scene) {
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
emissive: C.emissive,
|
||||
transparent: true,
|
||||
transparent: C.opacity < 1,
|
||||
opacity: C.opacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: true,
|
||||
@@ -117,6 +130,30 @@ export function createEarth(scene) {
|
||||
earth.rotation.x = EARTH_CONFIG.tiltRad;
|
||||
scene.add(earth);
|
||||
|
||||
const textureOverlayGeometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + C.textureOverlayAltitudeOffset,
|
||||
128,
|
||||
128,
|
||||
);
|
||||
_earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({
|
||||
color: 0xffffff,
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
transparent: true,
|
||||
opacity: C.textureOverlayOpacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
});
|
||||
_earthTextureOverlay = new THREE.Mesh(
|
||||
textureOverlayGeometry,
|
||||
_earthTextureOverlayMaterial,
|
||||
);
|
||||
_earthTextureOverlay.name = "earth-high-res-texture-overlay";
|
||||
_earthTextureOverlay.renderOrder = C.textureOverlayRenderOrder;
|
||||
_earthTextureOverlay.visible = false;
|
||||
earth.add(_earthTextureOverlay);
|
||||
|
||||
// Depth-mask occluder — invisible sphere slightly inside the earth,
|
||||
// writes to the depth buffer so far-side cables/satellites are occluded.
|
||||
const occluderGeometry = new THREE.SphereGeometry(
|
||||
@@ -198,10 +235,14 @@ export function createEarth(scene) {
|
||||
}
|
||||
|
||||
export function createClouds(scene, earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + CLOUD_LAYER_CONFIG.radiusOffset,
|
||||
CLOUD_LAYER_CONFIG.widthSegments,
|
||||
CLOUD_LAYER_CONFIG.heightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
opacity: CLOUD_LAYER_CONFIG.opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
@@ -209,10 +250,12 @@ export function createClouds(scene, earthObj) {
|
||||
});
|
||||
|
||||
clouds = new THREE.Mesh(geometry, material);
|
||||
clouds.name = "earth-atmosphere-clouds";
|
||||
clouds.visible = showClouds;
|
||||
earthObj.add(clouds);
|
||||
|
||||
textureLoader.load(
|
||||
'./assets/earth_clouds_1024.png',
|
||||
CLOUD_LAYER_CONFIG.textureUrl,
|
||||
function(texture) {
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
@@ -226,6 +269,17 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function toggleClouds(visible) {
|
||||
showClouds = Boolean(visible);
|
||||
if (clouds) {
|
||||
clouds.visible = showClouds;
|
||||
}
|
||||
}
|
||||
|
||||
export function getShowClouds() {
|
||||
return showClouds;
|
||||
}
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
@@ -251,7 +305,8 @@ export function createTerrain(earthObj) {
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
terrain.renderOrder = 1.2;
|
||||
terrain.raycast = () => {};
|
||||
earthObj.add(terrain);
|
||||
|
||||
return terrain;
|
||||
@@ -265,11 +320,11 @@ export function toggleTerrain(visible) {
|
||||
|
||||
export function createStars(scene) {
|
||||
const starGeometry = new THREE.BufferGeometry();
|
||||
const starCount = 8000;
|
||||
const starCount = STARFIELD_CONFIG.count;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
|
||||
for (let i = 0; i < starCount * 3; i += 3) {
|
||||
const r = 800 + Math.random() * 200;
|
||||
const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
|
||||
@@ -281,8 +336,8 @@ export function createStars(scene) {
|
||||
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
|
||||
const starMaterial = new THREE.PointsMaterial({
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
color: STARFIELD_CONFIG.color,
|
||||
size: STARFIELD_CONFIG.size,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
@@ -302,17 +357,19 @@ export function createGridLines(scene, earthObj) {
|
||||
latitudeLines = [];
|
||||
longitudeLines = [];
|
||||
|
||||
const earthRadius = 100.1;
|
||||
const earthRadius = CONFIG.earthRadius + GRID_CONFIG.radiusOffset;
|
||||
const gridMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x44aaff,
|
||||
color: GRID_CONFIG.color,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
linewidth: 1
|
||||
opacity: GRID_CONFIG.opacity,
|
||||
linewidth: GRID_CONFIG.lineWidth,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
for (let lat = -75; lat <= 75; lat += 15) {
|
||||
for (let lat = -75; lat <= 75; lat += GRID_CONFIG.latitudeStep) {
|
||||
const points = [];
|
||||
for (let lon = -180; lon <= 180; lon += 5) {
|
||||
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.segmentStep) {
|
||||
const point = latLonToVector3(lat, lon, earthRadius);
|
||||
points.push(point);
|
||||
}
|
||||
@@ -320,14 +377,15 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'latitude', value: lat };
|
||||
line.renderOrder = GRID_CONFIG.renderOrder;
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
latitudeLines.push(line);
|
||||
}
|
||||
|
||||
for (let lon = -180; lon <= 180; lon += 30) {
|
||||
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.longitudeStep) {
|
||||
const points = [];
|
||||
for (let lat = -90; lat <= 90; lat += 5) {
|
||||
for (let lat = -90; lat <= 90; lat += GRID_CONFIG.segmentStep) {
|
||||
const point = latLonToVector3(lat, lon, earthRadius);
|
||||
points.push(point);
|
||||
}
|
||||
@@ -335,6 +393,7 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'longitude', value: lon };
|
||||
line.renderOrder = GRID_CONFIG.renderOrder;
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
longitudeLines.push(line);
|
||||
@@ -359,14 +418,23 @@ export function getEarth() {
|
||||
return earth;
|
||||
}
|
||||
|
||||
export function getEarthSurfacePickTarget() {
|
||||
return _earthTextureOverlay?.visible ? _earthTextureOverlay : earth;
|
||||
}
|
||||
|
||||
export function getClouds() {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function clearEarthTexture() {
|
||||
if (!_earthMaterial) return;
|
||||
_earthMaterial.map = null;
|
||||
_earthMaterial.needsUpdate = true;
|
||||
_loadedTexture = null;
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = null;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setEarthSunDirection(direction) {
|
||||
@@ -390,10 +458,9 @@ export function setDayNightEnabled(enabled) {
|
||||
_earthMaterial.emissiveMap = null;
|
||||
} else {
|
||||
// Full bright: zero diffuse so directional light has no effect;
|
||||
// use original color as emissive map to show texture uniformly.
|
||||
_earthMaterial.color.setRGB(0, 0, 0);
|
||||
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
|
||||
_earthMaterial.emissiveMap = _earthMaterial.map;
|
||||
_earthMaterial.emissiveMap = null;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
@@ -401,7 +468,7 @@ export function setDayNightEnabled(enabled) {
|
||||
|
||||
export function loadEarthTexture() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
if (!_earthTextureOverlayMaterial) { resolve(); return; }
|
||||
|
||||
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
|
||||
const tryLoad = (index) => {
|
||||
@@ -418,12 +485,12 @@ export function loadEarthTexture() {
|
||||
texture.anisotropy = 16;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
_earthMaterial.map = texture;
|
||||
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
|
||||
if (!_dayNightEnabled) {
|
||||
_earthMaterial.emissiveMap = texture;
|
||||
_loadedTexture = texture;
|
||||
_earthTextureOverlayMaterial.map = texture;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = _textureVisible;
|
||||
}
|
||||
_earthMaterial.needsUpdate = true;
|
||||
resolve();
|
||||
},
|
||||
null,
|
||||
@@ -433,3 +500,18 @@ export function loadEarthTexture() {
|
||||
tryLoad(0);
|
||||
});
|
||||
}
|
||||
|
||||
export function setEarthTextureVisible(visible) {
|
||||
_textureVisible = Boolean(visible);
|
||||
if (_earthTextureOverlay) {
|
||||
_earthTextureOverlay.visible = _textureVisible && Boolean(_loadedTexture);
|
||||
}
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = _loadedTexture || null;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getEarthTextureVisible() {
|
||||
return _textureVisible;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ 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);
|
||||
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
|
||||
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
|
||||
const FILL_RINGS = 12;
|
||||
const FILL_SEGMENTS = 48;
|
||||
const RING_SEGMENTS = 72;
|
||||
|
||||
function disposeMaterial(material) {
|
||||
if (!material) return;
|
||||
@@ -19,34 +23,27 @@ function disposeMaterial(material) {
|
||||
function disposeObjectTree(object) {
|
||||
if (!object) return;
|
||||
object.traverse((child) => {
|
||||
if (child.geometry) {
|
||||
child.geometry.dispose();
|
||||
}
|
||||
if (child.material) {
|
||||
disposeMaterial(child.material);
|
||||
}
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) disposeMaterial(child.material);
|
||||
});
|
||||
}
|
||||
|
||||
function createIridiumClusterMaterial() {
|
||||
function createIridiumFillMaterial() {
|
||||
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 },
|
||||
uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) },
|
||||
uOpacity: { value: 0.55 },
|
||||
},
|
||||
vertexShader: `
|
||||
attribute vec2 aUv;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vUv = aUv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
@@ -54,12 +51,10 @@ function createIridiumClusterMaterial() {
|
||||
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;
|
||||
float r2 = dot(vUv, vUv);
|
||||
float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2));
|
||||
float alpha = glow * uOpacity;
|
||||
if (alpha <= 0.001) discard;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
@@ -67,6 +62,17 @@ function createIridiumClusterMaterial() {
|
||||
});
|
||||
}
|
||||
|
||||
function createIridiumRingMaterial() {
|
||||
return new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(IRIDIUM_OVERLAY_COLOR),
|
||||
transparent: true,
|
||||
opacity: 0.75,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
function projectOffsetToSurface(
|
||||
centerNormal,
|
||||
alongTrack,
|
||||
@@ -88,13 +94,55 @@ function projectOffsetToSurface(
|
||||
|
||||
function computeClusterRadiusKm(altitudeKm) {
|
||||
const altitudeScale = THREE.MathUtils.clamp(
|
||||
(Number(altitudeKm) || 780) / 780,
|
||||
(Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM,
|
||||
0.88,
|
||||
1.18,
|
||||
);
|
||||
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
|
||||
}
|
||||
|
||||
function buildFillGeometry() {
|
||||
// Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each.
|
||||
// Positions are updated in world space each frame; indices are static.
|
||||
const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS;
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const uvs = new Float32Array(vertexCount * 2);
|
||||
|
||||
// Center vertex: uv = (0,0)
|
||||
// Edge vertices: uv on unit circle, r = ring/FILL_RINGS
|
||||
|
||||
const indices = [];
|
||||
// Center to first ring: triangle fan
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const a = 1 + s;
|
||||
const b = 1 + (s + 1) % FILL_SEGMENTS;
|
||||
indices.push(0, a, b);
|
||||
}
|
||||
// Ring to ring
|
||||
for (let r = 0; r < FILL_RINGS - 1; r++) {
|
||||
const ringBase = 1 + r * FILL_SEGMENTS;
|
||||
const nextBase = ringBase + FILL_SEGMENTS;
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const s1 = (s + 1) % FILL_SEGMENTS;
|
||||
indices.push(ringBase + s, nextBase + s, ringBase + s1);
|
||||
indices.push(nextBase + s, nextBase + s1, ringBase + s1);
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function buildRingGeometry() {
|
||||
const positions = new Float32Array(RING_SEGMENTS * 3);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function createIridiumFootprintAdapter({
|
||||
earthObj,
|
||||
earthRadiusWorld,
|
||||
@@ -105,19 +153,21 @@ export function createIridiumFootprintAdapter({
|
||||
const group = new THREE.Group();
|
||||
group.name = "iridium-footprint-overlay";
|
||||
group.renderOrder = renderOrder;
|
||||
group.userData = {
|
||||
earthRadiusWorld,
|
||||
clusterGlow: null,
|
||||
};
|
||||
group.userData = { earthRadiusWorld, fill: null, outerRing: 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;
|
||||
const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial());
|
||||
fill.name = "iridium-cluster-fill";
|
||||
fill.renderOrder = renderOrder;
|
||||
fill.frustumCulled = false;
|
||||
group.add(fill);
|
||||
group.userData.fill = fill;
|
||||
|
||||
const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial());
|
||||
outerRing.name = "iridium-outer-ring";
|
||||
outerRing.renderOrder = renderOrder;
|
||||
outerRing.frustumCulled = false;
|
||||
group.add(outerRing);
|
||||
group.userData.outerRing = outerRing;
|
||||
|
||||
earthObj.add(group);
|
||||
return group;
|
||||
@@ -129,30 +179,65 @@ export function updateIridiumFootprintAdapter(
|
||||
) {
|
||||
if (!group || !position || !alongTrack || !crossTrack) return;
|
||||
|
||||
const earthRadiusWorld =
|
||||
group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
|
||||
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;
|
||||
const alongRadiusKm = clusterRadiusKm * 1.18;
|
||||
const crossRadiusKm = clusterRadiusKm * 0.96;
|
||||
|
||||
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,
|
||||
const fill = group.userData?.fill;
|
||||
if (fill) {
|
||||
const posAttr = fill.geometry.attributes.position;
|
||||
const uvAttr = fill.geometry.attributes.aUv;
|
||||
|
||||
// Center vertex
|
||||
const center = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld,
|
||||
);
|
||||
posAttr.setXYZ(0, center.x, center.y, center.z);
|
||||
uvAttr.setXY(0, 0, 0);
|
||||
|
||||
// Ring vertices
|
||||
for (let r = 1; r <= FILL_RINGS; r++) {
|
||||
const t = r / FILL_RINGS;
|
||||
const aKm = alongRadiusKm * t;
|
||||
const cKm = crossRadiusKm * t;
|
||||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||||
const angle = (s / FILL_SEGMENTS) * Math.PI * 2;
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
const pt = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack,
|
||||
aKm * cosA,
|
||||
cKm * sinA,
|
||||
earthRadiusWorld,
|
||||
);
|
||||
const vi = 1 + (r - 1) * FILL_SEGMENTS + s;
|
||||
posAttr.setXYZ(vi, pt.x, pt.y, pt.z);
|
||||
uvAttr.setXY(vi, t * cosA, t * sinA);
|
||||
}
|
||||
}
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
uvAttr.needsUpdate = true;
|
||||
fill.geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
const outerRing = group.userData?.outerRing;
|
||||
if (outerRing) {
|
||||
const posAttr = outerRing.geometry.attributes.position;
|
||||
for (let k = 0; k < RING_SEGMENTS; k++) {
|
||||
const angle = (k / RING_SEGMENTS) * Math.PI * 2;
|
||||
const pt = projectOffsetToSurface(
|
||||
centerNormal, alongTrack, crossTrack,
|
||||
alongRadiusKm * Math.cos(angle),
|
||||
crossRadiusKm * Math.sin(angle),
|
||||
earthRadiusWorld,
|
||||
);
|
||||
posAttr.setXYZ(k, pt.x, pt.y, pt.z);
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
outerRing.geometry.computeBoundingSphere();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ export function setLayerButtonState(button, options = {}) {
|
||||
const {
|
||||
active = null,
|
||||
loading = false,
|
||||
disabled = null,
|
||||
tooltip = null,
|
||||
statusText = null,
|
||||
} = options;
|
||||
button.classList.toggle("is-loading", loading);
|
||||
button.toggleAttribute("aria-busy", loading);
|
||||
button.disabled = loading;
|
||||
button.disabled = loading || (disabled === true);
|
||||
button.classList.toggle("is-disabled", disabled === true);
|
||||
if (typeof active === "boolean") {
|
||||
updateLayerButtonState(button, active);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
loadComputeCenters,
|
||||
toggleComputeCenters,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
getCountryBoundaryLegendItems,
|
||||
loadCountryBoundaries,
|
||||
toggleCountryBoundaries,
|
||||
} from "./country-boundaries.js";
|
||||
|
||||
/**
|
||||
* Layer startup task registry.
|
||||
@@ -72,10 +77,11 @@ export function registerLayerStartupTask(id, taskFactory) {
|
||||
|
||||
function registerBuiltinLayerStartupTasks() {
|
||||
startupTaskRegistry.clear();
|
||||
registerCountryBoundaryStartupTask();
|
||||
registerCableStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
registerComputeCenterStartupTask();
|
||||
registerBGPStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
}
|
||||
|
||||
function registerCableStartupTask() {
|
||||
@@ -176,6 +182,32 @@ function registerBGPStartupTask() {
|
||||
});
|
||||
}
|
||||
|
||||
function registerCountryBoundaryStartupTask() {
|
||||
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
await loadCountryBoundaries();
|
||||
if (!context.isCancelled()) {
|
||||
const textureOn = context.isEarthTextureVisible();
|
||||
toggleCountryBoundaries(context.getShowCountryBoundaries(), {
|
||||
showTint: !textureOn,
|
||||
showLandFill: true,
|
||||
suppressLandFill: false,
|
||||
});
|
||||
context.setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "国界", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
function registerComputeCenterStartupTask() {
|
||||
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHUDPanel } from "./hud-panels.js";
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
countryBoundaries: { title: "国界" },
|
||||
computeCenters: { title: "算力" },
|
||||
bgp: { title: "BGP" },
|
||||
};
|
||||
@@ -12,6 +13,7 @@ let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
countryBoundaries: [],
|
||||
computeCenters: [],
|
||||
bgp: [],
|
||||
};
|
||||
|
||||
@@ -33,11 +33,17 @@ import {
|
||||
createEarth,
|
||||
createClouds,
|
||||
createTerrain,
|
||||
getShowClouds,
|
||||
createGridLines,
|
||||
getEarth,
|
||||
getEarthSurfacePickTarget,
|
||||
loadEarthTexture,
|
||||
clearEarthTexture,
|
||||
setEarthSunDirection,
|
||||
setEarthTextureVisible,
|
||||
getEarthTextureVisible,
|
||||
toggleClouds,
|
||||
toggleTerrain,
|
||||
} from "./earth.js";
|
||||
import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js";
|
||||
import {
|
||||
@@ -50,6 +56,19 @@ import {
|
||||
setCelestialFollow,
|
||||
setCelestialDayNightEnabled,
|
||||
} from "./celestial.js";
|
||||
import {
|
||||
clearCountryBoundaryData,
|
||||
clearCountryBoundaryHover,
|
||||
createCountryBoundaryLayer,
|
||||
ensureCountryBoundariesReady,
|
||||
getCountryBoundaryLegendItems,
|
||||
getShowCountryBoundaries,
|
||||
setLandFillEnabled,
|
||||
setLandFillSuppressed,
|
||||
setSurfaceTintEnabled,
|
||||
toggleCountryBoundaries,
|
||||
updateCountryBoundaryHover,
|
||||
} from "./country-boundaries.js";
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
loadLandingPoints,
|
||||
@@ -162,6 +181,10 @@ import {
|
||||
getZoomLevel,
|
||||
setZoomLevel,
|
||||
teardownControls,
|
||||
getDayNightEnabled,
|
||||
setDayNightEnabledExternal,
|
||||
setTerrainLayerInteractable,
|
||||
setDayNightInteractable,
|
||||
} from "./controls.js";
|
||||
import {
|
||||
createLayerStartupTaskMap,
|
||||
@@ -227,7 +250,7 @@ let destroyed = false;
|
||||
let isDataLoading = false;
|
||||
let currentLoadToken = 0;
|
||||
let cablesEnabled = true;
|
||||
let satellitesEnabled = true;
|
||||
let satellitesEnabled = false;
|
||||
let cableToggleToken = 0;
|
||||
let satelliteToggleToken = 0;
|
||||
let satelliteHydrationToken = 0;
|
||||
@@ -242,6 +265,7 @@ let activeTouchPoints = new Map();
|
||||
let pinchGesture = null;
|
||||
let pointerDragDistance = 0;
|
||||
let suppressNextClick = false;
|
||||
let shouldRefreshSatellitesAfterVisibilityResume = false;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const interactionRaycaster = new THREE.Raycaster();
|
||||
@@ -253,6 +277,8 @@ const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchComputeCenterDirection = new THREE.Vector3();
|
||||
const scratchComputeCenterWorldPosition = new THREE.Vector3();
|
||||
const scratchSatelliteWorldPosition = new THREE.Vector3();
|
||||
const scratchSatelliteScreenPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
|
||||
const cleanupFns = [];
|
||||
@@ -471,6 +497,7 @@ function resetTransientComputeCenterStates() {
|
||||
function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
clearCountryBoundaryHover();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
|
||||
@@ -647,6 +674,13 @@ function getComputeCenterBriefHtml(marker) {
|
||||
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
|
||||
}
|
||||
|
||||
function getCountryBoundaryBriefHtml(country) {
|
||||
const name = country?.nameZh || country?.name || "未知国家";
|
||||
const code = country?.isoA3 || country?.isoA2 || "-";
|
||||
const continent = country?.continent || "-";
|
||||
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
|
||||
}
|
||||
|
||||
function showBGPInfo(marker, coords) {
|
||||
setLegendMode("bgp");
|
||||
const impactedRegions =
|
||||
@@ -1215,11 +1249,11 @@ function getBGPStatusText(bgpResult) {
|
||||
function updateComputeCenterHud(computeCenterResult) {
|
||||
const computeBtn = document.getElementById("toggle-compute-centers");
|
||||
if (computeBtn) {
|
||||
computeBtn.classList.add("active");
|
||||
const tooltip = computeBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = "隐藏算力中心";
|
||||
}
|
||||
setLayerButtonState(computeBtn, {
|
||||
active: getShowComputeCenters(),
|
||||
loading: false,
|
||||
tooltip: getShowComputeCenters() ? "隐藏算力中心" : "显示算力中心",
|
||||
});
|
||||
}
|
||||
|
||||
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount} 个`);
|
||||
@@ -1228,11 +1262,11 @@ function updateComputeCenterHud(computeCenterResult) {
|
||||
function updateBGPHud(bgpResult) {
|
||||
const bgpBtn = document.getElementById("toggle-bgp");
|
||||
if (bgpBtn) {
|
||||
bgpBtn.classList.add("active");
|
||||
const tooltip = bgpBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = ACTIVE_BGP_TOOLTIP_TEXT;
|
||||
}
|
||||
setLayerButtonState(bgpBtn, {
|
||||
active: getShowBGP(),
|
||||
loading: false,
|
||||
tooltip: getShowBGP() ? ACTIVE_BGP_TOOLTIP_TEXT : "显示BGP观测",
|
||||
});
|
||||
}
|
||||
|
||||
setEarthStatValue("bgp-anomaly-count", `${bgpResult.totalCount} 起`);
|
||||
@@ -1779,6 +1813,58 @@ function updatePointerFromEvent(event) {
|
||||
interactionRaycaster.setFromCamera(interactionMouse, camera);
|
||||
}
|
||||
|
||||
function getSatellitePointerIntersections(event) {
|
||||
if (!renderer || !camera || !getShowSatellites()) return [];
|
||||
|
||||
const satPoints = getSatellitePoints();
|
||||
const satPositions = getSatellitePositions();
|
||||
if (!satPoints?.visible || !Array.isArray(satPositions) || satPositions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const shaderSize = satPoints.material?.uniforms?.size?.value;
|
||||
const dotSizePx = Number.isFinite(shaderSize)
|
||||
? shaderSize / dpr
|
||||
: SATELLITE_CONFIG.dotBaseSize;
|
||||
const pickRadiusPx = Math.max(12, Math.min(30, dotSizePx * 4));
|
||||
const pickRadiusSq = pickRadiusPx * pickRadiusPx;
|
||||
const hits = [];
|
||||
|
||||
satPoints.updateMatrixWorld(true);
|
||||
camera.updateMatrixWorld(true);
|
||||
|
||||
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
|
||||
for (let index = 0; index < satelliteCount; index++) {
|
||||
const position = satPositions[index]?.current;
|
||||
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
|
||||
|
||||
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
|
||||
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
|
||||
if (
|
||||
scratchSatelliteScreenPosition.z < -1 ||
|
||||
scratchSatelliteScreenPosition.z > 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const screenX =
|
||||
rect.left + (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * rect.width;
|
||||
const screenY =
|
||||
rect.top + (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * rect.height;
|
||||
const dx = screenX - event.clientX;
|
||||
const dy = screenY - event.clientY;
|
||||
const distanceSq = dx * dx + dy * dy;
|
||||
if (distanceSq <= pickRadiusSq) {
|
||||
hits.push({ index, distanceSq });
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort((a, b) => a.distanceSq - b.distanceSq);
|
||||
return hits;
|
||||
}
|
||||
|
||||
function buildLoadErrorMessage(errors) {
|
||||
if (errors.length === 0) return "";
|
||||
return errors
|
||||
@@ -1921,6 +2007,7 @@ function disableSatellites() {
|
||||
satellitesEnabled = false;
|
||||
satelliteToggleToken += 1;
|
||||
satelliteHydrationToken += 1;
|
||||
toggleSatellites(false);
|
||||
resetSatelliteState();
|
||||
updateSatelliteToggleUi(false, 0);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
@@ -2001,6 +2088,7 @@ export function init() {
|
||||
initLegend();
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
const earthObj = createEarth(scene);
|
||||
@@ -2022,15 +2110,28 @@ export function init() {
|
||||
});
|
||||
setCelestialDayNightEnabled(true);
|
||||
createGridLines(scene, earthObj);
|
||||
createCountryBoundaryLayer(earthObj);
|
||||
createSatellites(scene, earthObj);
|
||||
|
||||
setupControls(camera, renderer, scene, earthObj);
|
||||
setupEventListeners();
|
||||
|
||||
clock.start();
|
||||
loadData();
|
||||
animate();
|
||||
registerGlobalApi();
|
||||
setupControls(camera, renderer, scene, earthObj)
|
||||
.catch((error) => {
|
||||
console.error("初始化 Earth 控制项失败:", error);
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "init",
|
||||
module: "controls",
|
||||
message: `初始化 Earth 控制项失败: ${error?.message || String(error)}`,
|
||||
detail: error,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (destroyed) return;
|
||||
setupEventListeners();
|
||||
clock.start();
|
||||
loadData();
|
||||
animate();
|
||||
registerGlobalApi();
|
||||
});
|
||||
}
|
||||
|
||||
function registerGlobalApi() {
|
||||
@@ -2161,6 +2262,7 @@ async function loadData() {
|
||||
clearCableData(earth);
|
||||
clearComputeCenterData(earth);
|
||||
clearSatelliteData();
|
||||
clearCountryBoundaryHover();
|
||||
|
||||
setLoadingMessage("正在初始化...");
|
||||
setLoading(true);
|
||||
@@ -2193,7 +2295,9 @@ async function loadData() {
|
||||
updateComputeCenterHud,
|
||||
updateBGPHud,
|
||||
getShowComputeCenters,
|
||||
getShowCountryBoundaries,
|
||||
getShowBGP,
|
||||
isEarthTextureVisible: () => getEarthTextureVisible(),
|
||||
getInitialSatelliteLoadLimit,
|
||||
shouldHydrateFullSatelliteSet,
|
||||
scheduleSatellitePositionWarmup,
|
||||
@@ -2246,6 +2350,7 @@ async function loadData() {
|
||||
updateSatelliteToggleUi(satellitesEnabled);
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
refreshLegend();
|
||||
@@ -2337,6 +2442,102 @@ export async function setCablesEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCountryBoundariesEnabled(
|
||||
enabled,
|
||||
{ suppressStatus = false } = {},
|
||||
) {
|
||||
if (!enabled) {
|
||||
toggleCountryBoundaries(false);
|
||||
clearCountryBoundaryHover();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("国界已隐藏", "info");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const countryCount = await ensureCountryBoundariesReady();
|
||||
const textureOn = getEarthTextureVisible();
|
||||
toggleCountryBoundaries(true, {
|
||||
showTint: !textureOn,
|
||||
showLandFill: true,
|
||||
suppressLandFill: false,
|
||||
});
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
refreshLegend();
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("国界已显示", "info");
|
||||
}
|
||||
return countryCount;
|
||||
} catch (error) {
|
||||
toggleCountryBoundaries(false);
|
||||
clearCountryBoundaryHover();
|
||||
const message = `国界加载失败: ${error?.message || String(error)}`;
|
||||
void reportEarthClientLog({
|
||||
level: "error",
|
||||
category: "layer-toggle",
|
||||
module: "country-boundaries",
|
||||
message,
|
||||
detail: error,
|
||||
});
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(message, "error");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let _dayNightBeforeTextureOff = null;
|
||||
let _terrainBeforeTextureOff = null;
|
||||
|
||||
export function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) {
|
||||
setEarthTextureVisible(enabled);
|
||||
setSurfaceTintEnabled(!enabled);
|
||||
setLandFillEnabled(true);
|
||||
setLandFillSuppressed(false);
|
||||
setTerrainLayerInteractable(enabled);
|
||||
setDayNightInteractable(enabled);
|
||||
if (!enabled) {
|
||||
if (_terrainBeforeTextureOff === null) {
|
||||
_terrainBeforeTextureOff = getShowTerrain();
|
||||
}
|
||||
toggleTerrain(false);
|
||||
if (_dayNightBeforeTextureOff === null) {
|
||||
_dayNightBeforeTextureOff = getDayNightEnabled();
|
||||
}
|
||||
setDayNightEnabledExternal(false, { persist: false });
|
||||
} else {
|
||||
if (_terrainBeforeTextureOff !== null) {
|
||||
toggleTerrain(_terrainBeforeTextureOff);
|
||||
_terrainBeforeTextureOff = null;
|
||||
}
|
||||
if (_dayNightBeforeTextureOff !== null) {
|
||||
setDayNightEnabledExternal(_dayNightBeforeTextureOff, { persist: false });
|
||||
_dayNightBeforeTextureOff = null;
|
||||
}
|
||||
}
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "高清材质已启用" : "高清材质已隐藏", "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function getHighResTextureEnabled() {
|
||||
return getEarthTextureVisible();
|
||||
}
|
||||
|
||||
export function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) {
|
||||
toggleClouds(enabled);
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function getAtmosphereCloudsEnabled() {
|
||||
return getShowClouds();
|
||||
}
|
||||
|
||||
export async function setSatellitesEnabled(
|
||||
enabled,
|
||||
{ suppressStatus = false, suppressLoadingUi = false } = {},
|
||||
@@ -2392,6 +2593,7 @@ export async function setSatellitesEnabled(
|
||||
|
||||
function setupEventListeners() {
|
||||
const handleResize = () => onWindowResize();
|
||||
const handleVisibilityChange = () => onVisibilityChange();
|
||||
const handlePointerMove = (event) => onPointerMove(event);
|
||||
const handlePointerDown = (event) => onPointerDown(event);
|
||||
const handlePointerUp = (event) => onPointerUp(event);
|
||||
@@ -2403,6 +2605,7 @@ function setupEventListeners() {
|
||||
const handleInfoCardDrag = () => repositionCruiseConnector();
|
||||
|
||||
bindListener(window, "resize", handleResize);
|
||||
bindListener(document, "visibilitychange", handleVisibilityChange);
|
||||
bindListener(window, "pagehide", handlePageHide);
|
||||
bindListener(window, "beforeunload", handlePageHide);
|
||||
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
|
||||
@@ -2425,6 +2628,20 @@ function setupEventListeners() {
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
shouldRefreshSatellitesAfterVisibilityResume = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRefreshSatellitesAfterVisibilityResume) return;
|
||||
shouldRefreshSatellitesAfterVisibilityResume = false;
|
||||
|
||||
// Drop the elapsed background time so the next RAF does not replay it.
|
||||
clock.getDelta();
|
||||
updateSatellitePositions(0, true, { resetTrails: true });
|
||||
}
|
||||
|
||||
function updateHudScale() {
|
||||
const widthScale = window.innerWidth / HUD_CONFIG.scaleReferenceWidth;
|
||||
const heightScale = window.innerHeight / HUD_CONFIG.scaleReferenceHeight;
|
||||
@@ -2544,6 +2761,7 @@ function onMouseMove(event) {
|
||||
inertialVelocity.y = rotationDeltaY;
|
||||
inertialVelocity.x = rotationDeltaX;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
@@ -2573,18 +2791,11 @@ function onMouseMove(event) {
|
||||
|
||||
let hoveredSat = null;
|
||||
let hoveredSatIndexFromIntersect = null;
|
||||
if (getShowSatellites()) {
|
||||
const satPoints = getSatellitePoints();
|
||||
if (satPoints) {
|
||||
const satIntersects = interactionRaycaster.intersectObject(satPoints);
|
||||
if (satIntersects.length > 0) {
|
||||
const satIndex = satIntersects[0].index;
|
||||
if (isSatelliteFrontFacing(satIndex, camera)) {
|
||||
hoveredSatIndexFromIntersect = satIndex;
|
||||
hoveredSat = selectSatellite(satIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
const satIntersects = getSatellitePointerIntersections(event);
|
||||
if (satIntersects.length > 0) {
|
||||
const satIndex = satIntersects[0].index;
|
||||
hoveredSatIndexFromIntersect = satIndex;
|
||||
hoveredSat = selectSatellite(satIndex);
|
||||
}
|
||||
|
||||
const hoveredBGPMarker = getPrimaryBGPHoverTarget(
|
||||
@@ -2689,7 +2900,7 @@ function onMouseMove(event) {
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
earth,
|
||||
getEarthSurfacePickTarget() || earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
@@ -2697,6 +2908,18 @@ function onMouseMove(event) {
|
||||
if (earthPoint) {
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||||
const hoveredCountry = getShowCountryBoundaries()
|
||||
? updateCountryBoundaryHover(coords)
|
||||
: null;
|
||||
if (hoveredCountry) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
getCountryBoundaryBriefHtml(hoveredCountry),
|
||||
);
|
||||
return;
|
||||
}
|
||||
clearCountryBoundaryHover();
|
||||
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
|
||||
const elevText = elevMeters !== null
|
||||
? elevMeters >= 1000
|
||||
@@ -2709,8 +2932,11 @@ function onMouseMove(event) {
|
||||
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
|
||||
);
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
}
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2847,6 +3073,7 @@ function onPointerUp(event) {
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
}
|
||||
|
||||
@@ -2881,9 +3108,7 @@ function onClick(event) {
|
||||
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
|
||||
)
|
||||
: [];
|
||||
const satIntersects = getShowSatellites()
|
||||
? interactionRaycaster.intersectObject(getSatellitePoints())
|
||||
: [];
|
||||
const satIntersects = getSatellitePointerIntersections(event);
|
||||
|
||||
const clickedBGPMarker = getShowBGP()
|
||||
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
|
||||
@@ -2979,9 +3204,7 @@ function onClick(event) {
|
||||
const clickX = event.clientX;
|
||||
const clickY = event.clientY;
|
||||
|
||||
const frontFacingSats = satIntersects.filter((sat) =>
|
||||
isSatelliteFrontFacing(sat.index, camera),
|
||||
);
|
||||
const frontFacingSats = satIntersects;
|
||||
if (frontFacingSats.length === 0) return;
|
||||
|
||||
let selectedIndex = frontFacingSats[0].index;
|
||||
@@ -3174,6 +3397,7 @@ export function destroy() {
|
||||
clearCableData(getEarth());
|
||||
clearBGPData(getEarth());
|
||||
clearComputeCenterData(getEarth());
|
||||
clearCountryBoundaryData();
|
||||
resetSatelliteState();
|
||||
clearUiState();
|
||||
disposeCelestialLayer();
|
||||
|
||||
@@ -42,6 +42,8 @@ let satelliteCapacity = 0;
|
||||
let satelliteSatrecCache = new Map();
|
||||
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
|
||||
|
||||
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
|
||||
|
||||
const SATELLITE_FOOTPRINT_POLICIES = Object.freeze({
|
||||
NONE: "none",
|
||||
STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
|
||||
@@ -66,8 +68,64 @@ const SATELLITE_CONSTELLATION_LABELS = Object.freeze({
|
||||
});
|
||||
|
||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||
|
||||
const TRAIL_RIBBON_VERTEX_SHADER = /* glsl */ `
|
||||
attribute vec3 instanceStart;
|
||||
attribute vec3 instanceEnd;
|
||||
attribute vec3 instanceColorStart;
|
||||
attribute vec3 instanceColorEnd;
|
||||
uniform vec2 resolution;
|
||||
uniform float lineWidth;
|
||||
varying vec3 vColor;
|
||||
void main() {
|
||||
float t = position.x;
|
||||
float side = position.y;
|
||||
|
||||
vec4 clipStart = projectionMatrix * modelViewMatrix * vec4(instanceStart, 1.0);
|
||||
vec4 clipEnd = projectionMatrix * modelViewMatrix * vec4(instanceEnd, 1.0);
|
||||
|
||||
vec2 screenStart = (clipStart.xy / clipStart.w * 0.5 + 0.5) * resolution;
|
||||
vec2 screenEnd = (clipEnd.xy / clipEnd.w * 0.5 + 0.5) * resolution;
|
||||
vec2 dir = screenEnd - screenStart;
|
||||
float segLen = length(dir);
|
||||
|
||||
vec4 clipPos = mix(clipStart, clipEnd, t);
|
||||
|
||||
if (segLen > 0.001) {
|
||||
dir /= segLen;
|
||||
vec2 normal = vec2(-dir.y, dir.x);
|
||||
clipPos.xy += normal * side * lineWidth * 0.5 / resolution * 2.0 * clipPos.w;
|
||||
vColor = mix(instanceColorStart, instanceColorEnd, t);
|
||||
} else {
|
||||
vColor = vec3(0.0);
|
||||
}
|
||||
|
||||
gl_Position = clipPos;
|
||||
}
|
||||
`;
|
||||
|
||||
const TRAIL_RIBBON_FRAGMENT_SHADER = /* glsl */ `
|
||||
varying vec3 vColor;
|
||||
void main() {
|
||||
gl_FragColor = vec4(vColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [
|
||||
"instanceStart",
|
||||
"instanceEnd",
|
||||
"instanceColorStart",
|
||||
"instanceColorEnd",
|
||||
];
|
||||
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FALLBACK_MIN_MEAN_MOTION = 12;
|
||||
const FALLBACK_MEAN_MOTION_SPREAD = 4;
|
||||
const FALLBACK_TRAIL_TIP_LENGTH = 0.004;
|
||||
const FALLBACK_TRAIL_ALPHA_START = 0.2;
|
||||
const FALLBACK_TRAIL_ALPHA_END = 0.8;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
const POSITION_UPDATE_INTERVAL_MS = 250;
|
||||
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
|
||||
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
|
||||
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
|
||||
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
|
||||
@@ -81,8 +139,10 @@ const LOCKED_HALO_CORE_PIXEL_RADIUS = 8;
|
||||
const LOCKED_HALO_PIXEL_RADIUS = 24;
|
||||
const FILLED_TEXTURE_RADIUS_RATIO = 0.28;
|
||||
const LOCKED_RING_IDLE_SCALE = 0.68;
|
||||
const LOCKED_RING_HOVER_SCALE = 1.32;
|
||||
const LOCKED_RING_HOVER_LINE_WIDTH = 5;
|
||||
const HOVER_RING_LINE_WIDTH = 3;
|
||||
const LOCKED_RING_IDLE_OPACITY = 0.92;
|
||||
const DOT_ZOOM_SCALE_POWER = 1;
|
||||
const EARTH_RADIUS_KM = 6378.137;
|
||||
const GROUND_FOOTPRINT_MIN_ELEVATION_DEG = 25;
|
||||
const GROUND_FOOTPRINT_RADIUS_OFFSET = 0.72;
|
||||
@@ -112,6 +172,71 @@ const satelliteSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
|
||||
|
||||
export let breathingPhase = 0;
|
||||
|
||||
function getPointPixelRatio() {
|
||||
return typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
||||
}
|
||||
|
||||
function getSatelliteDotBaseSize() {
|
||||
return SATELLITE_CONFIG.dotBaseSize;
|
||||
}
|
||||
|
||||
function getSatelliteDotBackdropSize() {
|
||||
return getSatelliteDotBaseSize() * SATELLITE_CONFIG.dotBackdropScale;
|
||||
}
|
||||
|
||||
function getSatelliteDotZoomScale(cameraDistance) {
|
||||
return Math.pow(CONFIG.defaultCameraZ / Math.max(cameraDistance, 1), SATELLITE_CONFIG.dotZoomScalePower);
|
||||
}
|
||||
|
||||
function createSatellitePointMaterial({
|
||||
texture,
|
||||
size,
|
||||
opacity = 1,
|
||||
useVertexColor = true,
|
||||
baseColor = 0xffffff,
|
||||
alphaTest = 0.04,
|
||||
}) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
pointTexture: { value: texture },
|
||||
size: { value: size * getPointPixelRatio() },
|
||||
opacity: { value: opacity },
|
||||
baseColor: { value: new THREE.Color(baseColor) },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
vertexShader: `
|
||||
uniform float size;
|
||||
uniform vec3 baseColor;
|
||||
attribute float alpha;
|
||||
${useVertexColor ? "attribute vec3 color;" : ""}
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vAlpha = alpha;
|
||||
vColor = ${useVertexColor ? "color" : "baseColor"};
|
||||
gl_PointSize = size;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D pointTexture;
|
||||
uniform float opacity;
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vec4 texel = texture2D(pointTexture, gl_PointCoord);
|
||||
float alphaValue = texel.a * opacity * vAlpha;
|
||||
if (alphaValue < ${alphaTest.toFixed(3)}) discard;
|
||||
gl_FragColor = vec4(vColor * texel.rgb, alphaValue);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
const SATELLITE_LEGEND_RULES = [
|
||||
{
|
||||
key: "equatorial",
|
||||
@@ -197,9 +322,22 @@ export function updateBreathingPhase(deltaTime = 16) {
|
||||
export function updateSatellitePointSize() {
|
||||
if (!satellitePoints || !cameraRef) return;
|
||||
const camDist = cameraRef.position.length();
|
||||
const zoomScale = Math.pow(CONFIG.defaultCameraZ / Math.max(camDist, 1), DOT_ZOOM_SCALE_POWER);
|
||||
satellitePoints.material.size = SATELLITE_CONFIG.dotSize * zoomScale;
|
||||
satelliteBackdropPoints.material.size = SATELLITE_CONFIG.dotSize * 1.28 * zoomScale;
|
||||
const zoomScale = getSatelliteDotZoomScale(camDist);
|
||||
const pointPixelRatio = getPointPixelRatio();
|
||||
const dotSize = getSatelliteDotBaseSize() * zoomScale;
|
||||
const backdropSize = getSatelliteDotBackdropSize() * zoomScale;
|
||||
const shaderDotSize = dotSize * pointPixelRatio;
|
||||
const shaderBackdropSize = backdropSize * pointPixelRatio;
|
||||
if (satellitePoints.material.uniforms?.size) {
|
||||
satellitePoints.material.uniforms.size.value = shaderDotSize;
|
||||
} else {
|
||||
satellitePoints.material.size = dotSize;
|
||||
}
|
||||
if (satelliteBackdropPoints.material.uniforms?.size) {
|
||||
satelliteBackdropPoints.material.uniforms.size.value = shaderBackdropSize;
|
||||
} else {
|
||||
satelliteBackdropPoints.material.size = backdropSize;
|
||||
}
|
||||
}
|
||||
|
||||
function getBreathingPulse(phase) {
|
||||
@@ -338,7 +476,7 @@ function createBackdropDotTexture() {
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
function createRingTexture(innerRadius, outerRadius, color = "#ffffff", lineWidth = 3) {
|
||||
const size = DOT_TEXTURE_SIZE * 2;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
@@ -347,7 +485,7 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
const center = size / 2;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
@@ -384,26 +522,21 @@ export function createSatellites(scene, earthObj) {
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
const backdropGeometry = new THREE.BufferGeometry();
|
||||
|
||||
const backdropMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize * 1.28,
|
||||
map: backdropTexture,
|
||||
color: 0x0b1626,
|
||||
transparent: true,
|
||||
const backdropMaterial = createSatellitePointMaterial({
|
||||
size: getSatelliteDotBackdropSize(),
|
||||
texture: backdropTexture,
|
||||
useVertexColor: false,
|
||||
baseColor: 0x0b1626,
|
||||
opacity: 0.42,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.04,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const pointsMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize,
|
||||
map: dotTexture,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
const pointsMaterial = createSatellitePointMaterial({
|
||||
size: getSatelliteDotBaseSize(),
|
||||
texture: dotTexture,
|
||||
useVertexColor: true,
|
||||
opacity: 0.9,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.1,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
|
||||
@@ -444,16 +577,32 @@ export function createSatellites(scene, earthObj) {
|
||||
earthObj.add(satelliteBackdropPoints);
|
||||
earthObj.add(satellitePoints);
|
||||
|
||||
const trailGeometry = new THREE.BufferGeometry();
|
||||
// Instanced screen-space ribbon: one quad instance per trail segment.
|
||||
// Single mesh / single draw call for all satellite trails.
|
||||
const ribbonGeometry = new THREE.InstancedBufferGeometry();
|
||||
// Base quad: position.x = t (0=seg-start, 1=seg-end), position.y = side (-1/+1)
|
||||
ribbonGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3),
|
||||
);
|
||||
ribbonGeometry.setIndex(new THREE.BufferAttribute(new Uint16Array([0, 2, 1, 2, 3, 1]), 1));
|
||||
|
||||
const trailMaterial = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
const trailResolution = new THREE.Vector2(window.innerWidth, window.innerHeight);
|
||||
const trailMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
lineWidth: { value: SATELLITE_CONFIG.trailLineWidth },
|
||||
resolution: { value: trailResolution },
|
||||
},
|
||||
vertexShader: TRAIL_RIBBON_VERTEX_SHADER,
|
||||
fragmentShader: TRAIL_RIBBON_FRAGMENT_SHADER,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
|
||||
satelliteTrails = new THREE.Mesh(ribbonGeometry, trailMaterial);
|
||||
satelliteTrails.onBeforeRender = (renderer) => renderer.getSize(trailResolution);
|
||||
satelliteTrails.frustumCulled = false;
|
||||
satelliteTrails.visible = false;
|
||||
satelliteTrails.userData = { type: "satelliteTrails" };
|
||||
earthObj.add(satelliteTrails);
|
||||
@@ -481,6 +630,31 @@ function createSatellitePositionState() {
|
||||
};
|
||||
}
|
||||
|
||||
function resetSatelliteTrailState() {
|
||||
satellitePositions.forEach((position) => {
|
||||
position.trail = [];
|
||||
position.trailIndex = 0;
|
||||
position.trailCount = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSatelliteTrailGeometry() {
|
||||
if (!satelliteTrails) return;
|
||||
|
||||
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
|
||||
const attr = satelliteTrails.geometry.attributes[name];
|
||||
if (attr?.array) {
|
||||
attr.array.fill(0);
|
||||
attr.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSatelliteTrails() {
|
||||
resetSatelliteTrailState();
|
||||
clearSatelliteTrailGeometry();
|
||||
}
|
||||
|
||||
function ensureSatelliteCapacity(count) {
|
||||
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
|
||||
|
||||
@@ -492,16 +666,27 @@ function ensureSatelliteCapacity(count) {
|
||||
const previousBackdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position?.array || null;
|
||||
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
|
||||
const previousTrailPositions =
|
||||
satelliteTrails.geometry.attributes.position?.array || null;
|
||||
const previousTrailColors =
|
||||
satelliteTrails.geometry.attributes.color?.array || null;
|
||||
const previousPointAlphas = satellitePoints.geometry.attributes.alpha?.array || null;
|
||||
const previousBackdropAlphas =
|
||||
satelliteBackdropPoints.geometry.attributes.alpha?.array || null;
|
||||
const previousInstanceStarts =
|
||||
satelliteTrails.geometry.attributes.instanceStart?.array || null;
|
||||
const previousInstanceEnds =
|
||||
satelliteTrails.geometry.attributes.instanceEnd?.array || null;
|
||||
const previousInstanceColorStarts =
|
||||
satelliteTrails.geometry.attributes.instanceColorStart?.array || null;
|
||||
const previousInstanceColorEnds =
|
||||
satelliteTrails.geometry.attributes.instanceColorEnd?.array || null;
|
||||
const previousSatellitePositions = satellitePositions;
|
||||
const previousCapacity = satelliteCapacity;
|
||||
|
||||
const positions = new Float32Array(nextCapacity * 3);
|
||||
const backdropPositions = new Float32Array(nextCapacity * 3);
|
||||
const colors = new Float32Array(nextCapacity * 3);
|
||||
const pointAlphas = new Float32Array(nextCapacity);
|
||||
const backdropAlphas = new Float32Array(nextCapacity);
|
||||
pointAlphas.fill(1);
|
||||
backdropAlphas.fill(1);
|
||||
if (previousPointPositions) {
|
||||
positions.set(
|
||||
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
|
||||
@@ -518,10 +703,27 @@ function ensureSatelliteCapacity(count) {
|
||||
if (previousColors) {
|
||||
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
|
||||
}
|
||||
if (previousPointAlphas) {
|
||||
pointAlphas.set(
|
||||
previousPointAlphas.subarray(0, Math.min(previousPointAlphas.length, pointAlphas.length)),
|
||||
);
|
||||
}
|
||||
if (previousBackdropAlphas) {
|
||||
backdropAlphas.set(
|
||||
previousBackdropAlphas.subarray(
|
||||
0,
|
||||
Math.min(previousBackdropAlphas.length, backdropAlphas.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(backdropPositions, 3),
|
||||
);
|
||||
satelliteBackdropPoints.geometry.setAttribute(
|
||||
"alpha",
|
||||
new THREE.BufferAttribute(backdropAlphas, 1),
|
||||
);
|
||||
satelliteBackdropPoints.geometry.setDrawRange(
|
||||
0,
|
||||
Math.min(previousCapacity, nextCapacity),
|
||||
@@ -534,34 +736,54 @@ function ensureSatelliteCapacity(count) {
|
||||
"color",
|
||||
new THREE.BufferAttribute(colors, 3),
|
||||
);
|
||||
satellitePoints.geometry.setAttribute(
|
||||
"alpha",
|
||||
new THREE.BufferAttribute(pointAlphas, 1),
|
||||
);
|
||||
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
|
||||
|
||||
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
if (previousTrailPositions) {
|
||||
trailPositions.set(
|
||||
previousTrailPositions.subarray(
|
||||
0,
|
||||
Math.min(previousTrailPositions.length, trailPositions.length),
|
||||
),
|
||||
const segCount = nextCapacity * (TRAIL_LENGTH - 1);
|
||||
const instanceStarts = new Float32Array(segCount * 3);
|
||||
const instanceEnds = new Float32Array(segCount * 3);
|
||||
const instanceColorStarts = new Float32Array(segCount * 3);
|
||||
const instanceColorEnds = new Float32Array(segCount * 3);
|
||||
if (previousInstanceStarts) {
|
||||
instanceStarts.set(
|
||||
previousInstanceStarts.subarray(0, Math.min(previousInstanceStarts.length, instanceStarts.length)),
|
||||
);
|
||||
}
|
||||
if (previousTrailColors) {
|
||||
trailColors.set(
|
||||
previousTrailColors.subarray(
|
||||
0,
|
||||
Math.min(previousTrailColors.length, trailColors.length),
|
||||
),
|
||||
if (previousInstanceEnds) {
|
||||
instanceEnds.set(
|
||||
previousInstanceEnds.subarray(0, Math.min(previousInstanceEnds.length, instanceEnds.length)),
|
||||
);
|
||||
}
|
||||
if (previousInstanceColorStarts) {
|
||||
instanceColorStarts.set(
|
||||
previousInstanceColorStarts.subarray(0, Math.min(previousInstanceColorStarts.length, instanceColorStarts.length)),
|
||||
);
|
||||
}
|
||||
if (previousInstanceColorEnds) {
|
||||
instanceColorEnds.set(
|
||||
previousInstanceColorEnds.subarray(0, Math.min(previousInstanceColorEnds.length, instanceColorEnds.length)),
|
||||
);
|
||||
}
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(trailPositions, 3),
|
||||
"instanceStart",
|
||||
new THREE.InstancedBufferAttribute(instanceStarts, 3),
|
||||
);
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(trailColors, 3),
|
||||
"instanceEnd",
|
||||
new THREE.InstancedBufferAttribute(instanceEnds, 3),
|
||||
);
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"instanceColorStart",
|
||||
new THREE.InstancedBufferAttribute(instanceColorStarts, 3),
|
||||
);
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"instanceColorEnd",
|
||||
new THREE.InstancedBufferAttribute(instanceColorEnds, 3),
|
||||
);
|
||||
satelliteTrails.geometry.instanceCount = segCount;
|
||||
|
||||
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
||||
const previousState = previousSatellitePositions[index];
|
||||
@@ -579,6 +801,25 @@ function ensureSatelliteCapacity(count) {
|
||||
satelliteCapacity = nextCapacity;
|
||||
}
|
||||
|
||||
function shouldHideSatellitePoint(index) {
|
||||
return index === hoveredSatelliteIndex || index === lockedSatelliteIndex;
|
||||
}
|
||||
|
||||
function updateSatellitePointVisibilityAttributes(count = satelliteData.length) {
|
||||
const pointAlphaAttr = satellitePoints?.geometry?.attributes?.alpha;
|
||||
const backdropAlphaAttr = satelliteBackdropPoints?.geometry?.attributes?.alpha;
|
||||
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
|
||||
|
||||
const visibleCount = Math.min(count, pointAlphaAttr.array.length, backdropAlphaAttr.array.length);
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const alpha = shouldHideSatellitePoint(i) ? 0 : 1;
|
||||
pointAlphaAttr.array[i] = alpha;
|
||||
backdropAlphaAttr.array[i] = alpha;
|
||||
}
|
||||
pointAlphaAttr.needsUpdate = true;
|
||||
backdropAlphaAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
function computeSatellitePosition(satellite, time) {
|
||||
try {
|
||||
const props = satellite.properties;
|
||||
@@ -752,7 +993,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
|
||||
};
|
||||
}
|
||||
|
||||
function generateFallbackPosition(satellite, index, total) {
|
||||
function generateFallbackPosition(satellite, index, total, time = new Date()) {
|
||||
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
|
||||
|
||||
const noradId = satellite.properties?.norad_cat_id || index;
|
||||
@@ -764,9 +1005,21 @@ function generateFallbackPosition(satellite, index, total) {
|
||||
.split("")
|
||||
.reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const randomOffset = (hash % 1000) / 1000;
|
||||
const rawMeanMotion = Number(satellite.properties?.mean_motion);
|
||||
const meanMotion =
|
||||
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
|
||||
? rawMeanMotion
|
||||
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
|
||||
|
||||
const normalizedIndex = index / total;
|
||||
const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180;
|
||||
const elapsedDays = Number.isFinite(time?.getTime?.())
|
||||
? time.getTime() / FALLBACK_ORBIT_DAY_MS
|
||||
: Date.now() / FALLBACK_ORBIT_DAY_MS;
|
||||
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
|
||||
const theta =
|
||||
normalizedIndex * Math.PI * 2 * 10 +
|
||||
(raan * Math.PI) / 180 +
|
||||
fallbackPhase;
|
||||
const phi =
|
||||
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
|
||||
|
||||
@@ -795,6 +1048,7 @@ export async function loadSatellites(options = {}) {
|
||||
const data = await response.json();
|
||||
satelliteData = data.features || [];
|
||||
satelliteSatrecCache = new Map();
|
||||
resetSatelliteTrailState();
|
||||
ensureSatelliteCapacity(satelliteData.length);
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
return {
|
||||
@@ -803,31 +1057,49 @@ export async function loadSatellites(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
export function updateSatellitePositions(deltaTime = 0, force = false, options = {}) {
|
||||
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
|
||||
|
||||
const shouldUpdateTrails =
|
||||
showSatellites || showTrails || lockedSatelliteIndex !== null;
|
||||
positionUpdateAccumulator += deltaTime;
|
||||
showSatellites ||
|
||||
showTrails ||
|
||||
lockedSatelliteIndex !== null;
|
||||
const shouldResetTrails =
|
||||
options.resetTrails ||
|
||||
(!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS);
|
||||
|
||||
if (shouldResetTrails) {
|
||||
clearSatelliteTrails();
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
} else {
|
||||
positionUpdateAccumulator += deltaTime;
|
||||
}
|
||||
|
||||
if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(
|
||||
positionUpdateAccumulator,
|
||||
POSITION_UPDATE_INTERVAL_MS,
|
||||
);
|
||||
const elapsedMs = shouldResetTrails
|
||||
? 0
|
||||
: Math.max(
|
||||
positionUpdateAccumulator,
|
||||
POSITION_UPDATE_INTERVAL_MS,
|
||||
);
|
||||
positionUpdateAccumulator = 0;
|
||||
|
||||
const positions = satellitePoints.geometry.attributes.position.array;
|
||||
const backdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position.array;
|
||||
const colors = satellitePoints.geometry.attributes.color.array;
|
||||
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
||||
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
||||
const pointAlphas = satellitePoints.geometry.attributes.alpha.array;
|
||||
const backdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha.array;
|
||||
const instanceStarts = satelliteTrails.geometry.attributes.instanceStart.array;
|
||||
const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array;
|
||||
const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array;
|
||||
const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array;
|
||||
const baseTime = new Date(Date.now() + elapsedMs);
|
||||
const count = Math.min(satelliteData.length, satelliteCapacity);
|
||||
let trailSegmentCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const satellite = satelliteData[i];
|
||||
@@ -839,16 +1111,30 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
|
||||
let pos = computeSatellitePosition(satellite, adjustedTime);
|
||||
if (!pos) {
|
||||
pos = generateFallbackPosition(satellite, i, count);
|
||||
pos = generateFallbackPosition(satellite, i, count, adjustedTime);
|
||||
}
|
||||
|
||||
satellitePositions[i].current.copy(pos);
|
||||
|
||||
if (shouldUpdateTrails && i !== lockedSatelliteIndex) {
|
||||
if (shouldUpdateTrails) {
|
||||
const satPos = satellitePositions[i];
|
||||
satPos.trail[satPos.trailIndex] = pos.clone();
|
||||
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
|
||||
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
|
||||
if (satPos.trailCount === 0 && TRAIL_LENGTH > 1) {
|
||||
for (let k = 0; k < TRAIL_LENGTH; k++) {
|
||||
const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS;
|
||||
const pastTime = new Date(adjustedTime.getTime() - offsetMs);
|
||||
let pastPos = computeSatellitePosition(satellite, pastTime);
|
||||
if (!pastPos) {
|
||||
pastPos = generateFallbackPosition(satellite, i, count, pastTime);
|
||||
}
|
||||
satPos.trail[satPos.trailIndex] = pastPos;
|
||||
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
|
||||
}
|
||||
satPos.trailCount = TRAIL_LENGTH;
|
||||
} else {
|
||||
satPos.trail[satPos.trailIndex] = pos.clone();
|
||||
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
|
||||
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
|
||||
}
|
||||
}
|
||||
|
||||
positions[i * 3] = pos.x;
|
||||
@@ -871,34 +1157,64 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
colors[i * 3] = r * pointBrightness;
|
||||
colors[i * 3 + 1] = g * pointBrightness;
|
||||
colors[i * 3 + 2] = b * pointBrightness;
|
||||
const pointAlpha = shouldHideSatellitePoint(i) ? 0 : 1;
|
||||
pointAlphas[i] = pointAlpha;
|
||||
backdropAlphas[i] = pointAlpha;
|
||||
|
||||
const satPosition = satellitePositions[i];
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||
|
||||
if (j < satPosition.trailCount) {
|
||||
const idx =
|
||||
(satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) %
|
||||
TRAIL_LENGTH;
|
||||
const trailPoint = satPosition.trail[idx];
|
||||
if (trailPoint) {
|
||||
trailPositions[trailIdx] = trailPoint.x;
|
||||
trailPositions[trailIdx + 1] = trailPoint.y;
|
||||
trailPositions[trailIdx + 2] = trailPoint.z;
|
||||
const alpha = (j + 1) / satPosition.trailCount;
|
||||
trailColors[trailIdx] = r * alpha * trailBrightness;
|
||||
trailColors[trailIdx + 1] = g * alpha * trailBrightness;
|
||||
trailColors[trailIdx + 2] = b * alpha * trailBrightness;
|
||||
continue;
|
||||
const tc = satPosition.trailCount;
|
||||
let hasVisibleTrail = false;
|
||||
for (let j = 0; j < TRAIL_LENGTH - 1; j++) {
|
||||
if (j + 1 < tc) {
|
||||
const idxA =
|
||||
(satPosition.trailIndex - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH;
|
||||
const idxB =
|
||||
(satPosition.trailIndex - tc + j + 1 + TRAIL_LENGTH) % TRAIL_LENGTH;
|
||||
const ptA = satPosition.trail[idxA];
|
||||
const ptB = satPosition.trail[idxB];
|
||||
if (ptA && ptB && ptA.distanceToSquared(ptB) > 1e-8) {
|
||||
const base = trailSegmentCount * 3;
|
||||
instanceStarts[base] = ptA.x;
|
||||
instanceStarts[base + 1] = ptA.y;
|
||||
instanceStarts[base + 2] = ptA.z;
|
||||
instanceEnds[base] = ptB.x;
|
||||
instanceEnds[base + 1] = ptB.y;
|
||||
instanceEnds[base + 2] = ptB.z;
|
||||
const alphaA = (j + 1) / tc;
|
||||
const alphaB = (j + 2) / tc;
|
||||
instanceColorStarts[base] = r * alphaA * trailBrightness;
|
||||
instanceColorStarts[base + 1] = g * alphaA * trailBrightness;
|
||||
instanceColorStarts[base + 2] = b * alphaA * trailBrightness;
|
||||
instanceColorEnds[base] = r * alphaB * trailBrightness;
|
||||
instanceColorEnds[base + 1] = g * alphaB * trailBrightness;
|
||||
instanceColorEnds[base + 2] = b * alphaB * trailBrightness;
|
||||
hasVisibleTrail = true;
|
||||
trailSegmentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
trailPositions[trailIdx] = pos.x;
|
||||
trailPositions[trailIdx + 1] = pos.y;
|
||||
trailPositions[trailIdx + 2] = pos.z;
|
||||
trailColors[trailIdx] = 0;
|
||||
trailColors[trailIdx + 1] = 0;
|
||||
trailColors[trailIdx + 2] = 0;
|
||||
}
|
||||
if (!hasVisibleTrail) {
|
||||
const base = trailSegmentCount * 3;
|
||||
const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z) || 1;
|
||||
const nx = pos.x / dist;
|
||||
const ny = pos.y / dist;
|
||||
const nz = pos.z / dist;
|
||||
const tip = FALLBACK_TRAIL_TIP_LENGTH;
|
||||
instanceStarts[base] = pos.x + nx * tip;
|
||||
instanceStarts[base + 1] = pos.y + ny * tip;
|
||||
instanceStarts[base + 2] = pos.z + nz * tip;
|
||||
instanceEnds[base] = pos.x;
|
||||
instanceEnds[base + 1] = pos.y;
|
||||
instanceEnds[base + 2] = pos.z;
|
||||
const fallbackAlphaStart = FALLBACK_TRAIL_ALPHA_START * trailBrightness;
|
||||
const fallbackAlphaEnd = FALLBACK_TRAIL_ALPHA_END * trailBrightness;
|
||||
instanceColorStarts[base] = r * fallbackAlphaStart;
|
||||
instanceColorStarts[base + 1] = g * fallbackAlphaStart;
|
||||
instanceColorStarts[base + 2] = b * fallbackAlphaStart;
|
||||
instanceColorEnds[base] = r * fallbackAlphaEnd;
|
||||
instanceColorEnds[base + 1] = g * fallbackAlphaEnd;
|
||||
instanceColorEnds[base + 2] = b * fallbackAlphaEnd;
|
||||
trailSegmentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,23 +1225,40 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
backdropPositions[i * 3] = 0;
|
||||
backdropPositions[i * 3 + 1] = 0;
|
||||
backdropPositions[i * 3 + 2] = 0;
|
||||
pointAlphas[i] = 0;
|
||||
backdropAlphas[i] = 0;
|
||||
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||
trailPositions[trailIdx] = 0;
|
||||
trailPositions[trailIdx + 1] = 0;
|
||||
trailPositions[trailIdx + 2] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const trailArrayLength = instanceStarts.length / 3;
|
||||
for (let i = trailSegmentCount; i < trailArrayLength; i++) {
|
||||
const base = i * 3;
|
||||
instanceStarts[base] = 0;
|
||||
instanceStarts[base + 1] = 0;
|
||||
instanceStarts[base + 2] = 0;
|
||||
instanceEnds[base] = 0;
|
||||
instanceEnds[base + 1] = 0;
|
||||
instanceEnds[base + 2] = 0;
|
||||
instanceColorStarts[base] = 0;
|
||||
instanceColorStarts[base + 1] = 0;
|
||||
instanceColorStarts[base + 2] = 0;
|
||||
instanceColorEnds[base] = 0;
|
||||
instanceColorEnds[base + 1] = 0;
|
||||
instanceColorEnds[base + 2] = 0;
|
||||
}
|
||||
|
||||
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
||||
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
||||
satellitePoints.geometry.attributes.alpha.needsUpdate = true;
|
||||
satellitePoints.geometry.setDrawRange(0, count);
|
||||
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteBackdropPoints.geometry.attributes.alpha.needsUpdate = true;
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, count);
|
||||
|
||||
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
||||
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
|
||||
satelliteTrails.geometry.attributes[name].needsUpdate = true;
|
||||
}
|
||||
satelliteTrails.geometry.instanceCount = trailSegmentCount;
|
||||
|
||||
// Keep the hover ring synced with the propagated satellite position even
|
||||
// when the pointer stays still and no new hover event is emitted.
|
||||
@@ -1014,10 +1347,12 @@ export function setSatelliteSunDirection(direction) {
|
||||
|
||||
export function setLockedSatelliteIndex(index) {
|
||||
lockedSatelliteIndex = index;
|
||||
updateSatellitePointVisibilityAttributes();
|
||||
}
|
||||
|
||||
export function setHoveredSatelliteIndex(index) {
|
||||
hoveredSatelliteIndex = index;
|
||||
updateSatellitePointVisibilityAttributes();
|
||||
}
|
||||
|
||||
function normalizeSatelliteDisplayStyle(nextStyle) {
|
||||
@@ -1190,7 +1525,7 @@ export function isSatelliteFrontFacing(index, camera = cameraRef) {
|
||||
function createLockedHaloMaterial(color = "#ffbf47") {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
uniforms: {
|
||||
@@ -1227,7 +1562,7 @@ function createGroundFootprintMaterial() {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color(0xffffff) },
|
||||
@@ -1397,6 +1732,7 @@ function clearLockedSatelliteStyleVisuals() {
|
||||
|
||||
function updateLockedDotWorldTransform(position) {
|
||||
if (!lockedDotSprite || !position || !earthObjRef) return;
|
||||
earthObjRef.updateMatrixWorld(true);
|
||||
const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
|
||||
lockedDotSprite.position.copy(worldPosition);
|
||||
if (cameraRef) {
|
||||
@@ -1417,6 +1753,7 @@ function updateLockedDotWorldTransform(position) {
|
||||
|
||||
function updateLockedHaloWorldTransform(position) {
|
||||
if (!position || !earthObjRef || !lockedHaloMesh) return;
|
||||
earthObjRef.updateMatrixWorld(true);
|
||||
const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
|
||||
const viewDirection = cameraRef
|
||||
? scratchToCamera.subVectors(cameraRef.position, worldPosition).normalize()
|
||||
@@ -1764,7 +2101,7 @@ function showSelfGlowStyle(position, color = "#ffd25a") {
|
||||
color: new THREE.Color(color),
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthTest: false,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
@@ -1791,6 +2128,7 @@ function showGroundFootprintStyle(position) {
|
||||
createGroundFootprintMaterial(),
|
||||
);
|
||||
fill.name = "footprint-fill";
|
||||
fill.renderOrder = GROUND_FOOTPRINT_RENDER_ORDER;
|
||||
lockedGroundFootprintMesh.add(fill);
|
||||
earthObjRef.add(lockedGroundFootprintMesh);
|
||||
updateGroundFootprintTransform(position);
|
||||
@@ -1824,6 +2162,7 @@ function createRingSprite(position, isLocked = false, color = "#ffcc00") {
|
||||
8,
|
||||
12,
|
||||
isLocked ? color : "#ffffff",
|
||||
isLocked ? LOCKED_RING_HOVER_LINE_WIDTH : HOVER_RING_LINE_WIDTH,
|
||||
);
|
||||
const filledTexture = isLocked
|
||||
? createFilledCircleTexture(color)
|
||||
@@ -1832,7 +2171,9 @@ function createRingSprite(position, isLocked = false, color = "#ffcc00") {
|
||||
map: ringTexture,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthTest: false,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
alphaTest: 0.01,
|
||||
sizeAttenuation: false,
|
||||
});
|
||||
|
||||
@@ -1866,24 +2207,8 @@ function updateLockedMarkerVisual(isHovered) {
|
||||
}
|
||||
}
|
||||
|
||||
function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
|
||||
if (!earthObjRef) return null;
|
||||
|
||||
const ringTexture = createRingTexture(7, 11, color);
|
||||
const spriteMaterial = new THREE.SpriteMaterial({
|
||||
map: ringTexture,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
depthTest: false,
|
||||
sizeAttenuation: false,
|
||||
});
|
||||
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.position.copy(position);
|
||||
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
|
||||
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
|
||||
earthObjRef.add(sprite);
|
||||
return sprite;
|
||||
function createRelatedSatelliteSprite(position) {
|
||||
return createRingSprite(position, false);
|
||||
}
|
||||
|
||||
export function showHoverRing(position, isLocked = false) {
|
||||
@@ -1962,7 +2287,7 @@ export function updateLockedRingPosition(position) {
|
||||
1 +
|
||||
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
const markerSize = isHovered
|
||||
? SATELLITE_CONFIG.ringSize
|
||||
? SATELLITE_CONFIG.ringSize * LOCKED_RING_HOVER_SCALE
|
||||
: SATELLITE_CONFIG.ringSize * LOCKED_RING_IDLE_SCALE;
|
||||
lockedRingSprite.scale.set(
|
||||
markerSize * breathScale,
|
||||
@@ -2026,26 +2351,38 @@ export function setSatelliteRingState(index, state, position) {
|
||||
hoveredSatelliteIndex = index;
|
||||
hideHoverRings();
|
||||
showHoverRing(position, false);
|
||||
updateSatellitePointVisibilityAttributes();
|
||||
break;
|
||||
case "locked":
|
||||
hoveredSatelliteIndex = null;
|
||||
hideHoverRings();
|
||||
showHoverRing(position, true);
|
||||
updateSatellitePointVisibilityAttributes();
|
||||
break;
|
||||
case "none":
|
||||
hoveredSatelliteIndex = null;
|
||||
hideHoverRings();
|
||||
hideLockedRing();
|
||||
updateSatellitePointVisibilityAttributes();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDimMaterialState(isDimmed) {
|
||||
if (satellitePoints) {
|
||||
satellitePoints.material.opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
|
||||
const opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
|
||||
if (satellitePoints.material.uniforms?.opacity) {
|
||||
satellitePoints.material.uniforms.opacity.value = opacity;
|
||||
} else {
|
||||
satellitePoints.material.opacity = opacity;
|
||||
}
|
||||
}
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.material.opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
|
||||
const opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
|
||||
if (satelliteBackdropPoints.material.uniforms?.opacity) {
|
||||
satelliteBackdropPoints.material.uniforms.opacity.value = opacity;
|
||||
} else {
|
||||
satelliteBackdropPoints.material.opacity = opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2069,7 +2406,7 @@ export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
|
||||
indices.forEach((index) => {
|
||||
const pos = satellitePositions?.[index]?.current;
|
||||
if (!pos) return;
|
||||
const sprite = createRelatedSatelliteSprite(pos, color);
|
||||
const sprite = createRelatedSatelliteSprite(pos);
|
||||
if (!sprite) return;
|
||||
relatedSatelliteSprites.push({ index, sprite, color });
|
||||
});
|
||||
@@ -2234,14 +2571,13 @@ export function clearSatelliteData() {
|
||||
|
||||
satellitePositions.forEach((position) => {
|
||||
position.current.set(0, 0, 0);
|
||||
position.trail = [];
|
||||
position.trailIndex = 0;
|
||||
position.trailCount = 0;
|
||||
});
|
||||
resetSatelliteTrailState();
|
||||
|
||||
if (satellitePoints) {
|
||||
const positionAttr = satellitePoints.geometry.attributes.position;
|
||||
const colorAttr = satellitePoints.geometry.attributes.color;
|
||||
const alphaAttr = satellitePoints.geometry.attributes.alpha;
|
||||
if (positionAttr?.array) {
|
||||
positionAttr.array.fill(0);
|
||||
positionAttr.needsUpdate = true;
|
||||
@@ -2250,31 +2586,30 @@ export function clearSatelliteData() {
|
||||
colorAttr.array.fill(0);
|
||||
colorAttr.needsUpdate = true;
|
||||
}
|
||||
if (alphaAttr?.array) {
|
||||
alphaAttr.array.fill(0);
|
||||
alphaAttr.needsUpdate = true;
|
||||
}
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
const backdropPositionAttr =
|
||||
satelliteBackdropPoints.geometry.attributes.position;
|
||||
const backdropAlphaAttr =
|
||||
satelliteBackdropPoints.geometry.attributes.alpha;
|
||||
if (backdropPositionAttr?.array) {
|
||||
backdropPositionAttr.array.fill(0);
|
||||
backdropPositionAttr.needsUpdate = true;
|
||||
}
|
||||
if (backdropAlphaAttr?.array) {
|
||||
backdropAlphaAttr.array.fill(0);
|
||||
backdropAlphaAttr.needsUpdate = true;
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteTrails) {
|
||||
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
|
||||
const trailColorAttr = satelliteTrails.geometry.attributes.color;
|
||||
if (trailPositionAttr?.array) {
|
||||
trailPositionAttr.array.fill(0);
|
||||
trailPositionAttr.needsUpdate = true;
|
||||
}
|
||||
if (trailColorAttr?.array) {
|
||||
trailColorAttr.array.fill(0);
|
||||
trailColorAttr.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
clearSatelliteTrailGeometry();
|
||||
|
||||
hideHoverRings();
|
||||
hideLockedRing();
|
||||
|
||||
@@ -242,13 +242,15 @@ export function openSearchPanel() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Running search failed:", error);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
runSearch().catch((error) => {
|
||||
console.warn("Running search failed:", error);
|
||||
});
|
||||
});
|
||||
}, 16);
|
||||
});
|
||||
}
|
||||
|
||||
export function focusSearchInput({ select = false } = {}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.40.2"
|
||||
version = "0.41.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
12
rules.md
12
rules.md
@@ -280,6 +280,18 @@ class BaseCollector:
|
||||
|
||||
---
|
||||
|
||||
## Country Data Validation - MANDATORY
|
||||
|
||||
- **ALL** data sources that carry a country, region, or territory field (API responses, GeoJSON, CSVs, scraped data, third-party enrichment) **MUST** have their country values validated against the project's canonical country dictionary at `backend/app/core/countries.py` before being stored or displayed
|
||||
- Use `normalize_country(value)` from `countries.py` as the single gate. If it returns `None`, the value is unrecognized and must be logged and rejected or flagged — **NEVER** silently pass it through
|
||||
- The dictionary encodes official political positions (e.g., Taiwan → 中国(台湾), Kosovo → 塞尔维亚, Gaza → 巴勒斯坦). Do **NOT** override these with raw source data labels
|
||||
- When integrating a new data source, run a pre-flight check: extract all distinct country values from the source and verify each one resolves via `normalize_country`. Fix unresolved values before wiring up the collector
|
||||
- Geographic boundary data (GeoJSON, shapefiles, tilesets) must be post-processed to align feature names and hover labels with the dictionary. The Natural Earth `ne_110m_admin_0_countries` dataset downloaded from GitHub was used as the base for the frontend boundary layer; political corrections were applied manually
|
||||
- If a new country alias needs to be added to the dictionary, add it to `COUNTRY_ENTRIES` in `countries.py` — **NEVER** scatter aliases across individual collectors or API handlers
|
||||
- Frontend hover tooltips and info cards that display country names must source the name from the canonical dictionary (via `NAME_ZH` after normalization), not raw source strings
|
||||
|
||||
---
|
||||
|
||||
## Frontend Layout - MANDATORY
|
||||
|
||||
- Backend/admin pages must be designed as a `single-screen workspace` first, not as a long vertically stacked document
|
||||
|
||||
Reference in New Issue
Block a user