release: bump version to 0.41.0
This commit is contained in:
@@ -8,6 +8,20 @@ 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
|
||||
|
||||
@@ -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,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.40.5`
|
||||
- `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 与位置漂移,新增悬停态缩放 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.40.5",
|
||||
"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',
|
||||
@@ -393,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -254,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 = [];
|
||||
@@ -472,6 +497,7 @@ function resetTransientComputeCenterStates() {
|
||||
function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
clearCountryBoundaryHover();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
|
||||
@@ -648,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 =
|
||||
@@ -1216,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} 个`);
|
||||
@@ -1229,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} 起`);
|
||||
@@ -1780,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
|
||||
@@ -1922,6 +2007,7 @@ function disableSatellites() {
|
||||
satellitesEnabled = false;
|
||||
satelliteToggleToken += 1;
|
||||
satelliteHydrationToken += 1;
|
||||
toggleSatellites(false);
|
||||
resetSatelliteState();
|
||||
updateSatelliteToggleUi(false, 0);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
@@ -2002,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);
|
||||
@@ -2023,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() {
|
||||
@@ -2162,6 +2262,7 @@ async function loadData() {
|
||||
clearCableData(earth);
|
||||
clearComputeCenterData(earth);
|
||||
clearSatelliteData();
|
||||
clearCountryBoundaryHover();
|
||||
|
||||
setLoadingMessage("正在初始化...");
|
||||
setLoading(true);
|
||||
@@ -2194,7 +2295,9 @@ async function loadData() {
|
||||
updateComputeCenterHud,
|
||||
updateBGPHud,
|
||||
getShowComputeCenters,
|
||||
getShowCountryBoundaries,
|
||||
getShowBGP,
|
||||
isEarthTextureVisible: () => getEarthTextureVisible(),
|
||||
getInitialSatelliteLoadLimit,
|
||||
shouldHydrateFullSatelliteSet,
|
||||
scheduleSatellitePositionWarmup,
|
||||
@@ -2247,6 +2350,7 @@ async function loadData() {
|
||||
updateSatelliteToggleUi(satellitesEnabled);
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
refreshLegend();
|
||||
@@ -2338,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 } = {},
|
||||
@@ -2561,6 +2761,7 @@ function onMouseMove(event) {
|
||||
inertialVelocity.y = rotationDeltaY;
|
||||
inertialVelocity.x = rotationDeltaX;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
@@ -2590,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(
|
||||
@@ -2706,7 +2900,7 @@ function onMouseMove(event) {
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
earth,
|
||||
getEarthSurfacePickTarget() || earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
@@ -2714,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
|
||||
@@ -2726,8 +2932,11 @@ function onMouseMove(event) {
|
||||
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
|
||||
);
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
}
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2864,6 +3073,7 @@ function onPointerUp(event) {
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
}
|
||||
|
||||
@@ -2898,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)
|
||||
@@ -2996,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;
|
||||
@@ -3191,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",
|
||||
@@ -1560,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) },
|
||||
@@ -2126,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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.40.5"
|
||||
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