Compare commits

...

10 Commits

Author SHA1 Message Date
linkong
c97dd83f3e Merge pull request 'release: bump version to 0.27.6' (#7) from dev into feature/ue5-led-client
Reviewed-on: #7
2026-04-15 01:20:07 +00:00
rayd1o
f8b43a995b release: bump version to 0.27.6 2026-04-15 07:37:41 +08:00
linkong
a4e6ce7489 docs: update mocap delivery timeline and migration workflow 2026-04-14 19:20:40 +08:00
linkong
7ffc8537e4 docs: update mocap TODO — confirmed as UE5 plugin, not Live Link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:00:50 +08:00
linkong
4dd396ea65 docs: update Marketplace → Fab in UE setup guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:56:37 +08:00
linkong
1e6f4b338b feat: add UE5 LED display client — backend API, C++ source, stereo framework
Backend:
- Add /api/v1/ue/* endpoints (compute-points, cables, landing-points, satellites, status)
  returning flat JSON optimised for UE5 C++ parsing

UE5 client (ue_client/):
- PlanetDataManager: HTTP fetch + local mock JSON loader, spawns ComputePointActors
- ComputePointActor / InteractiveObjectBase: hover/select state, material switching
- GlobeInteractionComponent: drag-to-rotate via CesiumGeoreference origin shift, inertia, zoom
- StereoRenderingManager: runtime SbS/TbB stereo toggle, IPD control (format TBD)
- MotionCaptureInterface: protocol-agnostic gesture/rotate/zoom delegate interface (impl TBD)
- PlanetPlayerController: unified mouse + motion-capture input routing
- PlanetGameMode, Build.cs, Config, mock data

Docs:
- ue5_mvp_fused_plan.md updated to v3.0 for LED display context
- ue_client_setup_guide.md: step-by-step editor setup guide
- ue_todo.md: pending items blocked on vendor answers (stereo format + mocap protocol)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:39:05 +08:00
linkong
d9adaf4134 release: bump version to 0.27.5 2026-04-14 17:34:04 +08:00
linkong
40e51d5b20 release: bump version to 0.27.4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:11:39 +08:00
linkong
93c1c1e550 release: bump version to 0.27.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:52:05 +08:00
linkong
48eb13b993 release: bump version to 0.27.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:55:04 +08:00
57 changed files with 4013 additions and 481 deletions

View File

@@ -1 +1 @@
0.27.1 0.27.6

View File

@@ -15,6 +15,7 @@ from app.api.v1 import (
bgp, bgp,
system_control, system_control,
tv, tv,
ue_data,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"]) api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"]) api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(ue_data.router, prefix="/ue", tags=["ue-client"])

View File

@@ -0,0 +1,351 @@
"""UE Client Data API
Flat JSON endpoints designed for easy parsing in Unreal Engine C++/Blueprint.
Avoids GeoJSON nesting — every field is at the top level of each item.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.collected_data_fields import get_record_field
from app.core.satellite_tle import build_tle_lines_from_elements
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.collected_data import CollectedData
router = APIRouter()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _current_stmt(source: str, limit: Optional[int] = None):
stmt = (
select(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
.order_by(CollectedData.id.desc())
)
if limit:
stmt = stmt.limit(limit)
return stmt
async def _fetch(db: AsyncSession, source: str, limit: Optional[int] = None) -> List[CollectedData]:
result = await db.execute(_current_stmt(source, limit))
return list(result.scalars().all())
def _safe_float(value: Any) -> Optional[float]:
try:
v = float(value)
return v if v == v else None # reject NaN
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Status endpoint
# ---------------------------------------------------------------------------
@router.get("/status")
async def ue_status(db: AsyncSession = Depends(get_db)):
"""Quick health-check + data counts for the UE client."""
from datetime import UTC, datetime
async def count_source(source: str) -> int:
result = await db.execute(
select(func.count())
.select_from(CollectedData)
.where(CollectedData.source == source)
.where(CollectedData.is_current.is_(True))
)
return result.scalar() or 0
return {
"ok": True,
"server_time": to_iso8601_utc(datetime.now(UTC)),
"compute_points_count": await count_source("top500"),
"cables_count": await count_source("telegeography_cables"),
"landing_points_count": await count_source("arcgis_landing"),
"satellites_count": await count_source("celestrak"),
}
# ---------------------------------------------------------------------------
# Compute points (TOP500 supercomputers)
# ---------------------------------------------------------------------------
@router.get("/compute-points")
async def ue_compute_points(
limit: int = Query(default=500, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
):
"""
Returns TOP500 supercomputer data as a flat JSON array.
Response shape:
{
"count": 500,
"items": [
{
"id": "top500_1",
"name": "Frontier",
"latitude": 36.01,
"longitude": -84.26,
"country": "United States",
"city": "Oak Ridge",
"rank": 1,
"rmax_tflops": 1194000.0,
"rpeak_tflops": 1679616.0,
"cores": 8730112,
"power_kw": 22703.0
}
]
}
"""
records = await _fetch(db, "top500", limit)
items = []
for record in records:
meta = record.extra_data or {}
lat = _safe_float(get_record_field(record, "latitude"))
lon = _safe_float(get_record_field(record, "longitude"))
if lat is None or lon is None:
continue
items.append({
"id": f"top500_{record.id}",
"name": record.name or "Unknown",
"latitude": lat,
"longitude": lon,
"country": get_record_field(record, "country") or "",
"city": get_record_field(record, "city") or "",
"rank": meta.get("rank"),
"rmax_tflops": _safe_float(get_record_field(record, "rmax")),
"rpeak_tflops": _safe_float(get_record_field(record, "rpeak")),
"cores": meta.get("cores"),
"power_kw": _safe_float(get_record_field(record, "power")),
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Cable landing points
# ---------------------------------------------------------------------------
@router.get("/landing-points")
async def ue_landing_points(db: AsyncSession = Depends(get_db)):
"""
Returns cable landing points as a flat JSON array.
Response shape:
{
"count": 1200,
"items": [
{
"id": "lp_42",
"name": "Shoreham",
"latitude": 50.83,
"longitude": -0.28,
"country": "United Kingdom",
"city": "Shoreham-by-Sea",
"cable_names": ["FLAG", "TAT-14"]
}
]
}
"""
# Load landing points
lp_records = await _fetch(db, "arcgis_landing")
# Load relation + cable data for cable_names mapping
rel_result = await db.execute(
select(CollectedData)
.where(CollectedData.source == "arcgis_relation")
.where(CollectedData.is_current.is_(True))
)
rel_records = list(rel_result.scalars().all())
cable_result = await db.execute(
select(CollectedData)
.where(CollectedData.source == "telegeography_cables")
.where(CollectedData.is_current.is_(True))
)
cable_records = list(cable_result.scalars().all())
# Build mapping: city_id → list of cable names
city_to_cable_ids: Dict[int, List[int]] = {}
for r in rel_records:
meta = r.extra_data or {}
city_id = meta.get("city_id")
cable_id = meta.get("cable_id")
if city_id is not None and cable_id is not None:
city_to_cable_ids.setdefault(city_id, [])
if cable_id not in city_to_cable_ids[city_id]:
city_to_cable_ids[city_id].append(cable_id)
cable_id_to_name: Dict[int, str] = {}
for r in cable_records:
meta = r.extra_data or {}
cable_id = meta.get("cable_id")
if cable_id and r.name:
cable_id_to_name[cable_id] = r.name
items = []
for record in lp_records:
lat = _safe_float(get_record_field(record, "latitude"))
lon = _safe_float(get_record_field(record, "longitude"))
if lat is None or lon is None:
continue
meta = record.extra_data or {}
city_id = meta.get("city_id")
cable_names = []
if city_id in city_to_cable_ids:
cable_names = [
cable_id_to_name[cid]
for cid in city_to_cable_ids[city_id]
if cid in cable_id_to_name
]
items.append({
"id": f"lp_{record.id}",
"name": record.name or "Unknown",
"latitude": lat,
"longitude": lon,
"country": get_record_field(record, "country") or "",
"city": get_record_field(record, "city") or "",
"cable_names": cable_names,
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Cables (route geometry)
# ---------------------------------------------------------------------------
@router.get("/cables")
async def ue_cables(db: AsyncSession = Depends(get_db)):
"""
Returns cable route geometry.
Each segment is a flat array of [lon, lat] pairs.
Response shape:
{
"count": 100,
"items": [
{
"id": "cable_42",
"cable_id": "flag",
"name": "FLAG",
"status": "active",
"length_km": 28000,
"segments": [
[[lon, lat], [lon, lat], ...]
]
}
]
}
"""
records = await _fetch(db, "telegeography_cables")
items = []
for record in records:
meta = record.extra_data or {}
route_coords = meta.get("route_coordinates", [])
segments: List[List[List[float]]] = []
if route_coords:
# Support both flat [lon,lat] array and array-of-arrays
if route_coords and isinstance(route_coords[0][0], list):
raw_lines = route_coords
else:
raw_lines = [route_coords]
for raw_line in raw_lines:
line = []
for pt in raw_line:
try:
line.append([float(pt[0]), float(pt[1])])
except (TypeError, ValueError, IndexError):
continue
if len(line) >= 2:
segments.append(line)
if not segments:
continue
items.append({
"id": f"cable_{record.id}",
"cable_id": record.source_id or record.name or "",
"name": record.name or "Unknown",
"status": meta.get("status", "active"),
"length_km": _safe_float(get_record_field(record, "value")),
"owners": meta.get("owners") or [],
"rfs": meta.get("rfs"),
"color": meta.get("color"),
"segments": segments,
})
return {"count": len(items), "items": items}
# ---------------------------------------------------------------------------
# Satellites (TLE data)
# ---------------------------------------------------------------------------
@router.get("/satellites")
async def ue_satellites(
limit: int = Query(default=200, ge=1, le=5000),
db: AsyncSession = Depends(get_db),
):
"""
Returns satellite TLE data for orbit propagation in UE.
Response shape:
{
"count": 200,
"items": [
{
"id": "sat_42",
"norad_id": "25544",
"name": "ISS (ZARYA)",
"tle_line1": "1 25544U ...",
"tle_line2": "2 25544 ...",
"epoch": "2026-04-14T00:00:00Z",
"inclination": 51.6,
"mean_motion": 15.5
}
]
}
"""
records = await _fetch(db, "celestrak", limit)
items = []
for record in records:
meta = record.extra_data or {}
norad_id = meta.get("norad_cat_id")
if not norad_id:
continue
tle1 = meta.get("tle_line1")
tle2 = meta.get("tle_line2")
if not tle1 or not tle2:
tle1, tle2 = build_tle_lines_from_elements(
norad_cat_id=norad_id,
epoch=meta.get("epoch"),
inclination=meta.get("inclination"),
raan=meta.get("raan"),
eccentricity=meta.get("eccentricity"),
arg_of_perigee=meta.get("arg_of_perigee"),
mean_anomaly=meta.get("mean_anomaly"),
mean_motion=meta.get("mean_motion"),
)
items.append({
"id": f"sat_{record.id}",
"norad_id": str(norad_id),
"name": record.name or "Unknown",
"tle_line1": tle1 or "",
"tle_line2": tle2 or "",
"epoch": meta.get("epoch") or "",
"inclination": _safe_float(meta.get("inclination")),
"raan": _safe_float(meta.get("raan")),
"eccentricity": _safe_float(meta.get("eccentricity")),
"mean_motion": _safe_float(meta.get("mean_motion")),
})
return {"count": len(items), "items": items}

View File

@@ -8,6 +8,59 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.27.6] — 2026-04-15
### 🔧 Improvements
- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链tabs content-holder 正确撑满剩余高度
- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部
- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口
- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer
---
## [0.27.4] — 2026-04-14
### 🔧 Improvements
- info-card 改为懒加载动态挂载:页面初始 DOM 不再含隐藏的 `#info-panel` 节点,仅首次点击交互元素时创建
---
## [0.27.5] — 2026-04-14
### 🔧 Improvements
- 统一控制台多页面滚动体验BGP、alerts、采集数据、用户管理、任务、设置、Playground 等区域接入自定义滚动条与表格滚动容器
- 优化 BGP 与 alerts 页响应式布局:顶部概览卡在窄宽度下优先重排,必要时才启用横向滚动,避免卡片裁切和全局滚动条接管
- 调整 `situational alerts` 布局策略:统计卡按宽度在单行、两列和横滚之间切换,下方详情卡保持单行高度优先
- 实时采集进度优化:一键采集完成后在未刷新页面时保留 100% 完成态,不再错误归零
- 补充 UE5 MVP 融合方案文档,完善后续集成规划沉淀
### 🐛 Fixes
- 修复 BGP summary 与 alerts 顶部卡片在无真实溢出时误出现横向滚动的问题
- 修复 alerts 页面缩窄后外层全局竖向滚动被接管的问题,恢复“一屏内、内部滚动”的布局逻辑
- 修复 `situational alerts` 在两排布局下详情卡竖向溢出的问题,改为更稳定的分区响应式排版
- 修复自定义滚动条交互反馈,悬停、聚焦、拖拽时颜色加深但不再显示多余外圈
---
## [0.27.3] — 2026-04-14
### 🔧 Improvements
- TV panel meta 折叠展开方向稳定:底部锚定时向上生长,拖拽后(顶部锚定)通过 JS 补偿 top 保持播放器底部位置不变
- 修复 TV panel 展开/折叠时视频区域跳动问题:移除面板 min-height使播放器高度在两种状态下保持一致
- 修正 TV panel meta toggle 箭头方向:展开朝下,折叠朝上
- 修复图例面板折叠按钮失效legend-bar-btn 补充进拖拽排除列表)
- 调整图层搜索框图标尺寸为 20pxBR 缩放角标改为直角 L 形
---
## [0.27.2] — 2026-04-14
### 🔧 Improvements
- 修复 brand copy 宽度不随内容收缩的问题,现在与 title 图片宽度保持一致
- 提取 `--brand-copy-width` CSS 自定义属性,消除 160px / 172px 魔法数字重复
---
## [0.27.1] — 2026-04-14 ## [0.27.1] — 2026-04-14
### 🔧 Improvements ### 🔧 Improvements

144
docs/ue5_led_context.md Normal file
View File

@@ -0,0 +1,144 @@
# 项目背景UE5 + 3D LED 大屏展示系统
> 供其他 AI 快速了解项目背景和当前状态。
> 最后更新2026-04-14
---
## 一、项目定位
这不是一个普通的桌面地球应用,而是一套**领导演示用的 3D 沉浸式展示系统**。
核心逻辑:
- 态势感知数据(超算、海缆、卫星)是内容
- 3D LED 大屏 + 实时渲染 + 动捕交互是"醋"——没有它,内容再好也只是普通屏幕
- 主要受众:领导/决策层,注重视觉冲击力
---
## 二、硬件配置
| 硬件 | 规格 | 说明 |
|------|------|------|
| 3D LED 大屏 | 5.12m × 2.88mP1.538mm,被动偏振式 | 观众戴无源 3D 眼镜≤18g60Hz 即可出稳定 3D |
| 渲染工作站 | 双路国产 X86 CPU + RTX 5090 32GB | 驱动 UE5 实时渲染 |
| 视频处理器 | 随屏配套(品牌待确认) | 接收 GPU 信号,驱动 LED 墙 |
| 动捕摄像头 | RGB 摄像头 ×24K直连电脑 | 无穿戴姿态识别 |
| 音响 | 解码功放 + 吸顶喇叭 ×5 + 低音炮 | 配套 |
---
## 三、软件架构
```
摄像头×2直连
↓ 动捕插件(供应商提供 UE5 插件)
UE5 主程序(我们开发)
├── Cesium 地球(实时渲染)
├── 超算数据点(可交互)
├── 其他可交互物件
└── 立体渲染输出
↓ 视频信号(格式待确认)
视频处理器(随屏配套)
↓ LED 驱动信号(行偏振)
5m 被动偏振 3D LED 大屏
```
---
## 四、分工
| 部分 | 谁做 | 状态 |
|------|------|------|
| LED 屏体 + 视频处理器 | 屏幕供应商 | 采购中 |
| 动捕插件UE5 插件形式) | 动捕供应商 | 待交付 |
| UE5 基础工程(关卡+角色+动捕绑定) | 动捕供应商 | 待交付 |
| 地球场景 + 数据可视化 + 交互逻辑 | 我们(本项目) | 开发中 |
| 后端数据接口 | 我们(本项目) | 已完成 |
| 其他 9 个定制 3D 资产和动画 | 3D 内容供应商 | 采购中 |
---
## 五、动捕交互方式
**交付形式(已确认)**
- 供应商给我们**完整 UE5 工程**,包含:
- 视频动捕插件
- 已配好绑定和重定向的 3D 角色
- 3D 模型和动画资产
- 接两台摄像头即可直接运行
- **我们的任务**:把他们工程的内容(插件 + 角色 + 资产)**迁移进我们的 `ue_client/` 工程**,然后把角色动作映射到地球操作
**待确认**:角色动作的触发点是什么形式?
- 蓝图 Custom Event`OnGestureRotate`
- AnimNotify
- 需要我们自己判断骨骼姿态?
**交互目标(一期)**
1. 手势旋转地球
2. 手势缩放地球
3. 手势指向/确认 → 选中数据点,弹出信息卡
4. 鼠标作为备用输入(始终可用)
---
## 六、立体渲染
**待确认**
1. 供应商基础工程里是否已配好立体渲染输出?
2. 如果没有视频处理器接受什么格式Side-by-Side / Top-Bottom / 其他)
3. 给供应商的文件形式UE5 工程 / .exe / 视频文件 / 直连实时输出?
**已准备**`StereoRenderingManager.h/.cpp` 支持运行时切换 SbS/TbB格式确认后直接启用。
---
## 七、已完成的代码
### 后端 (`backend/app/api/v1/ue_data.py`)
- `GET /api/v1/ue/status` — 健康检查
- `GET /api/v1/ue/compute-points` — TOP500 超算(平铺 JSON
- `GET /api/v1/ue/landing-points` — 海缆登陆点
- `GET /api/v1/ue/cables` — 海缆路由几何
- `GET /api/v1/ue/satellites` — 卫星 TLE 数据
### UE5 C++ (`ue_client/Source/PlanetClient/`)
| 文件 | 功能 |
|------|------|
| `PlanetDataTypes.h` | FComputePoint 等数据结构 |
| `PlanetDataManager` | HTTP 拉取 + mock 数据 + 生成 Actor |
| `ComputePointActor` | 超算点 Actor三态材质正常/悬停/选中)|
| `InteractiveObjectBase` | 所有可交互物件的基类 |
| `GlobeInteractionComponent` | 拖拽旋转地球(改 Cesium 经纬度原点)+ 缩放,带惯性 |
| `StereoRenderingManager` | 立体渲染开关SbS/TbBIPD 可调 |
| `MotionCaptureInterface.h` | 动捕接口抽象(待用插件 API 替换实现)|
| `PlanetPlayerController` | 统一处理鼠标 + 动捕输入 |
| `PlanetGameMode` | 场景入口 |
### 文档
- `docs/ue_client_setup_guide.md` — 编辑器操作 step-by-step 指南
- `docs/ue_todo.md` — 待供应商回复的 TODO
- `docs/ue5_mvp_fused_plan.md` — 完整实施方案v3.0
---
## 八、交付时间线
| 时间 | 内容 | 状态 |
|------|------|------|
| 本周末前 | 动捕供应商:含动捕插件 + 3D 角色(绑定/重定向已配好)的基础 UE5 工程 | 等待中 |
| 之后尽快 | 动捕供应商:动画资产(复制进 Content/ 即可直接调用) | 等待中 |
| TBD | LED 屏供应商:视频处理器接受的 3D 信号格式(或直接技术支持对接) | 等待中 |
**拿到基础工程后可立即做**:迁移插件和角色,接摄像头做动捕调试,然后对接动作→地球操作映射。
**双目 3D 显示**:供应商可提供技术支持,等视频处理器到位后直接对接。
---
## 九、当前阻塞项
1. **动捕基础工程**(本周末前到)→ 迁移内容,确认动作触发方式,完成交互对接
2. **动画资产**(尽快)→ 复制入 Content/,在场景中引用
3. **3D 显示格式**(有供应商技术支持)→ 配置 `StereoRenderingManager`

152
docs/ue5_mvp_fused_plan.md Normal file
View File

@@ -0,0 +1,152 @@
# 智能星球 UE5 客户端实施方案LED 大屏版)
> 版本v3.0
> 日期2026-04-14
> 背景更新:目标从"普通桌面地球"升级为"5m 被动偏振 3D LED 大屏 + 动捕交互演示系统"
---
## 一、真实场景描述
```
领导进入展示间
5.12m × 2.88m 被动偏振 3D LED 大屏开机
UE5 实时渲染的 3D 地球从屏幕"飞出"
(配合被动 3D 眼镜,数据点有真实景深)
演示者做手势(无穿戴动捕摄像头捕捉)
→ 地球旋转、缩放
→ 指向数据点 → 高亮
→ 确认手势 → 信息卡弹出(漂浮在屏幕前方)
鼠标/触控作为备用输入
```
---
## 二、系统架构
```
┌─────────────────────────────────────────────────┐
│ RTX 5090 渲染工作站 │
│ │
│ Planet 后端FastAPI
│ ↕ /api/v1/ue/* │
│ UE5 主程序(本项目) │
│ ├── Cesium 地球 │
│ ├── 超算数据点 │
│ ├── 其他可交互物件 │
│ └── 立体渲染输出 (Side-by-Side / Top-Bottom) │
│ │
└─────────────┬───────────────────────────────────┘
│ HDMI/DP 视频信号
视频处理器(随屏配套)
│ LED 驱动信号(行偏振)
5.12m × 2.88m 被动偏振 3D LED 大屏
观众戴无源 3D 眼镜≤18g无需充电
动捕摄像头×2
↓ 手势识别(无穿戴)
动捕中间件 ─→ UE5Live Link / OSC待确认
```
---
## 三、一期交付目标6 项)
1. ✅ 地球在 3D 大屏上正确显示,有景深效果
2. ✅ 超算数据点散布在地球上,可悬停高亮
3. ✅ 点击/手势确认 → 弹出数据点信息卡
4. ✅ 鼠标拖拽/手势 → 地球旋转(带惯性)
5. ✅ 滚轮/手势 → 缩放
6. ⏳ 立体渲染格式配置(等视频处理器格式答复)
7. ⏳ 动捕手势接入(等中间件协议答复)
---
## 四、已完成的代码
### 后端(`backend/app/api/v1/ue_data.py`
| 接口 | 说明 |
|------|------|
| `GET /api/v1/ue/status` | 健康检查 |
| `GET /api/v1/ue/compute-points` | 超算数据(平铺 JSON|
| `GET /api/v1/ue/landing-points` | 海缆登陆点 |
| `GET /api/v1/ue/cables` | 海缆几何 |
| `GET /api/v1/ue/satellites` | 卫星 TLE |
### UE5 C++ 源码(`ue_client/Source/PlanetClient/`
| 文件 | 功能 |
|------|------|
| `PlanetDataTypes.h` | 数据结构FComputePoint 等)|
| `PlanetDataManager.h/.cpp` | HTTP 拉取 + 本地 mock 数据 + 生成 Actor |
| `ComputePointActor.h/.cpp` | 单个超算点 Actor三态材质 |
| `InteractiveObjectBase.h/.cpp` | 所有可交互物件的基类 |
| `GlobeInteractionComponent.h/.cpp` | 地球旋转/缩放,带惯性 |
| `StereoRenderingManager.h/.cpp` | 立体渲染框架,运行时切换模式 |
| `MotionCaptureInterface.h` | 动捕接口定义,协议无关 |
| `PlanetPlayerController.h/.cpp` | 统一处理鼠标 + 动捕输入 |
| `PlanetGameMode.h/.cpp` | 场景入口,自动初始化管理器 |
| `PlanetClient.Build.cs` | 模块依赖(含 TODO 注释)|
### 数据和配置
| 文件 | 说明 |
|------|------|
| `Content/Data/mock_compute_points.json` | 10 个真实超算的 mock 数据 |
| `Config/DefaultGame.ini` | GameMode 配置 |
| `Config/DefaultEngine.ini` | 渲染设置 |
| `Config/DefaultInput.ini` | 键鼠输入绑定 |
---
## 五、你需要在编辑器里做的操作
> 完整步骤见 `docs/ue_client_setup_guide.md`
**最小操作清单Phase A 本地演示):**
1. 安装 UE5.3 + Cesium for Unreal 插件
2. 打开 `ue_client/PlanetClient.uproject`,等待编译
3. 创建空关卡 `EarthMap`,通过 Cesium 菜单添加地球
4. 拖入 CesiumDynamicPawn设置 Auto Possess Player 0
5. World Settings → GameMode → PlanetGameMode
6. 创建 `BP_ComputePointActor`(父类 `AComputePointActor`),配置球体网格 + 三色材质
7. 创建 `BP_PlanetDataManager`,拖入场景,绑定 `ComputePointClass`
8. Play → 看到 10 个橙色球体,可悬停 + 点击
---
## 六、TODO 项(等供应商答复)
> 详细对照表见 `docs/ue_todo.md`
### TODO-1立体渲染格式
- **等待**:视频处理器接受什么 3D 输入格式SbS / TbB / 行交错)
- **代码位置**`StereoRenderingManager.cpp`
- **工作量**0.5 天
### TODO-2动捕协议
- **等待**:中间件使用 Live Link / OSC / 私有 SDK
- **代码位置**`MotionCaptureInterface.h``PlanetPlayerController.cpp`
- **工作量**Live Link=2hOSC=1天私有SDK=1-3天
---
## 七、后续阶段规划
| 阶段 | 内容 | 状态 |
|------|------|------|
| Phase A | 本地 mock 数据,鼠标交互,单目 2D | ✅ 代码就绪 |
| Phase B | 接入真实后端 `/api/v1/ue/*` | ✅ 代码就绪 |
| Phase C | 立体 3D 输出 | ⏳ 等格式确认 |
| Phase D | 动捕手势交互 | ⏳ 等协议确认 |
| Phase E | 海缆 Spline 渲染 | 待开发 |
| Phase F | 其他可交互物件(基类已就绪) | 待定义 |

View File

@@ -0,0 +1,319 @@
# UE5 客户端手动操作指南
> 本指南对应 `ue_client/` 目录下已生成的所有代码和配置。
> 代码已写好,你只需要做编辑器里的点击操作。
> 遇到红色错误先看文末"常见问题"章节。
---
## 前置条件
| 软件 | 版本 | 下载地址 |
|------|------|---------|
| Unreal Engine | **5.3** | Epic Games Launcher → Library → 5.3 |
| Cesium for Unreal | 最新 | 直接在下一步从 Marketplace 安装 |
| Visual Studio | 2022 Community | visualstudio.microsoft.com安装 C++ 游戏开发工作负载) |
> **注意**Cesium for Unreal 必须先安装,否则代码会编译失败。
---
## 第一阶段Phase A本地演示版不需要后端
### 步骤 1安装 Cesium for Unreal
1. 打开 **Epic Games Launcher**
2. 顶部切换到 **Unreal Engine** 选项卡
3. 左侧点击 **Fab**(原 Marketplace已改名→ 搜索 `Cesium for Unreal`
4. 点击 **免费获取**Free然后点击 **安装到引擎** → 选择 5.3
5. 等待安装完成
---
### 步骤 2打开项目
1. 打开 **Epic Games Launcher****Unreal Engine****Library**
2. 找到 5.3,点击右侧 **Launch** 旁边的下拉箭头 → **Browse**
3. 导航到 `planet/ue_client/`,选择 `PlanetClient.uproject`,点击打开
4. UE 会提示"缺少模块,需要重新编译" → 点击 **Yes**
5. 等待编译完成(首次约 5-10 分钟)
> 如果编译报错:见文末"常见问题 → 编译错误"
---
### 步骤 3创建新关卡
1. 菜单栏 → **File****New Level**
2. 选择 **Empty Level**(空关卡)
3. 保存:**File** → **Save Current Level As**
路径:`Content/Maps/`,名称:`EarthMap`
4. 点击 **Save**
---
### 步骤 4添加 Cesium 地球
1. 顶部菜单栏 → **Cesium**如果没有此菜单说明插件未激活Edit → Plugins → 搜索 Cesium → 勾选 Enable → 重启)
2. 在 Cesium 面板里点击 **Add Blank 3D Tiles Tileset** — 这会自动在场景里添加:
- `CesiumGeoreference` Actor
- `Cesium3DTileset` Actor地球瓦片
3. 再点击 **Add Cesium ion Bing Maps Aerial** 添加卫星影像底图(需要免费 Cesium ion 账号)
> 如果没有 Cesium ion 账号Cesium 菜单 → Connect to Cesium ion → 注册免费账号
---
### 步骤 5添加相机 Pawn
1. 菜单栏 → **Cesium** → 找到 **Dynamic Pawn**(名称可能是 `CesiumFlyToComponent` 相关的 Blueprint
**或者**Content Browser → 顶部搜索框输入 `DynamicPawn` → 找到插件内容里的 `DynamicPawn` → 拖入场景
2.**Outliner** 面板里点击刚拖入的 `DynamicPawn`
3.**Details** 面板里,找到 **Auto Possess Player** → 改为 **Player 0**
> 这样游戏启动时摄像机会自动使用这个可飞行的地球相机。
---
### 步骤 6配置 GameMode
1. 菜单栏 → **Window****World Settings**(如果没有,也可以在 Details Panel 里找)
2.**World Settings** 面板里找到 **Game Mode Override**
3. 点击下拉框 → 搜索 `PlanetGameMode` → 选择它
4. 找到 **Default Pawn Class** → 改为上一步拖入的 `DynamicPawn`
---
### 步骤 7创建 ComputePoint Blueprint
这步把我写的 C++ 类包装成可以在编辑器里配置材质的 Blueprint。
1. **Content Browser** → 空白处右键 → **Blueprint Class**
2. 搜索父类:输入 `ComputePointActor` → 找到 `AComputePointActor` → 点击 **Select**
3. 命名为 `BP_ComputePointActor`,保存到 `Content/Blueprints/`
**配置 BP_ComputePointActor 的网格和材质:**
4. 双击打开 `BP_ComputePointActor`
5. 在左侧 **Components** 面板里点击 `SphereMesh`
6. 在右侧 **Details** 面板里找到 **Static Mesh** → 点击下拉 → 搜索 `Sphere` → 选择 **Engine/BasicShapes/Sphere**
7. 找到 **Material** → 点击下拉 → 搜索 `M_Basic_Wall` 或者创建新材质(见下方)
**创建三种状态的材质(颜色点即可):**
8. Content Browser → 右键 → **Material** → 命名 `M_PointNormal`
- 双击打开 → 右键空白区域 → 搜索 `Constant3Vector` → 连接到 `Base Color`
- 颜色设为橙色:`(1.0, 0.4, 0.0)`
- 保存
9. 同样方式创建 `M_PointHovered`(颜色白色 `1,1,1`)和 `M_PointSelected`(颜色青色 `0,1,1`
**回到 BP_ComputePointActor**
10.**Details** 面板里:
- **Normal Material** → 选 `M_PointNormal`
- **Hovered Material** → 选 `M_PointHovered`
- **Selected Material** → 选 `M_PointSelected`
- **Point Scale** → `80000`(根据实际效果调整)
11. 点击左上角 **Compile****Save**
---
### 步骤 8放置 DataManager 并配置
1. **Content Browser** → 右键 → **Blueprint Class** → 父类搜索 `PlanetDataManager` → 选择 `APlanetDataManager`
2. 命名为 `BP_PlanetDataManager`,保存到 `Content/Blueprints/`
3.`BP_PlanetDataManager` **拖入场景**Outliner 里会出现它)
4. 在 Outliner 里点击它 → 在 **Details** 面板里配置:
- **Use Local Mock Data** → ✅ **勾选**Phase A 不需要后端)
- **Mock Data Path** → 留空(代码会自动找 `Content/Data/mock_compute_points.json`
- **Compute Point Class** → 选择 `BP_ComputePointActor`
- **Point Altitude Meters** → `50000`(海拔 50km可调
---
### 步骤 9测试 Phase A
1. 点击顶部工具栏绿色 **Play** 按钮(或 Alt+P
2. 地球应该加载卫星影像
3. 应该看到 10 个橙色球体分布在地球上(对应 mock JSON 里的 TOP500 超算)
4. 鼠标移到球体上 → 变白色Hover
5. 点击球体 → 变青色Selected
**验证通过标准:**
- [x] 地球可见
- [x] 橙色球体出现在正确位置(美国、日本、荷兰、芬兰等)
- [x] 悬停变色
- [x] 点击变色
---
## 第二阶段Phase B连接真实后端
### 步骤 10确认后端新接口可用
后端代码已添加新路由,先验证它已经运行:
```bash
# 在 WSL 或终端里
curl http://localhost:8000/api/v1/ue/status
```
应该返回类似:
```json
{"ok": true, "server_time": "...", "compute_points_count": 500, ...}
```
如果 curl 失败:
- 检查 `planet.sh` 是否在运行(`./planet.sh start`
- 检查 WSL2 → Windows 的网络:在 UE 里使用 `172.x.x.x`WSL 网关地址)而不是 `localhost`
---
### 步骤 11获取 WSL2 → Windows 的正确 IP
在 WSL 终端里运行:
```bash
cat /etc/resolv.conf | grep nameserver | awk '{print $2}'
```
记下这个 IP例如 `172.22.32.1`)。
---
### 步骤 12切换 DataManager 到实时模式
1. 在 Outliner 里点击 `BP_PlanetDataManager`
2. Details 面板里:
- **Use Local Mock Data** → **取消勾选**
- **Backend Base URL** → 填入 `http://172.22.32.1:8000`(你的实际 WSL IP
3. 重新 Play → DataManager 会通过 HTTP 拉取真实数据
---
### 步骤 13绑定点击事件显示信息卡可选需要 UMG
这步是可选的,需要创建一个 Widget Blueprint 来显示选中点的信息。
1. Content Browser → 右键 → **User Interface****Widget Blueprint**
2. 命名为 `WBP_PointInfo`
**设计 Widget 布局:**
3. 双击打开 `WBP_PointInfo`
4. 从左侧 **Palette** 拖入以下控件到画布:
- `Canvas Panel`(容器,设置为全屏)
- `Border`(右下角定位,用作信息卡背景,宽 300高 200
- `Text Block` × 4名称、排名、算力、国家
**绑定事件Blueprint 里操作):**
5. 打开 `BP_PlanetDataManager` 的 Event Graph
6. 找到 **BeginPlay** 节点
7. 拖出线 → 搜索 **Bind Event to On Point Selected**(这是我在 PlayerController 里定义的委托)
> 具体蓝图连线:从 PlayerController 获取 OnPointSelected → Bind → 在回调里 Create Widget WBP_PointInfo → Add to Viewport → Set 各个文本
---
## 文件结构总览
```
ue_client/
PlanetClient.uproject ← UE 项目入口
Config/
DefaultGame.ini ← GameMode 配置
DefaultEngine.ini ← 渲染/引擎设置
DefaultInput.ini ← 键鼠输入绑定
Content/
Data/
mock_compute_points.json ← Phase A 本地测试数据10个超算
Maps/
EarthMap.umap ← 你在步骤3创建的关卡
Blueprints/
BP_ComputePointActor ← 步骤7创建
BP_PlanetDataManager ← 步骤8创建
Source/
PlanetClient/
PlanetClient.Build.cs ← 模块依赖HTTP、JSON、Cesium
PlanetClient.h/.cpp ← 模块入口
PlanetDataTypes.h ← 数据结构定义FComputePoint 等)
PlanetDataManager.h/.cpp ← HTTP 拉取 + 生成 Actor
ComputePointActor.h/.cpp ← 单个超算点的可视化 Actor
PlanetPlayerController.h/.cpp ← 鼠标点击、悬停、相机控制
PlanetGameMode.h/.cpp ← GameMode 入口
```
---
## 后端新接口一览
后端已新增以下接口(无需认证,直接访问):
| 接口 | 说明 |
|------|------|
| `GET /api/v1/ue/status` | 健康检查 + 各数据源数量 |
| `GET /api/v1/ue/compute-points` | TOP500 超算数据(平铺 JSON |
| `GET /api/v1/ue/landing-points` | 海缆登陆点(平铺 JSON |
| `GET /api/v1/ue/cables` | 海缆路由几何segments 数组) |
| `GET /api/v1/ue/satellites` | 卫星 TLE 数据 |
---
## 常见问题
### Q: 编译报错 "Cannot open include file: CesiumGeoreference.h"
**A:** Cesium for Unreal 没有正确安装,或者没有在 `.uproject` 里启用。检查:
1. Epic Launcher → 插件是否安装到 5.3
2. `PlanetClient.uproject``Plugins` 数组是否有 `CesiumForUnreal: true`
3. UE 编辑器 → Edit → Plugins → 搜索 Cesium → 确认已勾选 Enabled
### Q: Play 之后没有看到橙色球体
**A:** 按以下顺序排查:
1. Output LogWindow → Output Log里搜索 `PlanetDataManager` — 查看是否有报错
2. 检查 `BP_PlanetDataManager` 的 Details → **Compute Point Class** 是否已设置为 `BP_ComputePointActor`
3. 检查 **Mock Data Path** 是否正确(留空则自动用 `Content/Data/mock_compute_points.json`
4. 检查 **Use Local Mock Data** 是否已勾选
### Q: 地球是灰色的没有卫星影像
**A:** 需要 Cesium ion 账号:
1. Cesium 菜单 → Connect to Cesium ion
2. 注册免费账号并授权
3. 重新添加 **Cesium ion Bing Maps Aerial** tileset
### Q: WSL2 里的后端 UE 无法访问Phase B
**A:** WSL2 和 Windows 是不同网络命名空间。方法:
1. 在 WSL 里运行 `ip route show default | awk '{print $3}'` — 这是 Windows 主机的 IP
2. 后端绑定到 `0.0.0.0:8000`(检查 `uvicorn` 启动参数,应已如此配置)
3. 在 UE 的 DataManager 里填写这个 IP 而不是 `localhost`
### Q: TransformLongitudeLatitudeHeightPositionToUnreal 不存在
**A:** Cesium for Unreal API 在不同版本有变化。如果编译报此错,将 `PlanetDataManager.cpp` 里的调用改为:
```cpp
// Cesium for Unreal v1.x 的旧 API
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightToUnreal(
Pt.Longitude, Pt.Latitude, PointAltitudeMeters);
// 或者通过 GeoTransforms
#include "CesiumGlobeAnchorComponent.h"
// ... 见 Cesium 文档
```
### Q: 点击球体没有反应
**A:**
1. 确认 `SphereMesh`**Collision Presets** 不是 `NoCollision`
- 打开 `BP_ComputePointActor` → 点击 `SphereMesh` → Details → Collision → 改为 `BlockAllDynamic`
2. 确认 PlayerController 的 `bEnableClickEvents = true`(代码里已设置)
3. Output Log 里搜索是否有输入相关报错
---
## 下一步(一期完成后)
| 功能 | 说明 |
|------|------|
| 海缆路径渲染 | `/api/v1/ue/cables` 已就绪,需要在 UE 里用 Spline 绘制 |
| 信息卡 UMG | 创建 Widget Blueprint 并在 PlayerController OnPointSelected 里显示 |
| WebSocket 实时更新 | 后端已有 WebSocketUE 端需要使用 WebSockets 插件 |
| 卫星轨迹 | TLE 数据已就绪,需要在 UE 里做轨道传播计算 |

90
docs/ue_todo.md Normal file
View File

@@ -0,0 +1,90 @@
# UE5 客户端 — 待供应商回复的 TODO
> 以下两个问题答复后,对应代码可在一天内完成。
> 其余所有代码均已写好,不依赖这两个答案。
---
## TODO-1视频处理器输入格式
**等待信息**LED 屏配套视频处理器(如诺瓦星云)接受什么 3D 信号格式?
| 可能答案 | 对应操作 |
|---------|---------|
| Side-by-Side左右并排 | `StereoRenderingManager.cpp``EnableStereo``SideBySide` 分支已写好,直接启用 |
| Top-Bottom上下叠加 | 同上,切换到 `TopBottom` 分支 |
| 行交错(行偏振直驱) | 需要新写一个 PostProcess Material把左右眼奇偶行合并输出 |
| 私有协议 | 需要供应商提供 UE5 插件或信号格式文档 |
**代码位置**
```
ue_client/Source/PlanetClient/StereoRenderingManager.h — 第 8-18 行 TODO 注释
ue_client/Source/PlanetClient/StereoRenderingManager.cpp — EnableStereo() 函数
```
**需要同时确认**
- 视频处理器品牌和型号
- 屏幕物理分辨率(用于配置 UE5 输出分辨率)
- 3D 启用时是否需要特殊信号时序(如 3D Frame Packing
---
## TODO-2动捕角色接入
**已确认**
- **本周末前**:供应商交付含动捕插件 + 3D 角色(已配好绑定和重定向)的基础 UE5 工程
- 接入两台摄像头4080 以上显卡即可直接运行动捕调试
- **之后尽快**:动画资产单独交付,复制到工程 Content/ 目录即可直接调用
- **我们的任务**:把他们工程内容迁移进 `ue_client/`,把角色动作映射到地球操作
---
### 阶段 A拿到基础工程后本周末
**迁移步骤**
1. **迁移动捕插件**
- 从供应商工程 `Plugins/` 拷到 `ue_client/Plugins/`
- `PlanetClient.uproject``Plugins` 数组添加插件条目(`Enabled: true`
- `PlanetClient.Build.cs``PublicDependencyModuleNames` 加插件模块名
2. **迁移角色**
- 把角色 Blueprint、动画、骨骼网格从供应商 `Content/` 拷到 `ue_client/Content/`
- 在关卡里放置角色 Actor确认摄像头接入后能正常驱动
3. **接入动作触发(关键,看到工程后确认方式)**
| 需要确认的内容 | 用途 |
|-------------|------|
| 角色动作怎么暴露给外部? | Blueprint Custom Event / AnimNotify / 骨骼姿态变量? |
| 手势集合有哪些? | 填写 `EMotionGesture` 枚举,配置 `FMotionActionMapping` |
| 是否持续输出旋转增量? | 旋转地球用"持续增量"还是"离散手势触发" |
| 插件模块名称(`ModuleName`| 加入 `Build.cs` 依赖 |
**拿到工程后(我来做)**
- 用插件实际 API 实现 `UMotionCaptureReceiver` 子类
-`PlanetPlayerController::BeginPlay` 里取消注释 `BindMotionCaptureEvents()`
**代码位置(接口已预留)**
```
ue_client/Source/PlanetClient/MotionCaptureInterface.h — 基类和手势枚举
ue_client/Source/PlanetClient/PlanetPlayerController.cpp — BindMotionCaptureEvents()
ue_client/Source/PlanetClient/PlanetClient.Build.cs — TODO 注释处加插件模块名
ue_client/PlanetClient.uproject — Plugins 数组加插件条目
```
---
### 阶段 B动画资产到位后
- 把供应商提供的动画资产直接复制到 `ue_client/Content/` 对应目录
-`PlanetDataManager` / 场景 Actor 里引用这些资产即可调用
---
## 答复到位后的操作清单
拿到答案后告诉我,我来:
1. **视频处理器格式** → 配置 `StereoRenderingManager`,把 `bAutoEnableOnPlay` 改为 `true`,写进 setup guide
2. **动捕插件** → 插件放入 `ue_client/Plugins/`,实现 `UMotionCaptureReceiver` 子类对接插件 API更新 `Build.cs``.uproject`

View File

@@ -16,12 +16,17 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.27.1` - `dev` 当前开发分支历史推导到:`0.27.6`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复Playground 响应式按钮与输入框收起优化 |
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
| `0.27.2` | improvement | `dev` | — | 修复 brand copy 宽度问题,提取 --brand-copy-width CSS 变量 |
| `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 | | `0.27.1` | improvement | `dev` | — | HUD 面板拖拽 L 形边界约束、brand 组件整体缩放、图层面板宽度优化、搜索叉叉修复 |
| `0.27.0` | feature | `dev` | — | Earth HUD 重构图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 | | `0.27.0` | feature | `dev` | — | Earth HUD 重构图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 |
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit | | `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |

View File

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

View File

@@ -77,6 +77,8 @@
.hud-panel-header .hud-panel-title { .hud-panel-header .hud-panel-title {
margin-bottom: 0; margin-bottom: 0;
color: var(--hud-text-soft);
font-size: calc(0.82rem * var(--hud-scale));
} }
.hud-panel-drag-handle { .hud-panel-drag-handle {

View File

@@ -25,6 +25,7 @@
.hud-panel-brand { .hud-panel-brand {
--brand-scale: 0.88; --brand-scale: 0.88;
--brand-copy-width: 160px;
border-radius: 0; border-radius: 0;
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale)); padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
display: flex; display: flex;
@@ -51,16 +52,16 @@
.hud-panel-brand .earth-brand__copy { .hud-panel-brand .earth-brand__copy {
display: flex; display: flex;
flex: 1 1 auto; flex: 0 0 auto;
min-width: 0;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: calc(5px * var(--hud-scale) * var(--brand-scale)); gap: calc(5px * var(--hud-scale) * var(--brand-scale));
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
} }
.hud-panel-brand .earth-brand__title { .hud-panel-brand .earth-brand__title {
display: block; display: block;
width: min(100%, calc(160px * var(--hud-scale) * var(--brand-scale))); width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
max-width: 100%; max-width: 100%;
height: auto; height: auto;
min-height: calc(20px * var(--hud-scale) * var(--brand-scale)); min-height: calc(20px * var(--hud-scale) * var(--brand-scale));
@@ -80,9 +81,6 @@
line-height: 1.3; line-height: 1.3;
font-weight: 500; font-weight: 500;
letter-spacing: 0.01em; letter-spacing: 0.01em;
/* Prevent text from pushing brand wider than logo column */
width: fit-content;
max-width: 100%;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -93,15 +91,21 @@
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale)); font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
line-height: 1.3; line-height: 1.3;
letter-spacing: 0.08em; letter-spacing: 0.08em;
width: fit-content;
max-width: 100%;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.hud-panel-brand .earth-brand--en {
--brand-copy-width: 172px;
}
.hud-panel-brand .earth-brand--en .earth-brand__title { .hud-panel-brand .earth-brand--en .earth-brand__title {
width: min(100%, calc(172px * var(--hud-scale) * var(--brand-scale))); width: min(100%, calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale)));
}
.hud-panel-brand .earth-brand--en .earth-brand__copy {
width: calc(var(--brand-copy-width) * var(--hud-scale) * var(--brand-scale));
} }
.hud-panel-brand .earth-brand--en .earth-brand__subtitle, .hud-panel-brand .earth-brand--en .earth-brand__subtitle,

View File

@@ -38,7 +38,7 @@
.layer-panel-title { .layer-panel-title {
flex: 1 1 auto; flex: 1 1 auto;
margin: 0; margin: 0;
color: var(--hud-title); color: var(--hud-text-soft);
font-size: calc(0.82rem * var(--hud-scale)); font-size: calc(0.82rem * var(--hud-scale));
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@@ -76,30 +76,51 @@
transition: transform 0.22s ease; transition: transform 0.22s ease;
} }
/* Chevron rotates when collapsed */ /* Chevron:展开时朝上(可折叠),折叠时朝下(可展开) */
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded { .layer-panel-btn .material-symbols-rounded {
transform: rotate(180deg); transform: rotate(180deg);
} }
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
transform: rotate(0deg);
}
/* ── Search bar ───────────────────────────────────────────────── */ /* ── Search bar ───────────────────────────────────────────────── */
.layer-panel-search { .layer-panel-search {
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line);
}
.layer-panel-search-box {
display: flex; display: flex;
align-items: center; align-items: center;
gap: calc(5px * var(--hud-scale)); gap: calc(5px * var(--hud-scale));
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale)); padding: calc(5px * var(--hud-scale)) calc(8px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line); border: 1px solid rgba(201, 225, 247, 0.14);
border-radius: calc(8px * var(--hud-scale));
background: rgba(255, 255, 255, 0.04);
transition: border-color 0.18s ease;
box-sizing: border-box;
height: calc(38px * var(--hud-scale));
}
.layer-panel-search-box:focus-within {
border-color: rgba(201, 225, 247, 0.28);
} }
.layer-panel-search-icon { .layer-panel-search-icon {
flex-shrink: 0; flex-shrink: 0;
font-size: calc(14px * var(--hud-scale));
color: var(--hud-text-soft); color: var(--hud-text-soft);
line-height: 1; line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none; pointer-events: none;
} }
.layer-panel-search-icon.material-symbols-rounded {
font-size: calc(20px * var(--hud-scale));
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
}
.layer-panel-search-input { .layer-panel-search-input {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;

View File

@@ -6,17 +6,34 @@
width: calc(420px * var(--hud-scale)); width: calc(420px * var(--hud-scale));
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
min-width: calc(300px * var(--hud-scale)); min-width: calc(300px * var(--hud-scale));
min-height: calc(340px * var(--hud-scale)); padding: calc(10px * var(--hud-scale));
padding: calc(18px * var(--hud-scale));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hud-gap-sm); gap: var(--hud-gap-sm);
z-index: 18; z-index: 18;
} }
.tv-panel-header-copy { /* header 内嵌 select + actions */
display: grid; .hud-panel-tv .hud-panel-header {
gap: calc(3px * var(--hud-scale)); align-items: center;
gap: var(--hud-gap-xs);
}
.hud-panel-tv .hud-panel-header .hud-panel-close {
align-self: center;
}
.tv-panel-header-title {
flex: 0 0 auto;
white-space: nowrap;
margin: 0;
}
/* select / action 在 drag-handle 内,恢复正常指针 */
.hud-panel-tv .hud-panel-header .tv-panel-select,
.hud-panel-tv .hud-panel-header .tv-panel-action {
cursor: pointer;
user-select: auto;
} }
.tv-panel-status { .tv-panel-status {
@@ -26,13 +43,6 @@
text-transform: uppercase; text-transform: uppercase;
} }
.tv-panel-controls {
display: flex;
gap: var(--hud-gap-sm);
align-items: center;
min-width: 0;
}
.tv-panel-select { .tv-panel-select {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -59,28 +69,25 @@
} }
.tv-panel-action { .tv-panel-action {
border: 1px solid rgba(201, 225, 247, 0.12); border: 1px solid transparent;
border-radius: calc(12px * var(--hud-scale)); border-radius: calc(4px * var(--hud-scale));
background: rgba(255, 255, 255, 0.05); background: transparent;
color: var(--hud-text); color: var(--hud-text-muted);
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); padding: calc(7px * var(--hud-scale));
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
white-space: nowrap;
font-size: calc(0.84rem * var(--hud-scale));
line-height: 1;
cursor: pointer; cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; transition:
} background 0.18s ease,
border-color 0.18s ease,
.tv-panel-action--icon { color 0.18s ease,
padding: calc(9px * var(--hud-scale)); transform 0.18s ease,
border-radius: calc(10px * var(--hud-scale)); opacity 0.18s ease;
} }
.tv-panel-action--icon .material-symbols-rounded { .tv-panel-action--icon .material-symbols-rounded {
font-size: calc(18px * var(--hud-scale)); font-size: calc(16px * var(--hud-scale));
line-height: 1; line-height: 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
pointer-events: none; pointer-events: none;
@@ -88,7 +95,7 @@
.tv-panel-action:hover:not(:disabled) { .tv-panel-action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border-color: rgba(225, 239, 255, 0.2); border-color: rgba(225, 239, 255, 0.14);
color: var(--hud-accent-strong); color: var(--hud-accent-strong);
} }
@@ -97,6 +104,32 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* toggle 箭头:展开时朝下,折叠时朝上 */
.tv-panel-meta-toggle .material-symbols-rounded {
transition: transform 0.22s ease;
transform: rotate(0deg);
}
.tv-panel-meta-toggle.is-collapsed .material-symbols-rounded {
transform: rotate(180deg);
}
/* meta wrap折叠时用负 margin 抵消 flex gap无死区 */
.tv-panel-meta-wrap {
overflow: hidden;
max-height: calc(120px * var(--hud-scale));
opacity: 1;
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
}
.tv-panel-meta-wrap.is-collapsed {
max-height: 0;
opacity: 0;
pointer-events: none;
margin-top: calc(-1 * var(--hud-gap-sm));
margin-bottom: calc(-1 * var(--hud-gap-sm));
}
.tv-panel-meta { .tv-panel-meta {
display: grid; display: grid;
gap: calc(4px * var(--hud-scale)); gap: calc(4px * var(--hud-scale));
@@ -129,7 +162,7 @@
.tv-panel-player { .tv-panel-player {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 0 auto;
min-height: calc(220px * var(--hud-scale)); min-height: calc(220px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale)); border-radius: calc(16px * var(--hud-scale));
overflow: hidden; overflow: hidden;
@@ -163,33 +196,65 @@
background: #050a14; background: #050a14;
} }
.tv-panel-resize-handle { /* ── Multi-edge resize handles ───────────────────────────────── */
.tv-panel-edge {
position: absolute; position: absolute;
right: calc(8px * var(--hud-scale)); z-index: 10;
bottom: calc(8px * var(--hud-scale));
width: calc(18px * var(--hud-scale));
height: calc(18px * var(--hud-scale));
border: 0;
padding: 0;
background: transparent;
cursor: nwse-resize;
z-index: 2;
} }
.tv-panel-resize-handle::before { .tv-panel-edge[data-edge="r"] {
right: 0;
top: calc(12px * var(--hud-scale));
bottom: calc(12px * var(--hud-scale));
width: calc(6px * var(--hud-scale));
cursor: ew-resize;
}
.tv-panel-edge[data-edge="b"] {
bottom: 0;
left: calc(12px * var(--hud-scale));
right: calc(12px * var(--hud-scale));
height: calc(6px * var(--hud-scale));
cursor: ns-resize;
}
.tv-panel-edge[data-edge="l"] {
left: 0;
top: calc(12px * var(--hud-scale));
bottom: calc(12px * var(--hud-scale));
width: calc(6px * var(--hud-scale));
cursor: ew-resize;
}
.tv-panel-edge[data-edge="br"] {
right: 0;
bottom: 0;
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
cursor: nwse-resize;
}
.tv-panel-edge[data-edge="bl"] {
left: 0;
bottom: 0;
width: calc(20px * var(--hud-scale));
height: calc(20px * var(--hud-scale));
cursor: nesw-resize;
}
/* 右下角视觉标记 */
.tv-panel-edge[data-edge="br"]::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: calc(4px * var(--hud-scale));
border-right: 2px solid rgba(223, 235, 248, 0.46); border-right: 2px solid rgba(223, 235, 248, 0.4);
border-bottom: 2px solid rgba(223, 235, 248, 0.46); border-bottom: 2px solid rgba(223, 235, 248, 0.4);
border-bottom-right-radius: calc(10px * var(--hud-scale)); transition: border-color 0.18s ease;
opacity: 0.78;
transition: opacity 0.18s ease, border-color 0.18s ease;
} }
.tv-panel-resize-handle:hover::before { .tv-panel-edge[data-edge="br"]:hover::before {
opacity: 1; border-color: rgba(244, 249, 255, 0.75);
border-color: rgba(244, 249, 255, 0.78);
} }
.hud-panel-tv.is-resizing { .hud-panel-tv.is-resizing {
@@ -197,6 +262,25 @@
user-select: none; user-select: none;
} }
/* Whole panel is draggable; player overrides back to default */
.hud-panel-tv:not(.is-resizing) {
cursor: grab;
}
.hud-panel-tv.is-dragging {
cursor: grabbing;
}
.hud-panel-tv .tv-panel-player {
cursor: default;
}
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
.hud-panel-tv.is-dragging .tv-panel-iframe,
.hud-panel-tv.is-dragging .tv-panel-video {
pointer-events: none;
}
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) { .earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
bottom: var(--hud-offset); bottom: var(--hud-offset);
right: var(--hud-offset); right: var(--hud-offset);

View File

@@ -50,7 +50,7 @@
</defs> </defs>
</svg> </svg>
<div id="container" class="earth-app"> <div id="container" class="earth-app">
<div class="earth-left-column"> <div id="left-column" class="earth-left-column">
<div id="brand-panel" class="hud-panel hud-panel-brand"> <div id="brand-panel" class="hud-panel hud-panel-brand">
<div id="brand-root"></div> <div id="brand-root"></div>
</div> </div>
@@ -69,18 +69,20 @@
<div class="layer-panel-body" id="layer-panel-body"> <div class="layer-panel-body" id="layer-panel-body">
<!-- Search --> <!-- Search -->
<div class="layer-panel-search"> <div class="layer-panel-search">
<span class="material-symbols-rounded layer-panel-search-icon">search</span> <div class="layer-panel-search-box">
<input <span class="material-symbols-rounded layer-panel-search-icon">search</span>
type="text" <input
id="layer-search-input" type="text"
class="layer-panel-search-input" id="layer-search-input"
placeholder="搜索图层..." class="layer-panel-search-input"
autocomplete="off" placeholder="搜索图层..."
spellcheck="false" autocomplete="off"
> spellcheck="false"
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden> >
<span class="material-symbols-rounded">close</span> <button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
</button> <span class="material-symbols-rounded">close</span>
</button>
</div>
</div> </div>
<!-- Layer rows --> <!-- Layer rows -->
@@ -143,20 +145,7 @@
</div> </div>
</div> </div>
<!-- Floating detail panel — positioned near click by JS --> <div id="error-message" class="hud-error-message"></div>
<div id="info-panel" class="hud-panel hud-panel-info hud-panel-draggable" aria-live="polite">
<div id="info-card" class="info-card">
<div class="info-card-header hud-panel-drag-handle">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="info-card-content" class="info-card-content"></div>
</div>
<div id="error-message" class="hud-error-message"></div>
</div>
<div id="right-toolbar-group" class="earth-toolbar-group"> <div id="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar"> <div id="control-toolbar" class="earth-toolbar">
@@ -299,17 +288,9 @@
<span id="camera-distance" hidden></span> <span id="camera-distance" hidden></span>
</div> </div>
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel"> <div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true">
<div class="hud-panel-header hud-panel-drag-handle"> <div class="hud-panel-header hud-panel-drag-handle">
<div class="tv-panel-header-copy"> <span class="hud-panel-title tv-panel-header-title">新闻直播</span>
<h3 class="hud-panel-title">新闻直播</h3>
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
</div>
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="tv-panel-controls">
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select> <select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
<div class="tv-panel-actions"> <div class="tv-panel-actions">
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源"> <button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
@@ -318,13 +299,22 @@
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网"> <button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
<span class="material-symbols-rounded">open_in_new</span> <span class="material-symbols-rounded">open_in_new</span>
</button> </button>
<button id="tv-meta-toggle" class="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息">
<span class="material-symbols-rounded">expand_more</span>
</button>
</div> </div>
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
<span class="material-symbols-rounded">close</span>
</button>
</div> </div>
<div class="tv-panel-meta"> <div class="tv-panel-meta-wrap" id="tv-meta-wrap">
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div> <div class="tv-panel-meta" id="tv-panel-meta">
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div> <span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div> <div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div> <div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
</div>
</div> </div>
<div class="tv-panel-player"> <div class="tv-panel-player">
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div> <div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
@@ -338,13 +328,11 @@
></iframe> ></iframe>
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video> <video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
</div> </div>
<button <div class="tv-panel-edge" data-edge="r"></div>
id="tv-resize-handle" <div class="tv-panel-edge" data-edge="b"></div>
class="tv-panel-resize-handle" <div class="tv-panel-edge" data-edge="l"></div>
type="button" <div class="tv-panel-edge" data-edge="br"></div>
aria-label="调整电视直播窗口大小" <div class="tv-panel-edge" data-edge="bl"></div>
title="调整大小"
></button>
</div> </div>
<div id="loading" class="earth-loading"> <div id="loading" class="earth-loading">

View File

@@ -174,7 +174,9 @@ function setupDraggableHudPanels() {
if (!app || draggablePanels.length === 0) return; if (!app || draggablePanels.length === 0) return;
draggablePanels.forEach((panel) => { draggablePanels.forEach((panel) => {
const handle = panel.querySelector(".hud-panel-drag-handle"); const handle = panel.dataset.dragSelf === "true"
? panel
: panel.querySelector(".hud-panel-drag-handle");
if (!handle) return; if (!handle) return;
let isDragging = false; let isDragging = false;
@@ -228,7 +230,7 @@ function setupDraggableHudPanels() {
}; };
bindListener(handle, "pointerdown", (event) => { bindListener(handle, "pointerdown", (event) => {
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return; if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-action, .tv-panel-player, .tv-panel-edge, .legend-bar-btn")) return;
isDragging = true; isDragging = true;
startPointerX = event.clientX; startPointerX = event.clientX;
startPointerY = event.clientY; startPointerY = event.clientY;
@@ -238,6 +240,8 @@ function setupDraggableHudPanels() {
// If panel is inside a flow container (not a direct child of app), reparent // If panel is inside a flow container (not a direct child of app), reparent
// it so absolute positioning is relative to the app container. // it so absolute positioning is relative to the app container.
if (panel.parentElement !== app) { if (panel.parentElement !== app) {
panel.dataset.originalParentId = panel.parentElement?.id || "";
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
const capturedWidth = panelRect.width; const capturedWidth = panelRect.width;
panel.style.position = "absolute"; panel.style.position = "absolute";
panel.style.width = `${capturedWidth}px`; panel.style.width = `${capturedWidth}px`;
@@ -929,11 +933,28 @@ function updateLayoutUI(container) {
} }
function resetPanelInlineLayout(panel) { function resetPanelInlineLayout(panel) {
const originalParentId = panel.dataset.originalParentId;
if (originalParentId) {
const originalParent = document.getElementById(originalParentId);
if (originalParent) {
const nextId = panel.dataset.originalNextSiblingId;
const nextSibling = nextId ? document.getElementById(nextId) : null;
if (nextSibling) {
originalParent.insertBefore(panel, nextSibling);
} else {
originalParent.appendChild(panel);
}
}
delete panel.dataset.originalParentId;
delete panel.dataset.originalNextSiblingId;
}
panel.style.left = ""; panel.style.left = "";
panel.style.top = ""; panel.style.top = "";
panel.style.right = ""; panel.style.right = "";
panel.style.bottom = ""; panel.style.bottom = "";
panel.style.transform = ""; panel.style.transform = "";
panel.style.position = "";
panel.style.width = "";
delete panel.dataset.dragged; delete panel.dataset.dragged;
} }

View File

@@ -2,6 +2,7 @@
import { showStatusMessage } from './ui.js'; import { showStatusMessage } from './ui.js';
let currentType = null; let currentType = null;
let cardMounted = false;
const CARD_CONFIG = { const CARD_CONFIG = {
cable: { cable: {
@@ -105,6 +106,138 @@ function getPanel() {
return document.getElementById('info-panel'); return document.getElementById('info-panel');
} }
function setupInfoCardDrag(panel) {
const app = document.getElementById('container');
if (!app) return;
const handle = panel.querySelector('.hud-panel-drag-handle');
if (!handle) return;
let isDragging = false;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = () => {
isDragging = false;
panel.classList.remove('is-dragging');
document.body.style.userSelect = '';
};
const onMove = (event) => {
if (!isDragging) return;
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextLeft = Math.min(
Math.max(startLeft + (event.clientX - startPointerX), 0),
appRect.width - panelRect.width,
);
const nextTop = Math.min(
Math.max(startTop + (event.clientY - startPointerY), 0),
appRect.height - panelRect.height,
);
panel.style.left = `${nextLeft}px`;
panel.style.top = `${nextTop}px`;
};
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
isDragging = true;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
startLeft = panelRect.left - appRect.left;
startTop = panelRect.top - appRect.top;
panel.style.left = `${startLeft}px`;
panel.style.top = `${startTop}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.classList.add('is-dragging');
document.body.style.userSelect = 'none';
handle.setPointerCapture?.(event.pointerId);
});
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', stopDragging);
handle.addEventListener('pointercancel', stopDragging);
handle.addEventListener('lostpointercapture', stopDragging);
}
function mountCard() {
if (cardMounted) return;
const container = document.getElementById('container');
if (!container) return;
const panel = document.createElement('div');
panel.id = 'info-panel';
panel.className = 'hud-panel hud-panel-info';
panel.setAttribute('aria-live', 'polite');
panel.innerHTML = `
<div id="info-card" class="info-card">
<div class="info-card-header hud-panel-drag-handle">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="info-card-content" class="info-card-content"></div>
</div>
`;
container.appendChild(panel);
const card = panel.querySelector('#info-card');
const content = panel.querySelector('#info-card-content');
// Prevent pointer events from reaching the earth canvas
const stopEvent = (event) => { event.stopPropagation(); };
[
'mousemove', 'mousedown', 'mouseup', 'click', 'dblclick', 'wheel',
'pointerdown', 'pointerup', 'pointermove',
'touchstart', 'touchmove', 'touchend',
].forEach((evt) => card.addEventListener(evt, stopEvent, { passive: false }));
// Close button
const closeBtn = card.querySelector('.info-card-close');
if (closeBtn) {
closeBtn.addEventListener('click', (event) => {
event.stopPropagation();
hideInfoCard();
});
}
// Copy value on label click
content.addEventListener('click', async (event) => {
const label = event.target.closest('.info-card-label');
if (!label) return;
const property = label.closest('.info-card-property');
const valueEl = property?.querySelector('.info-card-value');
const value = valueEl?.textContent?.trim();
if (!value || value === '-') {
showStatusMessage('无可复制内容', 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(`已复制${label.textContent}${value}`, 'success');
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage('复制失败', 'error');
}
});
setupInfoCardDrag(panel);
cardMounted = true;
}
function positionPanel(panel, x, y) { function positionPanel(panel, x, y) {
if (!panel) return; if (!panel) return;
const margin = 12; const margin = 12;
@@ -142,71 +275,8 @@ function hidePanel() {
if (panel) panel.classList.remove('is-visible'); if (panel) panel.classList.remove('is-visible');
} }
export function initInfoCard() { // No-op: event binding now happens lazily in mountCard()
const card = document.getElementById('info-card'); export function initInfoCard() {}
const content = document.getElementById('info-card-content');
if (!card || !content) return;
if (card.dataset.interactionBound !== 'true') {
const stopEvent = (event) => {
event.stopPropagation();
};
[
'mousemove',
'mousedown',
'mouseup',
'click',
'dblclick',
'wheel',
'pointerdown',
'pointerup',
'pointermove',
'touchstart',
'touchmove',
'touchend',
].forEach((eventName) => {
card.addEventListener(eventName, stopEvent, { passive: false });
});
// Close button wires the panel hide
const closeBtn = card.querySelector('.info-card-close');
if (closeBtn) {
closeBtn.addEventListener('click', (event) => {
event.stopPropagation();
hideInfoCard();
});
}
card.dataset.interactionBound = 'true';
}
if (content.dataset.copyBound === 'true') return;
content.addEventListener('click', async (event) => {
const label = event.target.closest('.info-card-label');
if (!label) return;
const property = label.closest('.info-card-property');
const valueEl = property?.querySelector('.info-card-value');
const value = valueEl?.textContent?.trim();
if (!value || value === '-') {
showStatusMessage('无可复制内容', 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(`已复制${label.textContent}${value}`, 'success');
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage('复制失败', 'error');
}
});
content.dataset.copyBound = 'true';
}
export function setInfoCardNoBorder(noBorder = true) { export function setInfoCardNoBorder(noBorder = true) {
const card = document.getElementById('info-card'); const card = document.getElementById('info-card');
@@ -222,6 +292,8 @@ export function showInfoCard(type, data, options = {}) {
return; return;
} }
mountCard();
currentType = type; currentType = type;
const card = document.getElementById('info-card'); const card = document.getElementById('info-card');
const icon = document.getElementById('info-card-icon'); const icon = document.getElementById('info-card-icon');

View File

@@ -20,6 +20,9 @@ let initialized = false;
let refreshPromise = null; let refreshPromise = null;
let hlsPlayer = null; let hlsPlayer = null;
let hlsRecoveryAttempts = 0; let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null;
const META_AUTO_COLLAPSE_DELAY = 2500;
const HLS_MAX_RECOVERY_ATTEMPTS = 3; const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = { const HLS_RETRY_CONFIG = {
@@ -33,7 +36,7 @@ function getElements() {
return { return {
panel: document.getElementById("tv-panel"), panel: document.getElementById("tv-panel"),
toggleBtn: document.getElementById("toggle-tv"), toggleBtn: document.getElementById("toggle-tv"),
resizeHandle: document.getElementById("tv-resize-handle"), resizeHandle: null,
select: document.getElementById("tv-source-select"), select: document.getElementById("tv-source-select"),
title: document.getElementById("tv-source-title"), title: document.getElementById("tv-source-title"),
meta: document.getElementById("tv-source-meta"), meta: document.getElementById("tv-source-meta"),
@@ -45,9 +48,51 @@ function getElements() {
empty: document.getElementById("tv-empty-state"), empty: document.getElementById("tv-empty-state"),
refreshBtn: document.getElementById("tv-refresh"), refreshBtn: document.getElementById("tv-refresh"),
openBtn: document.getElementById("tv-open-external"), openBtn: document.getElementById("tv-open-external"),
metaPanel: document.getElementById("tv-panel-meta"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
}; };
} }
function setMetaCollapsed(collapsed) {
const { metaWrap, metaToggle, panel } = getElements();
if (!metaWrap) return;
const isDragged = panel?.dataset.dragged === "true" && panel.style.top;
if (isDragged) {
// Top-anchored panel: toggle instantly and compensate top so the player
// (panel bottom) stays visually fixed.
const bottomBefore = panel.getBoundingClientRect().bottom;
metaWrap.style.transition = "none";
metaWrap.classList.toggle("is-collapsed", collapsed);
metaToggle?.classList.toggle("is-collapsed", collapsed);
// Force synchronous reflow to get updated panel height
void panel.offsetHeight;
const delta = panel.getBoundingClientRect().bottom - bottomBefore;
if (delta !== 0) {
panel.style.top = `${parseFloat(panel.style.top) - delta}px`;
}
// Restore CSS transition after this paint
requestAnimationFrame(() => {
metaWrap.style.transition = "";
});
} else {
metaWrap.classList.toggle("is-collapsed", collapsed);
metaToggle?.classList.toggle("is-collapsed", collapsed);
}
}
function autoExpandMeta() {
clearTimeout(metaAutoCollapseTimer);
setMetaCollapsed(false);
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
}
function clearPanelPositioningForResize(panel) { function clearPanelPositioningForResize(panel) {
panel.style.left = `${panel.offsetLeft}px`; panel.style.left = `${panel.offsetLeft}px`;
panel.style.top = `${panel.offsetTop}px`; panel.style.top = `${panel.offsetTop}px`;
@@ -65,71 +110,80 @@ function getHudScale() {
} }
function setupResizeHandle() { function setupResizeHandle() {
const { panel, resizeHandle } = getElements(); const { panel } = getElements();
const container = document.getElementById("container"); const container = document.getElementById("container");
if (!(panel instanceof HTMLElement) || !(resizeHandle instanceof HTMLElement) || !(container instanceof HTMLElement)) { if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
return;
}
let resizing = false; let resizing = false;
let activeEdge = "";
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let startWidth = 0; let startWidth = 0;
let startHeight = 0; let startHeight = 0;
let startLeft = 0;
let startTop = 0;
const stopResize = () => { const stopResize = () => {
resizing = false; resizing = false;
activeEdge = "";
panel.classList.remove("is-resizing"); panel.classList.remove("is-resizing");
document.body.style.userSelect = ""; document.body.style.userSelect = "";
}; };
resizeHandle.addEventListener("pointerdown", (event) => { const onMove = (event) => {
if (document.getElementById("container")?.classList.contains("layout-expanded")) {
return;
}
event.preventDefault();
event.stopPropagation();
resizing = true;
startX = event.clientX;
startY = event.clientY;
clearPanelPositioningForResize(panel);
const rect = panel.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
panel.classList.add("is-resizing");
document.body.style.userSelect = "none";
resizeHandle.setPointerCapture?.(event.pointerId);
});
resizeHandle.addEventListener("pointermove", (event) => {
if (!resizing) return; if (!resizing) return;
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const currentLeft = panelRect.left - containerRect.left;
const currentTop = panelRect.top - containerRect.top;
const hudScale = getHudScale(); const hudScale = getHudScale();
const minWidth = Math.max(320, Math.round(360 * hudScale)); const minWidth = Math.max(320, Math.round(360 * hudScale));
const minHeight = Math.max(260, Math.round(340 * hudScale)); const minHeight = Math.max(260, Math.round(340 * hudScale));
const maxWidth = Math.max(minWidth, containerRect.width - currentLeft - 12); const dx = event.clientX - startX;
const maxHeight = Math.max(minHeight, containerRect.height - currentTop - 12); const dy = event.clientY - startY;
const nextWidth = Math.min(
maxWidth,
Math.max(minWidth, startWidth + (event.clientX - startX)),
);
const nextHeight = Math.min(
maxHeight,
Math.max(minHeight, startHeight + (event.clientY - startY)),
);
panel.style.width = `${nextWidth}px`; if (activeEdge.includes("r")) {
panel.style.minHeight = `${nextHeight}px`; const maxW = containerRect.width - startLeft - 12;
panel.style.width = `${Math.min(maxW, Math.max(minWidth, startWidth + dx))}px`;
}
if (activeEdge.includes("l")) {
const newW = Math.max(minWidth, startWidth - dx);
panel.style.width = `${newW}px`;
panel.style.left = `${Math.max(0, startLeft + startWidth - newW)}px`;
}
if (activeEdge.includes("b")) {
const maxH = containerRect.height - startTop - 12;
panel.style.minHeight = `${Math.min(maxH, Math.max(minHeight, startHeight + dy))}px`;
}
};
panel.querySelectorAll(".tv-panel-edge[data-edge]").forEach((edgeEl) => {
edgeEl.addEventListener("pointerdown", (event) => {
if (container.classList.contains("layout-expanded")) return;
event.preventDefault();
event.stopPropagation();
activeEdge = edgeEl.dataset.edge ?? "";
resizing = true;
startX = event.clientX;
startY = event.clientY;
clearPanelPositioningForResize(panel);
const rect = panel.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
startLeft = rect.left - cRect.left;
startTop = rect.top - cRect.top;
panel.classList.add("is-resizing");
document.body.style.userSelect = "none";
edgeEl.setPointerCapture?.(event.pointerId);
});
edgeEl.addEventListener("pointermove", onMove);
edgeEl.addEventListener("pointerup", stopResize);
edgeEl.addEventListener("pointercancel", stopResize);
edgeEl.addEventListener("lostpointercapture", stopResize);
}); });
resizeHandle.addEventListener("pointerup", stopResize);
resizeHandle.addEventListener("pointercancel", stopResize);
resizeHandle.addEventListener("lostpointercapture", stopResize);
} }
function updateToggleButton(visible) { function updateToggleButton(visible) {
@@ -502,6 +556,7 @@ function renderSource(source) {
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道", source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
); );
updateOpenButton(source); updateOpenButton(source);
autoExpandMeta();
} }
function resolveInitialSourceId() { function resolveInitialSourceId() {
@@ -592,6 +647,13 @@ export function initTVPanel() {
renderSource(findSourceById(currentSourceId)); renderSource(findSourceById(currentSourceId));
}); });
const { metaToggle } = getElements();
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed");
setMetaCollapsed(isNowCollapsed);
});
refreshBtn?.addEventListener("click", () => { refreshBtn?.addEventListener("click", () => {
refreshTVPanel(); refreshTVPanel();
}); });

View File

@@ -265,6 +265,7 @@ function Scrollbar({
{scrollbar.y.visible ? ( {scrollbar.y.visible ? (
<div <div
className="scrollbar__thumb scrollbar__thumb--y" className="scrollbar__thumb scrollbar__thumb--y"
tabIndex={0}
style={{ style={{
height: `${scrollbar.y.thumbSize}px`, height: `${scrollbar.y.thumbSize}px`,
transform: `translateY(${scrollbar.y.thumbOffset}px)`, transform: `translateY(${scrollbar.y.thumbOffset}px)`,
@@ -284,6 +285,7 @@ function Scrollbar({
{scrollbar.x.visible ? ( {scrollbar.x.visible ? (
<div <div
className="scrollbar__thumb scrollbar__thumb--x" className="scrollbar__thumb scrollbar__thumb--x"
tabIndex={0}
style={{ style={{
width: `${scrollbar.x.thumbSize}px`, width: `${scrollbar.x.thumbSize}px`,
transform: `translateX(${scrollbar.x.thumbOffset}px)`, transform: `translateX(${scrollbar.x.thumbOffset}px)`,

View File

@@ -0,0 +1,37 @@
import { forwardRef, useImperativeHandle, useRef, type CSSProperties, type ReactNode } from 'react'
import ScrollbarOverlay from './ScrollbarOverlay'
interface TableScrollRegionProps {
children: ReactNode
className?: string
style?: CSSProperties
targetSelector?: string
}
const TableScrollRegion = forwardRef<HTMLDivElement, TableScrollRegionProps>(function TableScrollRegion(
{
children,
className = '',
style,
targetSelector = '.ant-table-body',
},
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null)
useImperativeHandle(ref, () => containerRef.current as HTMLDivElement, [])
return (
<div
ref={containerRef}
className={['table-scroll-region', className].filter(Boolean).join(' ')}
style={style}
>
{children}
<ScrollbarOverlay containerRef={containerRef} targetSelector={targetSelector} />
</div>
)
})
export default TableScrollRegion

View File

@@ -140,7 +140,8 @@ body {
opacity: 0; opacity: 0;
cursor: pointer; cursor: pointer;
pointer-events: auto; pointer-events: auto;
transition: opacity 0.18s ease, background 0.18s ease; outline: none;
transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
} }
.scrollbar__thumb::after { .scrollbar__thumb::after {
@@ -173,6 +174,7 @@ body {
} }
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb, .scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
.scrollbar:focus-within .scrollbar__track--visible .scrollbar__thumb,
.scrollbar__track--visible .scrollbar__thumb, .scrollbar__track--visible .scrollbar__thumb,
.scrollbar__track--dragging .scrollbar__thumb { .scrollbar__track--dragging .scrollbar__thumb {
opacity: 1; opacity: 1;
@@ -182,12 +184,15 @@ body {
pointer-events: auto; pointer-events: auto;
} }
.scrollbar__thumb:hover { .scrollbar__thumb:hover,
background: rgba(216, 226, 240, 0.68); .scrollbar__thumb:focus-visible {
background: rgba(125, 146, 174, 0.82);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
} }
.scrollbar__track--dragging .scrollbar__thumb { .scrollbar__track--dragging .scrollbar__thumb {
background: rgba(226, 235, 246, 0.82); background: rgba(92, 115, 146, 0.9);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
} }
.dashboard-brand { .dashboard-brand {
@@ -385,6 +390,10 @@ body {
color: #64748b; color: #64748b;
} }
.playground-chat__service-btn {
display: none;
}
.playground-card__icon-button:hover { .playground-card__icon-button:hover {
color: #1677ff !important; color: #1677ff !important;
background: rgba(22, 119, 255, 0.08) !important; background: rgba(22, 119, 255, 0.08) !important;
@@ -398,9 +407,11 @@ body {
.playground-card__scroll { .playground-card__scroll {
height: 100%; height: 100%;
}
.playground-card__scroll .scrollbar__viewport {
height: 100%;
overflow: auto; overflow: auto;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
} }
.playground-card__scroll::-webkit-scrollbar, .playground-card__scroll::-webkit-scrollbar,
@@ -527,6 +538,21 @@ body {
padding: 12px; padding: 12px;
} }
.playground-chat__input-row {
display: flex;
align-items: center;
gap: 8px;
}
.playground-chat__input-row .playground-chat__input.ant-input {
flex: 1 1 auto;
min-width: 0;
}
.playground-chat__input-wrap--expanded .playground-chat__input-row .playground-chat__send-button.ant-btn {
flex: 0 0 auto;
}
.playground-chat__input.ant-input { .playground-chat__input.ant-input {
border: 0; border: 0;
box-shadow: none; box-shadow: none;
@@ -536,6 +562,15 @@ body {
margin-top: 10px; margin-top: 10px;
} }
.playground-chat__input-wrap--expanded .playground-chat__input.ant-input {
margin-top: 10px;
}
.playground-chat__input-wrap:not(.playground-chat__input-wrap--expanded) .playground-chat__input.ant-input {
margin-top: 0;
padding: 4px 2px;
}
.playground-chat__input.ant-input:focus, .playground-chat__input.ant-input:focus,
.playground-chat__input.ant-input-focused { .playground-chat__input.ant-input-focused {
box-shadow: none; box-shadow: none;
@@ -1251,25 +1286,21 @@ body {
.playground-result-modal__content { .playground-result-modal__content {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: auto;
padding-right: 6px; padding-right: 6px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
} }
.playground-result-modal__content::-webkit-scrollbar { .playground-result-modal__content .scrollbar__viewport {
width: 8px; height: 100%;
overflow: auto;
} }
.playground-result-modal__content::-webkit-scrollbar-thumb { .playground-result__blocks-scroll .scrollbar__viewport {
background: rgba(148, 163, 184, 0.82); height: 100%;
border-radius: 999px; overflow: auto;
border: 2px solid transparent;
background-clip: padding-box;
} }
.playground-result-modal__content::-webkit-scrollbar-track { .playground-result__blocks-scroll.scrollbar {
background: transparent; min-height: 0;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -1285,6 +1316,10 @@ body {
flex-direction: column; flex-direction: column;
} }
.playground-chat__service-btn {
display: inline-flex;
}
.playground-shell__sidebar { .playground-shell__sidebar {
display: none; display: none;
} }
@@ -1490,10 +1525,26 @@ body {
min-height: 0; min-height: 0;
} }
.users-table-region .ant-table-body { /* users table: flex-fill approach so overlay x-track aligns with table bottom */
height: auto !important; .users-table-region .ant-table-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
} }
.users-table-region .ant-table-header {
flex: 0 0 auto;
}
.users-table-region .ant-table-body {
flex: 1 1 auto;
min-height: 0;
height: 0 !important;
max-height: none !important;
}
.data-source-table-region .ant-table-wrapper, .data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading, .data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container { .data-source-table-region .ant-spin-container {
@@ -1583,14 +1634,14 @@ body {
padding: 10px 12px !important; padding: 10px 12px !important;
} }
.data-source-table-region .ant-table-body, .table-scroll-region .ant-table-body,
.data-source-table-region .ant-table-content { .table-scroll-region .ant-table-content {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
} }
.data-source-table-region .ant-table-body::-webkit-scrollbar, .table-scroll-region .ant-table-body::-webkit-scrollbar,
.data-source-table-region .ant-table-content::-webkit-scrollbar { .table-scroll-region .ant-table-content::-webkit-scrollbar {
width: 0; width: 0;
height: 0; height: 0;
} }
@@ -1757,7 +1808,10 @@ body {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding-right: 4px; padding-right: 4px;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent; scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
@@ -1836,10 +1890,11 @@ body {
display: grid; display: grid;
gap: 14px; gap: 14px;
max-height: calc(100vh - 180px); max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 6px; padding-right: 6px;
scrollbar-width: thin; }
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
.bgp-page__brief-modal-body .scrollbar__viewport {
overflow: auto;
} }
.bgp-page__brief-evidence { .bgp-page__brief-evidence {
@@ -1857,6 +1912,10 @@ body {
gap: 12px; gap: 12px;
} }
.alerts-brief-drawer .scrollbar__viewport {
overflow: auto;
}
.alerts-brief-drawer__loading { .alerts-brief-drawer__loading {
min-height: 160px; min-height: 160px;
display: flex; display: flex;
@@ -1887,8 +1946,25 @@ body {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
}
.alerts-tab-panel > .ant-space-item,
.system-alerts-page__stack > .ant-space-item,
.bgp-alerts-page__stack > .ant-space-item,
.situational-alerts-page__stack > .ant-space-item {
min-width: 0;
}
.alerts-tab-panel > .ant-space-item:last-child,
.system-alerts-page__stack > .ant-space-item:last-child,
.bgp-alerts-page__stack > .ant-space-item:last-child,
.situational-alerts-page__stack > .ant-space-item:last-child {
flex: 1 1 auto;
min-height: 0;
} }
.system-alerts-page__table-card, .system-alerts-page__table-card,
@@ -1915,8 +1991,22 @@ body {
min-height: 0; min-height: 0;
} }
.bgp-alerts-page__tabs, .bgp-alerts-page__tabs {
.bgp-alerts-page__tabs .ant-tabs-content-holder, min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.bgp-alerts-page__tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.bgp-alerts-page__tabs .ant-tabs-content, .bgp-alerts-page__tabs .ant-tabs-content,
.bgp-alerts-page__tabs .ant-tabs-tabpane { .bgp-alerts-page__tabs .ant-tabs-tabpane {
min-width: 0; min-width: 0;
@@ -2128,29 +2218,71 @@ body {
line-height: 1; line-height: 1;
} }
.bgp-page__summary-grid--compact { .bgp-page__summary-scroll.scrollbar,
flex-wrap: nowrap !important; .alerts-summary-scroll.scrollbar {
overflow: hidden;
}
.bgp-page__summary-scroll .scrollbar__viewport,
.alerts-summary-scroll .scrollbar__viewport {
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
padding-bottom: 4px; padding-bottom: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
} }
.bgp-page__summary-grid--compact::-webkit-scrollbar { .bgp-page__summary-grid,
width: 8px; .alerts-summary-grid {
height: 8px; display: flex;
flex-wrap: nowrap !important;
min-width: max-content;
} }
.bgp-page__summary-grid--compact::-webkit-scrollbar-thumb { .bgp-page__summary-cell,
background: rgba(148, 163, 184, 0.82); .alerts-summary-cell {
border-radius: 999px; flex: 0 0 auto;
border: 2px solid transparent;
background-clip: padding-box;
} }
.bgp-page__summary-grid--compact::-webkit-scrollbar-track { .situational-alerts-page__summary-grid {
background: transparent; display: flex;
flex-wrap: nowrap;
min-width: max-content;
}
.situational-alerts-page__panels-grid {
display: flex;
min-width: max-content;
}
.situational-alerts-page__panels-scroll .scrollbar__viewport {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
}
@media (max-width: 1200px) {
.situational-alerts-page__summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
min-width: 0;
width: 100%;
}
.situational-alerts-page__summary-grid .alerts-summary-cell {
width: auto !important;
}
}
@media (max-width: 560px) {
.situational-alerts-page__summary-grid {
display: flex;
flex-wrap: nowrap;
width: auto;
min-width: max-content;
}
.situational-alerts-page__summary-grid .alerts-summary-cell {
width: 220px !important;
}
} }
.bgp-page__table-card, .bgp-page__table-card,
@@ -2408,25 +2540,21 @@ body {
.settings-panel-scroll { .settings-panel-scroll {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
padding-right: 6px;
}
.settings-panel-scroll.scrollbar {
overflow: hidden;
}
.settings-panel-scroll .scrollbar__viewport {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding-right: 6px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.settings-panel-scroll::-webkit-scrollbar { .settings-panel-scroll .scrollbar__viewport > * {
width: 10px; min-width: 0;
}
.settings-panel-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.8);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.settings-panel-scroll::-webkit-scrollbar-track {
background: transparent;
} }
.settings-pane .data-source-table-region .ant-table-container { .settings-pane .data-source-table-region .ant-table-container {
@@ -3273,7 +3401,6 @@ body {
.dashboard-restart-log { .dashboard-restart-log {
max-height: 180px; max-height: 180px;
overflow-y: auto;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
background: #0f172a; background: #0f172a;
@@ -3281,16 +3408,11 @@ body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
scrollbar-width: thin;
} }
.dashboard-restart-log::-webkit-scrollbar { .dashboard-restart-log .scrollbar__viewport {
width: 8px; overflow-y: auto;
} max-height: 180px;
.dashboard-restart-log::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(148, 163, 184, 0.55);
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -5,10 +5,8 @@ import {
Alert, Alert,
Button, Button,
Card, Card,
Col,
Descriptions, Descriptions,
Modal, Modal,
Row,
Space, Space,
Spin, Spin,
Statistic, Statistic,
@@ -21,6 +19,8 @@ import {
} from 'antd' } from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness' import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
import { getSituationalAwarenessGateway } from '../../services/situational-awareness' import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -171,20 +171,22 @@ export function BGPAlertsPanel() {
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" /> <Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
<Row gutter={[12, 12]}> <Scrollbar className="alerts-summary-scroll bgp-alerts-page__summary-scroll">
<Col xs={24} sm={12} lg={6}> <div className="alerts-summary-grid" style={{ gap: '12px' }}>
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
<Col xs={24} sm={12} lg={6}> </div>
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
<Col xs={24} sm={12} lg={6}> </div>
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
<Col xs={24} sm={12} lg={6}> </div>
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
</Row> </div>
</div>
</Scrollbar>
<Card className="bgp-alerts-page__table-card"> <Card className="bgp-alerts-page__table-card">
<Tabs <Tabs
@@ -194,7 +196,7 @@ export function BGPAlertsPanel() {
key: 'incidents', key: 'incidents',
label: 'BGP 事件', label: 'BGP 事件',
children: ( children: (
<div className="table-scroll-region bgp-alerts-page__table-region"> <TableScrollRegion className="bgp-alerts-page__table-region">
<Table<BGPIncident> <Table<BGPIncident>
columns={incidentColumns} columns={incidentColumns}
dataSource={incidents} dataSource={incidents}
@@ -204,14 +206,14 @@ export function BGPAlertsPanel() {
scroll={{ x: 1200, y: 480 }} scroll={{ x: 1200, y: 480 }}
tableLayout="fixed" tableLayout="fixed"
/> />
</div> </TableScrollRegion>
), ),
}, },
{ {
key: 'anomalies', key: 'anomalies',
label: 'BGP 异常', label: 'BGP 异常',
children: ( children: (
<div className="table-scroll-region bgp-alerts-page__table-region"> <TableScrollRegion className="bgp-alerts-page__table-region">
<Table<BGPAnomaly> <Table<BGPAnomaly>
columns={anomalyColumns} columns={anomalyColumns}
dataSource={anomalies} dataSource={anomalies}
@@ -221,7 +223,7 @@ export function BGPAlertsPanel() {
scroll={{ x: 1100, y: 480 }} scroll={{ x: 1100, y: 480 }}
tableLayout="fixed" tableLayout="fixed"
/> />
</div> </TableScrollRegion>
), ),
}, },
]} ]}
@@ -244,14 +246,14 @@ export function BGPAlertsPanel() {
<Spin tip="正在生成 BGP AI 简报..." /> <Spin tip="正在生成 BGP AI 简报..." />
</div> </div>
) : brief ? ( ) : brief ? (
<div className="bgp-page__brief-modal-body"> <Scrollbar className="bgp-page__brief-modal-body">
<Descriptions size="small" column={3} className="bgp-page__brief-meta"> <Descriptions size="small" column={3} className="bgp-page__brief-meta">
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item> <Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item> <Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item> <Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
</Descriptions> </Descriptions>
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph> <Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
</div> </Scrollbar>
) : ( ) : (
<Text type="secondary"> BGP </Text> <Text type="secondary"> BGP </Text>
)} )}

View File

@@ -5,10 +5,8 @@ import {
Alert, Alert,
Button, Button,
Card, Card,
Col,
Descriptions, Descriptions,
Drawer, Drawer,
Row,
Space, Space,
Spin, Spin,
Statistic, Statistic,
@@ -18,6 +16,7 @@ import {
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import type { BGPSummarySnapshot } from '../../services/situational-awareness' import type { BGPSummarySnapshot } from '../../services/situational-awareness'
import { import {
getSituationalAwarenessGateway, getSituationalAwarenessGateway,
@@ -119,42 +118,46 @@ export function SituationalAlertsPanel() {
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。" message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
/> />
<Row gutter={[12, 12]}> <Scrollbar className="alerts-summary-scroll situational-alerts-page__summary-scroll">
<Col xs={24} sm={12} lg={6}> <div className="alerts-summary-grid situational-alerts-page__summary-grid" style={{ gap: '12px' }}>
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
<Col xs={24} sm={12} lg={6}> </div>
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
<Col xs={24} sm={12} lg={6}> </div>
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
<Col xs={24} sm={12} lg={6}> </div>
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
</Row> </div>
</div>
</Scrollbar>
<Row gutter={[12, 12]}> <Scrollbar className="alerts-summary-scroll situational-alerts-page__panels-scroll">
<Col xs={24} lg={12}> <div className="alerts-summary-grid situational-alerts-page__panels-grid" style={{ gap: '12px' }}>
<Card title="系统告警侧"> <div className="alerts-summary-cell" style={{ width: '360px' }}>
<Descriptions size="small" column={1}> <Card title="系统告警侧">
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item> <Descriptions size="small" column={1}>
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item> <Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item> <Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
</Descriptions> <Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
</Card> </Descriptions>
</Col> </Card>
<Col xs={24} lg={12}> </div>
<Card title="BGP 风险侧"> <div className="alerts-summary-cell" style={{ width: '360px' }}>
<Descriptions size="small" column={1}> <Card title="BGP 风险侧">
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item> <Descriptions size="small" column={1}>
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item> <Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item> <Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item> <Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
</Descriptions> <Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
</Card> </Descriptions>
</Col> </Card>
</Row> </div>
</div>
</Scrollbar>
</Space> </Space>
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}> <Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>

View File

@@ -5,11 +5,9 @@ import {
Alert, Alert,
Button, Button,
Card, Card,
Col,
Descriptions, Descriptions,
Drawer, Drawer,
Modal, Modal,
Row,
Space, Space,
Spin, Spin,
Statistic, Statistic,
@@ -22,6 +20,8 @@ import {
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness' import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -216,20 +216,22 @@ export function SystemAlertsPanel() {
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" /> <Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
<Row gutter={[12, 12]}> <Scrollbar className="alerts-summary-scroll system-alerts-page__summary-scroll">
<Col xs={24} sm={8}> <div className="alerts-summary-grid" style={{ gap: '12px' }}>
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
<Col xs={24} sm={8}> </div>
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
<Col xs={24} sm={8}> </div>
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card> <div className="alerts-summary-cell" style={{ width: '220px' }}>
</Col> <Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
</Row> </div>
</div>
</Scrollbar>
<Card className="system-alerts-page__table-card" title="系统告警列表"> <Card className="system-alerts-page__table-card" title="系统告警列表">
<div className="table-scroll-region system-alerts-page__table-region"> <TableScrollRegion className="system-alerts-page__table-region">
<Table<AlertRecord> <Table<AlertRecord>
columns={columns} columns={columns}
dataSource={alerts} dataSource={alerts}
@@ -239,7 +241,7 @@ export function SystemAlertsPanel() {
scroll={{ x: 1100, y: 480 }} scroll={{ x: 1100, y: 480 }}
tableLayout="fixed" tableLayout="fixed"
/> />
</div> </TableScrollRegion>
</Card> </Card>
</Space> </Space>
@@ -260,7 +262,7 @@ export function SystemAlertsPanel() {
</Modal> </Modal>
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}> <Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
<div className="alerts-brief-drawer"> <Scrollbar className="alerts-brief-drawer">
{briefLoading ? ( {briefLoading ? (
<div className="alerts-brief-drawer__loading"> <div className="alerts-brief-drawer__loading">
<Spin tip="正在汇总系统告警事实并生成简报..." /> <Spin tip="正在汇总系统告警事实并生成简报..." />
@@ -293,7 +295,7 @@ export function SystemAlertsPanel() {
</Card> </Card>
</Space> </Space>
) : null} ) : null}
</div> </Scrollbar>
</Drawer> </Drawer>
</div> </div>
</AppLayout> </AppLayout>

View File

@@ -4,10 +4,8 @@ import {
Alert, Alert,
Button, Button,
Card, Card,
Col,
Descriptions, Descriptions,
Modal, Modal,
Row,
Select, Select,
Space, Space,
Spin, Spin,
@@ -22,6 +20,8 @@ import {
} from 'antd' } from 'antd'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
import { import {
type BGPAnomaly, type BGPAnomaly,
@@ -708,37 +708,36 @@ function BGP() {
/> />
<Card className="bgp-page__summary-card"> <Card className="bgp-page__summary-card">
<Row <Scrollbar className="bgp-page__summary-scroll">
gutter={[compactViewport ? 8 : 12, compactViewport ? 8 : 12]} <div
className={`bgp-page__summary-grid${compactViewport ? ' bgp-page__summary-grid--compact' : ''}`} className="bgp-page__summary-grid"
wrap={!compactViewport} style={{ gap: `${compactViewport ? 8 : 12}px` }}
> >
{summaryItems.map((item) => ( {summaryItems.map((item) => (
<Col <div
key={item.label} key={item.label}
xs={24} className="bgp-page__summary-cell"
sm={12} style={{ width: compactViewport ? '180px' : '220px' }}
md={8} >
flex={compactViewport ? '180px' : undefined} <div className="bgp-page__summary-item">
> <div className="bgp-page__summary-label">{item.label}</div>
<div className="bgp-page__summary-item"> <Statistic className="bgp-page__summary-stat" value={item.value} />
<div className="bgp-page__summary-label">{item.label}</div> </div>
<Statistic className="bgp-page__summary-stat" value={item.value} />
</div> </div>
</Col> ))}
))} </div>
</Row> </Scrollbar>
</Card> </Card>
<Card className="bgp-page__table-card"> <Card className="bgp-page__table-card">
<div ref={tableRegionRef} className="table-scroll-region bgp-page__table-region"> <TableScrollRegion ref={tableRegionRef} className="bgp-page__table-region">
<Tabs <Tabs
className="bgp-page__tabs" className="bgp-page__tabs"
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
items={tabItems} items={tabItems}
/> />
</div> </TableScrollRegion>
</Card> </Card>
</Space> </Space>
</div> </div>
@@ -755,7 +754,7 @@ function BGP() {
destroyOnHidden destroyOnHidden
> >
{brief ? ( {brief ? (
<div className="bgp-page__brief-modal-body"> <Scrollbar className="bgp-page__brief-modal-body">
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? ( {(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
<div className="bgp-page__brief-evidence"> <div className="bgp-page__brief-evidence">
{brief.facts.length > 0 ? ( {brief.facts.length > 0 ? (
@@ -785,7 +784,7 @@ function BGP() {
</div> </div>
) : null} ) : null}
<MarkdownRenderer markdown={brief.content_markdown} /> <MarkdownRenderer markdown={brief.content_markdown} />
</div> </Scrollbar>
) : ( ) : (
<Text type="secondary"></Text> <Text type="secondary"></Text>
)} )}

View File

@@ -14,6 +14,7 @@ import { Link } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useWebSocket } from '../../hooks/useWebSocket' import { useWebSocket } from '../../hooks/useWebSocket'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -532,11 +533,11 @@ function Dashboard() {
<div className="dashboard-restart-section"> <div className="dashboard-restart-section">
<Text className="dashboard-restart-section__label"></Text> <Text className="dashboard-restart-section__label"></Text>
<div className="dashboard-restart-log"> <Scrollbar className="dashboard-restart-log">
{restartLogs.length > 0 ? restartLogs.map((line, index) => ( {restartLogs.length > 0 ? restartLogs.map((line, index) => (
<div key={`${line}-${index}`}>{line}</div> <div key={`${line}-${index}`}>{line}</div>
)) : <div></div>} )) : <div></div>}
</div> </Scrollbar>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -12,6 +12,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime' import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -939,7 +940,7 @@ function DataList() {
/> />
</div> </div>
</div> </div>
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}> <TableScrollRegion className="data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}
@@ -960,7 +961,7 @@ function DataList() {
showTotal: (count) => `${count}`, showTotal: (count) => `${count}`,
}} }}
/> />
</div> </TableScrollRegion>
</Card> </Card>
</div> </div>
</div> </div>

View File

@@ -116,18 +116,39 @@ function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgres
if (!batch || batch.sourceIds.length === 0) { if (!batch || batch.sourceIds.length === 0) {
return null return null
} }
return batch
}
const hasRunningItem = batch.sourceIds.some((sourceId) => batch.items[sourceId]?.is_running) function resolveTerminalBatchItem(
if (hasRunningItem) { sourceId: number,
return batch batch: BulkProgressBatch,
builtInSources: BuiltInDataSource[],
taskProgress: Record<number, TaskTrackerState>,
): BulkProgressItem | null {
const currentItem = batch.items[sourceId]
const source = builtInSources.find((item) => item.id === sourceId)
const trackedTask = taskProgress[sourceId]
const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false
if (isRunning) {
return null
} }
const allFinished = batch.sourceIds.every((sourceId) => { const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null
const status = batch.items[sourceId]?.status if (!status || status === 'running') {
return Boolean(status && status !== 'running') return null
}) }
return allFinished ? null : batch return {
task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null,
progress:
status === 'success'
? 100
: trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0,
is_running: false,
phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null,
status,
}
} }
interface WebSocketTaskMessage { interface WebSocketTaskMessage {
@@ -436,6 +457,41 @@ function DataSources() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [builtInSources, taskProgress, taskSocketConnected, fetchData]) }, [builtInSources, taskProgress, taskSocketConnected, fetchData])
useEffect(() => {
if (!bulkProgressBatch) return
let changed = false
const nextItems = { ...bulkProgressBatch.items }
for (const sourceId of bulkProgressBatch.sourceIds) {
const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress)
if (!nextItem) continue
const previousItem = bulkProgressBatch.items[sourceId]
if (
previousItem?.status === nextItem.status &&
previousItem?.is_running === nextItem.is_running &&
previousItem?.progress === nextItem.progress &&
previousItem?.phase === nextItem.phase
) {
continue
}
nextItems[sourceId] = nextItem
changed = true
}
if (!changed) return
setBulkProgressBatch((prev) => {
if (!prev) return prev
return finalizeBulkProgressBatch({
...prev,
items: nextItems,
})
})
}, [bulkProgressBatch, builtInSources, taskProgress])
const triggerDatasource = async (id: number, options?: { force?: boolean }) => { const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
const force = options?.force ?? false const force = options?.force ?? false
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, { const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {

View File

@@ -22,6 +22,8 @@ import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
const { Title, Text, Paragraph } = Typography const { Title, Text, Paragraph } = Typography
@@ -237,8 +239,10 @@ function Playground() {
const [editingContent, setEditingContent] = useState('') const [editingContent, setEditingContent] = useState('')
const [editSaving, setEditSaving] = useState(false) const [editSaving, setEditSaving] = useState(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [composerFocused, setComposerFocused] = useState(false)
const pollTimerRef = useRef<number | null>(null) const pollTimerRef = useRef<number | null>(null)
const messagesContainerRef = useRef<HTMLDivElement | null>(null) const messagesContainerRef = useRef<HTMLDivElement | null>(null)
const messagesShellRef = useRef<HTMLDivElement | null>(null)
const forceScrollToBottomRef = useRef(true) const forceScrollToBottomRef = useRef(true)
const selectedPreset = useMemo( const selectedPreset = useMemo(
@@ -575,7 +579,7 @@ function Playground() {
/> />
)} )}
> >
<div className="playground-card__scroll"> <Scrollbar className="playground-card__scroll">
<Spin spinning={statusLoading}> <Spin spinning={statusLoading}>
{providerStatus ? ( {providerStatus ? (
<div className="playground-provider-panel"> <div className="playground-provider-panel">
@@ -617,7 +621,7 @@ function Playground() {
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" /> <Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
)} )}
</Spin> </Spin>
</div> </Scrollbar>
</Card> </Card>
) )
@@ -678,7 +682,7 @@ function Playground() {
title="AI Chatbox" title="AI Chatbox"
extra={( extra={(
<Space size={4}> <Space size={4}>
<Tooltip title="服务状态"> <Tooltip title="服务状态" className="playground-chat__service-btn">
<Button <Button
type="text" type="text"
shape="circle" shape="circle"
@@ -699,7 +703,7 @@ function Playground() {
</Space> </Space>
)} )}
> >
<div className="playground-chat__messages-shell"> <div ref={messagesShellRef} className="playground-chat__messages-shell">
<div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}> <div className="playground-chat__messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
{messages.map((entry) => ( {messages.map((entry) => (
<div key={entry.id} className={`playground-message playground-message--${entry.role}`}> <div key={entry.id} className={`playground-message playground-message--${entry.role}`}>
@@ -760,8 +764,9 @@ function Playground() {
<Button size="small" type="primary" loading={editSaving} onClick={() => void handleSaveEditMessage(entry)}> <Button size="small" type="primary" loading={editSaving} onClick={() => void handleSaveEditMessage(entry)}>
</Button> </Button>
</div> </div>
</div> <ScrollbarOverlay containerRef={messagesShellRef} targetSelector=".playground-chat__messages" />
</div>
</div> </div>
) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? ( ) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? (
<div className="playground-message__markdown"> <div className="playground-message__markdown">
@@ -845,31 +850,50 @@ function Playground() {
</div> </div>
<div className="playground-chat__composer"> <div className="playground-chat__composer">
<div className="playground-chat__input-wrap"> <div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
<div className="playground-preset-strip__actions"> {(composerFocused || !!inputValue) && (
{PLAYGROUND_PRESETS.map((preset) => ( <div className="playground-preset-strip__actions">
<Tag.CheckableTag {PLAYGROUND_PRESETS.map((preset) => (
key={preset.key} <Tag.CheckableTag
checked={preset.key === selectedPreset.key} key={preset.key}
onChange={() => handleApplyPreset(preset)} checked={preset.key === selectedPreset.key}
> onChange={() => handleApplyPreset(preset)}
{preset.label} >
</Tag.CheckableTag> {preset.label}
))} </Tag.CheckableTag>
))}
</div>
)}
<div className="playground-chat__input-row">
<Input.TextArea
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
placeholder="在这里输入本次分析请求..."
className="playground-chat__input"
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
void handleSend()
}
}}
/>
{!(composerFocused || !!inputValue) && (
<Tooltip title={sendButtonTooltip}>
<Button
type="text"
shape="circle"
className={`playground-chat__send-button${requestPending || streaming ? ' playground-chat__send-button--stop' : ''}`}
icon={requestPending || streaming ? <BorderOutlined /> : <ArrowUpOutlined />}
onClick={requestPending || streaming ? handleStop : () => void handleSend()}
aria-label={sendButtonTooltip}
/>
</Tooltip>
)}
</div> </div>
<Input.TextArea {(composerFocused || !!inputValue) && (
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
autoSize={{ minRows: 4, maxRows: 10 }}
placeholder="在这里输入本次分析请求。你可以写观察、问题、目标,或者直接贴一段待分析事实。"
className="playground-chat__input"
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
void handleSend()
}
}}
/>
<div className="playground-chat__actions"> <div className="playground-chat__actions">
<div className="playground-chat__hints"> <div className="playground-chat__hints">
<Tag>{title}</Tag> <Tag>{title}</Tag>
@@ -886,8 +910,9 @@ function Playground() {
/> />
</Tooltip> </Tooltip>
</div> </div>
</div> )}
</div> </div>
</div>
</Card> </Card>
</div> </div>
</div> </div>
@@ -1000,42 +1025,42 @@ function Playground() {
/> />
</div> </div>
<div className="playground-result-modal__content"> <Scrollbar className="playground-result-modal__content">
<Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}> <Space direction="vertical" size={16} className="playground-result__stack" style={{ width: '100%' }}>
{analysis.text_blocks.length ? ( {analysis.text_blocks.length ? (
<div className="playground-result__blocks"> <div className="playground-result__blocks">
<Text strong></Text> <Text strong></Text>
<div className="playground-result__blocks-scroll"> <Scrollbar className="playground-result__blocks-scroll">
{analysis.text_blocks.map((block, index) => ( {analysis.text_blocks.map((block, index) => (
<Card key={`${index}-${block.slice(0, 12)}`} size="small"> <Card key={`${index}-${block.slice(0, 12)}`} size="small">
<pre>{block}</pre> <pre>{block}</pre>
</Card> </Card>
))} ))}
</div> </Scrollbar>
</div> </div>
) : null} ) : null}
{analysis.thinking_blocks.length ? ( {analysis.thinking_blocks.length ? (
<div className="playground-result__blocks"> <div className="playground-result__blocks">
<Text strong>Thinking Blocks</Text> <Text strong>Thinking Blocks</Text>
<div className="playground-result__blocks-scroll"> <Scrollbar className="playground-result__blocks-scroll">
{analysis.thinking_blocks.map((block, index) => ( {analysis.thinking_blocks.map((block, index) => (
<Card key={`${index}-${block.slice(0, 12)}`} size="small"> <Card key={`${index}-${block.slice(0, 12)}`} size="small">
<pre>{block}</pre> <pre>{block}</pre>
</Card> </Card>
))} ))}
</div> </Scrollbar>
</div> </div>
) : null} ) : null}
<div className="playground-result__blocks"> <div className="playground-result__blocks">
<Text strong>Raw Response</Text> <Text strong>Raw Response</Text>
<div className="playground-result__blocks-scroll playground-result__blocks-scroll--raw"> <Scrollbar className="playground-result__blocks-scroll playground-result__blocks-scroll--raw">
<Card size="small"> <Card size="small">
<pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre> <pre>{JSON.stringify(analysis.raw_response, null, 2)}</pre>
</Card> </Card>
</div> </Scrollbar>
</div> </div>
</Space> </Space>
</div> </Scrollbar>
</div> </div>
) : null} ) : null}
</Modal> </Modal>

View File

@@ -15,6 +15,8 @@ import {
} from 'antd' } from 'antd'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -91,7 +93,7 @@ function SettingsPanel({
return ( return (
<div className="settings-pane"> <div className="settings-pane">
<Card className="settings-panel-card" loading={loading}> <Card className="settings-panel-card" loading={loading}>
<div className="settings-panel-scroll">{children}</div> <Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
</Card> </Card>
</div> </div>
) )
@@ -642,7 +644,7 @@ function Settings() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="table-scroll-region data-source-table-region"> <TableScrollRegion className="data-source-table-region">
<Table <Table
rowKey="id" rowKey="id"
columns={tvSourceColumns} columns={tvSourceColumns}
@@ -652,7 +654,7 @@ function Settings() {
tableLayout="fixed" tableLayout="fixed"
size="small" size="small"
/> />
</div> </TableScrollRegion>
</div> </div>
</Card> </Card>
</div> </div>
@@ -668,7 +670,7 @@ function Settings() {
loading={loading} loading={loading}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
> >
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region"> <TableScrollRegion ref={collectorTableRegionRef} className="data-source-table-region">
<Table <Table
rowKey="id" rowKey="id"
columns={collectorColumns} columns={collectorColumns}
@@ -678,7 +680,7 @@ function Settings() {
tableLayout="fixed" tableLayout="fixed"
size="small" size="small"
/> />
</div> </TableScrollRegion>
</Card> </Card>
</div> </div>
), ),

View File

@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons' import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime' import { formatDateTimeZhCN } from '../../utils/datetime'
interface Task { interface Task {
@@ -146,9 +147,9 @@ function Tasks() {
</Button> </Button>
} }
> >
<div className="table-scroll-region"> <TableScrollRegion>
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" /> <Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</div> </TableScrollRegion>
</Card> </Card>
</AppLayout> </AppLayout>
) )

View File

@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd' import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import axios from 'axios' import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout' import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
interface User { interface User {
id: number id: number
@@ -18,8 +19,6 @@ function Users() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const [tableHeight, setTableHeight] = useState(360)
const [form] = Form.useForm() const [form] = Form.useForm()
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -36,24 +35,6 @@ function Users() {
fetchUsers() fetchUsers()
}, []) }, [])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = tableRegionRef.current?.offsetHeight || 0
setTableHeight(Math.max(220, regionHeight - 56))
}
updateTableHeight()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateTableHeight)
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
return () => observer.disconnect()
}, [users.length])
const handleAdd = () => { const handleAdd = () => {
setEditingUser(null) setEditingUser(null)
form.resetFields() form.resetFields()
@@ -144,16 +125,17 @@ function Users() {
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div> </div>
<div className="page-shell__body"> <div className="page-shell__body">
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}> <TableScrollRegion className="data-source-table-region users-table-region">
<Table <Table
columns={columns} columns={columns}
dataSource={users} dataSource={users}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
scroll={{ x: 'max-content', y: tableHeight }} scroll={{ x: 960, y: 10000 }}
pagination={false}
tableLayout="fixed" tableLayout="fixed"
/> />
</div> </TableScrollRegion>
</div> </div>
</div> </div>
<Modal <Modal

View File

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

View File

@@ -0,0 +1,10 @@
[/Script/Engine.RendererSettings]
r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange=True
r.AntiAliasingMethod=4
[/Script/Engine.Engine]
NearClipPlane=1.0
[Core.System]
Paths=../../../Engine/Content
Paths=%GAMEDIR%Content

View File

@@ -0,0 +1,3 @@
[/Script/EngineSettings.GameMapsSettings]
GlobalDefaultGameMode=/Script/PlanetClient.PlanetGameMode
GameDefaultMap=/Game/Maps/EarthMap

View File

@@ -0,0 +1,6 @@
[/Script/Engine.InputSettings]
+ActionMappings=(ActionName="SelectPoint",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=LeftMouseButton)
+ActionMappings=(ActionName="RightMouseDown",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=RightMouseButton)
+AxisMappings=(AxisName="ZoomCamera",Scale=-1.0,Key=MouseWheelAxis)
+AxisMappings=(AxisName="RotateYaw",Scale=1.0,Key=MouseX)
+AxisMappings=(AxisName="RotatePitch",Scale=-1.0,Key=MouseY)

View File

@@ -0,0 +1,135 @@
{
"count": 10,
"items": [
{
"id": "top500_1",
"name": "Frontier",
"latitude": 36.01,
"longitude": -84.26,
"country": "United States",
"city": "Oak Ridge",
"rank": 1,
"rmax_tflops": 1194000.0,
"rpeak_tflops": 1679616.0,
"cores": 8730112,
"power_kw": 22703.0
},
{
"id": "top500_2",
"name": "Aurora",
"latitude": 41.71,
"longitude": -87.98,
"country": "United States",
"city": "Lemont",
"rank": 2,
"rmax_tflops": 1012000.0,
"rpeak_tflops": 1321000.0,
"cores": 9264128,
"power_kw": 38698.0
},
{
"id": "top500_3",
"name": "Eagle",
"latitude": 52.36,
"longitude": 4.90,
"country": "Netherlands",
"city": "Amsterdam",
"rank": 3,
"rmax_tflops": 561200.0,
"rpeak_tflops": 736000.0,
"cores": 4620800,
"power_kw": 12410.0
},
{
"id": "top500_4",
"name": "Fugaku",
"latitude": 34.66,
"longitude": 135.22,
"country": "Japan",
"city": "Kobe",
"rank": 4,
"rmax_tflops": 442010.0,
"rpeak_tflops": 537212.0,
"cores": 7630848,
"power_kw": 29899.0
},
{
"id": "top500_5",
"name": "LUMI",
"latitude": 65.01,
"longitude": 25.46,
"country": "Finland",
"city": "Kajaani",
"rank": 5,
"rmax_tflops": 379700.0,
"rpeak_tflops": 531000.0,
"cores": 2748384,
"power_kw": 6016.0
},
{
"id": "top500_6",
"name": "MareNostrum 5",
"latitude": 41.39,
"longitude": 2.11,
"country": "Spain",
"city": "Barcelona",
"rank": 6,
"rmax_tflops": 138200.0,
"rpeak_tflops": 314000.0,
"cores": 2764800,
"power_kw": 10000.0
},
{
"id": "top500_7",
"name": "Summit",
"latitude": 35.93,
"longitude": -84.31,
"country": "United States",
"city": "Oak Ridge",
"rank": 7,
"rmax_tflops": 148600.0,
"rpeak_tflops": 200795.0,
"cores": 2414592,
"power_kw": 10096.0
},
{
"id": "top500_8",
"name": "Tianhe-2A",
"latitude": 23.13,
"longitude": 113.27,
"country": "China",
"city": "Guangzhou",
"rank": 8,
"rmax_tflops": 100679.0,
"rpeak_tflops": 100679.0,
"cores": 4981760,
"power_kw": 18482.0
},
{
"id": "top500_9",
"name": "Selene",
"latitude": 40.82,
"longitude": -74.17,
"country": "United States",
"city": "Santa Clara",
"rank": 9,
"rmax_tflops": 63460.0,
"rpeak_tflops": 79200.0,
"cores": 555520,
"power_kw": 2646.0
},
{
"id": "top500_10",
"name": "Perlmutter",
"latitude": 37.88,
"longitude": -122.25,
"country": "United States",
"city": "Berkeley",
"rank": 10,
"rmax_tflops": 93750.0,
"rpeak_tflops": 120000.0,
"cores": 761856,
"power_kw": 3060.0
}
]
}

View File

@@ -0,0 +1,53 @@
#include "ComputePointActor.h"
#include "Components/StaticMeshComponent.h"
AComputePointActor::AComputePointActor()
{
PrimaryActorTick.bCanEverTick = false;
SphereMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("SphereMesh"));
RootComponent = SphereMesh;
// Enable mouse-over events so the PlayerController can detect hover
SphereMesh->bReceivesDecals = false;
}
void AComputePointActor::BeginPlay()
{
Super::BeginPlay();
SetActorScale3D(FVector(PointScale));
ApplyMaterial();
}
void AComputePointActor::SetPointData(const FComputePoint& Data)
{
PointData = Data;
// Optional: name the actor in the Outliner for easy debugging
SetActorLabel(FString::Printf(TEXT("[%d] %s"), Data.Rank, *Data.Name));
}
void AComputePointActor::SetVisualState(EPointVisualState NewState)
{
if (VisualState == NewState) return;
VisualState = NewState;
ApplyMaterial();
}
void AComputePointActor::ApplyMaterial()
{
if (!SphereMesh) return;
UMaterialInterface* Mat = nullptr;
switch (VisualState)
{
case EPointVisualState::Hovered: Mat = HoveredMaterial; break;
case EPointVisualState::Selected: Mat = SelectedMaterial; break;
default: Mat = NormalMaterial; break;
}
if (Mat)
{
SphereMesh->SetMaterial(0, Mat);
}
}

View File

@@ -0,0 +1,95 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PlanetDataTypes.h"
#include "ComputePointActor.generated.h"
class UStaticMeshComponent;
class UBillboardComponent;
UENUM(BlueprintType)
enum class EPointVisualState : uint8
{
Normal UMETA(DisplayName="Normal"),
Hovered UMETA(DisplayName="Hovered"),
Selected UMETA(DisplayName="Selected"),
};
/**
* AComputePointActor
*
* Represents one supercomputer data point on the Cesium globe.
*
* Usage:
* 1. Create a Blueprint subclass: Content Browser → New Blueprint →
* search "AComputePointActor" as parent class → name it BP_ComputePointActor
* 2. In the Blueprint, assign a sphere mesh to SphereMesh (Engine/BasicShapes/Sphere)
* 3. Assign a material to NormalMaterial / HoveredMaterial / SelectedMaterial
* 4. Set this Blueprint class in APlanetDataManager → ComputePointClass
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API AComputePointActor : public AActor
{
GENERATED_BODY()
public:
AComputePointActor();
// -----------------------------------------------------------------------
// Components
// -----------------------------------------------------------------------
// The visible sphere. Assign a sphere mesh in your Blueprint subclass.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
UStaticMeshComponent* SphereMesh;
// -----------------------------------------------------------------------
// Materials (assign in Blueprint subclass)
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
UMaterialInterface* NormalMaterial;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
UMaterialInterface* HoveredMaterial;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
UMaterialInterface* SelectedMaterial;
// -----------------------------------------------------------------------
// Point scale (world units). Tweak this in the Details Panel.
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Visual")
float PointScale = 80000.f;
// -----------------------------------------------------------------------
// Data (set by APlanetDataManager after spawn)
// -----------------------------------------------------------------------
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FComputePoint PointData;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
EPointVisualState VisualState = EPointVisualState::Normal;
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
// Called by APlanetDataManager immediately after spawning.
UFUNCTION(BlueprintCallable, Category="Planet")
void SetPointData(const FComputePoint& Data);
// Called by PlanetPlayerController on hover/select.
UFUNCTION(BlueprintCallable, Category="Planet")
void SetVisualState(EPointVisualState NewState);
// Convenience: is this point currently selected?
UFUNCTION(BlueprintPure, Category="Planet")
bool IsSelected() const { return VisualState == EPointVisualState::Selected; }
protected:
virtual void BeginPlay() override;
private:
void ApplyMaterial();
};

View File

@@ -0,0 +1,122 @@
#include "GlobeInteractionComponent.h"
#include "CesiumGeoreference.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Kismet/GameplayStatics.h"
UGlobeInteractionComponent::UGlobeInteractionComponent()
{
PrimaryComponentTick.bCanEverTick = true;
}
void UGlobeInteractionComponent::BeginPlay()
{
Super::BeginPlay();
FindGeoreference();
}
void UGlobeInteractionComponent::FindGeoreference()
{
Georeference = ACesiumGeoreference::GetDefaultGeoreference(GetWorld());
if (!Georeference)
{
UE_LOG(LogTemp, Error,
TEXT("GlobeInteractionComponent: 场景中没有 CesiumGeoreference请添加一个"));
}
}
// ---------------------------------------------------------------------------
// 旋转
// ---------------------------------------------------------------------------
void UGlobeInteractionComponent::RotateGlobe(float DeltaYaw, float DeltaPitch)
{
if (!Georeference) return;
// 累积惯性速度(本帧的输入叠加到惯性上)
InertiaYaw += DeltaYaw * RotateSensitivity;
InertiaPitch += DeltaPitch * RotateSensitivity;
// 立即应用Tick 里再处理惯性衰减)
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
double NewLat = FMath::Clamp(
Georeference->GetOriginLatitude() + InertiaPitch,
-85.0, 85.0
);
Georeference->SetOriginLongitude(NewLon);
Georeference->SetOriginLatitude(NewLat);
// 保持经度在 [-180, 180]
if (NewLon > 180.0) Georeference->SetOriginLongitude(NewLon - 360.0);
if (NewLon < -180.0) Georeference->SetOriginLongitude(NewLon + 360.0);
}
// ---------------------------------------------------------------------------
// 缩放
// ---------------------------------------------------------------------------
void UGlobeInteractionComponent::ZoomGlobe(float Value)
{
if (FMath::IsNearlyZero(Value)) return;
// Value > 0 = 拉近高度减小Value < 0 = 推远(高度增大)
CurrentAltitudeMeters -= Value * ZoomSensitivity;
CurrentAltitudeMeters = FMath::Clamp(CurrentAltitudeMeters,
MinAltitudeMeters,
MaxAltitudeMeters);
ApplyAltitudeToCamera();
}
void UGlobeInteractionComponent::ApplyAltitudeToCamera()
{
if (!Georeference) return;
// 把当前 Origin 经纬度 + 新高度转换为 Unreal 世界坐标,然后移动拥有者 Pawn
AController* Controller = Cast<AController>(GetOwner());
APawn* Pawn = Controller ? Controller->GetPawn() : Cast<APawn>(GetOwner());
if (!Pawn) return;
double Lon = Georeference->GetOriginLongitude();
double Lat = Georeference->GetOriginLatitude();
FVector NewPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
FVector(Lon, Lat, CurrentAltitudeMeters)
);
Pawn->SetActorLocation(NewPos);
}
// ---------------------------------------------------------------------------
// 惯性 Tick
// ---------------------------------------------------------------------------
void UGlobeInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 只有惯性速度足够大时才继续转动
if (FMath::IsNearlyZero(InertiaYaw, 0.001f) &&
FMath::IsNearlyZero(InertiaPitch, 0.001f)) return;
if (!Georeference) return;
double NewLon = Georeference->GetOriginLongitude() - InertiaYaw;
double NewLat = FMath::Clamp(
Georeference->GetOriginLatitude() + InertiaPitch,
-85.0, 85.0
);
Georeference->SetOriginLongitude(NewLon);
Georeference->SetOriginLatitude(NewLat);
// 惯性衰减
InertiaYaw *= InertiaDamping;
InertiaPitch *= InertiaDamping;
}
void UGlobeInteractionComponent::StopInertia()
{
InertiaYaw = 0.f;
InertiaPitch = 0.f;
}

View File

@@ -0,0 +1,96 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GlobeInteractionComponent.generated.h"
class ACesiumGeoreference;
/**
* UGlobeInteractionComponent
*
* 挂在 PlayerController 或 Pawn 上,处理地球旋转和缩放。
*
* 旋转原理:
* 修改 CesiumGeoreference 的 OriginLongitude / OriginLatitude
* 相当于"移动地球"到新中心,视觉效果等同于旋转地球。
*
* 缩放原理:
* 沿相机到地球中心方向移动相机,改变观察高度。
*
* 使用方式:
* 在 PlanetPlayerController::BeginPlay 里 AddComponent
* 收到拖拽/滚轮输入时调用 RotateGlobe / ZoomGlobe。
*/
UCLASS(ClassGroup=(Planet), meta=(BlueprintSpawnableComponent), BlueprintType)
class PLANETCLIENT_API UGlobeInteractionComponent : public UActorComponent
{
GENERATED_BODY()
public:
UGlobeInteractionComponent();
// -----------------------------------------------------------------------
// 可在 Details 面板调整的参数
// -----------------------------------------------------------------------
// 拖拽灵敏度:每像素对应的经纬度偏移量(度)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
float RotateSensitivity = 0.15f;
// 缩放灵敏度:每格滚轮对应的高度变化(米)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
float ZoomSensitivity = 200000.f;
// 最小观察高度(米),防止穿入地面
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
double MinAltitudeMeters = 500000.0;
// 最大观察高度(米)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe")
double MaxAltitudeMeters = 30000000.0;
// 旋转惯性平滑系数 (0=无惯性, 1=永不停)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Globe",
meta=(ClampMin="0.0", ClampMax="0.99"))
float InertiaDamping = 0.85f;
// -----------------------------------------------------------------------
// 公开 API
// -----------------------------------------------------------------------
// 每帧由 PlayerController 调用,传入鼠标位移(像素)
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
void RotateGlobe(float DeltaYaw, float DeltaPitch);
// 每帧由 PlayerController 调用Value > 0 = 拉近,< 0 = 推远
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
void ZoomGlobe(float Value);
// 立即停止惯性
UFUNCTION(BlueprintCallable, Category="Planet|Globe")
void StopInertia();
// 返回当前相机高度(米)
UFUNCTION(BlueprintPure, Category="Planet|Globe")
double GetCurrentAltitudeMeters() const { return CurrentAltitudeMeters; }
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
virtual void BeginPlay() override;
private:
UPROPERTY()
ACesiumGeoreference* Georeference = nullptr;
// 惯性速度(经度/帧,纬度/帧)
float InertiaYaw = 0.f;
float InertiaPitch = 0.f;
// 当前相机高度(米),随 Zoom 更新
double CurrentAltitudeMeters = 5000000.0;
void FindGeoreference();
void ApplyAltitudeToCamera();
};

View File

@@ -0,0 +1,53 @@
#include "InteractiveObjectBase.h"
AInteractiveObjectBase::AInteractiveObjectBase()
{
PrimaryActorTick.bCanEverTick = false;
}
void AInteractiveObjectBase::SetInteractiveState(EInteractiveObjectState NewState)
{
if (CurrentState == NewState) return;
if (CurrentState == EInteractiveObjectState::Disabled &&
NewState != EInteractiveObjectState::Normal) return;
EInteractiveObjectState OldState = CurrentState;
CurrentState = NewState;
switch (NewState)
{
case EInteractiveObjectState::Hovered: OnHovered(); break;
case EInteractiveObjectState::Selected: OnSelected(); break;
case EInteractiveObjectState::Normal:
if (OldState == EInteractiveObjectState::Selected) OnDeselected();
else OnRestored();
break;
default: break;
}
OnStateChanged.Broadcast(NewState);
}
// ---------------------------------------------------------------------------
// 默认实现(子类可重写)
// ---------------------------------------------------------------------------
FText AInteractiveObjectBase::GetInfoTitle_Implementation() const
{
return FText::FromString(GetActorLabel());
}
FText AInteractiveObjectBase::GetInfoBody_Implementation() const
{
return FText::FromString(TEXT(""));
}
UTexture2D* AInteractiveObjectBase::GetInfoIcon_Implementation() const
{
return nullptr;
}
void AInteractiveObjectBase::OnHovered_Implementation() {}
void AInteractiveObjectBase::OnSelected_Implementation() {}
void AInteractiveObjectBase::OnDeselected_Implementation(){}
void AInteractiveObjectBase::OnRestored_Implementation() {}

View File

@@ -0,0 +1,93 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InteractiveObjectBase.generated.h"
UENUM(BlueprintType)
enum class EInteractiveObjectState : uint8
{
Normal UMETA(DisplayName="正常"),
Hovered UMETA(DisplayName="悬停高亮"),
Selected UMETA(DisplayName="已选中"),
Disabled UMETA(DisplayName="不可交互"),
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectStateChanged,
EInteractiveObjectState, NewState);
/**
* AInteractiveObjectBase
*
* 所有可交互 3D 物件的基类。
* 当前场景中的可交互物件包括:
* - AComputePointActor超算数据点—— 已实现
* - 其他演示物件TODO: 根据需求添加子类)
*
* 子类需要:
* 1. 重写 OnHovered / OnSelected / OnDeselected或响应 OnStateChanged 委托)
* 2. 将根 Component 设置为可被射线检测SetCollisionResponseToChannel
* 3. 可选:重写 GetInfoTitle / GetInfoBody 供信息卡使用
*/
UCLASS(Abstract, BlueprintType, Blueprintable)
class PLANETCLIENT_API AInteractiveObjectBase : public AActor
{
GENERATED_BODY()
public:
AInteractiveObjectBase();
// -----------------------------------------------------------------------
// 状态变更委托
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|Interactive")
FOnObjectStateChanged OnStateChanged;
// -----------------------------------------------------------------------
// 状态 API由 PlanetPlayerController 调用)
// -----------------------------------------------------------------------
UFUNCTION(BlueprintCallable, Category="Planet|Interactive")
void SetInteractiveState(EInteractiveObjectState NewState);
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
EInteractiveObjectState GetInteractiveState() const { return CurrentState; }
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
bool IsSelected() const { return CurrentState == EInteractiveObjectState::Selected; }
UFUNCTION(BlueprintPure, Category="Planet|Interactive")
bool IsHovered() const { return CurrentState == EInteractiveObjectState::Hovered; }
// -----------------------------------------------------------------------
// 信息卡内容(子类可重写提供具体文本)
// -----------------------------------------------------------------------
// 信息卡标题,默认返回 Actor 名
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
FText GetInfoTitle() const;
// 信息卡正文,返回富文本格式的详情
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
FText GetInfoBody() const;
// 信息卡图标(可选,返回空则不显示图标)
UFUNCTION(BlueprintNativeEvent, BlueprintPure, Category="Planet|Interactive")
UTexture2D* GetInfoIcon() const;
protected:
// 子类重写这三个函数来处理视觉状态变化
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnHovered();
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnSelected();
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnDeselected();
UFUNCTION(BlueprintNativeEvent, Category="Planet|Interactive")
void OnRestored(); // 从任何状态回到 Normal
private:
EInteractiveObjectState CurrentState = EInteractiveObjectState::Normal;
};

View File

@@ -0,0 +1,150 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "MotionCaptureInterface.generated.h"
// ---------------------------------------------------------------------------
// 动捕事件 —— 供应商中间件触发这些事件UE5 侧响应
// ---------------------------------------------------------------------------
/**
* 手势类型枚举
*
* TODO等动捕供应商确认手势集合后补充:
* 当前列出的是推测的基本手势,以实际交付为准。
*/
UENUM(BlueprintType)
enum class EMotionGesture : uint8
{
None UMETA(DisplayName=""),
SwipeLeft UMETA(DisplayName="向左划"), // 地球向左转
SwipeRight UMETA(DisplayName="向右划"), // 地球向右转
SwipeUp UMETA(DisplayName="向上划"), // 地球向上转
SwipeDown UMETA(DisplayName="向下划"), // 地球向下转
PinchIn UMETA(DisplayName="捏合(缩小)"),// 缩放
PinchOut UMETA(DisplayName="展开(放大)"),// 缩放
Point UMETA(DisplayName="指向"), // 悬停选中
Confirm UMETA(DisplayName="确认"), // 点击选中
Dismiss UMETA(DisplayName="挥手取消"), // 关闭信息卡
};
// 动捕系统触发某个离散手势时广播
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnGestureDetected, EMotionGesture, Gesture);
// 动捕系统持续输出指向方向时广播(用于悬停高亮)
// Direction: 归一化方向向量,由动捕系统解算后传入
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnPointingDirectionChanged, FVector, Direction);
// 动捕系统输出旋转增量时广播(用于连续拖拽旋转地球)
// DeltaYaw/DeltaPitch: 单帧内的角度增量(度)
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnRotateDelta, float, DeltaYaw, float, DeltaPitch);
// 动捕系统输出缩放增量时广播
// DeltaScale: > 0 放大,< 0 缩小
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnZoomDelta, float, DeltaScale);
/**
* UMotionCaptureReceiver
*
* 动捕接收器基类。根据供应商协议,创建对应的子类实现:
*
* - LiveLink 协议 → UMotionCaptureReceiverLiveLink TODO
* - OSC/UDP 协议 → UMotionCaptureReceiverOSC TODO
*
* 子类负责接收数据并调用 BroadcastXxx() 方法,
* PlanetPlayerController 订阅这些委托即可,不关心底层协议。
*
* TODO等动捕供应商回复后:
* 1. 确认协议Live Link / OSC / 私有 SDK
* 2. 在对应子类里实现连接和数据接收
* 3. 把子类拖进场景,在 PlanetDataManager / PlayerController 里引用它
*/
UCLASS(Abstract, BlueprintType, Blueprintable)
class PLANETCLIENT_API UMotionCaptureReceiver : public UObject
{
GENERATED_BODY()
public:
// -----------------------------------------------------------------------
// 外部订阅这些委托
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnGestureDetected OnGestureDetected;
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnPointingDirectionChanged OnPointingDirectionChanged;
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnRotateDelta OnRotateDelta;
UPROPERTY(BlueprintAssignable, Category="Planet|MotionCapture")
FOnZoomDelta OnZoomDelta;
// 连接到动捕中间件(子类实现)
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
virtual void Connect() {}
// 断开连接(子类实现)
UFUNCTION(BlueprintCallable, Category="Planet|MotionCapture")
virtual void Disconnect() {}
UFUNCTION(BlueprintPure, Category="Planet|MotionCapture")
virtual bool IsConnected() const { return false; }
protected:
// 子类解析到数据后调用这些方法广播
void BroadcastGesture(EMotionGesture Gesture)
{
OnGestureDetected.Broadcast(Gesture);
}
void BroadcastPointing(const FVector& Direction)
{
OnPointingDirectionChanged.Broadcast(Direction);
}
void BroadcastRotateDelta(float DeltaYaw, float DeltaPitch)
{
OnRotateDelta.Broadcast(DeltaYaw, DeltaPitch);
}
void BroadcastZoomDelta(float DeltaScale)
{
OnZoomDelta.Broadcast(DeltaScale);
}
};
// ---------------------------------------------------------------------------
// 动作映射配置
//
// 把动捕手势映射到具体的 UE 操作,在 Details 面板里可以改。
// 这样不用改代码就能重新映射手势。
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FMotionActionMapping
{
GENERATED_BODY()
// 触发"旋转地球"的手势(持续输出增量)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture RotateGlobe = EMotionGesture::None; // TODO: 填入供应商手势名
// 触发"放大"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture ZoomIn = EMotionGesture::PinchOut;
// 触发"缩小"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture ZoomOut = EMotionGesture::PinchIn;
// 触发"选中数据点"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture SelectPoint = EMotionGesture::Confirm;
// 触发"关闭信息卡"的手势
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
EMotionGesture DismissInfoCard = EMotionGesture::Dismiss;
};

View File

@@ -0,0 +1,33 @@
using UnrealBuildTool;
public class PlanetClient : ModuleRules
{
public PlanetClient(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"HTTP",
"Json",
"JsonUtilities",
"CesiumRuntime",
"UMG",
"Slate",
"SlateCore",
"RenderCore", // StereoRenderingManager 用到
"RHI", // 渲染硬件接口
// TODO: 动捕协议确认后按需添加:
// "LiveLink", // Live Link 协议
// "LiveLinkInterface",
// "Networking", // OSC/UDP 协议
// "Sockets",
});
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}

View File

@@ -0,0 +1,4 @@
#include "PlanetClient.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, PlanetClient, "PlanetClient");

View File

@@ -0,0 +1,3 @@
#pragma once
#include "CoreMinimal.h"

View File

@@ -0,0 +1,220 @@
#include "PlanetDataManager.h"
#include "ComputePointActor.h"
#include "HttpModule.h"
#include "Interfaces/IHttpResponse.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Engine/World.h"
// Cesium coordinate conversion
#include "CesiumGeoreference.h"
APlanetDataManager::APlanetDataManager()
{
PrimaryActorTick.bCanEverTick = false;
}
void APlanetDataManager::BeginPlay()
{
Super::BeginPlay();
// Build default mock path if not overridden
if (MockDataPath.IsEmpty())
{
MockDataPath = FPaths::ProjectContentDir() / TEXT("Data/mock_compute_points.json");
}
FetchAllData();
}
void APlanetDataManager::FetchAllData()
{
// Clear previous actors
for (AComputePointActor* Point : SpawnedPoints)
{
if (IsValid(Point)) Point->Destroy();
}
SpawnedPoints.Empty();
if (bUseLocalMockData)
{
LoadMockComputePoints();
}
else
{
FetchComputePoints();
}
}
// ---------------------------------------------------------------------------
// Phase A: local JSON file
// ---------------------------------------------------------------------------
void APlanetDataManager::LoadMockComputePoints()
{
FString JsonStr;
if (!FFileHelper::LoadFileToString(JsonStr, *MockDataPath))
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: cannot read mock file: %s"), *MockDataPath);
return;
}
TArray<FComputePoint> Points = ParseComputePointsJson(JsonStr);
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: loaded %d points from mock file"), Points.Num());
SpawnComputePoints(Points);
}
// ---------------------------------------------------------------------------
// Phase B: HTTP request
// ---------------------------------------------------------------------------
void APlanetDataManager::FetchComputePoints()
{
const FString Url = BackendBaseUrl + TEXT("/api/v1/ue/compute-points");
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Req = FHttpModule::Get().CreateRequest();
Req->SetURL(Url);
Req->SetVerb(TEXT("GET"));
Req->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Req->OnProcessRequestComplete().BindUObject(
this, &APlanetDataManager::OnComputePointsResponse);
Req->ProcessRequest();
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: GET %s"), *Url);
}
void APlanetDataManager::OnComputePointsResponse(FHttpRequestPtr Request,
FHttpResponsePtr Response,
bool bSuccess)
{
if (!bSuccess || !Response.IsValid())
{
UE_LOG(LogTemp, Error,
TEXT("PlanetDataManager: HTTP request failed. Is the backend running at %s?"),
*BackendBaseUrl);
return;
}
if (Response->GetResponseCode() != 200)
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: HTTP %d from %s"),
Response->GetResponseCode(), *Request->GetURL());
return;
}
TArray<FComputePoint> Points = ParseComputePointsJson(Response->GetContentAsString());
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: received %d compute points"), Points.Num());
SpawnComputePoints(Points);
}
// ---------------------------------------------------------------------------
// JSON parser
// ---------------------------------------------------------------------------
TArray<FComputePoint> APlanetDataManager::ParseComputePointsJson(const FString& JsonStr)
{
TArray<FComputePoint> Result;
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonStr);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: JSON parse failed"));
return Result;
}
const TArray<TSharedPtr<FJsonValue>>* Items;
if (!Root->TryGetArrayField(TEXT("items"), Items))
{
UE_LOG(LogTemp, Error, TEXT("PlanetDataManager: no 'items' array in JSON"));
return Result;
}
for (const TSharedPtr<FJsonValue>& Val : *Items)
{
const TSharedPtr<FJsonObject>* ObjPtr;
if (!Val->TryGetObject(ObjPtr)) continue;
const TSharedPtr<FJsonObject>& Obj = *ObjPtr;
FComputePoint Pt;
Obj->TryGetStringField(TEXT("id"), Pt.Id);
Obj->TryGetStringField(TEXT("name"), Pt.Name);
Obj->TryGetStringField(TEXT("country"), Pt.Country);
Obj->TryGetStringField(TEXT("city"), Pt.City);
double Lat, Lon;
if (!Obj->TryGetNumberField(TEXT("latitude"), Lat)) continue;
if (!Obj->TryGetNumberField(TEXT("longitude"), Lon)) continue;
Pt.Latitude = (float)Lat;
Pt.Longitude = (float)Lon;
int32 Rank;
if (Obj->TryGetNumberField(TEXT("rank"), Rank)) Pt.Rank = Rank;
double Rmax;
if (Obj->TryGetNumberField(TEXT("rmax_tflops"), Rmax)) Pt.RmaxTFlops = (float)Rmax;
double Rpeak;
if (Obj->TryGetNumberField(TEXT("rpeak_tflops"), Rpeak)) Pt.RpeakTFlops = (float)Rpeak;
int32 Cores;
if (Obj->TryGetNumberField(TEXT("cores"), Cores)) Pt.Cores = Cores;
double Power;
if (Obj->TryGetNumberField(TEXT("power_kw"), Power)) Pt.PowerKw = (float)Power;
Result.Add(Pt);
}
return Result;
}
// ---------------------------------------------------------------------------
// Spawn actors
// ---------------------------------------------------------------------------
void APlanetDataManager::SpawnComputePoints(const TArray<FComputePoint>& Points)
{
if (!ComputePointClass)
{
UE_LOG(LogTemp, Warning,
TEXT("PlanetDataManager: ComputePointClass not set — set it in the Details Panel"));
return;
}
UWorld* World = GetWorld();
if (!World) return;
// Find the CesiumGeoreference in the level
ACesiumGeoreference* Georeference = ACesiumGeoreference::GetDefaultGeoreference(World);
if (!Georeference)
{
UE_LOG(LogTemp, Error,
TEXT("PlanetDataManager: no CesiumGeoreference in level. Add a CesiumGeoreference actor."));
return;
}
for (const FComputePoint& Pt : Points)
{
// Convert geographic coordinates to Unreal world coordinates
// Altitude is PointAltitudeMeters above sea level
FVector WorldPos = Georeference->TransformLongitudeLatitudeHeightPositionToUnreal(
FVector(Pt.Longitude, Pt.Latitude, PointAltitudeMeters)
);
FActorSpawnParameters Params;
Params.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AComputePointActor* Actor = World->SpawnActor<AComputePointActor>(
ComputePointClass, WorldPos, FRotator::ZeroRotator, Params);
if (Actor)
{
Actor->SetPointData(Pt);
SpawnedPoints.Add(Actor);
}
}
OnComputePointsLoaded.Broadcast(SpawnedPoints.Num());
UE_LOG(LogTemp, Log, TEXT("PlanetDataManager: spawned %d compute point actors"),
SpawnedPoints.Num());
}

View File

@@ -0,0 +1,97 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Interfaces/IHttpRequest.h"
#include "PlanetDataTypes.h"
#include "PlanetDataManager.generated.h"
class AComputePointActor;
// Fired once all compute points have been spawned into the world
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnComputePointsLoaded, int32, Count);
/**
* APlanetDataManager
*
* Place one of these in your level. On BeginPlay it fetches data from the
* Planet backend and spawns AComputePointActor instances at the correct
* Cesium globe positions.
*
* Phase A (local): Set bUseLocalMockData = true and point MockDataPath to
* Content/Data/mock_compute_points.json.
* Phase B (live): Set bUseLocalMockData = false and fill BackendBaseUrl.
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API APlanetDataManager : public AActor
{
GENERATED_BODY()
public:
APlanetDataManager();
// -----------------------------------------------------------------------
// Inspector-editable properties
// -----------------------------------------------------------------------
// Base URL of the Planet backend, e.g. "http://localhost:8000"
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
FString BackendBaseUrl = TEXT("http://localhost:8000");
// When true, loads mock_compute_points.json from disk instead of the API.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
bool bUseLocalMockData = true;
// Absolute path to the mock JSON file (Phase A).
// Default points to <ProjectDir>/Content/Data/mock_compute_points.json
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config",
meta=(EditCondition="bUseLocalMockData"))
FString MockDataPath;
// The Blueprint subclass of AComputePointActor to spawn.
// Set this in the Details Panel to your BP_ComputePointActor.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
TSubclassOf<AComputePointActor> ComputePointClass;
// Height above sea level (metres) at which to place data points.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Config")
double PointAltitudeMeters = 50000.0;
// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
FOnComputePointsLoaded OnComputePointsLoaded;
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
// Call this to re-fetch and rebuild all points.
UFUNCTION(BlueprintCallable, Category="Planet")
void FetchAllData();
UFUNCTION(BlueprintCallable, Category="Planet")
TArray<AComputePointActor*> GetAllComputePoints() const { return SpawnedPoints; }
protected:
virtual void BeginPlay() override;
private:
TArray<AComputePointActor*> SpawnedPoints;
// HTTP handlers
void FetchComputePoints();
void OnComputePointsResponse(FHttpRequestPtr Request,
FHttpResponsePtr Response,
bool bSuccess);
// Local mock data loader (Phase A)
void LoadMockComputePoints();
// Shared spawn logic
void SpawnComputePoints(const TArray<FComputePoint>& Points);
// JSON → FComputePoint array
static TArray<FComputePoint> ParseComputePointsJson(const FString& JsonStr);
};

View File

@@ -0,0 +1,108 @@
#pragma once
#include "CoreMinimal.h"
#include "PlanetDataTypes.generated.h"
// ---------------------------------------------------------------------------
// FComputePoint — corresponds to /api/v1/ue/compute-points items
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FComputePoint
{
GENERATED_BODY()
// Unique string id (e.g. "top500_42")
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Id;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Name;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Latitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Longitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Country;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString City;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
int32 Rank = 0;
// Rmax in TFlops. 0 means not available.
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float RmaxTFlops = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float RpeakTFlops = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
int32 Cores = 0;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float PowerKw = 0.f;
};
// ---------------------------------------------------------------------------
// FLandingPoint — corresponds to /api/v1/ue/landing-points items
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FLandingPoint
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Id;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Name;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Latitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float Longitude = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Country;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString City;
// Names of cables that pass through this landing point
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
TArray<FString> CableNames;
};
// ---------------------------------------------------------------------------
// FCableSegment / FCable — corresponds to /api/v1/ue/cables items
// ---------------------------------------------------------------------------
USTRUCT(BlueprintType)
struct FCable
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Id;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString CableId; // slug, e.g. "flag"
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Name;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
FString Status;
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
float LengthKm = 0.f;
// Each inner TArray is one segment: ordered [lon, lat] pairs
// Stored as FVector2D(lon, lat) per point
UPROPERTY(BlueprintReadOnly, Category="Planet|Data")
TArray<TArray<FVector2D>> Segments;
};

View File

@@ -0,0 +1,28 @@
#include "PlanetGameMode.h"
#include "PlanetPlayerController.h"
#include "StereoRenderingManager.h"
#include "GameFramework/SpectatorPawn.h"
#include "Engine/World.h"
APlanetGameMode::APlanetGameMode()
{
PlayerControllerClass = APlanetPlayerController::StaticClass();
// 真实运行时在 World Settings 里改为 CesiumDynamicPawn
DefaultPawnClass = ASpectatorPawn::StaticClass();
}
void APlanetGameMode::BeginPlay()
{
Super::BeginPlay();
// 自动在场景里生成 StereoRenderingManager如果场景里没有的话
TArray<AActor*> Found;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AStereoRenderingManager::StaticClass(), Found);
if (Found.Num() == 0)
{
GetWorld()->SpawnActor<AStereoRenderingManager>(
AStereoRenderingManager::StaticClass(),
FVector::ZeroVector, FRotator::ZeroRotator);
UE_LOG(LogTemp, Log, TEXT("PlanetGameMode: 已自动生成 StereoRenderingManager"));
}
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "Kismet/GameplayStatics.h"
#include "PlanetGameMode.generated.h"
/**
* APlanetGameMode
*
* 场景入口。BeginPlay 时自动生成 StereoRenderingManager。
*
* 编辑器操作(步骤见 ue_client_setup_guide.md:
* 1. World Settings → Game Mode Override → PlanetGameMode
* 2. World Settings → Default Pawn Class → CesiumDynamicPawn
*/
UCLASS()
class PLANETCLIENT_API APlanetGameMode : public AGameModeBase
{
GENERATED_BODY()
public:
APlanetGameMode();
protected:
virtual void BeginPlay() override;
};

View File

@@ -0,0 +1,246 @@
#include "PlanetPlayerController.h"
#include "GlobeInteractionComponent.h"
#include "InteractiveObjectBase.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
APlanetPlayerController::APlanetPlayerController()
{
bShowMouseCursor = true;
bEnableClickEvents = true;
bEnableMouseOverEvents = true;
PrimaryActorTick.bCanEverTick = true;
GlobeInteraction = CreateDefaultSubobject<UGlobeInteractionComponent>(
TEXT("GlobeInteraction"));
}
void APlanetPlayerController::BeginPlay()
{
Super::BeginPlay();
SetInputMode(FInputModeGameAndUI());
// TODO供应商回复后取消注释:
// BindMotionCaptureEvents();
}
// ---------------------------------------------------------------------------
// 输入绑定
// ---------------------------------------------------------------------------
void APlanetPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
InputComponent->BindAction("SelectPoint", IE_Pressed, this,
&APlanetPlayerController::OnLeftMouseDown);
InputComponent->BindAction("SelectPoint", IE_Released, this,
&APlanetPlayerController::OnLeftMouseUp);
InputComponent->BindAction("Escape", IE_Pressed, this,
&APlanetPlayerController::OnEscape);
InputComponent->BindAxis("RotateYaw", this, &APlanetPlayerController::OnMouseX);
InputComponent->BindAxis("RotatePitch", this, &APlanetPlayerController::OnMouseY);
InputComponent->BindAxis("ZoomCamera", this, &APlanetPlayerController::OnZoom);
}
// ---------------------------------------------------------------------------
// Tick悬停检测
// ---------------------------------------------------------------------------
void APlanetPlayerController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (!bIsDragging) UpdateHover();
}
// ---------------------------------------------------------------------------
// 鼠标输入
// ---------------------------------------------------------------------------
void APlanetPlayerController::OnLeftMouseDown()
{
bLeftMouseDown = true;
bIsDragging = false;
float X, Y;
GetMousePosition(X, Y);
MouseDownPosition = FVector2D(X, Y);
// 停止惯性,准备接管控制
if (GlobeInteraction) GlobeInteraction->StopInertia();
}
void APlanetPlayerController::OnLeftMouseUp()
{
if (!bIsDragging)
{
// 没有拖拽 → 判定为点击
AInteractiveObjectBase* Clicked = GetObjectUnderCursor();
if (Clicked && Clicked != SelectedObject)
{
// 取消上一个选中
DeselectCurrent();
// 选中新物件
SelectedObject = Clicked;
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
OnObjectSelected.Broadcast(SelectedObject);
}
else if (!Clicked)
{
// 点了空白处 → 取消选中
DeselectCurrent();
}
else
{
// 点了已选中的物件 → 取消选中
DeselectCurrent();
}
}
bLeftMouseDown = false;
bIsDragging = false;
}
void APlanetPlayerController::OnMouseX(float Value)
{
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
// 判断是否超过拖拽阈值
if (!bIsDragging)
{
float X, Y;
GetMousePosition(X, Y);
float Dist = FVector2D::Distance(FVector2D(X, Y), MouseDownPosition);
if (Dist < DragThresholdPixels) return;
bIsDragging = true;
}
if (GlobeInteraction) GlobeInteraction->RotateGlobe(Value * MouseDragSensitivity, 0.f);
}
void APlanetPlayerController::OnMouseY(float Value)
{
if (!bLeftMouseDown || FMath::IsNearlyZero(Value)) return;
if (!bIsDragging) return;
if (GlobeInteraction) GlobeInteraction->RotateGlobe(0.f, Value * MouseDragSensitivity);
}
void APlanetPlayerController::OnZoom(float Value)
{
if (FMath::IsNearlyZero(Value)) return;
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(Value);
}
void APlanetPlayerController::OnEscape()
{
DeselectCurrent();
}
// ---------------------------------------------------------------------------
// 悬停
// ---------------------------------------------------------------------------
void APlanetPlayerController::UpdateHover()
{
AInteractiveObjectBase* UnderCursor = GetObjectUnderCursor();
if (HoveredObject && HoveredObject != UnderCursor && HoveredObject != SelectedObject)
{
HoveredObject->SetInteractiveState(EInteractiveObjectState::Normal);
}
HoveredObject = UnderCursor;
if (HoveredObject && HoveredObject != SelectedObject)
{
HoveredObject->SetInteractiveState(EInteractiveObjectState::Hovered);
}
}
// ---------------------------------------------------------------------------
// 取消选中
// ---------------------------------------------------------------------------
void APlanetPlayerController::DeselectCurrent()
{
if (!SelectedObject) return;
SelectedObject->SetInteractiveState(EInteractiveObjectState::Normal);
SelectedObject = nullptr;
OnObjectDeselected.Broadcast();
}
// ---------------------------------------------------------------------------
// 射线检测
// ---------------------------------------------------------------------------
AInteractiveObjectBase* APlanetPlayerController::GetObjectUnderCursor() const
{
FHitResult Hit;
bool bHit = GetHitResultUnderCursorByChannel(
UEngineTypes::ConvertToTraceType(ECC_Visibility), true, Hit);
if (!bHit) return nullptr;
return Cast<AInteractiveObjectBase>(Hit.GetActor());
}
// ---------------------------------------------------------------------------
// 动捕事件TODO: 供应商回复后实现)
// ---------------------------------------------------------------------------
void APlanetPlayerController::BindMotionCaptureEvents()
{
if (!MotionCaptureReceiver)
{
UE_LOG(LogTemp, Warning,
TEXT("PlayerController: MotionCaptureReceiver 未设置,动捕功能禁用"));
return;
}
MotionCaptureReceiver->OnGestureDetected.AddDynamic(
this, &APlanetPlayerController::OnMotionGesture);
MotionCaptureReceiver->OnRotateDelta.AddDynamic(
this, &APlanetPlayerController::OnMotionRotate);
MotionCaptureReceiver->OnZoomDelta.AddDynamic(
this, &APlanetPlayerController::OnMotionZoom);
MotionCaptureReceiver->Connect();
UE_LOG(LogTemp, Log, TEXT("PlayerController: 动捕事件已绑定"));
}
void APlanetPlayerController::OnMotionGesture(EMotionGesture Gesture)
{
if (Gesture == MotionActionMapping.SelectPoint)
{
// 手势确认 → 等同于点击当前悬停的物件
if (HoveredObject)
{
DeselectCurrent();
SelectedObject = HoveredObject;
SelectedObject->SetInteractiveState(EInteractiveObjectState::Selected);
OnObjectSelected.Broadcast(SelectedObject);
}
}
else if (Gesture == MotionActionMapping.DismissInfoCard)
{
DeselectCurrent();
}
else if (Gesture == MotionActionMapping.ZoomIn)
{
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(1.f);
}
else if (Gesture == MotionActionMapping.ZoomOut)
{
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(-1.f);
}
}
void APlanetPlayerController::OnMotionRotate(float DeltaYaw, float DeltaPitch)
{
if (GlobeInteraction) GlobeInteraction->RotateGlobe(DeltaYaw, DeltaPitch);
}
void APlanetPlayerController::OnMotionZoom(float DeltaScale)
{
if (GlobeInteraction) GlobeInteraction->ZoomGlobe(DeltaScale);
}

View File

@@ -0,0 +1,117 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MotionCaptureInterface.h"
#include "PlanetPlayerController.generated.h"
class AInteractiveObjectBase;
class UGlobeInteractionComponent;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnObjectSelected, AInteractiveObjectBase*, Object);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnObjectDeselected);
/**
* APlanetPlayerController
*
* 统一处理两路输入:
* 1. 鼠标/键盘(始终可用,调试和演示备用)
* 2. 动捕手势TODO: 供应商协议确认后接入)
*
* 鼠标操作:
* 左键拖拽 → 旋转地球
* 滚轮 → 缩放
* 悬停 → 高亮物件
* 左键单击 → 选中物件 / 取消选中
* ESC → 取消选中
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API APlanetPlayerController : public APlayerController
{
GENERATED_BODY()
public:
APlanetPlayerController();
// -----------------------------------------------------------------------
// 组件
// -----------------------------------------------------------------------
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Planet|Components")
UGlobeInteractionComponent* GlobeInteraction;
// -----------------------------------------------------------------------
// 动捕配置TODO: 供应商回复后配置)
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
FMotionActionMapping MotionActionMapping;
// 动捕接收器实例TODO: 确认协议后指向具体子类实例)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|MotionCapture")
UMotionCaptureReceiver* MotionCaptureReceiver = nullptr;
// -----------------------------------------------------------------------
// 鼠标灵敏度
// -----------------------------------------------------------------------
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
float MouseDragSensitivity = 0.3f;
// 判定为"拖拽"而非"点击"的最小移动距离(像素)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Input")
float DragThresholdPixels = 5.f;
// -----------------------------------------------------------------------
// 事件
// -----------------------------------------------------------------------
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
FOnObjectSelected OnObjectSelected;
UPROPERTY(BlueprintAssignable, Category="Planet|Events")
FOnObjectDeselected OnObjectDeselected;
// -----------------------------------------------------------------------
// 公开 API
// -----------------------------------------------------------------------
UFUNCTION(BlueprintCallable, Category="Planet")
void DeselectCurrent();
UFUNCTION(BlueprintPure, Category="Planet")
AInteractiveObjectBase* GetSelectedObject() const { return SelectedObject; }
protected:
virtual void BeginPlay() override;
virtual void Tick(float DeltaTime) override;
virtual void SetupInputComponent() override;
private:
// 鼠标拖拽状态
bool bLeftMouseDown = false;
bool bIsDragging = false;
FVector2D MouseDownPosition = FVector2D::ZeroVector;
// 交互状态
AInteractiveObjectBase* SelectedObject = nullptr;
AInteractiveObjectBase* HoveredObject = nullptr;
// 输入回调
void OnLeftMouseDown();
void OnLeftMouseUp();
void OnMouseX(float Value);
void OnMouseY(float Value);
void OnZoom(float Value);
void OnEscape();
// 悬停检测
void UpdateHover();
// 射线检测
AInteractiveObjectBase* GetObjectUnderCursor() const;
// 动捕事件绑定TODO: 供应商回复后在 BeginPlay 里绑定接收器)
UFUNCTION()
void OnMotionGesture(EMotionGesture Gesture);
UFUNCTION()
void OnMotionRotate(float DeltaYaw, float DeltaPitch);
UFUNCTION()
void OnMotionZoom(float DeltaScale);
void BindMotionCaptureEvents();
};

View File

@@ -0,0 +1,131 @@
#include "StereoRenderingManager.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
AStereoRenderingManager::AStereoRenderingManager()
{
PrimaryActorTick.bCanEverTick = false;
}
void AStereoRenderingManager::BeginPlay()
{
Super::BeginPlay();
if (bAutoEnableOnPlay)
{
EnableStereo(DefaultStereoMode);
}
else
{
UE_LOG(LogTemp, Log,
TEXT("StereoRenderingManager: 立体渲染未自动启动。"
"确认视频处理器格式后,在 Details 面板勾选 bAutoEnableOnPlay。"));
}
}
// ---------------------------------------------------------------------------
// 公开 API
// ---------------------------------------------------------------------------
void AStereoRenderingManager::EnableStereo(EStereoOutputMode Mode)
{
if (Mode == EStereoOutputMode::Off)
{
DisableStereo();
return;
}
// TODO供应商回复后检查这里的命令是否与视频处理器匹配:
//
// UE5 内置立体模式通过以下方式启用。
// 如果视频处理器需要特殊格式,可能需要修改 stereo mode 参数。
//
// 参考命令:
// stereo on —— 开启立体渲染(需要 VR 设备或自定义立体设备)
// r.StereoRendering 1 —— 软件层立体渲染标志
// vr.StereoMode <n> —— 0=SbS full, 1=SbS half, 2=TbB full, 3=TbB half
//
// 目前使用 r.StereoRendering 的"双视口"方式,兼容性最好。
switch (Mode)
{
case EStereoOutputMode::SideBySide:
// Side-by-Side左半=左眼,右半=右眼,总分辨率 2x 宽
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
ExecConsoleCommand(TEXT("stereo on"));
ExecConsoleCommand(TEXT("vr.StereoMode 0")); // TODO: 根据实际效果调整
break;
case EStereoOutputMode::TopBottom:
// Top-Bottom上半=左眼,下半=右眼,总分辨率 2x 高
ExecConsoleCommand(TEXT("r.StereoRendering 1"));
ExecConsoleCommand(TEXT("stereo on"));
ExecConsoleCommand(TEXT("vr.StereoMode 2")); // TODO: 根据实际效果调整
break;
case EStereoOutputMode::FrameSequential:
// 本项目屏幕 60Hz帧序列需要 120Hz暂不支持
UE_LOG(LogTemp, Warning,
TEXT("StereoRenderingManager: Frame Sequential 需要 120Hz 输出,"
"当前屏幕规格不支持,已忽略。"));
return;
default: break;
}
// 设置 IPD
SetIPD(IPDCm);
bStereoActive = true;
CurrentMode = Mode;
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已开启,模式=%sIPD=%.1fcm"),
*ModeToString(Mode), IPDCm);
}
void AStereoRenderingManager::DisableStereo()
{
ExecConsoleCommand(TEXT("stereo off"));
ExecConsoleCommand(TEXT("r.StereoRendering 0"));
bStereoActive = false;
CurrentMode = EStereoOutputMode::Off;
UE_LOG(LogTemp, Log, TEXT("StereoRenderingManager: 立体渲染已关闭,切回 2D"));
}
void AStereoRenderingManager::ToggleStereo()
{
if (bStereoActive)
DisableStereo();
else
EnableStereo(DefaultStereoMode);
}
void AStereoRenderingManager::SetIPD(float NewIPDCm)
{
IPDCm = FMath::Clamp(NewIPDCm, 1.f, 10.f);
// UE5 以米为单位设置 IPD
FString Cmd = FString::Printf(TEXT("vr.HMDIPDInMeters %.4f"), IPDCm / 100.f);
ExecConsoleCommand(Cmd);
}
// ---------------------------------------------------------------------------
// 内部工具
// ---------------------------------------------------------------------------
void AStereoRenderingManager::ExecConsoleCommand(const FString& Cmd)
{
if (GEngine)
{
GEngine->Exec(GetWorld(), *Cmd);
}
}
FString AStereoRenderingManager::ModeToString(EStereoOutputMode Mode)
{
switch (Mode)
{
case EStereoOutputMode::SideBySide: return TEXT("SideBySide");
case EStereoOutputMode::TopBottom: return TEXT("TopBottom");
case EStereoOutputMode::FrameSequential: return TEXT("FrameSequential");
default: return TEXT("Off");
}
}

View File

@@ -0,0 +1,97 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "StereoRenderingManager.generated.h"
/**
* 立体渲染输出格式
*
* TODO等待视频处理器供应商回复:
* 确认视频处理器接受哪种格式后,在 DefaultStereoMode 里选择对应值。
* - 如果是诺瓦星云/Brompton 处理器,通常接受 SideBySide
* - Frame Sequential 需要 120Hz+ 输出,本项目屏幕不需要,基本不选
*/
UENUM(BlueprintType)
enum class EStereoOutputMode : uint8
{
Off UMETA(DisplayName="关闭(普通 2D 输出)"),
SideBySide UMETA(DisplayName="左右并排 Side-by-Side"), // 最常见,优先尝试
TopBottom UMETA(DisplayName="上下叠加 Top-Bottom"), // 备选
FrameSequential UMETA(DisplayName="帧序列(需 120Hz"), // 本项目暂不用
};
/**
* AStereoRenderingManager
*
* 放一个在场景里(或在 GameMode 里 SpawnActor控制立体渲染的开关和参数。
* 运行时可以通过 Blueprint 或控制台随时切换模式,方便现场调试。
*
* TODO等待供应商回复后填写:
* 1. 确认视频处理器输入格式 → 修改 DefaultStereoMode
* 2. 确认屏幕分辨率 → 修改渲染分辨率(目前按 5120x1440 SbS 估算)
* 3. 调整 IPDCm 到适合大屏幕观看距离的值(推荐 3-6cm距离越远 IPD 越小)
*/
UCLASS(BlueprintType, Blueprintable)
class PLANETCLIENT_API AStereoRenderingManager : public AActor
{
GENERATED_BODY()
public:
AStereoRenderingManager();
// -----------------------------------------------------------------------
// 可在 Details 面板调整
// -----------------------------------------------------------------------
// TODO: 等供应商回复后改为正确格式
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
EStereoOutputMode DefaultStereoMode = EStereoOutputMode::SideBySide;
// 瞳距cm。大屏幕 + 远距离观看建议 3.0-5.0cm
// TODO: 根据实际屏幕尺寸和观看距离校准
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo",
meta=(ClampMin="1.0", ClampMax="10.0"))
float IPDCm = 6.5f;
// 是否在 BeginPlay 时自动启用立体渲染
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Stereo")
bool bAutoEnableOnPlay = false; // 默认 false等确认格式后改为 true
// -----------------------------------------------------------------------
// Blueprint 可调用的运行时 API
// -----------------------------------------------------------------------
// 开启立体渲染
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void EnableStereo(EStereoOutputMode Mode);
// 关闭立体渲染,切回普通 2D
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void DisableStereo();
// 切换(当前开 → 关,当前关 → 用 DefaultStereoMode 开)
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void ToggleStereo();
// 调整 IPD运行时微调景深效果
UFUNCTION(BlueprintCallable, Category="Planet|Stereo")
void SetIPD(float NewIPDCm);
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
bool IsStereoActive() const { return bStereoActive; }
UFUNCTION(BlueprintPure, Category="Planet|Stereo")
EStereoOutputMode GetCurrentMode() const { return CurrentMode; }
protected:
virtual void BeginPlay() override;
private:
bool bStereoActive = false;
EStereoOutputMode CurrentMode = EStereoOutputMode::Off;
// 执行实际的控制台命令
void ExecConsoleCommand(const FString& Cmd);
static FString ModeToString(EStereoOutputMode Mode);
};

2
uv.lock generated
View File

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