diff --git a/VERSION b/VERSION
index 72a8a631..9ed317fb 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.41.0
+0.41.1
diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py
index 89996f2b..b147f5e0 100644
--- a/backend/app/api/v1/visualization.py
+++ b/backend/app/api/v1/visualization.py
@@ -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)):
"""获取所有可视化数据的统一端点
diff --git a/backend/tests/test_visualization_compute_centers.py b/backend/tests/test_visualization_compute_centers.py
index 0d0adb99..85c5f7ec 100644
--- a/backend/tests/test_visualization_compute_centers.py
+++ b/backend/tests/test_visualization_compute_centers.py
@@ -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()
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index fe18aaab..314c0129 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -8,6 +8,17 @@ 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
diff --git a/docs/technical/earth-layer-style-reference.md b/docs/technical/earth-layer-style-reference.md
index 899cf667..e3790dec 100644
--- a/docs/technical/earth-layer-style-reference.md
+++ b/docs/technical/earth-layer-style-reference.md
@@ -137,15 +137,22 @@
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
-| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.1` | 登陆点球位置 |
-| 登陆点半径 | `CABLE_CONFIG.landingPoint.radius` | `0.4` | 登陆点球几何 |
-| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `2.5` | 登陆点缩放 |
-| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `MeshStandardMaterial.color` |
-| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | `MeshStandardMaterial.emissive` |
-| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | `emissiveIntensity` |
-| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `MeshStandardMaterial.opacity` |
+| 登陆点半径偏移 | `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` | 高亮脉冲 |
-| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.3` | dim 状态 |
+| 非相关登陆点颜色 | `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
@@ -226,4 +233,3 @@
| 星空半径范围 | `minRadius + radiusJitter` | `800 + 200` | 随机分布 |
| 星空点颜色 | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
| 星空点大小 | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |
-
diff --git a/docs/version-history.md b/docs/version-history.md
index b0a24501..1d4a91eb 100644
--- a/docs/version-history.md
+++ b/docs/version-history.md
@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
-- `dev` 当前开发分支历史推导到:`0.41.0`
+- `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` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 |
diff --git a/frontend/package.json b/frontend/package.json
index 962bf31f..75196480 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
- "version": "0.41.0",
+ "version": "0.41.1",
"private": true,
"packageManager": "bun@1",
"dependencies": {
diff --git a/frontend/public/earth/assets/icons/bgp-collector.svg b/frontend/public/earth/assets/icons/bgp-collector.svg
new file mode 100644
index 00000000..708e5218
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-collector.svg
@@ -0,0 +1,19 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-burst.svg b/frontend/public/earth/assets/icons/bgp-event-burst.svg
new file mode 100644
index 00000000..1c8e2cf1
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-burst.svg
@@ -0,0 +1,13 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-dot.svg b/frontend/public/earth/assets/icons/bgp-event-dot.svg
new file mode 100644
index 00000000..b74bce5a
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-dot.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-exclamation.svg b/frontend/public/earth/assets/icons/bgp-event-exclamation.svg
new file mode 100644
index 00000000..9bc60bea
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-exclamation.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-leak.svg b/frontend/public/earth/assets/icons/bgp-event-leak.svg
new file mode 100644
index 00000000..4b3927e4
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-leak.svg
@@ -0,0 +1,7 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-ring.svg b/frontend/public/earth/assets/icons/bgp-event-ring.svg
new file mode 100644
index 00000000..538e6fa7
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-ring.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-triangle.svg b/frontend/public/earth/assets/icons/bgp-event-triangle.svg
new file mode 100644
index 00000000..da9c9c0b
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-triangle.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-event-wave.svg b/frontend/public/earth/assets/icons/bgp-event-wave.svg
new file mode 100644
index 00000000..d84634ad
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-event-wave.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/public/earth/assets/icons/bgp-glow-dot.svg b/frontend/public/earth/assets/icons/bgp-glow-dot.svg
new file mode 100644
index 00000000..1ee04289
--- /dev/null
+++ b/frontend/public/earth/assets/icons/bgp-glow-dot.svg
@@ -0,0 +1,12 @@
+
diff --git a/frontend/public/earth/assets/icons/compute-gpu-cluster.svg b/frontend/public/earth/assets/icons/compute-gpu-cluster.svg
new file mode 100644
index 00000000..4b68e692
--- /dev/null
+++ b/frontend/public/earth/assets/icons/compute-gpu-cluster.svg
@@ -0,0 +1,16 @@
+
diff --git a/frontend/public/earth/assets/icons/compute-supercomputer.svg b/frontend/public/earth/assets/icons/compute-supercomputer.svg
new file mode 100644
index 00000000..f32769d0
--- /dev/null
+++ b/frontend/public/earth/assets/icons/compute-supercomputer.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/public/earth/assets/icons/marker-landing-point.svg b/frontend/public/earth/assets/icons/marker-landing-point.svg
new file mode 100644
index 00000000..387ae548
--- /dev/null
+++ b/frontend/public/earth/assets/icons/marker-landing-point.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
index 9fcac7f5..56b71209 100644
--- a/frontend/public/earth/js/cables.js
+++ b/frontend/public/earth/js/cables.js
@@ -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);
});
}
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
index c31aff0b..71390ad2 100644
--- a/frontend/public/earth/js/constants.js
+++ b/frontend/public/earth/js/constants.js
@@ -201,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',
};
@@ -259,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,
@@ -277,7 +280,7 @@ export const CABLE_CONFIG = {
},
landingPointVisual: {
pulseSpeed: 0.003,
- dimBrightness: 0.3,
+ dimBrightness: 0.62,
related: {
emissiveIntensityBase: 0.5,
emissiveIntensityPulse: 0.5,
@@ -287,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,
},
},
};
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index d07cfcda..ba5f6a89 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -915,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;
}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 15c05fea..d578e6d4 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -260,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;
@@ -1246,6 +1247,61 @@ 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) {
@@ -1885,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) {
@@ -1898,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() {
@@ -2015,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 卫星图",
});
@@ -2269,6 +2341,11 @@ async function loadData() {
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
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index 0d1c294a..467d763f 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index 34c78b7b..7b31e0f9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planet"
-version = "0.41.0"
+version = "0.41.1"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
diff --git a/rules.md b/rules.md
index 0789bf74..abaa3285 100644
--- a/rules.md
+++ b/rules.md
@@ -320,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 `