From 655e2a7d2d2e0ce59d081101322f42f9e0a1c1ad Mon Sep 17 00:00:00 2001 From: linkong Date: Mon, 27 Apr 2026 16:31:34 +0800 Subject: [PATCH] release: bump version to 0.41.1 Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- backend/app/api/v1/visualization.py | 65 +++++++ .../test_visualization_compute_centers.py | 130 ++++++++++++++ docs/CHANGELOG.md | 11 ++ docs/technical/earth-layer-style-reference.md | 24 ++- docs/version-history.md | 3 +- frontend/package.json | 2 +- .../earth/assets/icons/bgp-collector.svg | 19 ++ .../earth/assets/icons/bgp-event-burst.svg | 13 ++ .../earth/assets/icons/bgp-event-dot.svg | 4 + .../assets/icons/bgp-event-exclamation.svg | 5 + .../earth/assets/icons/bgp-event-leak.svg | 7 + .../earth/assets/icons/bgp-event-ring.svg | 4 + .../earth/assets/icons/bgp-event-triangle.svg | 4 + .../earth/assets/icons/bgp-event-wave.svg | 4 + .../earth/assets/icons/bgp-glow-dot.svg | 12 ++ .../assets/icons/compute-gpu-cluster.svg | 16 ++ .../assets/icons/compute-supercomputer.svg | 10 ++ .../assets/icons/marker-landing-point.svg | 10 ++ frontend/public/earth/js/cables.js | 165 +++++++++++++----- frontend/public/earth/js/constants.js | 23 +-- frontend/public/earth/js/controls.js | 5 +- frontend/public/earth/js/main.js | 95 +++++++++- frontend/public/earth/js/ui.js | 24 ++- pyproject.toml | 2 +- rules.md | 59 +++++++ uv.lock | 2 +- 27 files changed, 637 insertions(+), 83 deletions(-) create mode 100644 frontend/public/earth/assets/icons/bgp-collector.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-burst.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-dot.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-exclamation.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-leak.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-ring.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-triangle.svg create mode 100644 frontend/public/earth/assets/icons/bgp-event-wave.svg create mode 100644 frontend/public/earth/assets/icons/bgp-glow-dot.svg create mode 100644 frontend/public/earth/assets/icons/compute-gpu-cluster.svg create mode 100644 frontend/public/earth/assets/icons/compute-supercomputer.svg create mode 100644 frontend/public/earth/assets/icons/marker-landing-point.svg 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 `` 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 diff --git a/uv.lock b/uv.lock index 0d9ba438..dd44dbdf 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.41.0" +version = "0.41.1" source = { virtual = "." } dependencies = [ { name = "aiofiles" },