Compare commits

...

3 Commits

Author SHA1 Message Date
linkong
655e2a7d2d release: bump version to 0.41.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:31:34 +08:00
linkong
3ea99a9529 release: bump version to 0.41.0 2026-04-27 13:58:29 +08:00
rayd1o
f9c1334365 release: bump version to 0.40.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 05:03:30 +08:00
43 changed files with 2676 additions and 398 deletions

View File

@@ -1 +1 @@
0.40.4
0.41.1

View File

@@ -1353,6 +1353,71 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/summary")
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"celestrak_tle",
"top500",
"epoch_ai_gpu",
],
)
cables = convert_cable_to_geojson(records_by_source.get("arcgis_cables", []))
landing_points = convert_landing_point_to_geojson(
records_by_source.get("arcgis_landing_points", []),
)
satellites = convert_satellite_to_geojson(
_filter_known_records(records_by_source.get("celestrak_tle", [])),
)
compute_centers = convert_compute_centers_to_geojson(
_filter_known_records(
records_by_source.get("top500", [])
+ records_by_source.get("epoch_ai_gpu", []),
),
)
compute_features = compute_centers.get("features", [])
active_incident_result = await db.execute(
select(func.count(BGPIncident.id)).where(BGPIncident.status == "active"),
)
active_anomaly_result = await db.execute(
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active"),
)
active_incident_count = int(active_incident_result.scalar() or 0)
active_anomaly_count = int(active_anomaly_result.scalar() or 0)
bgp_collectors = await build_bgp_collector_coverage(
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
)
return {
"generated_at": to_iso8601_utc(datetime.now(UTC)),
"stats": {
"cable_count": len(cables.get("features", [])),
"landing_point_count": len(landing_points.get("features", [])),
"satellite_count": len(satellites.get("features", [])),
"compute_center_count": len(compute_features),
"supercomputer_count": sum(
1 for feature in compute_features
if feature.get("properties", {}).get("site_type") == "supercomputer"
),
"gpu_cluster_count": sum(
1 for feature in compute_features
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
),
"bgp_event_count": active_incident_count or active_anomaly_count,
"bgp_incident_count": active_incident_count,
"bgp_anomaly_count": active_anomaly_count,
"bgp_collector_count": len([item for item in bgp_collectors if item.get("collector")]),
},
}
@router.get("/all")
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
"""获取所有可视化数据的统一端点

View File

@@ -215,3 +215,133 @@ async def test_compute_centers_geojson_endpoint_returns_stats():
assert data["features"][0]["properties"]["data_type"] == "compute_center"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_visualization_geo_summary_returns_counts(monkeypatch):
records = [
_build_record(
record_id=1,
source="arcgis_cables",
data_type="submarine_cable",
name="Test Cable",
country="",
city="",
latitude=0,
longitude=0,
metadata={
"route_coordinates": [[[0, 0], [1, 1]]],
"status": "active",
},
),
_build_record(
record_id=2,
source="arcgis_landing_points",
data_type="landing_point",
name="Test Landing",
country="United States",
city="New York",
latitude=40.7,
longitude=-74.0,
metadata={"city_id": 10},
),
_build_record(
record_id=3,
source="celestrak_tle",
data_type="satellite_tle",
name="TESTSAT",
country="",
city="",
latitude=0,
longitude=0,
metadata={
"norad_cat_id": 12345,
"tle_line1": "1 12345U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 9991",
"tle_line2": "2 12345 51.6000 100.0000 0001000 10.0000 20.0000 15.50000000 01",
},
),
_build_record(
record_id=4,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="Oak Ridge",
latitude=35.93,
longitude=-84.31,
metadata={"rank": 1, "rmax": 1102000.0},
),
_build_record(
record_id=5,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Colossus",
country="United States",
city="Memphis",
latitude=35.15,
longitude=-90.05,
metadata={"value": "20000", "unit": "TFlop/s"},
),
]
class _ScalarResult:
def __init__(self, rows=None, scalar_value=None):
self._rows = rows or []
self._scalar_value = scalar_value
def scalar(self):
return self._scalar_value
def scalars(self):
class _Scalars:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
return _Scalars(self._rows)
class _FakeSession:
async def execute(self, query):
query_text = str(query)
if "bgp_incidents" in query_text:
return _ScalarResult(scalar_value=2)
if "bgp_anomalies" in query_text:
return _ScalarResult(scalar_value=3)
return _ScalarResult(rows=records)
async def override_get_db():
yield _FakeSession()
async def _fake_build_bgp_collector_coverage(*_args, **_kwargs):
return [
{"collector": "rrc00"},
{"collector": "rrc01"},
]
monkeypatch.setattr(
"app.api.v1.visualization.build_bgp_collector_coverage",
_fake_build_bgp_collector_coverage,
)
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/visualization/geo/summary")
assert response.status_code == 200
stats = response.json()["stats"]
assert stats["cable_count"] == 1
assert stats["landing_point_count"] == 1
assert stats["satellite_count"] == 1
assert stats["compute_center_count"] == 2
assert stats["supercomputer_count"] == 1
assert stats["gpu_cluster_count"] == 1
assert stats["bgp_event_count"] == 2
assert stats["bgp_incident_count"] == 2
assert stats["bgp_anomaly_count"] == 3
assert stats["bgp_collector_count"] == 2
finally:
app.dependency_overrides.clear()

View File

@@ -8,6 +8,41 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.41.1] — 2026-04-27
### 🐛 Fixes
- 修复新闻直播面板设置项持久化失效:`closeTransientMobileOverlays` 通过旁路路径隐藏面板导致下次 persist 快照到错误状态,改为不重新从 DOM 读取面板可见性
- 修复登陆点 pin 在地球侧面被半截遮挡改为在接近地平线前dot < 0.05)主动隐藏,避免深度测试切片
### 🔧 Improvements
- 将所有画布绘制的图标抽取为 SVG存入 `frontend/public/earth/assets/icons/`,新增图标规范到 `rules.md`
---
## [0.41.0] — 2026-04-27
### ✨ Highlights
- Earth 图层系统完成地表到天空的注册顺序与关注优先的面板顺序拆分支持基座海陆色块、国界、高清材质、云图、地形、算力、BGP、卫星、轨迹与海缆的稳定层级
- 国界层新增真实行政区轮廓交互与中国/台湾联动高亮修复高清材质、地形、footprint、卫星与经纬线之间的遮挡和 hover 竞争
### 🔧 Improvements
- 新增无轮廓基座地图,所有图层关闭时仍保留 `#010609` 海洋与 `#080f1b` 陆地色块
- 将大气云图抽象为独立图层并接入桌面/移动端图层开关、持久化状态与启动同步
- 高清材质改为独立纹理覆盖层,地形显示在高清材质上方,并在高清材质关闭/恢复时保持原地形开关意图
- 补充 Earth 渲染层级与图层样式文档,记录正式图层名、变量名、材质颜色、线宽与 renderOrder
---
## [0.40.5] — 2026-04-26
### 🔧 Improvements
- 卫星拖尾改用 Instanced screen-space ribbon单 draw call 渲染所有轨迹段,支持像素级宽度控制
- Iridium 地面覆盖重写为球面投影径向网格,修复填充光晕不可见问题;新增外圈 LineLoop
- 搜索面板打开时改用双 rAF 延迟聚焦输入框,确保 CSS 过渡完成后焦点可靠触发
- 代码清理:提取 `IRIDIUM_OVERLAY_COLOR``IRIDIUM_REFERENCE_ALTITUDE_KM` 常量,消除重复三角函数调用
---
## [0.39.0] — 2026-04-24
## [0.40.4] — 2026-04-26

View File

@@ -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)

View File

@@ -12,6 +12,8 @@
- 前端上下文
- Earth 前端结构
- Earth 卫星 footprint 策略
- Earth 渲染图层顺序
- Earth 图层样式属性索引
- 后端运行控制
- collector 现状
- 采集格式约定

View File

@@ -0,0 +1,235 @@
# 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.48` | 对齐算力中心贴地表 marker 高度 |
| 登陆点 icon 贴图尺寸 | `CABLE_CONFIG.landingPoint.textureSize` | `256` | canvas 渲染 EPS 参考图的实心 map-pin中间圆孔透明镂空 |
| 登陆点 icon 宽高比 | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` |
| 登陆点 icon 锚点 | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`,将 pin 下端点对齐登陆点经纬度 |
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `12` | 对齐算力中心等地表 icon 的 sprite 高度 |
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | 兼容旧球体材质sprite 不使用 |
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | 兼容旧球体材质sprite 不使用 |
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | 对齐算力中心地表设施层级 |
| 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 |
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
| 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 |
| 非相关登陆点 emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | dim 状态弱琥珀自发光 |
| 非相关登陆点 emissive 强度 | `landingPointVisual.dimmed.emissiveIntensity` | `0.18` | dim 状态弱发光强度 |
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 |
## 卫星、轨迹和 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` |

View 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-testedGroup 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 或地表层挡住卫星点击。 |

View File

@@ -16,12 +16,15 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.40.4`
- `dev` 当前开发分支历史推导到:`0.41.1`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.41.1` | improvement | `dev` | `pending` | 修复新闻直播持久化失效、pin 边缘遮挡;图标抽取为 SVG 并建立规范 |
| `0.41.0` | feature | `dev` | `pending` | Earth 图层顺序拆分、基座海陆色块、国界交互、高清材质/云图/地形层级与样式文档落地 |
| `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 |
| `0.40.4` | bugfix | `dev` | `pending` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 |
| `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial修复锁定环 depthTest 与位置漂移,新增悬停态缩放 |
| `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 |

View File

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

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP route collector marker. Outer ring + Material access_point icon. -->
<!-- States: idle opacity=0.74, hover/locked use brighter blend (color controlled externally) -->
<!-- Outer ring -->
<circle cx="64" cy="64" r="22" fill="none" stroke="rgba(111,160,197,0.34)" stroke-width="1.2"/>
<!-- access_point icon: 24x24 path scaled 4x and offset to (16,16) in 128x128 canvas space -->
<g transform="translate(16 16) scale(4 4)"
fill="rgba(214,224,233,0.88)"
stroke="rgba(64,106,136,0.74)"
stroke-width="0.9"
stroke-linejoin="round"
stroke-linecap="round">
<path d="M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"/>
<!-- Center dot override -->
<circle cx="12" cy="12" r="0.85" fill="rgba(222,231,239,0.72)" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP specific/burst anomaly symbol: 6 radial spokes (r 26→48) + center dot (r=16). -->
<!-- Spoke endpoints calculated as: inner=64+cos(angle)*26, outer=64+cos(angle)*48 for 6 angles -->
<g stroke="currentColor" stroke-width="10" stroke-linecap="round">
<line x1="90" y1="64" x2="112" y2="64"/>
<line x1="77" y1="86.5" x2="88" y2="105.6"/>
<line x1="51" y1="86.5" x2="40" y2="105.6"/>
<line x1="38" y1="64" x2="16" y2="64"/>
<line x1="51" y1="41.5" x2="40" y2="22.4"/>
<line x1="77" y1="41.5" x2="88" y2="22.4"/>
</g>
<circle cx="64" cy="64" r="16" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP generic event symbol: filled circle. -->
<circle cx="64" cy="64" r="28" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP withdraw event symbol: exclamation mark (rounded bar + dot). -->
<rect x="52" y="22" width="24" height="62" rx="12" fill="currentColor"/>
<circle cx="64" cy="102" r="10" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP route leak symbol: two nested open triangular outlines (outer + inner chevron). -->
<g stroke="currentColor" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none">
<polyline points="28,96 64,28 100,96"/>
<polyline points="40,82 64,54 88,82"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Ring overlay for BGP event markers. Stroked circle, no fill. -->
<circle cx="64" cy="64" r="44" fill="none" stroke="rgba(255,255,255,0.98)" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP origin anomaly symbol: upward triangle. -->
<polygon points="64,18 110,106 18,106" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP flap event symbol: zigzag/wave (filled W shape, closed). -->
<polygon points="14,100 38,26 64,100 90,26 114,100" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- BGP collector base glow: radial gradient dot. Inner r=8 fully opaque, fades to transparent at r=56. -->
<defs>
<radialGradient id="bgp-glow" cx="64" cy="64" r="56" fx="64" fy="64" fr="8" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="white" stop-opacity="1"/>
<stop offset="24%" stop-color="white" stop-opacity="0.92"/>
<stop offset="58%" stop-color="white" stop-opacity="0.35"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="64" cy="64" r="56" fill="url(#bgp-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- GPU cluster marker: database/cylinder stack icon. -->
<!-- Color: #2dd4bf (teal) per COMPUTE_CENTER_CONFIG.colors.gpu_cluster -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<!-- Outer cylinder: top ellipse cap + side rect + bottom half-ellipse -->
<!-- Inner groove ring: smaller cylinder shape overlaid at same color (subtle shape layering) -->
<g fill="#2dd4bf">
<rect x="46" y="46" width="36" height="28"/>
<ellipse cx="64" cy="46" rx="18" ry="8"/>
<path d="M 82,74 A 18,8 0 0,1 46,74 Z"/>
<rect x="52" y="58" width="24" height="6"/>
<ellipse cx="64" cy="58" rx="12" ry="4.5"/>
<path d="M 76,64 A 12,4.5 0 0,1 52,64 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 787 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Supercomputer marker: flat-screen monitor with neck and base stand. -->
<!-- Color: #38bdf8 (sky-blue) per COMPUTE_CENTER_CONFIG.colors.supercomputer -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<g fill="#38bdf8">
<rect x="40" y="42" width="48" height="30" rx="7"/>
<rect x="58" y="74" width="12" height="8" rx="3"/>
<rect x="50" y="84" width="28" height="5" rx="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 50 600 680">
<!-- Location pin with circular cutout. Coordinates in 1000-unit space (canvas scale: size/1000). -->
<!-- States: color via fill attribute — default white for 3D canvas, orange (#ffaa00) for normal, white for hover/locked -->
<path
fill-rule="evenodd"
fill="currentColor"
d="M400 704 C386 704 375 697 367 684 L173 378 C117 290 144 173 229 111 C278 75 337 57 400 57 C463 57 522 75 571 111 C656 173 683 290 627 378 L433 684 C425 697 414 704 400 704 Z
M400 320 m-86 0 a86 86 0 1 0 172 0 a86 86 0 1 0 -172 0"
/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@@ -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;

View File

@@ -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: "";

File diff suppressed because one or more lines are too long

View File

@@ -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 -->

View File

@@ -20,7 +20,56 @@ export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
let landingPointGeometry = null;
let landingPointTexture = null;
const _lpWorldPos = new THREE.Vector3();
function createLandingPointTexture() {
const size = CABLE_CONFIG.landingPoint.textureSize;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const iconPath = new Path2D(
[
"M400 704",
"C386 704 375 697 367 684",
"L173 378",
"C117 290 144 173 229 111",
"C278 75 337 57 400 57",
"C463 57 522 75 571 111",
"C656 173 683 290 627 378",
"L433 684",
"C425 697 414 704 400 704",
"Z",
].join(" "),
);
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.translate(size * 0.12, size * 0.02);
ctx.scale(size / 1000, size / 1000);
ctx.fillStyle = "#ffffff";
ctx.fill(iconPath);
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(400, 320, 86, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
function getLandingPointTexture() {
if (!landingPointTexture) {
landingPointTexture = createLandingPointTexture();
}
return landingPointTexture;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
@@ -49,7 +98,7 @@ function disposeMaterial(material) {
return;
}
if (material.map) {
if (material.map && !material.userData?.sharedMap) {
material.map.dispose();
}
material.dispose();
@@ -69,6 +118,22 @@ function disposeObject(object, parent) {
}
}
function setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
point.material.color.set(color);
point.material.opacity = opacity;
if (point.material.emissive && emissive !== undefined) {
point.material.emissive.setHex(emissive);
}
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
point.material.emissiveIntensity = emissiveIntensity;
}
}
function setLandingPointScale(point, heightScale) {
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
point.scale.set(heightScale * aspect, heightScale, 1);
}
function getCableColor(properties) {
if (properties.color) {
if (
@@ -357,13 +422,6 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
clearLandingPoints(earthObj);
if (!landingPointGeometry) {
landingPointGeometry = new THREE.SphereGeometry(
CABLE_CONFIG.landingPoint.radius,
CABLE_CONFIG.landingPoint.widthSegments,
CABLE_CONFIG.landingPoint.heightSegments,
);
}
let validCount = 0;
for (const feature of data.features) {
@@ -396,29 +454,35 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
continue;
}
const sphere = new THREE.Mesh(
landingPointGeometry,
new THREE.MeshStandardMaterial({
const marker = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getLandingPointTexture(),
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
depthTest: true,
depthWrite: false,
}),
);
sphere.position.copy(position);
sphere.userData = {
marker.material.userData.sharedMap = true;
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
marker.center.set(
CABLE_CONFIG.landingPoint.anchorX,
CABLE_CONFIG.landingPoint.anchorY,
);
marker.position.copy(position);
marker.userData = {
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale,
sharedGeometry: true,
};
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale);
earthObj.add(sphere);
landingPoints.push(sphere);
earthObj.add(marker);
landingPoints.push(marker);
validCount++;
}
@@ -533,6 +597,18 @@ export function getAllLandingPoints() {
return landingPoints;
}
// Hide pins within ~3° of the limb (normalised dot < 0.05) to prevent the
// "half-clipped-into-globe" look caused by depthTest clipping the billboard
// against closer earth-surface geometry near the horizon.
const _FACING_DOT_MIN_SQ = 0.05 * 0.05;
function isFacingCamera(lp, camera) {
lp.getWorldPosition(_lpWorldPos);
const d = _lpWorldPos.dot(camera.position);
if (d <= 0) return false;
return d * d > _FACING_DOT_MIN_SQ * _lpWorldPos.lengthSq() * camera.position.lengthSq();
}
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
const pulse =
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
@@ -544,58 +620,65 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
: [];
landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isRelated =
!dimAll &&
Array.isArray(lp.userData.cableNames) &&
lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) {
lp.material.color.setHex(0xffd27a);
lp.material.emissive.setHex(0x7a4a00);
lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
lp.material.opacity =
Math.max(
setLandingPointMaterialState(lp, {
color: 0xffd27a,
emissive: 0x7a4a00,
emissiveIntensity:
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
opacity: Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
);
),
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(
setLandingPointScale(
lp,
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
baseScale *
distanceScale,
distanceScale,
);
} else {
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
const r = dimColor.r * brightness;
const g = dimColor.g * brightness;
const b = dimColor.b * brightness;
lp.material.color.setRGB(r / 255, g / 255, b / 255);
lp.material.emissive.setHex(CABLE_CONFIG.landingPointVisual.dimmed.emissive);
lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity;
lp.material.opacity = CABLE_CONFIG.landingPointVisual.dimmed.opacity;
setLandingPointMaterialState(lp, {
color: new THREE.Color(r / 255, g / 255, b / 255),
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(baseScale * distanceScale);
setLandingPointScale(lp, baseScale * distanceScale);
}
});
}
export function resetLandingPointVisualState(camera = null) {
landingPoints.forEach((lp) => {
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
lp.material.emissiveIntensity = CABLE_CONFIG.landingPoint.emissiveIntensity;
lp.material.opacity = CABLE_CONFIG.landingPoint.opacity;
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
setLandingPointMaterialState(lp, {
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
opacity: CABLE_CONFIG.landingPoint.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar(baseScale * distanceScale);
setLandingPointScale(lp, baseScale * distanceScale);
});
}

View File

@@ -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,

View File

@@ -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',
@@ -175,6 +201,7 @@ export const PATHS = {
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
earthSummaryApi: '/api/v1/visualization/geo/summary',
earthClientLogsApi: '/api/v1/system/logs/earth-client',
};
@@ -233,15 +260,17 @@ export const CABLE_CONFIG = {
renderOrder: 1,
},
landingPoint: {
altitudeOffset: 0.1,
radius: 0.4,
widthSegments: 16,
heightSegments: 16,
baseScale: 2.5,
altitudeOffset: 0.48,
textureSize: 256,
iconAspectRatio: 0.82,
anchorX: 0.52,
anchorY: 0.276,
baseScale: 12,
color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5,
opacity: 1.0,
renderOrder: 4.5,
},
landingPointSizeStabilization: {
enabled: true,
@@ -251,7 +280,7 @@ export const CABLE_CONFIG = {
},
landingPointVisual: {
pulseSpeed: 0.003,
dimBrightness: 0.3,
dimBrightness: 0.62,
related: {
emissiveIntensityBase: 0.5,
emissiveIntensityPulse: 0.5,
@@ -261,10 +290,10 @@ export const CABLE_CONFIG = {
scalePulse: 0.3,
},
dimmed: {
colorRGB: { r: 255, g: 170, b: 0 },
emissive: 0x000000,
emissiveIntensity: 0,
opacity: 0.3,
colorRGB: { r: 180, g: 116, b: 28 },
emissive: 0x3a2200,
emissiveIntensity: 0.18,
opacity: 0.78,
},
},
};
@@ -277,9 +306,10 @@ export const CABLE_STATE = {
export const SATELLITE_CONFIG = {
maxCount: -1,
initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true,
initialLoadCount: null,
hydrateFullAfterInitialLoad: false,
trailLength: 10,
trailLineWidth: 3,
displayAltitudeOffset: 8,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
@@ -392,19 +422,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,

View File

@@ -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) {
@@ -849,7 +915,10 @@ function syncEarthSettingsStateFromRuntime() {
const scope = getSettingsViewportScope();
nextSettings.shared = getCurrentSharedSettingsSnapshot();
nextSettings.views[scope].panelVisibility = getCurrentPanelVisibilitySnapshot();
// panelVisibility is maintained in earthSettingsState via setHudPanelVisibility.
// Do not re-snapshot from DOM here: transient hides (e.g. closeTransientMobileOverlays)
// change the DOM without going through setHudPanelVisibility and would corrupt the
// user's persisted preference.
earthSettingsState = nextSettings;
return nextSettings;
}
@@ -1149,6 +1218,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 +1249,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 +1341,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 +1355,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 +1391,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 +1398,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 +1409,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 +1467,8 @@ function getBuiltinLayerDefinitions() {
meta: "Subsea Cables",
keywords: "海缆 subsea cables",
defaultActive: true,
startupPriority: 20,
displayOrder: 10,
startupPriority: 50,
startupMode: "visible",
startupLabel: "海缆",
startupMessage: {
@@ -1345,7 +1487,8 @@ function getBuiltinLayerDefinitions() {
meta: "Compute Centers",
keywords: "算力中心 compute centers gpu 超算",
defaultActive: true,
startupPriority: 35,
displayOrder: 40,
startupPriority: 60,
startupMode: "preload",
startupLabel: "算力中心",
startupMessage: "正在加载算力中心...",
@@ -1361,7 +1504,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 +1513,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 +1626,7 @@ function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) {
function registerLayerDefinition(definition, options = {}) {
const normalizedDefinition = {
persist: true,
displayOrder: null,
startupPriority: null,
startupMode: "visible",
startupLabel: "",
@@ -1818,6 +2015,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 +2182,7 @@ function setupSettingsControls() {
});
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
settingsApplyPromise = applyEarthSettings(loadEarthSettings());
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
@@ -2283,7 +2505,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 +2514,8 @@ export function setupControls(camera, renderer, scene, earth) {
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
await settingsApplyPromise;
syncTrailsAvailability();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
@@ -2622,7 +2846,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 +2866,7 @@ export function registerLayer({
keywords = "",
defaultActive = false,
persist = true,
displayOrder = null,
startupPriority = null,
startupMode = "visible",
startupLabel = "",
@@ -2658,6 +2887,7 @@ export function registerLayer({
keywords,
defaultActive,
persist,
displayOrder,
startupPriority,
startupMode,
startupLabel,

View 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: "海洋填色" },
];
}

View File

@@ -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;
}

View File

@@ -5,7 +5,11 @@ const SURFACE_SCALE = 1.003;
const SURFACE_OFFSET = 0.72;
const CLUSTER_DIAMETER_KM_APPROX = 4500;
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
const SURFACE_AXIS = new THREE.Vector3(0, 0, 1);
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
const FILL_RINGS = 12;
const FILL_SEGMENTS = 48;
const RING_SEGMENTS = 72;
function disposeMaterial(material) {
if (!material) return;
@@ -19,34 +23,27 @@ function disposeMaterial(material) {
function disposeObjectTree(object) {
if (!object) return;
object.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
disposeMaterial(child.material);
}
if (child.geometry) child.geometry.dispose();
if (child.material) disposeMaterial(child.material);
});
}
function createIridiumClusterMaterial() {
function createIridiumFillMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3,
blending: THREE.AdditiveBlending,
uniforms: {
uColor: { value: new THREE.Color(0x5faeff) },
uOpacity: { value: 0.24 },
uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) },
uOpacity: { value: 0.55 },
},
vertexShader: `
attribute vec2 aUv;
varying vec2 vUv;
void main() {
vUv = uv;
vUv = aUv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
@@ -54,12 +51,10 @@ function createIridiumClusterMaterial() {
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
void main() {
vec2 p = vUv * 2.0 - 1.0;
float ellipseMetric = p.x * p.x * 0.82 + p.y * p.y * 1.06;
float alpha = exp(-ellipseMetric * 1.05) * (1.0 - smoothstep(0.86, 1.24, ellipseMetric));
alpha *= uOpacity;
float r2 = dot(vUv, vUv);
float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2));
float alpha = glow * uOpacity;
if (alpha <= 0.001) discard;
gl_FragColor = vec4(uColor, alpha);
}
@@ -67,6 +62,17 @@ function createIridiumClusterMaterial() {
});
}
function createIridiumRingMaterial() {
return new THREE.LineBasicMaterial({
color: new THREE.Color(IRIDIUM_OVERLAY_COLOR),
transparent: true,
opacity: 0.75,
blending: THREE.AdditiveBlending,
depthTest: true,
depthWrite: false,
});
}
function projectOffsetToSurface(
centerNormal,
alongTrack,
@@ -88,13 +94,55 @@ function projectOffsetToSurface(
function computeClusterRadiusKm(altitudeKm) {
const altitudeScale = THREE.MathUtils.clamp(
(Number(altitudeKm) || 780) / 780,
(Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM,
0.88,
1.18,
);
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
}
function buildFillGeometry() {
// Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each.
// Positions are updated in world space each frame; indices are static.
const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS;
const positions = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
// Center vertex: uv = (0,0)
// Edge vertices: uv on unit circle, r = ring/FILL_RINGS
const indices = [];
// Center to first ring: triangle fan
for (let s = 0; s < FILL_SEGMENTS; s++) {
const a = 1 + s;
const b = 1 + (s + 1) % FILL_SEGMENTS;
indices.push(0, a, b);
}
// Ring to ring
for (let r = 0; r < FILL_RINGS - 1; r++) {
const ringBase = 1 + r * FILL_SEGMENTS;
const nextBase = ringBase + FILL_SEGMENTS;
for (let s = 0; s < FILL_SEGMENTS; s++) {
const s1 = (s + 1) % FILL_SEGMENTS;
indices.push(ringBase + s, nextBase + s, ringBase + s1);
indices.push(nextBase + s, nextBase + s1, ringBase + s1);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(indices);
return geometry;
}
function buildRingGeometry() {
const positions = new Float32Array(RING_SEGMENTS * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
return geometry;
}
export function createIridiumFootprintAdapter({
earthObj,
earthRadiusWorld,
@@ -105,19 +153,21 @@ export function createIridiumFootprintAdapter({
const group = new THREE.Group();
group.name = "iridium-footprint-overlay";
group.renderOrder = renderOrder;
group.userData = {
earthRadiusWorld,
clusterGlow: null,
};
group.userData = { earthRadiusWorld, fill: null, outerRing: null };
const clusterGlow = new THREE.Mesh(
new THREE.CircleGeometry(1, 72),
createIridiumClusterMaterial(),
);
clusterGlow.name = "iridium-cluster-glow";
clusterGlow.renderOrder = renderOrder - 1;
group.add(clusterGlow);
group.userData.clusterGlow = clusterGlow;
const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial());
fill.name = "iridium-cluster-fill";
fill.renderOrder = renderOrder;
fill.frustumCulled = false;
group.add(fill);
group.userData.fill = fill;
const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial());
outerRing.name = "iridium-outer-ring";
outerRing.renderOrder = renderOrder;
outerRing.frustumCulled = false;
group.add(outerRing);
group.userData.outerRing = outerRing;
earthObj.add(group);
return group;
@@ -129,30 +179,65 @@ export function updateIridiumFootprintAdapter(
) {
if (!group || !position || !alongTrack || !crossTrack) return;
const earthRadiusWorld =
group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
const earthRadiusWorld = group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
const centerNormal = position.clone().normalize();
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
const clusterGlow = group.userData?.clusterGlow || null;
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
const alongRadiusKm = clusterRadiusKm * 1.18;
const crossRadiusKm = clusterRadiusKm * 0.96;
if (clusterGlow) {
const clusterCenter = projectOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
0,
0,
earthRadiusWorld,
);
const clusterNormal = clusterCenter.clone().normalize();
clusterGlow.position.copy(clusterCenter);
clusterGlow.quaternion.setFromUnitVectors(SURFACE_AXIS, clusterNormal);
clusterGlow.scale.set(
clusterRadiusKm * worldUnitsPerKm * 1.18,
clusterRadiusKm * worldUnitsPerKm * 0.96,
1,
const fill = group.userData?.fill;
if (fill) {
const posAttr = fill.geometry.attributes.position;
const uvAttr = fill.geometry.attributes.aUv;
// Center vertex
const center = projectOffsetToSurface(
centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld,
);
posAttr.setXYZ(0, center.x, center.y, center.z);
uvAttr.setXY(0, 0, 0);
// Ring vertices
for (let r = 1; r <= FILL_RINGS; r++) {
const t = r / FILL_RINGS;
const aKm = alongRadiusKm * t;
const cKm = crossRadiusKm * t;
for (let s = 0; s < FILL_SEGMENTS; s++) {
const angle = (s / FILL_SEGMENTS) * Math.PI * 2;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const pt = projectOffsetToSurface(
centerNormal, alongTrack, crossTrack,
aKm * cosA,
cKm * sinA,
earthRadiusWorld,
);
const vi = 1 + (r - 1) * FILL_SEGMENTS + s;
posAttr.setXYZ(vi, pt.x, pt.y, pt.z);
uvAttr.setXY(vi, t * cosA, t * sinA);
}
}
posAttr.needsUpdate = true;
uvAttr.needsUpdate = true;
fill.geometry.computeBoundingSphere();
}
const outerRing = group.userData?.outerRing;
if (outerRing) {
const posAttr = outerRing.geometry.attributes.position;
for (let k = 0; k < RING_SEGMENTS; k++) {
const angle = (k / RING_SEGMENTS) * Math.PI * 2;
const pt = projectOffsetToSurface(
centerNormal, alongTrack, crossTrack,
alongRadiusKm * Math.cos(angle),
crossRadiusKm * Math.sin(angle),
earthRadiusWorld,
);
posAttr.setXYZ(k, pt.x, pt.y, pt.z);
}
posAttr.needsUpdate = true;
outerRing.geometry.computeBoundingSphere();
}
}

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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: [],
};

View File

@@ -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;
@@ -237,6 +260,7 @@ let calloutConnector = null;
let cruiseBGPAdapter = null;
let cruiseNewsAdapter = null;
let cruiseSequencer = null;
let earthStatsSummary = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
let pinchGesture = null;
@@ -254,6 +278,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 +498,7 @@ function resetTransientComputeCenterStates() {
function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
clearCountryBoundaryHover();
hoveredBGP = null;
hoveredComputeCenter = null;
@@ -648,6 +675,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 =
@@ -1213,14 +1247,69 @@ function getBGPStatusText(bgpResult) {
return "当前无活跃事件";
}
function toCount(value) {
const count = Number(value);
return Number.isFinite(count) ? count : 0;
}
function formatBGPStatusFromSummary(summary) {
if (!summary) return "-";
if (summary.bgpIncidentCount > 0) {
return `${summary.bgpIncidentCount} 起活跃事件`;
}
if (summary.bgpAnomalyCount > 0) {
return `${summary.bgpAnomalyCount} 条活跃异常`;
}
return "当前无活跃事件";
}
function applyEarthStatsSummary(summary) {
if (!summary) return;
updateEarthStats({
cableCount: `${summary.cableCount}`,
landingPointCount: `${summary.landingPointCount}`,
satelliteCount: `${summary.satelliteCount}`,
computeCenterCount: `${summary.computeCenterCount}`,
bgpAnomalyCount: `${summary.bgpEventCount}`,
bgpCollectorCount: `${summary.bgpCollectorCount}`,
bgpStatusSummary: formatBGPStatusFromSummary(summary),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
}
async function loadEarthStatsSummary() {
try {
const response = await fetch(PATHS.earthSummaryApi);
if (!response.ok) {
throw new Error(`Earth summary HTTP ${response.status}`);
}
const payload = await response.json();
const stats = payload?.stats || {};
earthStatsSummary = {
cableCount: toCount(stats.cable_count),
landingPointCount: toCount(stats.landing_point_count),
satelliteCount: toCount(stats.satellite_count),
computeCenterCount: toCount(stats.compute_center_count),
bgpEventCount: toCount(stats.bgp_event_count),
bgpIncidentCount: toCount(stats.bgp_incident_count),
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
bgpCollectorCount: toCount(stats.bgp_collector_count),
};
applyEarthStatsSummary(earthStatsSummary);
} catch (error) {
console.warn("全球态势聚合统计加载失败:", error);
}
}
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 +1318,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 +1869,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
@@ -1800,7 +1941,8 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
});
}
setEarthStatValue("satellite-count", `${satelliteCount}`);
const resolvedCount = satelliteCount || earthStatsSummary?.satelliteCount || 0;
setEarthStatValue("satellite-count", `${resolvedCount}`);
}
function updateCableToggleUi(enabled) {
@@ -1813,8 +1955,11 @@ function updateCableToggleUi(enabled) {
});
}
setEarthStatValue("cable-count", `${getCableLines().length}`);
setEarthStatValue("landing-point-count", `${getLandingPoints().length}`);
const cableCount = getCableLines().length || earthStatsSummary?.cableCount || 0;
const landingPointCount =
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
setEarthStatValue("cable-count", `${cableCount}`);
setEarthStatValue("landing-point-count", `${landingPointCount}`);
}
async function ensureCablesEnabled() {
@@ -1922,6 +2067,7 @@ function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
satelliteHydrationToken += 1;
toggleSatellites(false);
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
@@ -1929,13 +2075,25 @@ function disableSatellites() {
}
function updateStatsSummary() {
const cableCount = getCableLines().length || earthStatsSummary?.cableCount || 0;
const landingPointCount =
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
const satelliteCount = getSatelliteCount() || earthStatsSummary?.satelliteCount || 0;
const computeCenterCount =
getComputeCenterCount() || earthStatsSummary?.computeCenterCount || 0;
const bgpEventCount = getBGPCount() || earthStatsSummary?.bgpEventCount || 0;
const bgpCollectorCount =
getBGPCollectorCount() || earthStatsSummary?.bgpCollectorCount || 0;
updateEarthStats({
cableCount: getCableLines().length,
landingPointCount: getLandingPoints().length,
computeCenterCount: `${getComputeCenterCount()} `,
bgpAnomalyCount: `${getBGPCount()} `,
bgpCollectorCount: `${getBGPCollectorCount()} `,
bgpStatusSummary: getBGPStatusSummary(),
cableCount: `${cableCount}`,
landingPointCount: `${landingPointCount}`,
satelliteCount: `${satelliteCount} `,
computeCenterCount: `${computeCenterCount} `,
bgpAnomalyCount: `${bgpEventCount} `,
bgpCollectorCount: `${bgpCollectorCount}`,
bgpStatusSummary: getBGPCount()
? getBGPStatusSummary()
: formatBGPStatusFromSummary(earthStatsSummary),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
@@ -2002,6 +2160,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 +2182,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,12 +2334,18 @@ async function loadData() {
clearCableData(earth);
clearComputeCenterData(earth);
clearSatelliteData();
clearCountryBoundaryHover();
setLoadingMessage("正在初始化...");
setLoading(true);
await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
setLoadingMessage("正在读取全球态势统计...");
await loadEarthStatsSummary();
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(12);
const errors = [];
// Step 1 — Earth texture
@@ -2194,7 +2372,9 @@ async function loadData() {
updateComputeCenterHud,
updateBGPHud,
getShowComputeCenters,
getShowCountryBoundaries,
getShowBGP,
isEarthTextureVisible: () => getEarthTextureVisible(),
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
@@ -2247,6 +2427,7 @@ async function loadData() {
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
@@ -2338,6 +2519,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 +2838,7 @@ function onMouseMove(event) {
inertialVelocity.y = rotationDeltaY;
inertialVelocity.x = rotationDeltaX;
previousMousePosition = { x: event.clientX, y: event.clientY };
clearCountryBoundaryHover();
hideTooltip();
return;
}
@@ -2590,18 +2868,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 +2977,7 @@ function onMouseMove(event) {
event.clientX,
event.clientY,
camera,
earth,
getEarthSurfacePickTarget() || earth,
document.body,
interactionRaycaster,
interactionMouse,
@@ -2714,6 +2985,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 +3009,11 @@ function onMouseMove(event) {
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
);
} else {
clearCountryBoundaryHover();
hideTooltip();
}
} else {
clearCountryBoundaryHover();
}
}
@@ -2864,6 +3150,7 @@ function onPointerUp(event) {
}
function onMouseLeave() {
clearCountryBoundaryHover();
hideTooltip();
}
@@ -2898,9 +3185,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 +3281,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 +3474,7 @@ export function destroy() {
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());
clearCountryBoundaryData();
resetSatelliteState();
clearUiState();
disposeCelestialLayer();

View File

@@ -42,6 +42,8 @@ let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
const SATELLITE_FOOTPRINT_POLICIES = Object.freeze({
NONE: "none",
STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
@@ -66,6 +68,61 @@ const SATELLITE_CONSTELLATION_LABELS = Object.freeze({
});
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const TRAIL_RIBBON_VERTEX_SHADER = /* glsl */ `
attribute vec3 instanceStart;
attribute vec3 instanceEnd;
attribute vec3 instanceColorStart;
attribute vec3 instanceColorEnd;
uniform vec2 resolution;
uniform float lineWidth;
varying vec3 vColor;
void main() {
float t = position.x;
float side = position.y;
vec4 clipStart = projectionMatrix * modelViewMatrix * vec4(instanceStart, 1.0);
vec4 clipEnd = projectionMatrix * modelViewMatrix * vec4(instanceEnd, 1.0);
vec2 screenStart = (clipStart.xy / clipStart.w * 0.5 + 0.5) * resolution;
vec2 screenEnd = (clipEnd.xy / clipEnd.w * 0.5 + 0.5) * resolution;
vec2 dir = screenEnd - screenStart;
float segLen = length(dir);
vec4 clipPos = mix(clipStart, clipEnd, t);
if (segLen > 0.001) {
dir /= segLen;
vec2 normal = vec2(-dir.y, dir.x);
clipPos.xy += normal * side * lineWidth * 0.5 / resolution * 2.0 * clipPos.w;
vColor = mix(instanceColorStart, instanceColorEnd, t);
} else {
vColor = vec3(0.0);
}
gl_Position = clipPos;
}
`;
const TRAIL_RIBBON_FRAGMENT_SHADER = /* glsl */ `
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`;
const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [
"instanceStart",
"instanceEnd",
"instanceColorStart",
"instanceColorEnd",
];
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
const FALLBACK_MIN_MEAN_MOTION = 12;
const FALLBACK_MEAN_MOTION_SPREAD = 4;
const FALLBACK_TRAIL_TIP_LENGTH = 0.004;
const FALLBACK_TRAIL_ALPHA_START = 0.2;
const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
@@ -520,16 +577,32 @@ export function createSatellites(scene, earthObj) {
earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints);
const trailGeometry = new THREE.BufferGeometry();
// Instanced screen-space ribbon: one quad instance per trail segment.
// Single mesh / single draw call for all satellite trails.
const ribbonGeometry = new THREE.InstancedBufferGeometry();
// Base quad: position.x = t (0=seg-start, 1=seg-end), position.y = side (-1/+1)
ribbonGeometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3),
);
ribbonGeometry.setIndex(new THREE.BufferAttribute(new Uint16Array([0, 2, 1, 2, 3, 1]), 1));
const trailMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
const trailResolution = new THREE.Vector2(window.innerWidth, window.innerHeight);
const trailMaterial = new THREE.ShaderMaterial({
uniforms: {
lineWidth: { value: SATELLITE_CONFIG.trailLineWidth },
resolution: { value: trailResolution },
},
vertexShader: TRAIL_RIBBON_VERTEX_SHADER,
fragmentShader: TRAIL_RIBBON_FRAGMENT_SHADER,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
satelliteTrails = new THREE.Mesh(ribbonGeometry, trailMaterial);
satelliteTrails.onBeforeRender = (renderer) => renderer.getSize(trailResolution);
satelliteTrails.frustumCulled = false;
satelliteTrails.visible = false;
satelliteTrails.userData = { type: "satelliteTrails" };
earthObj.add(satelliteTrails);
@@ -568,15 +641,12 @@ function resetSatelliteTrailState() {
function clearSatelliteTrailGeometry() {
if (!satelliteTrails) return;
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color;
if (trailPositionAttr?.array) {
trailPositionAttr.array.fill(0);
trailPositionAttr.needsUpdate = true;
}
if (trailColorAttr?.array) {
trailColorAttr.array.fill(0);
trailColorAttr.needsUpdate = true;
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
const attr = satelliteTrails.geometry.attributes[name];
if (attr?.array) {
attr.array.fill(0);
attr.needsUpdate = true;
}
}
}
@@ -599,10 +669,14 @@ function ensureSatelliteCapacity(count) {
const previousPointAlphas = satellitePoints.geometry.attributes.alpha?.array || null;
const previousBackdropAlphas =
satelliteBackdropPoints.geometry.attributes.alpha?.array || null;
const previousTrailPositions =
satelliteTrails.geometry.attributes.position?.array || null;
const previousTrailColors =
satelliteTrails.geometry.attributes.color?.array || null;
const previousInstanceStarts =
satelliteTrails.geometry.attributes.instanceStart?.array || null;
const previousInstanceEnds =
satelliteTrails.geometry.attributes.instanceEnd?.array || null;
const previousInstanceColorStarts =
satelliteTrails.geometry.attributes.instanceColorStart?.array || null;
const previousInstanceColorEnds =
satelliteTrails.geometry.attributes.instanceColorEnd?.array || null;
const previousSatellitePositions = satellitePositions;
const previousCapacity = satelliteCapacity;
@@ -668,32 +742,48 @@ function ensureSatelliteCapacity(count) {
);
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
if (previousTrailPositions) {
trailPositions.set(
previousTrailPositions.subarray(
0,
Math.min(previousTrailPositions.length, trailPositions.length),
),
const segCount = nextCapacity * (TRAIL_LENGTH - 1);
const instanceStarts = new Float32Array(segCount * 3);
const instanceEnds = new Float32Array(segCount * 3);
const instanceColorStarts = new Float32Array(segCount * 3);
const instanceColorEnds = new Float32Array(segCount * 3);
if (previousInstanceStarts) {
instanceStarts.set(
previousInstanceStarts.subarray(0, Math.min(previousInstanceStarts.length, instanceStarts.length)),
);
}
if (previousTrailColors) {
trailColors.set(
previousTrailColors.subarray(
0,
Math.min(previousTrailColors.length, trailColors.length),
),
if (previousInstanceEnds) {
instanceEnds.set(
previousInstanceEnds.subarray(0, Math.min(previousInstanceEnds.length, instanceEnds.length)),
);
}
if (previousInstanceColorStarts) {
instanceColorStarts.set(
previousInstanceColorStarts.subarray(0, Math.min(previousInstanceColorStarts.length, instanceColorStarts.length)),
);
}
if (previousInstanceColorEnds) {
instanceColorEnds.set(
previousInstanceColorEnds.subarray(0, Math.min(previousInstanceColorEnds.length, instanceColorEnds.length)),
);
}
satelliteTrails.geometry.setAttribute(
"position",
new THREE.BufferAttribute(trailPositions, 3),
"instanceStart",
new THREE.InstancedBufferAttribute(instanceStarts, 3),
);
satelliteTrails.geometry.setAttribute(
"color",
new THREE.BufferAttribute(trailColors, 3),
"instanceEnd",
new THREE.InstancedBufferAttribute(instanceEnds, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceColorStart",
new THREE.InstancedBufferAttribute(instanceColorStarts, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceColorEnd",
new THREE.InstancedBufferAttribute(instanceColorEnds, 3),
);
satelliteTrails.geometry.instanceCount = segCount;
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
const previousState = previousSatellitePositions[index];
@@ -903,7 +993,7 @@ function buildTleLinesFromElements(props, fallbackTime) {
};
}
function generateFallbackPosition(satellite, index, total) {
function generateFallbackPosition(satellite, index, total, time = new Date()) {
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
@@ -915,9 +1005,21 @@ function generateFallbackPosition(satellite, index, total) {
.split("")
.reduce((a, b) => a + b.charCodeAt(0), 0);
const randomOffset = (hash % 1000) / 1000;
const rawMeanMotion = Number(satellite.properties?.mean_motion);
const meanMotion =
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
? rawMeanMotion
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
const normalizedIndex = index / total;
const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180;
const elapsedDays = Number.isFinite(time?.getTime?.())
? time.getTime() / FALLBACK_ORBIT_DAY_MS
: Date.now() / FALLBACK_ORBIT_DAY_MS;
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
const theta =
normalizedIndex * Math.PI * 2 * 10 +
(raan * Math.PI) / 180 +
fallbackPhase;
const phi =
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
@@ -946,6 +1048,7 @@ export async function loadSatellites(options = {}) {
const data = await response.json();
satelliteData = data.features || [];
satelliteSatrecCache = new Map();
resetSatelliteTrailState();
ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return {
@@ -958,7 +1061,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null;
showSatellites ||
showTrails ||
lockedSatelliteIndex !== null;
const shouldResetTrails =
options.resetTrails ||
(!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS);
@@ -988,10 +1093,13 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
const colors = satellitePoints.geometry.attributes.color.array;
const pointAlphas = satellitePoints.geometry.attributes.alpha.array;
const backdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array;
const instanceStarts = satelliteTrails.geometry.attributes.instanceStart.array;
const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array;
const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array;
const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array;
const baseTime = new Date(Date.now() + elapsedMs);
const count = Math.min(satelliteData.length, satelliteCapacity);
let trailSegmentCount = 0;
for (let i = 0; i < count; i++) {
const satellite = satelliteData[i];
@@ -1003,16 +1111,30 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
let pos = computeSatellitePosition(satellite, adjustedTime);
if (!pos) {
pos = generateFallbackPosition(satellite, i, count);
pos = generateFallbackPosition(satellite, i, count, adjustedTime);
}
satellitePositions[i].current.copy(pos);
if (shouldUpdateTrails && i !== lockedSatelliteIndex) {
if (shouldUpdateTrails) {
const satPos = satellitePositions[i];
satPos.trail[satPos.trailIndex] = pos.clone();
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
if (satPos.trailCount === 0 && TRAIL_LENGTH > 1) {
for (let k = 0; k < TRAIL_LENGTH; k++) {
const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS;
const pastTime = new Date(adjustedTime.getTime() - offsetMs);
let pastPos = computeSatellitePosition(satellite, pastTime);
if (!pastPos) {
pastPos = generateFallbackPosition(satellite, i, count, pastTime);
}
satPos.trail[satPos.trailIndex] = pastPos;
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
}
satPos.trailCount = TRAIL_LENGTH;
} else {
satPos.trail[satPos.trailIndex] = pos.clone();
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
}
}
positions[i * 3] = pos.x;
@@ -1040,32 +1162,59 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
backdropAlphas[i] = pointAlpha;
const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
if (j < satPosition.trailCount) {
const idx =
(satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) %
TRAIL_LENGTH;
const trailPoint = satPosition.trail[idx];
if (trailPoint) {
trailPositions[trailIdx] = trailPoint.x;
trailPositions[trailIdx + 1] = trailPoint.y;
trailPositions[trailIdx + 2] = trailPoint.z;
const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha * trailBrightness;
trailColors[trailIdx + 1] = g * alpha * trailBrightness;
trailColors[trailIdx + 2] = b * alpha * trailBrightness;
continue;
const tc = satPosition.trailCount;
let hasVisibleTrail = false;
for (let j = 0; j < TRAIL_LENGTH - 1; j++) {
if (j + 1 < tc) {
const idxA =
(satPosition.trailIndex - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH;
const idxB =
(satPosition.trailIndex - tc + j + 1 + TRAIL_LENGTH) % TRAIL_LENGTH;
const ptA = satPosition.trail[idxA];
const ptB = satPosition.trail[idxB];
if (ptA && ptB && ptA.distanceToSquared(ptB) > 1e-8) {
const base = trailSegmentCount * 3;
instanceStarts[base] = ptA.x;
instanceStarts[base + 1] = ptA.y;
instanceStarts[base + 2] = ptA.z;
instanceEnds[base] = ptB.x;
instanceEnds[base + 1] = ptB.y;
instanceEnds[base + 2] = ptB.z;
const alphaA = (j + 1) / tc;
const alphaB = (j + 2) / tc;
instanceColorStarts[base] = r * alphaA * trailBrightness;
instanceColorStarts[base + 1] = g * alphaA * trailBrightness;
instanceColorStarts[base + 2] = b * alphaA * trailBrightness;
instanceColorEnds[base] = r * alphaB * trailBrightness;
instanceColorEnds[base + 1] = g * alphaB * trailBrightness;
instanceColorEnds[base + 2] = b * alphaB * trailBrightness;
hasVisibleTrail = true;
trailSegmentCount++;
}
}
trailPositions[trailIdx] = pos.x;
trailPositions[trailIdx + 1] = pos.y;
trailPositions[trailIdx + 2] = pos.z;
trailColors[trailIdx] = 0;
trailColors[trailIdx + 1] = 0;
trailColors[trailIdx + 2] = 0;
}
if (!hasVisibleTrail) {
const base = trailSegmentCount * 3;
const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z) || 1;
const nx = pos.x / dist;
const ny = pos.y / dist;
const nz = pos.z / dist;
const tip = FALLBACK_TRAIL_TIP_LENGTH;
instanceStarts[base] = pos.x + nx * tip;
instanceStarts[base + 1] = pos.y + ny * tip;
instanceStarts[base + 2] = pos.z + nz * tip;
instanceEnds[base] = pos.x;
instanceEnds[base + 1] = pos.y;
instanceEnds[base + 2] = pos.z;
const fallbackAlphaStart = FALLBACK_TRAIL_ALPHA_START * trailBrightness;
const fallbackAlphaEnd = FALLBACK_TRAIL_ALPHA_END * trailBrightness;
instanceColorStarts[base] = r * fallbackAlphaStart;
instanceColorStarts[base + 1] = g * fallbackAlphaStart;
instanceColorStarts[base + 2] = b * fallbackAlphaStart;
instanceColorEnds[base] = r * fallbackAlphaEnd;
instanceColorEnds[base + 1] = g * fallbackAlphaEnd;
instanceColorEnds[base + 2] = b * fallbackAlphaEnd;
trailSegmentCount++;
}
}
@@ -1079,12 +1228,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
pointAlphas[i] = 0;
backdropAlphas[i] = 0;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
trailPositions[trailIdx] = 0;
trailPositions[trailIdx + 1] = 0;
trailPositions[trailIdx + 2] = 0;
}
}
const trailArrayLength = instanceStarts.length / 3;
for (let i = trailSegmentCount; i < trailArrayLength; i++) {
const base = i * 3;
instanceStarts[base] = 0;
instanceStarts[base + 1] = 0;
instanceStarts[base + 2] = 0;
instanceEnds[base] = 0;
instanceEnds[base + 1] = 0;
instanceEnds[base + 2] = 0;
instanceColorStarts[base] = 0;
instanceColorStarts[base + 1] = 0;
instanceColorStarts[base + 2] = 0;
instanceColorEnds[base] = 0;
instanceColorEnds[base + 1] = 0;
instanceColorEnds[base + 2] = 0;
}
satellitePoints.geometry.attributes.position.needsUpdate = true;
@@ -1095,8 +1255,10 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
satelliteBackdropPoints.geometry.attributes.alpha.needsUpdate = true;
satelliteBackdropPoints.geometry.setDrawRange(0, count);
satelliteTrails.geometry.attributes.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true;
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
satelliteTrails.geometry.attributes[name].needsUpdate = true;
}
satelliteTrails.geometry.instanceCount = trailSegmentCount;
// Keep the hover ring synced with the propagated satellite position even
// when the pointer stays still and no new hover event is emitted.
@@ -1400,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) },
@@ -1966,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);
@@ -2044,24 +2207,8 @@ function updateLockedMarkerVisual(isHovered) {
}
}
function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
if (!earthObjRef) return null;
const ringTexture = createRingTexture(7, 11, color);
const spriteMaterial = new THREE.SpriteMaterial({
map: ringTexture,
transparent: true,
opacity: 0.55,
depthTest: false,
sizeAttenuation: false,
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
function createRelatedSatelliteSprite(position) {
return createRingSprite(position, false);
}
export function showHoverRing(position, isLocked = false) {
@@ -2259,7 +2406,7 @@ export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
const sprite = createRelatedSatelliteSprite(pos, color);
const sprite = createRelatedSatelliteSprite(pos);
if (!sprite) return;
relatedSatelliteSprites.push({ index, sprite, color });
});

View File

@@ -242,13 +242,15 @@ export function openSearchPanel() {
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
);
window.setTimeout(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
});
});
}, 16);
});
}
export function focusSearchInput({ select = false } = {}) {

View File

@@ -185,14 +185,22 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
setEarthStatValue("cable-count", String(stats.cableCount || 0));
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
const has = (key) => Object.prototype.hasOwnProperty.call(stats, key);
if (has("cableCount")) setEarthStatValue("cable-count", String(stats.cableCount || 0));
if (has("landingPointCount")) {
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
}
if (has("satelliteCount")) setEarthStatValue("satellite-count", String(stats.satelliteCount || 0));
if (has("computeCenterCount")) {
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
}
if (has("bgpAnomalyCount")) setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
if (has("bgpCollectorCount")) {
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
}
if (has("bgpStatusSummary")) setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
if (has("terrainOn")) setEarthStatValue("terrain-status", stats.terrainOn ? "开启" : "关闭");
if (has("textureQuality")) setEarthStatValue("texture-quality", stats.textureQuality || "8K 卫星图");
}
// Show/hide loading via status message

View File

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

View File

@@ -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
@@ -308,3 +320,62 @@ class BaseCollector:
- verify layouts under browser zoom `125%` and `150%`
- Avoid using wrapper components with implicit layout behavior, such as `Space`, for height-critical scroll regions unless their generated DOM is fully accounted for
- Any UI state that hides data or a layer must also reconcile related hover/lock/tooltip/selection state so hidden content is not still “active” in the UI
---
## Icon System - MANDATORY
All canvas-drawn marker icons for the 3D earth visualization **MUST** have a canonical SVG in:
```
frontend/public/earth/assets/icons/
```
This directory is the **single source of truth** for icon shapes. The canvas/Three.js drawing code may use inline `Path2D` strings or `<canvas>` draw calls derived from these SVGs, but the geometry must originate here.
### Naming convention
`{module}-{description}.svg` in kebab-case.
| Module prefix | Context |
|---------------|---------|
| `marker-` | Surface map markers (landing points, etc.) |
| `bgp-` | BGP/routing layer icons and event symbols |
| `compute-` | Compute center markers |
Examples: `marker-landing-point.svg`, `bgp-event-triangle.svg`, `compute-gpu-cluster.svg`
### Existing icons
| File | Used in | Description |
|------|---------|-------------|
| `marker-landing-point.svg` | `cables.js` | Cable landing point pin (with circular cutout) |
| `bgp-collector.svg` | `bgp.js` | BGP collector marker (access_point icon + outer ring) |
| `bgp-glow-dot.svg` | `bgp.js` | Base radial glow dot under BGP collector |
| `bgp-event-ring.svg` | `bgp.js` | Ring overlay on event markers |
| `bgp-event-triangle.svg` | `bgp.js` | Origin anomaly |
| `bgp-event-exclamation.svg` | `bgp.js` | Withdraw event |
| `bgp-event-wave.svg` | `bgp.js` | Flap event |
| `bgp-event-burst.svg` | `bgp.js` | Specific/burst anomaly |
| `bgp-event-leak.svg` | `bgp.js` | Route leak |
| `bgp-event-dot.svg` | `bgp.js` | Generic event |
| `compute-supercomputer.svg` | `compute-centers.js` | Supercomputer (#38bdf8) |
| `compute-gpu-cluster.svg` | `compute-centers.js` | GPU cluster (#2dd4bf) |
### Color rules
- Use `fill=”currentColor”` for single-color icons so the caller controls the color (event symbols, landing point)
- Hardcode brand colors only when the color is part of the icon identity (compute center types)
- State variants (hover, locked, dimmed) are handled by the calling canvas code via color/opacity — **do not create separate SVG files per state**
### Coordinate system
- Use the native canvas coordinate space as the `viewBox` (typically `0 0 128 128`)
- Exception: `marker-landing-point.svg` uses a `viewBox` cropped from 1000-unit path space
- SVG must visually match the canvas output at the same scale
### When adding a new icon
1. Create the SVG in `assets/icons/` following naming rules above
2. Add a row to the table in this section
3. Reference the SVG path/geometry in the canvas drawing code — do not invent new shapes directly in JS

2
uv.lock generated
View File

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