Compare commits

..

8 Commits
v0.35.0 ... zg

Author SHA1 Message Date
opencode
8f67d8d108 feat: add ue_scene websocket integration 2026-05-09 15:51:46 +08:00
linkong
61baee00f6 Merge pull request 'dev' (#8) from dev into main
Reviewed-on: #8
2026-04-21 21:30:45 +00:00
linkong
620190819b Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-04-13 23:41:04 +00:00
linkong
f67d6bde60 Merge pull request 'codex/aiprovider-foundation' (#4) from codex/aiprovider-foundation into main
Reviewed-on: #4
2026-04-07 09:35:48 +00:00
linkong
36672e4c53 Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-03-25 09:25:38 +00:00
linkong
506402ce16 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-03-20 21:17:30 +00:00
linkong
9d135bf2e1 revert 49a9c33836
revert feat(earth): toolbar and zoom improvements

- Add box-sizing/padding normalization to toolbar buttons
- Remove zoom slider, implement click/hold zoom behavior (+/- buttons)
- Add 10% step on click, 1% continuous on hold
- Fix satellite init: show satellite points immediately, delay trail visibility
- Fix breathing effect: faster pulse, wider opacity range
- Add toggle-cables functionality with visibility state
- Initialize satellites and cables as visible by default
2026-03-20 21:16:45 +00:00
linkong
49a9c33836 feat(earth): toolbar and zoom improvements
- Add box-sizing/padding normalization to toolbar buttons
- Remove zoom slider, implement click/hold zoom behavior (+/- buttons)
- Add 10% step on click, 1% continuous on hold
- Fix satellite init: show satellite points immediately, delay trail visibility
- Fix breathing effect: faster pulse, wider opacity range
- Add toggle-cables functionality with visibility state
- Initialize satellites and cables as visible by default
2026-03-20 17:13:02 +08:00
39 changed files with 2877 additions and 3534 deletions

View File

@@ -1 +1 @@
0.35.0
0.33.0

View File

@@ -1,7 +1,6 @@
"""WebSocket API endpoints"""
import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Optional
@@ -12,9 +11,20 @@ from jose import jwt, JWTError
from app.core.config import settings
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
from app.core.websocket.ue_scene import expand_scene_payload_for_transport, ue_scene_state_store
from app.db.session import async_session_factory
logger = logging.getLogger(__name__)
router = APIRouter()
SUPPORTED_CHANNELS = [
"gpu_clusters",
"submarine_cables",
"ixp_nodes",
"alerts",
"dashboard",
"datasource_tasks",
"ue_scene",
]
async def authenticate_token(token: str) -> Optional[dict]:
@@ -50,18 +60,12 @@ async def websocket_endpoint(
await websocket.send_json(
{
"type": "connection_established",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {
"connection_id": f"conn_{user_id}",
"server_version": settings.VERSION,
"heartbeat_interval": 30,
"supported_channels": [
"gpu_clusters",
"submarine_cables",
"ixp_nodes",
"alerts",
"dashboard",
"datasource_tasks",
],
"supported_channels": SUPPORTED_CHANNELS,
},
}
)
@@ -74,26 +78,79 @@ async def websocket_endpoint(
await websocket.send_json(
{
"type": "heartbeat",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {"action": "pong", "timestamp": to_iso8601_utc(datetime.now(UTC))},
}
)
elif data.get("type") == "subscribe":
channels = data.get("data", {}).get("channels", [])
requested_channels = data.get("data", {}).get("channels", [])
channels = manager.subscribe(websocket, requested_channels)
await websocket.send_json(
{
"type": "subscription_confirmed",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {"action": "subscribe", "channels": channels},
}
)
elif data.get("type") == "sync_request":
sync_data = data.get("data", {})
channel = sync_data.get("channel")
if channel == "ue_scene":
async with async_session_factory() as session:
scene_payloads = await ue_scene_state_store.get_sync_payloads(
session,
last_sequence=sync_data.get("last_sequence"),
reason=sync_data.get("reason"),
)
for scene_payload in scene_payloads:
for transport_payload in expand_scene_payload_for_transport(scene_payload):
await websocket.send_json(
{
"type": "data_frame",
"channel": "ue_scene",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": transport_payload,
}
)
else:
await websocket.send_json(
{
"type": "error",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {
"message": f"Unsupported sync channel: {channel}",
},
}
)
elif data.get("type") == "control_frame":
await websocket.send_json(
{"type": "control_acknowledged", "data": {"received": True}}
{
"type": "control_acknowledged",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {
"target": data.get("data", {}).get("target"),
"command": data.get("data", {}).get("command"),
"accepted": True,
},
}
)
else:
await websocket.send_json({"type": "ack", "data": {"received": True}})
await websocket.send_json(
{
"type": "ack",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {"received": True},
}
)
except asyncio.TimeoutError:
await websocket.send_json({"type": "heartbeat", "data": {"action": "ping"}})
await websocket.send_json(
{
"type": "heartbeat",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {"action": "ping"},
}
)
except WebSocketDisconnect:
pass

View File

@@ -1,11 +1,12 @@
"""Data broadcaster for WebSocket connections"""
"""Data broadcaster for WebSocket connections."""
import asyncio
from datetime import UTC, datetime
from typing import Dict, Any, Optional
from typing import Any, Dict
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
from app.db.session import async_session_factory
@@ -45,6 +46,30 @@ class DataBroadcaster:
pass
await asyncio.sleep(interval)
async def broadcast_ue_scene(self, interval: int = 5) -> None:
"""Broadcast UE scene updates for nDisplay primary nodes."""
from app.core.websocket.ue_scene import expand_scene_payload_for_transport, ue_scene_state_store
while self.running:
try:
async with async_session_factory() as session:
scene_payloads = await ue_scene_state_store.get_broadcast_payloads(session)
for scene_data in scene_payloads:
for transport_payload in expand_scene_payload_for_transport(scene_data):
await manager.broadcast(
{
"type": "data_frame",
"channel": "ue_scene",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": transport_payload,
},
channel="ue_scene",
)
except Exception:
pass
await asyncio.sleep(interval)
async def broadcast_alert(self, alert: Dict[str, Any]):
"""Broadcast an alert to all connected clients"""
await manager.broadcast(
@@ -75,7 +100,7 @@ class DataBroadcaster:
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data,
},
channel=channel if channel in manager.active_connections else "all",
channel=channel,
)
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
@@ -95,6 +120,7 @@ class DataBroadcaster:
if not self.running:
self.running = True
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
self.tasks["ue_scene"] = asyncio.create_task(self.broadcast_ue_scene(5))
def stop(self):
"""Stop all broadcasters"""

View File

@@ -1,9 +1,7 @@
"""WebSocket Connection Manager"""
"""WebSocket connection manager with channel subscriptions."""
from typing import Dict, Optional, Set
import json
import asyncio
from typing import Dict, Set, Optional
from datetime import datetime
from fastapi import WebSocket
import redis.asyncio as redis
@@ -11,17 +9,25 @@ from app.core.config import settings
class ConnectionManager:
"""Manages WebSocket connections"""
"""Manage user connections and channel subscriptions."""
def __init__(self):
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
def __init__(self) -> None:
self.user_connections: Dict[str, Set[WebSocket]] = {}
self.socket_users: Dict[WebSocket, str] = {}
self.channel_connections: Dict[str, Set[WebSocket]] = {}
self.socket_channels: Dict[WebSocket, Set[str]] = {}
self.redis_client: Optional[redis.Redis] = None
async def connect(self, websocket: WebSocket, user_id: str):
@property
def active_connections(self) -> Dict[str, Set[WebSocket]]:
"""Compatibility alias for existing callers."""
return self.user_connections
async def connect(self, websocket: WebSocket, user_id: str) -> None:
await websocket.accept()
if user_id not in self.active_connections:
self.active_connections[user_id] = set()
self.active_connections[user_id].add(websocket)
self.user_connections.setdefault(user_id, set()).add(websocket)
self.socket_users[websocket] = user_id
self.socket_channels.setdefault(websocket, set())
if self.redis_client is None:
redis_url = settings.REDIS_URL
@@ -35,32 +41,69 @@ class ConnectionManager:
decode_responses=True,
)
def disconnect(self, websocket: WebSocket, user_id: str):
if user_id in self.active_connections:
self.active_connections[user_id].discard(websocket)
if not self.active_connections[user_id]:
del self.active_connections[user_id]
def disconnect(self, websocket: WebSocket, user_id: str) -> None:
self.unsubscribe(websocket, list(self.socket_channels.get(websocket, set())))
async def send_personal_message(self, message: dict, user_id: str):
if user_id in self.active_connections:
for connection in self.active_connections[user_id]:
try:
await connection.send_json(message)
except Exception:
pass
if user_id in self.user_connections:
self.user_connections[user_id].discard(websocket)
if not self.user_connections[user_id]:
del self.user_connections[user_id]
async def broadcast(self, message: dict, channel: str = "all"):
self.socket_users.pop(websocket, None)
self.socket_channels.pop(websocket, None)
def subscribe(self, websocket: WebSocket, channels: list[str]) -> list[str]:
subscribed_channels: list[str] = []
socket_channel_set = self.socket_channels.setdefault(websocket, set())
for channel in channels:
normalized_channel = channel.strip()
if not normalized_channel:
continue
self.channel_connections.setdefault(normalized_channel, set()).add(websocket)
socket_channel_set.add(normalized_channel)
subscribed_channels.append(normalized_channel)
return subscribed_channels
def unsubscribe(self, websocket: WebSocket, channels: list[str]) -> None:
socket_channel_set = self.socket_channels.setdefault(websocket, set())
for channel in channels:
normalized_channel = channel.strip()
if not normalized_channel:
continue
if normalized_channel in self.channel_connections:
self.channel_connections[normalized_channel].discard(websocket)
if not self.channel_connections[normalized_channel]:
del self.channel_connections[normalized_channel]
socket_channel_set.discard(normalized_channel)
async def send_personal_message(self, message: dict, user_id: str) -> None:
for connection in list(self.user_connections.get(user_id, set())):
try:
await connection.send_json(message)
except Exception:
self.disconnect(connection, user_id)
async def broadcast(self, message: dict, channel: str = "all") -> None:
if channel == "all":
for user_id in self.active_connections:
await self.send_personal_message(message, user_id)
targets = list(self.socket_users.keys())
else:
await self.send_personal_message(message, channel)
targets = list(self.channel_connections.get(channel, set()))
async def close_all(self):
for user_id in self.active_connections:
for connection in self.active_connections[user_id]:
await connection.close()
self.active_connections.clear()
for connection in targets:
try:
await connection.send_json(message)
except Exception:
user_id = self.socket_users.get(connection)
if user_id is not None:
self.disconnect(connection, user_id)
async def close_all(self) -> None:
for websocket, user_id in list(self.socket_users.items()):
await websocket.close()
self.disconnect(websocket, user_id)
manager = ConnectionManager()

View File

@@ -0,0 +1,562 @@
"""UE scene state built from visualization aggregate output."""
import asyncio
import json
import zlib
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Dict, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
from app.models.bgp_anomaly import BGPAnomaly
from app.models.bgp_incident import BGPIncident
if TYPE_CHECKING:
from app.models.collected_data import CollectedData
DEFAULT_LAYER_ORDER = (
"satellites",
"supercomputers",
"gpu_clusters",
"submarine_cables",
"landing_points",
"bgp_anomalies",
"bgp_incidents",
"bgp_collectors",
"alerts",
)
FULL_RESYNC_REASONS = {"initial_connect", "manual_resync", "profile_changed"}
UE_SCENE_LAYER_CHUNK_ITEM_LIMITS = {
"satellites": 500,
"supercomputers": 200,
"gpu_clusters": 100,
"submarine_cables": 25,
"landing_points": 250,
"bgp_anomalies": 100,
"bgp_incidents": 100,
"bgp_collectors": 100,
"alerts": 100,
}
def _default_display_profile() -> Dict[str, Any]:
return {
"profile_id": "polarized-wall-a",
"stereo_mode": "polarized",
"screen_width_m": 3.0,
"screen_height_m": 2.0,
"target_refresh_hz": 120,
}
def _default_camera_state() -> Dict[str, Any]:
return {
"mode": "auto_cruise",
"path_id": "global_overview",
"fov": 42.0,
}
def _stable_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
def _entity_type_for_layer(layer_name: str) -> str:
return {
"satellites": "satellite",
"supercomputers": "supercomputer",
"gpu_clusters": "gpu_cluster",
"submarine_cables": "submarine_cable",
"landing_points": "landing_point",
"bgp_anomalies": "bgp_anomaly",
"bgp_incidents": "bgp_incident",
"bgp_collectors": "bgp_collector",
"alerts": "alert",
}[layer_name]
def _default_visual_for_layer(layer_name: str) -> Dict[str, Any]:
visuals = {
"satellites": {"style": "satellite_marker", "size": 0.7, "color": "#9BDBFF"},
"supercomputers": {"style": "supercomputer_marker", "size": 1.2, "color": "#FF6B6B"},
"gpu_clusters": {"style": "pulse_marker", "size": 1.0, "color": "#FF8C42"},
"submarine_cables": {"style": "cable_arc", "width": 2.0, "color": "#4ECDC4"},
"landing_points": {"style": "landing_point_marker", "size": 0.8, "color": "#45B7D1"},
"bgp_anomalies": {"style": "anomaly_marker", "size": 1.0, "color": "#FFB703"},
"bgp_incidents": {"style": "incident_marker", "size": 1.2, "color": "#E63946"},
"bgp_collectors": {"style": "collector_marker", "size": 0.9, "color": "#7B9ACC"},
"alerts": {"style": "alert_marker", "size": 1.0, "color": "#FFD166"},
}
return visuals[layer_name]
def _empty_scene_layers() -> Dict[str, Dict[str, Any]]:
return {
layer_name: {"revision": 0, "items": {}}
for layer_name in DEFAULT_LAYER_ORDER
}
def _empty_changes() -> Dict[str, Dict[str, List[Any]]]:
return {
layer_name: {"added": [], "updated": [], "removed": []}
for layer_name in DEFAULT_LAYER_ORDER
}
def _point_geo(coordinates: List[Any]) -> Dict[str, Any]:
lng = coordinates[0] if len(coordinates) > 0 else None
lat = coordinates[1] if len(coordinates) > 1 else None
alt = coordinates[2] if len(coordinates) > 2 else 0.0
return {"lat": lat, "lng": lng, "alt": alt}
def _path_geo(geometry_type: str, coordinates: Any) -> Dict[str, Any]:
if geometry_type == "LineString":
return {
"path": [
{"lat": point[1], "lng": point[0], "alt": point[2] if len(point) > 2 else 0.0}
for point in coordinates
if isinstance(point, list) and len(point) >= 2
]
}
if geometry_type == "MultiLineString":
return {
"segments": [
[
{"lat": point[1], "lng": point[0], "alt": point[2] if len(point) > 2 else 0.0}
for point in line
if isinstance(point, list) and len(point) >= 2
]
for line in coordinates
if isinstance(line, list)
]
}
return {}
def _item_identifier(layer_name: str, feature: Dict[str, Any], index: int) -> str:
properties = feature.get("properties") or {}
feature_id = feature.get("id") or properties.get("id") or properties.get("source_id")
return f"{layer_name}:{feature_id or index}"
def _item_revision(item: Dict[str, Any]) -> int:
return zlib.crc32(_stable_json(item).encode("utf-8")) & 0xFFFFFFFF
def _layer_revision(items: Dict[str, Dict[str, Any]]) -> int:
if not items:
return 0
joined = "|".join(
f"{item_id}:{items[item_id]['revision']}"
for item_id in sorted(items)
)
return zlib.crc32(joined.encode("utf-8")) & 0xFFFFFFFF
def _serialize_feature(layer_name: str, feature: Dict[str, Any], index: int) -> Dict[str, Any]:
properties = dict(feature.get("properties") or {})
geometry = feature.get("geometry") or {}
geometry_type = geometry.get("type", "")
coordinates = geometry.get("coordinates") or []
item_id = _item_identifier(layer_name, feature, index)
geo: Dict[str, Any] = {}
if geometry_type == "Point":
geo = _point_geo(coordinates)
elif geometry_type in {"LineString", "MultiLineString"}:
geo = _path_geo(geometry_type, coordinates)
title = (
properties.get("name")
or properties.get("Name")
or properties.get("title")
or item_id
)
subtitle_parts = [
properties.get("city"),
properties.get("country"),
properties.get("region"),
]
item = {
"id": item_id,
"entity_type": _entity_type_for_layer(layer_name),
"geo": geo,
"visual": {
**_default_visual_for_layer(layer_name),
**({"color": properties["color"]} if properties.get("color") else {}),
},
"metrics": properties,
"labels": {
"title": title,
"subtitle": ", ".join([part for part in subtitle_parts if part]),
},
"status": {
"health": properties.get("status", "normal"),
"alert_level": properties.get("severity", "none"),
},
}
item["revision"] = _item_revision(item)
return item
async def build_visualization_scene_state(db: AsyncSession) -> Dict[str, Any]:
from app.api.v1.visualization import (
_build_landing_point_cable_maps,
_filter_known_records,
_load_current_collected_data_by_sources,
build_anomaly_geography_hints,
build_incident_geography_hints,
convert_bgp_anomalies_to_geojson,
convert_bgp_collectors_to_geojson,
convert_bgp_incidents_to_geojson,
convert_cable_to_geojson,
convert_gpu_cluster_to_geojson,
convert_landing_point_to_geojson,
convert_satellite_to_geojson,
convert_supercomputer_to_geojson,
)
from app.services.bgp_collectors import build_bgp_collector_coverage
records_by_source = await _load_current_collected_data_by_sources(
db,
[
"arcgis_cables",
"arcgis_landing_points",
"arcgis_cable_landing_relation",
"celestrak_tle",
"top500",
"epoch_ai_gpu",
],
)
cables_records = records_by_source.get("arcgis_cables", [])
landing_point_records = records_by_source.get("arcgis_landing_points", [])
relation_records = records_by_source.get("arcgis_cable_landing_relation", [])
satellites_records = _filter_known_records(records_by_source.get("celestrak_tle", []))
supercomputer_records = _filter_known_records(records_by_source.get("top500", []))
gpu_records = _filter_known_records(records_by_source.get("epoch_ai_gpu", []))
bgp_anomalies_result = await db.execute(
select(BGPAnomaly)
.where(BGPAnomaly.status == "active")
.order_by(BGPAnomaly.created_at.desc())
.limit(200)
)
bgp_anomalies = list(bgp_anomalies_result.scalars().all())
bgp_anomaly_geography_hints = await build_anomaly_geography_hints(db, bgp_anomalies)
bgp_incidents_result = await db.execute(
select(BGPIncident)
.where(BGPIncident.status == "active")
.order_by(BGPIncident.created_at.desc())
.limit(100)
)
bgp_incidents = list(bgp_incidents_result.scalars().all())
bgp_incident_geography_hints = await build_incident_geography_hints(db, bgp_incidents)
bgp_collector_coverage = await build_bgp_collector_coverage(
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
)
bgp_coverage_by_collector = {
item["collector"]: item
for item in bgp_collector_coverage
if item.get("collector")
}
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records,
cables_records,
)
aggregate = {
"satellites": convert_satellite_to_geojson(satellites_records),
"supercomputers": convert_supercomputer_to_geojson(supercomputer_records),
"gpu_clusters": convert_gpu_cluster_to_geojson(gpu_records),
"submarine_cables": convert_cable_to_geojson(cables_records),
"landing_points": convert_landing_point_to_geojson(
landing_point_records,
city_to_cable_ids_map,
cable_id_to_name_map,
),
"bgp_anomalies": convert_bgp_anomalies_to_geojson(
bgp_anomalies,
bgp_anomaly_geography_hints,
),
"bgp_incidents": convert_bgp_incidents_to_geojson(
bgp_incidents,
bgp_incident_geography_hints,
),
"bgp_collectors": convert_bgp_collectors_to_geojson(bgp_coverage_by_collector),
"alerts": {"type": "FeatureCollection", "features": []},
}
layers = _empty_scene_layers()
total_records = 0
for layer_name in DEFAULT_LAYER_ORDER:
feature_collection = aggregate.get(layer_name) or {"features": []}
items = {
item["id"]: item
for index, feature in enumerate(feature_collection.get("features", []), start=1)
for item in [_serialize_feature(layer_name, feature, index)]
}
layers[layer_name]["items"] = items
layers[layer_name]["revision"] = _layer_revision(items)
total_records += len(items)
timestamp = to_iso8601_utc(datetime.now(UTC))
state_hash = zlib.crc32(
_stable_json(
{
layer_name: {
item_id: item["revision"]
for item_id, item in layers[layer_name]["items"].items()
}
for layer_name in DEFAULT_LAYER_ORDER
}
).encode("utf-8")
) & 0xFFFFFFFF
return {
"generated_at": timestamp,
"state_hash": state_hash,
"total_records": total_records,
"layers": layers,
}
def _changes_exist(changes: Dict[str, Dict[str, List[Any]]]) -> bool:
return any(
layer_changes["added"] or layer_changes["updated"] or layer_changes["removed"]
for layer_changes in changes.values()
)
def build_incremental_changes(
previous_state: Dict[str, Any],
current_state: Dict[str, Any],
) -> Dict[str, Dict[str, List[Any]]]:
changes = _empty_changes()
for layer_name in DEFAULT_LAYER_ORDER:
previous_items = previous_state["layers"][layer_name]["items"]
current_items = current_state["layers"][layer_name]["items"]
previous_ids = set(previous_items)
current_ids = set(current_items)
for added_id in sorted(current_ids - previous_ids):
changes[layer_name]["added"].append(current_items[added_id])
for removed_id in sorted(previous_ids - current_ids):
changes[layer_name]["removed"].append(removed_id)
for common_id in sorted(previous_ids & current_ids):
if _stable_json(previous_items[common_id]) != _stable_json(current_items[common_id]):
changes[layer_name]["updated"].append(current_items[common_id])
return changes
def _full_payload_from_state(state: Dict[str, Any], sequence: int) -> Dict[str, Any]:
return {
"update_type": "full",
"sequence": sequence,
"cluster_time": state["generated_at"],
"display_profile": _default_display_profile(),
"camera_state": _default_camera_state(),
"payload": {
"meta": {
"generated_at": state["generated_at"],
"total_records": state["total_records"],
"state_hash": state["state_hash"],
},
"layers": {
layer_name: {
"revision": layer_data["revision"],
"items": [
layer_data["items"][item_id]
for item_id in sorted(layer_data["items"])
],
}
for layer_name, layer_data in state["layers"].items()
},
},
}
def _incremental_payload(
*,
current_state: Dict[str, Any],
sequence: int,
base_sequence: int,
changes: Dict[str, Dict[str, List[Any]]],
) -> Dict[str, Any]:
return {
"update_type": "incremental",
"sequence": sequence,
"base_sequence": base_sequence,
"cluster_time": current_state["generated_at"],
"changes": changes,
"meta": {
"generated_at": current_state["generated_at"],
"total_records": current_state["total_records"],
"state_hash": current_state["state_hash"],
},
}
def _noop_incremental_payload(state: Dict[str, Any], sequence: int) -> Dict[str, Any]:
return _incremental_payload(
current_state=state,
sequence=sequence,
base_sequence=sequence,
changes=_empty_changes(),
)
def expand_scene_payload_for_transport(scene_payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Split oversized scene payloads into smaller transport-safe chunks."""
update_type = scene_payload.get("update_type")
if update_type != "full":
return [scene_payload]
payload = scene_payload.get("payload") or {}
layers = payload.get("layers") or {}
if not layers:
return [scene_payload]
chunks: List[Dict[str, Any]] = []
for layer_name in DEFAULT_LAYER_ORDER:
layer_data = layers.get(layer_name) or {}
items = list(layer_data.get("items") or [])
if not items:
continue
chunk_size = UE_SCENE_LAYER_CHUNK_ITEM_LIMITS.get(layer_name, 100)
total_layer_chunks = max(1, (len(items) + chunk_size - 1) // chunk_size)
for chunk_index, offset in enumerate(range(0, len(items), chunk_size), start=1):
chunk_items = items[offset : offset + chunk_size]
chunks.append(
{
**scene_payload,
"payload": {
"meta": {
**(payload.get("meta") or {}),
"chunked": True,
"layer_name": layer_name,
"layer_chunk_index": chunk_index,
"layer_chunk_count": total_layer_chunks,
},
"layers": {
layer_name: {
"revision": layer_data.get("revision", 0),
"items": chunk_items,
}
},
},
}
)
if not chunks:
return [scene_payload]
total_chunks = len(chunks)
for transport_index, chunk in enumerate(chunks, start=1):
chunk_payload = chunk.setdefault("payload", {})
chunk_meta = chunk_payload.setdefault("meta", {})
chunk_meta["transport_chunk_index"] = transport_index
chunk_meta["transport_chunk_count"] = total_chunks
return chunks
class UeSceneStateStore:
"""Maintain cached ue_scene state and a bounded incremental replay history."""
def __init__(self, history_limit: int = 20) -> None:
self.history_limit = history_limit
self.sequence = 0
self.state: Dict[str, Any] | None = None
self.history: List[Dict[str, Any]] = []
self.lock = asyncio.Lock()
async def _refresh_locked(self, db: AsyncSession) -> List[Dict[str, Any]]:
current_state = await build_visualization_scene_state(db)
if self.state is None:
self.sequence = 1
self.state = current_state
return [_full_payload_from_state(self.state, self.sequence)]
if current_state["state_hash"] == self.state["state_hash"]:
self.state = current_state
return []
previous_sequence = self.sequence
previous_state = self.state
self.sequence += 1
self.state = current_state
changes = build_incremental_changes(previous_state, current_state)
incremental = _incremental_payload(
current_state=current_state,
sequence=self.sequence,
base_sequence=previous_sequence,
changes=changes,
)
self.history.append(incremental)
if len(self.history) > self.history_limit:
self.history = self.history[-self.history_limit :]
return [incremental]
async def get_broadcast_payloads(self, db: AsyncSession) -> List[Dict[str, Any]]:
async with self.lock:
payloads = await self._refresh_locked(db)
return [payload for payload in payloads if payload["update_type"] == "full" or _changes_exist(payload.get("changes", {}))]
async def get_sync_payloads(
self,
db: AsyncSession,
*,
last_sequence: int | None,
reason: str | None,
) -> List[Dict[str, Any]]:
async with self.lock:
await self._refresh_locked(db)
if self.state is None:
return []
normalized_reason = (reason or "").strip()
if last_sequence is None or normalized_reason in FULL_RESYNC_REASONS:
return [_full_payload_from_state(self.state, self.sequence)]
if last_sequence == self.sequence:
return [_noop_incremental_payload(self.state, self.sequence)]
if last_sequence > self.sequence:
return [_full_payload_from_state(self.state, self.sequence)]
replay_payloads = [
payload
for payload in self.history
if payload["base_sequence"] >= last_sequence
and payload["sequence"] > last_sequence
]
if replay_payloads:
expected_base = last_sequence
ordered_payloads: List[Dict[str, Any]] = []
for payload in replay_payloads:
if payload["base_sequence"] != expected_base:
return [_full_payload_from_state(self.state, self.sequence)]
ordered_payloads.append(payload)
expected_base = payload["sequence"]
if ordered_payloads and ordered_payloads[-1]["sequence"] == self.sequence:
return ordered_payloads
return [_full_payload_from_state(self.state, self.sequence)]
ue_scene_state_store = UeSceneStateStore()

View File

@@ -0,0 +1,150 @@
import pytest
from app.core.websocket.manager import ConnectionManager
from app.core.websocket.ue_scene import (
UeSceneStateStore,
_item_revision,
build_incremental_changes,
)
class _FakeSession:
pass
class _FakeWebSocket:
def __init__(self):
self.accepted = False
self.messages = []
async def accept(self):
self.accepted = True
async def send_json(self, message):
self.messages.append(message)
async def close(self):
return None
def _scene_item(item_id: str, title: str, *, lat: float = 0.0, lng: float = 0.0):
item = {
"id": item_id,
"entity_type": "gpu_cluster",
"geo": {"lat": lat, "lng": lng, "alt": 0.0},
"visual": {"style": "pulse_marker", "size": 1.0, "color": "#FF8C42"},
"metrics": {"name": title},
"labels": {"title": title, "subtitle": ""},
"status": {"health": "normal", "alert_level": "none"},
}
item["revision"] = _item_revision(item)
return item
def _scene_state(state_hash: int, items_by_layer: dict[str, dict[str, dict]]):
base_layers = {
"satellites": {"revision": 0, "items": {}},
"supercomputers": {"revision": 0, "items": {}},
"gpu_clusters": {"revision": 0, "items": {}},
"submarine_cables": {"revision": 0, "items": {}},
"landing_points": {"revision": 0, "items": {}},
"alerts": {"revision": 0, "items": {}},
}
for layer_name, items in items_by_layer.items():
base_layers[layer_name]["items"] = items
base_layers[layer_name]["revision"] = len(items)
return {
"generated_at": "2026-04-17T00:00:00Z",
"state_hash": state_hash,
"total_records": sum(len(items) for items in items_by_layer.values()),
"layers": base_layers,
}
def test_build_incremental_changes_detects_add_update_remove():
previous_state = _scene_state(
1,
{
"gpu_clusters": {
"gpu:a": _scene_item("gpu:a", "A"),
"gpu:b": _scene_item("gpu:b", "B"),
}
},
)
current_state = _scene_state(
2,
{
"gpu_clusters": {
"gpu:b": _scene_item("gpu:b", "B Updated"),
"gpu:c": _scene_item("gpu:c", "C"),
}
},
)
changes = build_incremental_changes(previous_state, current_state)
assert changes["gpu_clusters"]["added"][0]["id"] == "gpu:c"
assert changes["gpu_clusters"]["updated"][0]["id"] == "gpu:b"
assert changes["gpu_clusters"]["removed"] == ["gpu:a"]
@pytest.mark.asyncio
async def test_state_store_replays_incremental_history(monkeypatch):
state_list = [
_scene_state(1, {"gpu_clusters": {"gpu:a": _scene_item("gpu:a", "A")}}),
_scene_state(
2,
{"gpu_clusters": {"gpu:a": _scene_item("gpu:a", "A"), "gpu:b": _scene_item("gpu:b", "B")}},
),
_scene_state(
3,
{"gpu_clusters": {"gpu:a": _scene_item("gpu:a", "A Updated"), "gpu:b": _scene_item("gpu:b", "B")}},
),
]
state_index = {"value": 0}
async def _fake_build_visualization_scene_state(_db):
current_index = state_index["value"]
if current_index < len(state_list) - 1:
state_index["value"] += 1
return state_list[current_index]
monkeypatch.setattr(
"app.core.websocket.ue_scene.build_visualization_scene_state",
_fake_build_visualization_scene_state,
)
store = UeSceneStateStore(history_limit=5)
session = _FakeSession()
first_payloads = await store.get_broadcast_payloads(session)
second_payloads = await store.get_broadcast_payloads(session)
third_payloads = await store.get_broadcast_payloads(session)
replay_payloads = await store.get_sync_payloads(
session,
last_sequence=1,
reason="sequence_gap",
)
assert first_payloads[0]["update_type"] == "full"
assert second_payloads[0]["update_type"] == "incremental"
assert third_payloads[0]["sequence"] == 3
assert [payload["sequence"] for payload in replay_payloads] == [2, 3]
@pytest.mark.asyncio
async def test_connection_manager_broadcasts_to_subscribed_channel_only():
manager = ConnectionManager()
ws_dashboard = _FakeWebSocket()
ws_scene = _FakeWebSocket()
await manager.connect(ws_dashboard, "user-dashboard")
await manager.connect(ws_scene, "user-scene")
manager.subscribe(ws_dashboard, ["dashboard"])
manager.subscribe(ws_scene, ["ue_scene"])
await manager.broadcast({"type": "data_frame", "channel": "ue_scene"}, channel="ue_scene")
assert ws_dashboard.messages == []
assert ws_scene.messages == [{"type": "data_frame", "channel": "ue_scene"}]

View File

@@ -8,41 +8,6 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.35.0] — 2026-04-22
### ✨ Highlights
- Earth 移动端底部抽屉系统全面上线响应式布局自动切换、Tab 导航、手势上拉/下滑开合、惯性速度判定
- 移动端点击可交互物件海缆、登陆点、卫星、BGP后弹出智能定位悬浮卡片可拖动点击跳转详情
### 🔧 Improvements
- 抽屉把手区域缩小至 36pxcollapsed 时仅露出把手,不遮挡地球操作区)
- 抽屉定期弹跳动画提示用户可上拉5 秒间隔,打开后自动停止
- 通知胶囊位置调整,不再覆盖品牌 logo
- 移动端单指旋转、双指捏合缩放地球触控事件冲突修复pointer-events 级联)
### 🐛 Fixes
- 修复移动端抽屉 shell 因 layout 高度240px+遮挡地球触控区域pointer-events 改为按层级精确控制
- 修复悬浮卡片因 setPointerCapture 在 iOS Safari 抑制合成 click 事件导致无法点击的问题
---
## [0.34.0] — 2026-04-22
### ✨ Highlights
- Earth 搜索面板正式接入支持搜索海缆、登陆点、卫星、BGP 事件与观测站,并可直接聚焦到对应对象
- `planet.sh --allow-lan` 打通 Bun + Vite 的局域网开放链路,启动成功后自动打印推荐访问地址与后端健康检查地址
### 🔧 Improvements
- 前端开发启动链统一改成 Bun 直接执行 Vite 入口,不再依赖 shell 中额外暴露的 Node 路径
- Earth 搜索结果接入登陆点详情卡片与对象聚焦,搜索后可直接进入对应详情流
- `planet.sh` 补充局域网 IPv4 自动识别与推荐地址输出,减少 WSL 局域网调试成本
### 🐛 Fixes
- 修复 `./planet.sh restart --allow-lan` 全量重启时未把 `--allow-lan` 继续传给 `start()`,导致前端退回本机监听的问题
- 修复 WSL + Bun 环境下前端偶发因 Vite 启动链不稳定而无法正确监听 `0.0.0.0:3000` 的问题
---
## [0.33.0] — 2026-04-22
### ✨ Highlights
@@ -100,6 +65,8 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
## [0.31.2] — 2026-04-21
### ✨ Highlights
@@ -136,8 +103,6 @@ This project follows the repository versioning rule:
---
## [0.31.0] — 2026-04-21
### ✨ Features
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件逐帧追踪连接线位置支持外部交互立即中断序列cancel notifier 模式)
- 巡航目标事件点高亮显示hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
@@ -151,6 +116,8 @@ This project follows the repository versioning rule:
---
## [0.29.1] — 2026-04-20
## [0.30.0] — 2026-04-21
### ✨ Features

View File

@@ -16,7 +16,6 @@
当前重点入口:
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)

View File

@@ -0,0 +1,352 @@
# UE nDisplay WebSocket 协议草案
## 目的
本协议定义后端到 UE `nDisplay` 集群运行时之间的场景同步契约。
设计目标:
- 由 UE 主节点单点接入后端
- 使用全量快照重建完整场景
- 使用增量更新降低同步成本
- 支持控制帧和显示配置
- 支持序列号、重放和重同步
## 适用范围
该协议建立在通用 WebSocket 协议之上,参考 [websocket_protocol.md](/D:/work/planet/planet/websocket_protocol.md:1)。
新增的核心内容是 `ue_scene` 频道。
## 连接模型
### 客户端
- UE 主节点连接 `ws://<backend-host>:8000/ws?token=<access_token>`
- UE 渲染节点不直接连接后端场景通道
### 订阅
UE 主节点建议订阅:
- `ue_scene`
- `alerts`
- `dashboard`
## 协议规则
- 每次场景更新都带单调递增的 `sequence`
- 增量更新必须包含 `base_sequence`
- UE 如果发现序列断裂,应先发 `sync_request`
- 服务端优先返回可重放的增量历史;无法重放时返回全量快照
- 后端始终发送地理坐标UE 本地负责转成球体世界坐标
- 控制消息与场景消息分离
## 消息类型
### `connection_established`
服务端 -> 客户端
```json
{
"type": "connection_established",
"timestamp": "2026-04-17T02:30:00.000Z",
"data": {
"connection_id": "conn_primary_node_01",
"server_version": "0.19.0",
"heartbeat_interval": 30,
"supported_channels": [
"ue_scene",
"alerts",
"dashboard",
"datasource_tasks"
]
}
}
```
### `subscribe`
客户端 -> 服务端
```json
{
"type": "subscribe",
"timestamp": "2026-04-17T02:30:01.000Z",
"data": {
"channels": ["ue_scene", "alerts"]
}
}
```
### `sync_request`
客户端 -> 服务端
```json
{
"type": "sync_request",
"timestamp": "2026-04-17T02:30:05.000Z",
"data": {
"channel": "ue_scene",
"reason": "initial_connect",
"last_sequence": null
}
}
```
`reason` 常见值:
- `initial_connect`
- `sequence_gap`
- `manual_resync`
- `profile_changed`
### `data_frame` 全量快照
服务端 -> 客户端
```json
{
"type": "data_frame",
"channel": "ue_scene",
"timestamp": "2026-04-17T02:30:06.000Z",
"data": {
"update_type": "full",
"sequence": 12,
"cluster_time": "2026-04-17T02:30:06.000Z",
"display_profile": {
"profile_id": "polarized-wall-a",
"stereo_mode": "polarized",
"screen_width_m": 3.0,
"screen_height_m": 2.0,
"target_refresh_hz": 120
},
"camera_state": {
"mode": "auto_cruise",
"path_id": "global_overview",
"fov": 42.0
},
"payload": {
"meta": {
"generated_at": "2026-04-17T02:30:06.000Z",
"total_records": 20800,
"state_hash": 123456789
},
"layers": {
"satellites": {
"revision": 111,
"items": []
},
"supercomputers": {
"revision": 222,
"items": []
},
"gpu_clusters": {
"revision": 333,
"items": []
},
"submarine_cables": {
"revision": 444,
"items": []
},
"landing_points": {
"revision": 555,
"items": []
},
"alerts": {
"revision": 0,
"items": []
}
}
}
}
}
```
### `data_frame` 增量更新
服务端 -> 客户端
```json
{
"type": "data_frame",
"channel": "ue_scene",
"timestamp": "2026-04-17T02:31:00.000Z",
"data": {
"update_type": "incremental",
"sequence": 13,
"base_sequence": 12,
"cluster_time": "2026-04-17T02:31:00.000Z",
"meta": {
"generated_at": "2026-04-17T02:31:00.000Z",
"total_records": 20805,
"state_hash": 123456790
},
"changes": {
"gpu_clusters": {
"added": [],
"updated": [],
"removed": []
},
"submarine_cables": {
"added": [],
"updated": [],
"removed": []
}
}
}
}
```
### `control_frame`
客户端 -> 服务端
```json
{
"type": "control_frame",
"timestamp": "2026-04-17T02:32:00.000Z",
"data": {
"target": "ue_scene",
"command": "set_camera_mode",
"arguments": {
"mode": "manual",
"camera_id": "operator_cam_1"
}
}
}
```
常见命令:
- `set_camera_mode`
- `set_camera_path`
- `focus_entity`
- `set_layer_visibility`
- `set_alert_filter`
- `set_display_profile`
- `pause_auto_cruise`
- `resume_auto_cruise`
### `control_acknowledged`
服务端 -> 客户端
```json
{
"type": "control_acknowledged",
"timestamp": "2026-04-17T02:32:00.050Z",
"data": {
"target": "ue_scene",
"command": "set_camera_mode",
"accepted": true
}
}
```
### `heartbeat`
双向
```json
{
"type": "heartbeat",
"timestamp": "2026-04-17T02:32:30.000Z",
"data": {
"action": "ping"
}
}
```
服务端返回:
```json
{
"type": "heartbeat",
"timestamp": "2026-04-17T02:32:30.020Z",
"data": {
"action": "pong"
}
}
```
## 场景实体结构
每个场景实体建议包含:
- `id`
- `entity_type`
- `revision`
- `geo`
- `visual`
- `metrics`
- `labels`
- `status`
示例:
```json
{
"id": "gpu_clusters:123",
"entity_type": "gpu_cluster",
"revision": 987654321,
"geo": {
"lat": 35.9327,
"lng": -84.3107,
"alt": 0.0
},
"visual": {
"style": "pulse_marker",
"size": 1.0,
"color": "#FF8C42"
},
"metrics": {
"name": "Frontier"
},
"labels": {
"title": "Frontier",
"subtitle": "Oak Ridge, US"
},
"status": {
"health": "normal",
"alert_level": "none"
}
}
```
## 同步策略
UE 主节点建议这样处理:
1. 保存本地最后一次成功应用的 `sequence`
2. 收到增量帧时检查 `base_sequence`
3.`base_sequence` 不等于本地序列,则立即发 `sync_request`
4. 如果服务端能重放缺失增量,则按顺序依次应用
5. 如果服务端无法重放,则接收并应用新的全量快照
6. 场景更新应在主节点原子提交,再交给集群同步显示
## 服务端重同步规则
服务端建议行为:
- `initial_connect`:直接返回全量快照
- `manual_resync`:直接返回全量快照
- `profile_changed`:直接返回全量快照
- `sequence_gap`:优先尝试从历史缓存中回放增量
- 如果历史缓存不完整:回退到全量快照
- 如果客户端已是最新序列:返回空变化增量或保持静默
## 集群职责约束
- 只有 UE 主节点接收后端场景同步消息
- 渲染节点不直接重放后端消息
- 服务端表达的是场景意图,而不是每个节点的底层渲染状态
## 推荐的后端扩展
-`ue_scene` 增加更多真实可视化层
- 将告警/BGP 事件并入统一场景层
- 为控制命令增加真正的状态持久化与回执
- 为主节点断线重连补状态恢复策略

View File

@@ -16,14 +16,12 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.35.0`
- `dev` 当前开发分支历史推导到:`0.33.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
| `0.33.0` | feature | `dev` | `pending` | `news_live_streams` 默认接入 iptv-org 频道目录,内置数据源支持直接编辑 override并修复 TV 合并采集源后默认频道消失的问题 |
| `0.32.0` | feature | `dev` | `pending` | Earth 设置新增默认地球大小真源并继续收口卫星焦点层次、toolbar/scrollbar 性能与 HUD 设置面板细节 |
| `0.31.3` | bugfix | `dev` | `pending` | 收口 Earth 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.35.0",
"version": "0.33.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {
@@ -25,8 +25,8 @@
"vite": "^5.0.10"
},
"scripts": {
"dev": "bun ./node_modules/vite/bin/vite.js",
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
"preview": "bun ./node_modules/vite/bin/vite.js preview"
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}

View File

@@ -8,10 +8,6 @@
:root {
--hud-scale: 1;
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--hud-offset: calc(20px * var(--hud-scale));
--hud-radius: calc(22px * var(--hud-scale));
--hud-panel-padding: calc(18px * var(--hud-scale));
@@ -76,10 +72,6 @@ body.earth-page {
height: 100vh;
}
.earth-app canvas {
touch-action: none;
}
.earth-app.dragging {
cursor: grabbing;
}

View File

@@ -133,20 +133,3 @@
right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-stats {
position: fixed;
top: calc(8px + var(--safe-top));
right: 8px;
width: min(180px, calc(100vw - 16px));
z-index: 205;
}
.layout-mode-mobile.earth-search-open .hud-panel-stats,
.layout-mode-mobile.earth-settings-open .hud-panel-stats,
.layout-mode-mobile.earth-media-open .hud-panel-stats,
.layout-mode-mobile.earth-info-open .hud-panel-stats {
opacity: 0;
pointer-events: none;
transform: translateY(-12px);
}

File diff suppressed because it is too large Load Diff

View File

@@ -367,39 +367,3 @@
.earth-app.layout-expanded .earth-left-column {
transform: translate(calc(-100% + var(--hud-offset)), 0);
}
.layout-mode-mobile .earth-left-column {
top: calc(8px + var(--safe-top));
left: 8px;
max-width: min(300px, calc(100vw - 16px));
}
.layout-mode-mobile .hud-panel-info {
position: fixed;
left: 8px !important;
right: 8px !important;
top: auto !important;
bottom: calc(84px + var(--safe-bottom)) !important;
width: auto;
max-width: none;
max-height: min(58vh, 520px);
z-index: 240;
}
.layout-mode-mobile .info-card-header {
cursor: default;
}
.layout-mode-mobile .info-card-content {
max-height: min(46vh, 420px);
}
.layout-mode-mobile .info-card-property {
flex-direction: column;
align-items: stretch;
}
.layout-mode-mobile .info-card-value {
max-width: none;
text-align: left;
}

View File

@@ -317,29 +317,3 @@
/* Layout-expanded: layer panel slides off with .earth-left-column — no
individual rule needed since the whole column translates together. */
.layout-mode-mobile .hud-panel-layers {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(88px + var(--safe-bottom));
width: auto;
max-height: min(60vh, 520px);
margin-top: 0;
z-index: 220;
transform: translateY(calc(100% + 28px));
opacity: 0;
pointer-events: none;
transition: transform 0.24s ease, opacity 0.2s ease;
}
.layout-mode-mobile .hud-panel-layers.is-mobile-open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.layout-mode-mobile .layer-panel-body {
max-height: min(52vh, 460px);
overflow: auto;
}

View File

@@ -138,24 +138,3 @@
bottom: var(--hud-offset);
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
}
.layout-mode-mobile .hud-panel-legend {
position: fixed;
left: 8px;
bottom: calc(84px + var(--safe-bottom));
width: min(172px, calc(100vw - 16px));
z-index: 205;
}
.layout-mode-mobile .legend-list {
max-height: min(20vh, 180px);
}
.layout-mode-mobile.earth-search-open .hud-panel-legend,
.layout-mode-mobile.earth-settings-open .hud-panel-legend,
.layout-mode-mobile.earth-media-open .hud-panel-legend,
.layout-mode-mobile.earth-info-open .hud-panel-legend {
opacity: 0;
pointer-events: none;
transform: translateY(12px);
}

View File

@@ -105,14 +105,6 @@
pointer-events: auto;
}
.earth-toolbar-orb:has(#layer-action) {
display: none;
}
.layout-mode-mobile .earth-toolbar-group {
display: none;
}
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb > * {
pointer-events: none;
}

View File

@@ -285,27 +285,6 @@
cursor: nesw-resize;
}
.layout-mode-mobile .hud-panel-media {
position: fixed;
left: 8px;
right: 8px;
top: calc(8px + var(--safe-top));
bottom: calc(84px + var(--safe-bottom));
width: auto;
max-width: none;
max-height: none;
min-width: 0;
z-index: 230;
}
.layout-mode-mobile .tv-panel-player {
min-height: min(42vh, 360px);
}
.layout-mode-mobile .tv-panel-edge {
display: none;
}
/* 右下角视觉标记 */
.tv-panel-edge[data-edge="br"]::before {
content: "";

View File

@@ -156,22 +156,14 @@
<div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">layers</span>
</span>
<span class="tooltip earth-toolbar-tooltip">图层</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">search</span>
</span>
<span class="tooltip earth-toolbar-tooltip">搜索</span>
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
<span class="icon rotate-icon icon-pause" aria-hidden="true">
<span class="material-symbols-rounded">pause</span>
@@ -182,7 +174,7 @@
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">live_tv</span>
@@ -190,7 +182,7 @@
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">refresh</span>
@@ -198,7 +190,7 @@
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
</button>
</div>
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">zoom_in</span>
@@ -211,7 +203,7 @@
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button>
</div>
</div>
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">settings</span>
@@ -219,7 +211,7 @@
<span class="tooltip earth-toolbar-tooltip">设置</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">my_location</span>
@@ -227,7 +219,7 @@
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
<span class="icon layout-icon layout-expand" aria-hidden="true">
<span class="material-symbols-rounded">open_in_full</span>
@@ -420,295 +412,6 @@
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="tooltip" class="earth-tooltip"></div>
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
<div class="earth-mobile-popup-body">
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>
<div class="earth-mobile-popup-sub" id="earth-mobile-popup-sub"></div>
</div>
<span class="material-symbols-rounded earth-mobile-popup-chevron">chevron_right</span>
</div>
<div id="mobile-drawer-overlay" class="earth-mobile-drawer-overlay" hidden></div>
<div id="mobile-drawer-shell" class="earth-mobile-drawer-shell" aria-hidden="true">
<div class="earth-mobile-drawer-sheet">
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
</div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
</div>
<div class="earth-mobile-drawer-content">
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
<div class="earth-mobile-page earth-mobile-page--layers">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Layer Control</span>
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
</div>
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
<div class="earth-mobile-page earth-mobile-page--search">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Object Search</span>
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星和 BGP 事件</span>
</div>
<div class="earth-mobile-search-shell">
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
<input
id="mobile-earth-search-input"
class="earth-mobile-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="输入名称、地点、NORAD、ASN..."
>
<button id="mobile-earth-search-clear" class="earth-mobile-search-clear" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
</div>
</section>
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
<div class="earth-mobile-page earth-mobile-page--situation">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Situation</span>
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
</div>
<div class="earth-mobile-stats-grid">
<div class="earth-mobile-stat-card">
<span id="mobile-cable-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">海缆系统</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-landing-point-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">登陆点</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-satellite-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">在轨卫星</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num"></span>
<span class="earth-mobile-stat-label">BGP 事件</span>
</div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">图例</div>
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">BGP 状态</div>
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status">暂无观测数据</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
<div class="earth-mobile-page earth-mobile-page--news">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">News</span>
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
</div>
<div class="earth-mobile-news-focus">
<div>
<div class="earth-mobile-news-focus-kicker">当前关注区域</div>
<div id="mobile-news-focus-label" class="earth-mobile-news-focus-label">全球焦点</div>
<div id="mobile-news-focus-coords" class="earth-mobile-news-focus-coords">跟随当前视角自动聚焦</div>
</div>
<div id="mobile-news-source-count" class="earth-mobile-news-source-count">0 路聚合源</div>
</div>
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
<div class="earth-mobile-news-actions">
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
</div>
<a id="mobile-news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="tv">
<div class="earth-mobile-page earth-mobile-page--tv">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">TV</span>
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
id="mobile-tv-iframe"
class="earth-mobile-tv-iframe"
hidden
title="移动端新闻直播"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; fullscreen; picture-in-picture"
></iframe>
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
</div>
<div class="earth-mobile-tv-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
<div class="earth-mobile-page earth-mobile-page--settings">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Settings</span>
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">旋转</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地球默认大小</span>
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.5"
max="5"
step="0.01"
value="1"
data-default-earth-size-slider
aria-label="移动端调整地球默认大小"
>
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">地形</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地形透明度</span>
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.05"
max="1"
step="0.01"
value="0.62"
data-terrain-opacity-slider
aria-label="移动端调整地形透明度"
>
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">系统</div>
<div class="earth-mobile-settings-actions">
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
<div class="earth-mobile-page earth-mobile-page--details">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Details</span>
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
</div>
<div class="earth-mobile-detail-card">
<div class="earth-mobile-detail-header">
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
<div class="earth-mobile-detail-heading">
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
</div>
</div>
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
<div class="earth-mobile-detail-empty">点击海缆、BGP 事件或卫星后在这里查看详情。</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<div id="search-modal" class="earth-search-modal" aria-hidden="true">
<div id="search-backdrop" class="earth-search-backdrop"></div>
<div class="earth-search-sheet hud-panel" role="dialog" aria-modal="true" aria-label="搜索">
<div class="earth-search-header hud-panel__header">
<div class="hud-panel__title-group">
<div class="earth-search-kicker">搜索</div>
</div>
<div class="hud-panel__actions">
<button id="search-close" class="earth-search-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭搜索">
<span class="material-symbols-rounded">close</span>
</button>
</div>
</div>
<div class="earth-search-content hud-panel__body">
<div class="earth-search-input-shell">
<span class="material-symbols-rounded earth-search-input-icon" aria-hidden="true">search</span>
<input
id="earth-search-input"
class="earth-search-input"
type="text"
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="搜索海缆、登陆点、卫星、BGP 事件..."
>
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
</div>
</div>
</div>
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">

View File

@@ -26,19 +26,12 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
import { ensureTVPanelReady } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
import {
closeSearchPanel,
focusSearchInput,
isSearchPanelOpen,
openSearchPanel,
refreshSearchResults,
} from "./search.js";
import {
setButtonTooltip,
setLayerButtonState,
@@ -92,436 +85,6 @@ let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let layerRegistry = new Map();
let layerPanelInitialized = false;
let layoutMode = "desktop";
let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
function detectLayoutMode() {
const width = window.innerWidth;
const height = window.innerHeight;
if (width <= 820) {
return "mobile";
}
if (width <= 1080 || height <= 760) {
return "compact";
}
return "desktop";
}
export function getLayoutMode() {
return layoutMode;
}
export function isMobileLayout() {
return layoutMode === "mobile";
}
function isCompactLayout() {
return layoutMode === "compact";
}
function syncMobileDrawerState() {
const shell = document.getElementById("mobile-drawer-shell");
const overlay = document.getElementById("mobile-drawer-overlay");
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
const tabs = document.querySelectorAll("[data-drawer-card]");
const slots = document.querySelectorAll("[data-drawer-slot]");
const isMobile = isMobileLayout();
document.body.classList.toggle(
"earth-mobile-drawer-open",
isMobile && mobileDrawerOpen,
);
if (shell instanceof HTMLElement) {
shell.setAttribute("aria-hidden", (!isMobile).toString());
}
if (overlay instanceof HTMLElement) {
overlay.hidden = !isMobile;
}
tabs.forEach((tab) => {
if (!(tab instanceof HTMLButtonElement)) return;
const isActive = isMobile && tab.dataset.drawerCard === mobileDrawerCard;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", String(isActive));
});
slots.forEach((slot) => {
if (!(slot instanceof HTMLElement)) return;
slot.classList.toggle(
"is-active",
isMobile && slot.dataset.drawerSlot === mobileDrawerCard,
);
});
const layerPanel = document.getElementById("layer-toggles");
if (layerPanel instanceof HTMLElement) {
layerPanel.classList.toggle("is-mobile-open", false);
}
if (sheet instanceof HTMLElement) {
if (isMobile && !mobileDrawerOpen) {
if (!mobileDrawerHintTimer) {
mobileDrawerHintTimer = setInterval(() => {
if (mobileDrawerOpen) return;
sheet.classList.remove("is-hinting");
void sheet.offsetWidth;
sheet.classList.add("is-hinting");
}, 5000);
}
} else {
clearInterval(mobileDrawerHintTimer);
mobileDrawerHintTimer = null;
sheet.classList.remove("is-hinting");
}
}
}
function setMobileDrawerOpen(panelId, open) {
if (!isMobileLayout()) return;
if (panelId === "layer-toggles") {
mobileDrawerCard = "layers";
}
mobileDrawerOpen = open;
activeMobileDrawerId = open ? panelId : null;
syncMobileDrawerState();
}
function closeTransientMobileOverlays({ except = null } = {}) {
if (isMobileLayout()) {
if (!except) {
mobileDrawerOpen = false;
syncMobileDrawerState();
}
return;
}
if (except !== "search" && isSearchPanelOpen()) {
closeSearchPanel();
}
if (except !== "settings" && isSettingsModalOpen()) {
closeSettingsModal();
}
if (except !== "layer-toggles" && activeMobileDrawerId === "layer-toggles") {
setMobileDrawerOpen("layer-toggles", false);
}
if (except !== "media" && isTVPanelVisible()) {
setTVPanelVisible(false);
}
}
function applyResponsiveLayout() {
layoutMode = detectLayoutMode();
const isMobile = isMobileLayout();
const isCompact = isCompactLayout();
const container = document.getElementById("container");
document.documentElement.classList.toggle("layout-mode-mobile", isMobile);
document.documentElement.classList.toggle("layout-mode-compact", isCompact);
document.body.classList.toggle("layout-mode-mobile", isMobile);
document.body.classList.toggle("layout-mode-compact", isCompact);
container?.classList.toggle("layout-mode-mobile", isMobile);
container?.classList.toggle("layout-mode-compact", isCompact);
if (!isMobile) {
activeMobileDrawerId = null;
mobileDrawerOpen = false;
}
syncMobileDrawerState();
}
function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard } = {}) {
mobileDrawerOpen = Boolean(open);
mobileDrawerCard = card || "layers";
activeMobileDrawerId = mobileDrawerOpen && mobileDrawerCard === "layers"
? "layer-toggles"
: null;
syncMobileDrawerState();
if (!isMobileLayout()) {
return;
}
if (mobileDrawerOpen) {
closeFloatingMenus();
closeSearchPanel();
if (isSettingsModalOpen()) {
closeSettingsModal();
}
}
if (!mobileDrawerOpen) {
return;
}
if (mobileDrawerCard === "search") {
window.setTimeout(() => {
focusSearchInput({ select: true });
refreshSearchResults().catch((error) => {
console.warn("刷新抽屉搜索失败:", error);
});
}, 16);
} else if (mobileDrawerCard === "tv") {
ensureTVPanelReady().catch((error) => {
console.error("初始化媒体抽屉失败:", error);
});
}
}
function getMobileLayerButtons(layerId) {
return Array.from(
document.querySelectorAll(`[data-mobile-layer-button="${layerId}"]`),
).filter((button) => button instanceof HTMLButtonElement);
}
function syncMobileLayerCards() {
const summary = document.getElementById("mobile-layer-summary");
const definitions = getSortedLayerDefinitions();
let activeCount = 0;
definitions.forEach((definition) => {
const visible = Boolean(definition.getVisible?.());
if (visible) {
activeCount += 1;
}
getMobileLayerButtons(definition.id).forEach((button) => {
button.classList.toggle("is-active", visible);
button.setAttribute("aria-checked", visible ? "true" : "false");
const status = button.querySelector("[data-mobile-layer-status]");
if (status) {
status.textContent = visible ? "开启" : "关闭";
}
});
});
if (summary) {
summary.textContent = `已启用 ${activeCount} 个图层`;
}
}
function renderMobileLayerCards() {
const list = document.getElementById("mobile-layer-list");
if (!(list instanceof HTMLElement)) return;
const definitions = getSortedLayerDefinitions();
list.innerHTML = definitions
.map((definition) => `
<button
class="earth-mobile-layer-card"
type="button"
data-mobile-layer-button="${definition.id}"
role="switch"
aria-checked="${definition.getVisible?.() ? "true" : "false"}"
>
<span class="earth-mobile-layer-card-icon material-symbols-rounded">${definition.icon}</span>
<span class="earth-mobile-layer-card-copy">
<span class="earth-mobile-layer-card-title">${definition.label}</span>
<span class="earth-mobile-layer-card-subtitle">${definition.meta || ""}</span>
</span>
<span class="earth-mobile-layer-card-status" data-mobile-layer-status>${definition.getVisible?.() ? "开启" : "关闭"}</span>
</button>
`)
.join("");
list.querySelectorAll("[data-mobile-layer-button]").forEach((button) => {
bindListener(button, "click", async (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const layerId = target.dataset.mobileLayerButton;
const definition = layerId ? getLayerDefinition(layerId) : null;
if (!definition) return;
await definition.setVisible(!definition.getVisible());
syncMobileLayerCards();
});
});
syncMobileLayerCards();
}
function setupMobileDrawerShell() {
const overlay = document.getElementById("mobile-drawer-overlay");
const shell = document.getElementById("mobile-drawer-shell");
const handle = document.getElementById("mobile-drawer-handle");
const tabs = document.querySelectorAll("[data-drawer-card]");
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
bindListener(overlay, "click", () => {
setMobileDrawerState({ open: false });
});
tabs.forEach((tab) => {
bindListener(tab, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const card = target.dataset.drawerCard || "layers";
setMobileDrawerState({ open: true, card });
});
});
if (handle instanceof HTMLElement && sheet instanceof HTMLElement) {
const DRAWER_HANDLE_PX = 36;
const SWIPE_OPEN_VELOCITY = 0.3; // px/ms upward → open regardless of position
const SWIPE_IDLE_VELOCITY = 0.05; // px/ms threshold below which position decides
const SWIPE_CLOSE_VELOCITY = 0.5; // px/ms downward on content → close
let startY = 0;
let startTranslate = 0;
let dragging = false;
let activePointerId = null;
let lastMoveY = 0;
let lastMoveTime = 0;
let velocityY = 0;
sheet.addEventListener("animationend", () => {
sheet.classList.remove("is-hinting");
});
const getClosedOffset = () => {
const safeBottom = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
return Math.max(sheet.offsetHeight - DRAWER_HANDLE_PX - safeBottom, 0);
};
const applyTranslate = (value) => {
sheet.style.transition = "none";
sheet.style.transform = `translateY(${Math.max(0, Math.min(getClosedOffset(), value))}px)`;
};
const stopDragging = (event) => {
if (!dragging) return;
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
const currentTransform = sheet.style.transform;
const match = currentTransform.match(/translateY\(([-\d.]+)px\)/);
const finalOffset = match ? Number.parseFloat(match[1]) : startTranslate;
const closedOffset = getClosedOffset();
const velocity = velocityY;
dragging = false;
activePointerId = null;
velocityY = 0;
lastMoveY = 0;
lastMoveTime = 0;
sheet.style.transition = "";
sheet.style.transform = "";
const shouldOpen =
velocity < -SWIPE_OPEN_VELOCITY
|| (velocity <= SWIPE_IDLE_VELOCITY && finalOffset < closedOffset * 0.5);
if (shouldOpen) {
setMobileDrawerState({ open: true, card: mobileDrawerCard || "layers" });
} else {
setMobileDrawerState({ open: false });
}
};
bindListener(handle, "click", () => {
if (dragging) return;
setMobileDrawerState({ open: !mobileDrawerOpen, card: mobileDrawerCard || "layers" });
});
bindListener(handle, "pointerdown", (event) => {
if (!isMobileLayout()) return;
sheet.classList.remove("is-hinting");
dragging = true;
activePointerId = event.pointerId;
startY = event.clientY;
lastMoveY = event.clientY;
lastMoveTime = performance.now();
velocityY = 0;
startTranslate = mobileDrawerOpen ? 0 : getClosedOffset();
applyTranslate(startTranslate);
handle.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
bindListener(window, "pointermove", (event) => {
if (!dragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
const now = performance.now();
const dt = now - lastMoveTime;
if (dt > 0) {
velocityY = (event.clientY - lastMoveY) / dt;
}
lastMoveY = event.clientY;
lastMoveTime = now;
const deltaY = event.clientY - startY;
applyTranslate(startTranslate + deltaY);
event.preventDefault();
}, { passive: false });
bindListener(window, "pointerup", stopDragging);
bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
}
bindListener(window, "earth:open-details-tab", () => {
if (isMobileLayout()) setMobileDrawerState({ open: true, card: "details" });
});
const content = shell?.querySelector(".earth-mobile-drawer-content");
if (content instanceof HTMLElement) {
let contentStartY = 0;
let contentLastY = 0;
let contentLastTime = 0;
let contentVelocityY = 0;
let contentTracking = false;
bindListener(content, "pointerdown", (event) => {
if (!isMobileLayout() || !mobileDrawerOpen) return;
contentStartY = event.clientY;
contentLastY = event.clientY;
contentLastTime = performance.now();
contentVelocityY = 0;
contentTracking = true;
});
bindListener(content, "pointermove", (event) => {
if (!contentTracking) return;
const now = performance.now();
const dt = now - contentLastTime;
if (dt > 0) {
contentVelocityY = (event.clientY - contentLastY) / dt;
}
contentLastY = event.clientY;
contentLastTime = now;
});
const endContentTrack = () => {
if (!contentTracking) return;
contentTracking = false;
const activeSlot = content.querySelector(".earth-mobile-drawer-slot.is-active");
const atTop = !activeSlot || activeSlot.scrollTop <= 2;
if (atTop && contentVelocityY > SWIPE_CLOSE_VELOCITY) {
setMobileDrawerState({ open: false });
}
contentVelocityY = 0;
};
bindListener(content, "pointerup", endContentTrack);
bindListener(content, "pointercancel", endContentTrack);
}
}
function compareLayerDefinitionsByStartupPriority(left, right) {
const leftPriority = Number.isFinite(left?.startupPriority)
@@ -718,23 +281,20 @@ function persistEarthSettings() {
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
const slider = document.getElementById("default-earth-size-slider");
const value = document.getElementById("default-earth-size-value");
const zoomValue = document.getElementById("zoom-value");
const tooltipText = getZoomResetTooltipText(nextZoom);
sliders.forEach((slider) => {
if (!(slider instanceof HTMLInputElement)) return;
if (slider instanceof HTMLInputElement) {
slider.min = CONFIG.minZoom.toString();
slider.max = CONFIG.maxZoom.toString();
slider.step = DEFAULT_EARTH_ZOOM_STEP.toString();
slider.value = nextZoom.toFixed(2);
});
values.forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = formatZoomPercent(nextZoom);
}
});
}
if (value) {
value.textContent = formatZoomPercent(nextZoom);
}
if (zoomValue instanceof HTMLElement) {
zoomValue.title = tooltipText;
const tooltip = zoomValue.querySelector(".tooltip");
@@ -771,16 +331,14 @@ async function applyEarthSettings(settings) {
});
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity);
document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.value = appliedOpacity.toFixed(2);
}
});
document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]").forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = `${Math.round(appliedOpacity * 100)}%`;
}
});
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySlider.value = appliedOpacity.toFixed(2);
}
if (terrainOpacityValue) {
terrainOpacityValue.textContent = `${Math.round(appliedOpacity * 100)}%`;
}
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true });
@@ -818,7 +376,6 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
if (!enabled) {
applyTerrainUiState(button, false);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("地形已隐藏", "info");
@@ -841,7 +398,6 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
if (toggleToken !== terrainToggleToken) return showTerrain;
applyTerrainUiState(button, true);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形已显示", "success");
@@ -850,7 +406,6 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
} catch (error) {
console.error("加载真实地形失败:", error);
applyTerrainUiState(button, false);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形暂时不可用", "error");
@@ -873,14 +428,11 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
if (!enabled && !silent) {
showStatusMessage("卫星已隐藏", "info");
} else if (enabled) {
["satellite-count", "mobile-satellite-count"].forEach((id) => {
const satelliteCountEl = document.getElementById(id);
if (satelliteCountEl) {
satelliteCountEl.textContent = `${getSatelliteCount()}`;
}
});
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${getSatelliteCount()}`;
}
}
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
@@ -890,7 +442,6 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
loading: false,
tooltip: "显示卫星",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
@@ -910,7 +461,6 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
if (bgpCountEl) {
bgpCountEl.textContent = `${getBGPCount()}`;
}
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
@@ -924,7 +474,6 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
active: enabled,
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
@@ -936,12 +485,10 @@ async function setCablesLayerEnabled(button, enabled, { persist = true, silent =
clearSelectionIfHiding(!enabled);
try {
await setCablesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换线缆显示失败:", error);
syncMobileLayerCards();
if (persist) persistEarthSettings();
return getShowCables();
}
@@ -1113,7 +660,6 @@ function registerLayerDefinition(definition, options = {}) {
if (row && layerPanelInitialized) {
bindLayerButton(row, normalizedDefinition);
}
renderMobileLayerCards();
return normalizedDefinition;
}
@@ -1202,15 +748,6 @@ export function applyImmediateView(targetEarthObj, camera, options = {}) {
}
}
export function setZoomLevel(nextZoom, camera = activeCamera) {
zoomLevel = clampEarthZoomLevel(nextZoom);
if (camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}
return zoomLevel;
}
function cancelSettingsSheetAnimation() {
if (settingsSheetAnimation) {
settingsSheetAnimation.cancel();
@@ -1354,10 +891,6 @@ function closeFloatingMenus() {
}
function openSettingsModal() {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "settings" });
return;
}
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
@@ -1367,9 +900,7 @@ function openSettingsModal() {
settingsModalTimer = null;
}
closeFloatingMenus();
closeTransientMobileOverlays({ except: "settings" });
cancelSettingsSheetAnimation();
document.body.classList.add("earth-settings-open");
modal.classList.remove("is-closing");
modal.classList.add("is-opening");
modal.classList.add("is-open");
@@ -1386,15 +917,11 @@ function openSettingsModal() {
}
function closeSettingsModal() {
if (isMobileLayout()) {
return;
}
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
cancelSettingsSheetAnimation();
document.body.classList.remove("earth-settings-open");
modal.classList.remove("is-open");
modal.classList.add("is-closing");
if (sheet instanceof HTMLElement) {
@@ -1414,10 +941,6 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
if (!visible && activeMobileDrawerId === panelId) {
activeMobileDrawerId = null;
syncMobileDrawerState();
}
syncSettingsToggle(panelId, visible);
if (panelId === "media-panel") {
updateTVToggleUI(visible);
@@ -1450,11 +973,10 @@ function syncAllHudPanelToggles() {
}
function syncDayNightToggle(enabled) {
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
const input = document.getElementById("toggle-daynight");
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
}
function applyDayNightEnabled(enabled, { persist = true } = {}) {
@@ -1506,29 +1028,24 @@ function setupSettingsControls() {
});
});
const terrainOpacitySliders = document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]");
const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]");
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
const defaultEarthSizeSlider = document.getElementById("default-earth-size-slider");
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.value = nextOpacity.toFixed(2);
}
});
terrainOpacityValues.forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = `${safeOpacity}%`;
}
});
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySlider.value = nextOpacity.toFixed(2);
}
if (terrainOpacityValue) {
terrainOpacityValue.textContent = `${safeOpacity}%`;
}
};
syncTerrainOpacityUi(getTerrainOpacity());
syncDefaultEarthZoomUi(defaultEarthZoom);
terrainOpacitySliders.forEach((terrainOpacitySlider) => {
if (!(terrainOpacitySlider instanceof HTMLInputElement)) return;
if (terrainOpacitySlider instanceof HTMLInputElement) {
bindListener(terrainOpacitySlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
@@ -1539,10 +1056,9 @@ function setupSettingsControls() {
syncTerrainOpacityUi(appliedOpacity);
persistEarthSettings();
});
});
}
defaultEarthSizeSliders.forEach((defaultEarthSizeSlider) => {
if (!(defaultEarthSizeSlider instanceof HTMLInputElement)) return;
if (defaultEarthSizeSlider instanceof HTMLInputElement) {
bindListener(defaultEarthSizeSlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
@@ -1552,7 +1068,7 @@ function setupSettingsControls() {
{ persist: true, applyToCurrentView: true },
);
});
});
}
rotationModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
@@ -1564,17 +1080,12 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
const dayNightToggle = document.getElementById("toggle-daynight");
if (dayNightToggle instanceof HTMLInputElement) {
bindListener(dayNightToggle, "change", () => {
applyDayNightEnabled(dayNightToggle.checked);
});
});
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
bindListener(mobileSettingsReset, "click", () => {
resetEarthSettings();
});
}
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
@@ -1592,10 +1103,6 @@ function setupHudPanelControls() {
if (!(target instanceof HTMLElement)) return;
const panelId = target.dataset.closePanel;
if (!panelId) return;
if (isMobileLayout() && panelId === "layer-toggles") {
setMobileDrawerOpen(panelId, false);
return;
}
setHudPanelVisibility(panelId, false);
});
});
@@ -1723,32 +1230,19 @@ function setupDraggableHudPanels() {
if (!handle) return;
let isDragging = false;
let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = (event) => {
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
const stopDragging = () => {
isDragging = false;
activePointerId = null;
panel.classList.remove("is-dragging");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!isDragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
if (isMobileLayout()) return;
event.preventDefault();
const desiredLeft = startLeft + (event.clientX - startPointerX);
const desiredTop = startTop + (event.clientY - startPointerY);
capturePanelAnchor(app, panel, desiredLeft, desiredTop);
@@ -1768,11 +1262,8 @@ function setupDraggableHudPanels() {
};
bindListener(handle, "pointerdown", (event) => {
if (isMobileLayout()) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -1807,9 +1298,9 @@ function setupDraggableHudPanels() {
handle.setPointerCapture?.(event.pointerId);
});
bindListener(window, "pointermove", onMove, { passive: false });
bindListener(window, "pointerup", stopDragging);
bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "pointermove", onMove);
bindListener(handle, "pointerup", stopDragging);
bindListener(handle, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
});
@@ -1902,7 +1393,6 @@ export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
activeCamera = camera;
earthObj = earth;
applyResponsiveLayout();
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
@@ -1910,21 +1400,6 @@ export function setupControls(camera, renderer, scene, earth) {
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
bindListener(window, "resize", () => {
applyResponsiveLayout();
});
bindListener(window, "earth:search-open-change", (event) => {
if (event instanceof CustomEvent && event.detail?.open) {
closeTransientMobileOverlays({ except: "search" });
}
});
bindListener(window, "earth:tv-visibility-change", (event) => {
if (event instanceof CustomEvent && event.detail?.visible) {
closeTransientMobileOverlays({ except: "media" });
}
});
// No longer auto-navigates to details tab on mobile — popup handles the display.
// Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup.
}
function setupZoomControls(camera) {
@@ -2310,7 +1785,6 @@ export function getStartupLoadLayers() {
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
const layerBtn = document.getElementById("layer-action");
const searchBtn = document.getElementById("search-action");
const terrainBtn = getLayerButton("terrain");
const layoutBtn = document.getElementById("layout-toggle");
@@ -2321,30 +1795,9 @@ function setupTerrainControls() {
setupHudPanelControls();
setupDraggableHudPanels();
setupLayerPanel();
setupMobileDrawerShell();
renderMobileLayerCards();
bindListener(layerBtn, "click", (event) => {
event.preventDefault();
event.stopPropagation();
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && mobileDrawerCard === "layers");
setMobileDrawerState({ open: nextOpen, card: "layers" });
return;
}
const panel = document.getElementById("layer-toggles");
const currentlyVisible = !panel?.classList.contains("hud-panel-hidden");
setHudPanelVisibility("layer-toggles", !currentlyVisible);
});
bindListener(searchBtn, "click", () => {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "search" });
return;
}
closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
showStatusMessage("搜索功能待开发", "info");
});
bindListener(terrainBtn, "pointerenter", () => {
@@ -2367,15 +1820,6 @@ function setupTerrainControls() {
const openGroups = [zoomGroup].filter((group) =>
group?.classList.contains("open"),
);
if (
isMobileLayout() &&
mobileDrawerOpen &&
event.target instanceof Element &&
!event.target.closest("#mobile-drawer-shell")
) {
setMobileDrawerState({ open: false });
}
if (openGroups.length === 0) return;
const clickedInsideOpenGroup = openGroups.some((group) =>
@@ -2406,7 +1850,6 @@ function setupTerrainControls() {
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
applyResponsiveLayout();
updateLayoutUI(container);
}
@@ -2414,21 +1857,11 @@ function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
if (isSearchPanelOpen()) {
closeSearchPanel();
return;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
return;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
@@ -2536,12 +1969,9 @@ function setupToolbarHubCluster() {
let collapseTimer = null;
let expandedToolbarBounds = null;
let refreshBoundsFrameId = 0;
let hubPinnedOpen = false;
const layoutToolbarOrbs = () => {
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter(
(orb) => getComputedStyle(orb).display !== "none",
);
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
@@ -2625,7 +2055,6 @@ function setupToolbarHubCluster() {
};
const scheduleCollapse = () => {
if (hubPinnedOpen) return;
if (collapseTimer) clearTimeout(collapseTimer);
collapseTimer = window.setTimeout(() => {
setExpanded(false);
@@ -2664,19 +2093,6 @@ function setupToolbarHubCluster() {
setExpanded(true);
});
bindListener(hub, "click", (event) => {
event.preventDefault();
event.stopPropagation();
cancelCollapse();
if (isMobileLayout()) {
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
return;
}
hubPinnedOpen = !cluster.classList.contains("is-expanded");
setExpanded(hubPinnedOpen);
});
const HOVER_PADDING_PX = 12;
const collectExpandedToolbarBounds = () => {
const rects = [];
@@ -2736,7 +2152,6 @@ function setupToolbarHubCluster() {
};
bindListener(document, "mousemove", (event) => {
if (hubPinnedOpen) return;
if (!cluster.classList.contains("is-expanded")) return;
if (
event.target instanceof Element &&
@@ -2772,24 +2187,6 @@ function setupToolbarHubCluster() {
scheduleExpandedToolbarBoundsRefresh();
}
});
cluster.querySelectorAll(".earth-toolbar-orb > button").forEach((button) => {
if (!(button instanceof HTMLButtonElement) || button === hub) return;
bindListener(button, "click", () => {
if (hubPinnedOpen) {
hubPinnedOpen = false;
setExpanded(false);
}
});
});
bindListener(document, "pointerdown", (event) => {
if (!hubPinnedOpen) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest("#toolbar-cluster")) return;
hubPinnedOpen = false;
setExpanded(false);
});
}
export function teardownControls() {

View File

@@ -4,189 +4,6 @@ import { showStatusMessage } from './ui.js';
let currentType = null;
let cardMounted = false;
// ── Mobile popup ─────────────────────────────────────────────
function getMobilePopupTitle(type, data) {
switch (type) {
case 'cable': return data.name || '海缆';
case 'landing_point': return data.name || '登陆点';
case 'satellite': return data.name || '卫星';
case 'bgp': return data.anomaly_type || 'BGP事件';
case 'bgp_collector': return data.collector || 'BGP观测站';
case 'supercomputer': return data.name || '超算';
case 'gpu_cluster': return data.name || 'GPU集群';
default: return '详情';
}
}
function getMobilePopupSubtitle(type, data) {
switch (type) {
case 'cable': return data.owner || data.status || '海缆';
case 'landing_point': return data.country || '登陆点';
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
case 'bgp': return data.severity || 'BGP路由异常';
case 'bgp_collector': return data.location || 'BGP观测站';
case 'supercomputer': return data.country || '超级计算机';
case 'gpu_cluster': return data.country || 'GPU集群';
default: return '';
}
}
function positionMobilePopup(popup, touchX, touchY) {
const margin = 14;
const drawerClearance = 52;
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const safeBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
) || 0;
const bottomBound = vpH - drawerClearance - safeBottom;
// Measure actual popup size (it's rendered but invisible via opacity)
const popW = popup.offsetWidth || 200;
const popH = popup.offsetHeight || 68;
const gap = 22;
const spaceRight = vpW - touchX;
const spaceLeft = touchX;
const spaceBottom = bottomBound - touchY;
const spaceTop = touchY;
let left, top;
// Horizontal: side with more room
if (spaceRight >= popW + gap + margin) {
left = touchX + gap;
} else if (spaceLeft >= popW + gap + margin) {
left = touchX - gap - popW;
} else {
left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
}
// Vertical: prefer above touch, then below
if (spaceTop >= popH + gap + margin) {
top = touchY - gap - popH;
} else if (spaceBottom >= popH + gap + margin) {
top = touchY + gap;
} else {
top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
}
left = Math.max(margin, Math.min(left, vpW - popW - margin));
top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
}
let popupShowToken = 0;
function showMobilePopup(type, data, x, y) {
// Require coordinates — skip if called without position (e.g. from handleCableClick)
if (x == null || y == null) return;
const popup = document.getElementById('earth-mobile-popup');
const iconEl = document.getElementById('earth-mobile-popup-icon');
const titleEl = document.getElementById('earth-mobile-popup-title');
const subEl = document.getElementById('earth-mobile-popup-sub');
if (!popup || !iconEl || !titleEl || !subEl) return;
const config = CARD_CONFIG[type];
if (!config) return;
iconEl.textContent = config.icon;
titleEl.textContent = getMobilePopupTitle(type, data);
subEl.textContent = getMobilePopupSubtitle(type, data);
// Invalidate any in-flight hide listener
popupShowToken += 1;
const token = popupShowToken;
popup.removeAttribute('hidden');
popup.classList.remove('is-visible');
requestAnimationFrame(() => {
positionMobilePopup(popup, x, y);
requestAnimationFrame(() => {
if (token !== popupShowToken) return; // superseded
popup.classList.add('is-visible');
});
});
}
function hideMobilePopup() {
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
popupShowToken += 1; // invalidate any pending show
popup.classList.remove('is-visible');
popup.addEventListener('transitionend', () => {
if (!popup.classList.contains('is-visible')) {
popup.setAttribute('hidden', '');
}
}, { once: true });
}
let popupClickBound = false;
function ensurePopupClickHandler() {
if (popupClickBound) return;
popupClickBound = true;
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
let dragPointerId = null;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
let dragged = false;
const DRAG_THRESHOLD = 10;
popup.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
e.stopPropagation();
dragPointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
const rect = popup.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
dragged = false;
});
// Track drag at document level so pointer can leave popup bounds
document.addEventListener('pointermove', (e) => {
if (e.pointerId !== dragPointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
dragged = true;
e.stopPropagation();
const margin = 8;
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
});
document.addEventListener('pointerup', (e) => {
if (e.pointerId !== dragPointerId) return;
const wasDragged = dragged;
dragPointerId = null;
dragged = false;
if (!wasDragged) {
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
}
});
document.addEventListener('pointercancel', (e) => {
if (e.pointerId === dragPointerId) {
dragPointerId = null;
dragged = false;
}
});
// Block click from bubbling to document (which would close the drawer)
popup.addEventListener('click', (e) => e.stopPropagation());
}
const CARD_CONFIG = {
cable: {
icon: '🛥️',
@@ -201,18 +18,6 @@ const CARD_CONFIG = {
{ key: 'rfs', label: '投入使用' }
]
},
landing_point: {
icon: '📍',
title: '登陆点详情',
className: 'cable',
fields: [
{ key: 'name', label: '名称' },
{ key: 'country', label: '国家' },
{ key: 'status', label: '状态' },
{ key: 'cable_count', label: '关联海缆数' },
{ key: 'cables', label: '关联海缆' }
]
},
satellite: {
icon: '🛰️',
title: '卫星详情',
@@ -309,31 +114,19 @@ function setupInfoCardDrag(panel) {
if (!handle) return;
let isDragging = false;
let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = (event) => {
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
const stopDragging = () => {
isDragging = false;
activePointerId = null;
panel.classList.remove('is-dragging');
document.body.style.userSelect = '';
};
const onMove = (event) => {
if (!isDragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
event.preventDefault();
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextLeft = Math.min(
@@ -350,9 +143,7 @@ function setupInfoCardDrag(panel) {
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -368,9 +159,9 @@ function setupInfoCardDrag(panel) {
handle.setPointerCapture?.(event.pointerId);
});
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', stopDragging);
window.addEventListener('pointercancel', stopDragging);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', stopDragging);
handle.addEventListener('pointercancel', stopDragging);
handle.addEventListener('lostpointercapture', stopDragging);
}
@@ -449,13 +240,6 @@ function mountCard() {
function positionPanel(panel, x, y, options = {}) {
if (!panel) return;
if (document.body.classList.contains('layout-mode-mobile')) {
panel.style.left = '8px';
panel.style.right = '8px';
panel.style.top = 'auto';
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
return;
}
const margin = 12;
const offset = 14;
const vpW = window.innerWidth;
@@ -500,19 +284,11 @@ function showPanel(x, y, options = {}) {
if (!panel) return;
if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible');
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
function hidePanel() {
const panel = getPanel();
if (panel) panel.classList.remove('is-visible');
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
}
// No-op: event binding now happens lazily in mountCard()
@@ -532,51 +308,6 @@ export function showInfoCard(type, data, options = {}) {
return;
}
if (document.body.classList.contains('layout-mode-mobile')) {
currentType = type;
// Fill drawer details slot (accessible when user taps popup → opens details tab)
const icon = document.getElementById('mobile-info-card-icon');
const title = document.getElementById('mobile-info-card-title');
const typeLabel = document.getElementById('mobile-info-card-type');
const content = document.getElementById('mobile-info-card-content');
if (icon) icon.textContent = config.icon;
if (title) title.textContent = config.title;
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
if (content) {
let html = '';
for (const field of config.fields) {
let value = data[field.key];
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') value = value + ' ' + field.unit;
html += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
}
// Show the floating mini popup near the touch point (requires coordinates)
if (options.x != null && options.y != null) {
ensurePopupClickHandler();
showMobilePopup(type, data, options.x, options.y);
document.body.classList.add('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
);
}
return;
}
mountCard();
currentType = type;
@@ -616,15 +347,6 @@ export function showInfoCard(type, data, options = {}) {
}
export function hideInfoCard() {
if (document.body.classList.contains('layout-mode-mobile')) {
hideMobilePopup();
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
currentType = null;
return;
}
hidePanel();
currentType = null;
}

View File

@@ -62,18 +62,17 @@ export function setLegendItems(mode, items) {
}
function syncCurrentLabel(mode) {
const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
[document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
.forEach((labelEl) => {
if (labelEl) {
labelEl.textContent = nextLabel;
}
});
const labelEl = document.getElementById("legend-current-label");
if (!labelEl) return;
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
}
function renderLegend(mode) {
const listEl = document.querySelector("#legend .legend-list");
if (!listEl) return;
const items = legendItemsByMode[mode] || [];
const html = items
listEl.innerHTML = items
.map(
(item) => `
<div class="legend-item">
@@ -82,14 +81,4 @@ function renderLegend(mode) {
</div>`,
)
.join("");
const desktopList = document.querySelector("#legend .legend-list");
if (desktopList) {
desktopList.innerHTML = html;
}
const mobileList = document.getElementById("mobile-situation-legend-list");
if (mobileList) {
mobileList.innerHTML = html;
}
}

View File

@@ -72,7 +72,6 @@ import {
toggleSatellites,
getShowSatellites,
getSatelliteLegendItems,
getSatelliteData,
setSelectedSatelliteLegend,
clearSelectedSatelliteLegend,
getSatelliteCount,
@@ -137,7 +136,6 @@ import {
applyImmediateView,
focusEarthView,
getZoomLevel,
setZoomLevel,
teardownControls,
} from "./controls.js";
import {
@@ -164,7 +162,6 @@ import {
import { mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js";
export let scene;
export let camera;
@@ -207,11 +204,6 @@ let cruisePollTimerId = null;
let cruiseConnector = null;
let cruiseBGPAdapter = null;
let cruiseSequencer = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
let pinchGesture = null;
let pointerDragDistance = 0;
let suppressNextClick = false;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
@@ -232,7 +224,6 @@ const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8;
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
@@ -246,8 +237,6 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#mobile-drawer-shell",
"#mobile-drawer-shell *",
];
function bindListener(target, eventName, handler, options) {
@@ -285,13 +274,6 @@ function getDragRotationFactor() {
return CONFIG.dragRotationFactorBase * scale;
}
function getTouchDistance(firstPoint, secondPoint) {
return Math.hypot(
secondPoint.clientX - firstPoint.clientX,
secondPoint.clientY - firstPoint.clientY,
);
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
@@ -627,391 +609,6 @@ function getBGPCollectorBriefHtml(marker) {
return `<strong>${name}</strong><br>${count} 条事件`;
}
function getSearchCardCoords() {
return {
x: Math.round(window.innerWidth * SEARCH_CARD_X_RATIO),
y: Math.round(window.innerHeight * SEARCH_CARD_Y_RATIO),
absolute: true,
};
}
function normalizeSearchString(...parts) {
return parts
.flat()
.filter((part) => part !== undefined && part !== null && part !== false)
.map((part) => String(part).trim())
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function computeSearchScore(query, ...parts) {
const text = normalizeSearchString(...parts);
if (!text) return -1;
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return -1;
if (text === normalizedQuery) return 240;
if (text.startsWith(normalizedQuery)) return 180;
if (text.includes(normalizedQuery)) return 120;
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return -1;
let score = 0;
for (const token of tokens) {
if (text.startsWith(token)) {
score += 60;
continue;
}
if (text.includes(token)) {
score += 36;
continue;
}
return -1;
}
return score;
}
function getCableFocusCoords(cable) {
if (!cable?.userData?.localCenter) return null;
return vector3ToLatLon(cable.userData.localCenter);
}
function getLandingPointFocusCoords(point) {
if (!point?.position) return null;
return vector3ToLatLon(point.position);
}
function getSatelliteFocusCoords(index) {
const positions = getSatellitePositions();
const vector = positions?.[index]?.current;
if (!vector) return null;
return vector3ToLatLon(vector);
}
function getBGPFocusCoords(marker) {
const lat = marker?.userData?.displayLatitude ?? marker?.userData?.latitude;
const lon = marker?.userData?.displayLongitude ?? marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
if (!coords || !camera) return;
await focusEarthView(camera, {
lat: coords.lat,
lon: coords.lon,
zoom,
duration: 950,
suppressStatus: true,
});
}
function showLandingPointInfo(point, coords) {
const cableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
setLegendMode("cables");
showInfoCard(
"landing_point",
{
name: point?.userData?.name || "-",
country: point?.userData?.country || "-",
status: point?.userData?.status || "-",
cable_count: cableNames.length,
cables: cableNames.length > 0 ? cableNames.join(" / ") : "-",
},
coords,
);
}
async function focusSearchCable(cable) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getCableFocusCoords(cable);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.14));
}
const cableId = cable?.userData?.cableId;
if (cableId !== undefined) {
setCableState(cableId, CABLE_STATE.LOCKED);
}
lockedObject = cable;
lockedObjectType = "cable";
handleCableClick(cable);
showCableInfo(cable, getSearchCardCoords());
}
async function focusSearchLandingPoint(point) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getLandingPointFocusCoords(point);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.22));
}
const relatedCableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
clearAllCableStates();
getCableLines().forEach((cable) => {
if (relatedCableNames.includes(cable.userData?.name)) {
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
}
});
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
showLandingPointInfo(point, getSearchCardCoords());
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
}
async function focusSearchSatellite(index) {
await setSatellitesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const sat = selectSatellite(index);
if (!sat?.properties) return;
const coords = getSatelliteFocusCoords(index);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.18));
}
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = index;
setLockedSatelliteIndex(index);
showPredictedOrbit(sat);
const satPositions = getSatellitePositions();
if (satPositions?.[index]) {
setSatelliteRingState(index, "locked", satPositions[index].current);
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
}
async function focusSearchBGPMarker(marker) {
if (!getShowBGP()) {
toggleBGP(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getBGPFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
}
const earth = getEarth();
if (marker?.userData?.type === "bgp") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp";
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
showBGPInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
return;
}
if (marker?.userData?.type === "bgp_collector") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp_collector";
showBGPCollectorCoverageOverlay(marker, earth);
showBGPCollectorInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
}
}
function resolveEarthSearchResults(query) {
const results = [];
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return results;
getCableLines().forEach((cable) => {
const score = computeSearchScore(
normalizedQuery,
cable.userData?.name,
cable.userData?.owner,
cable.userData?.status,
cable.userData?.length,
"海缆 电缆 cable",
);
if (score < 0) return;
results.push({
id: `cable:${cable.userData?.cableId || cable.uuid}`,
kind: "cable",
icon: "cable",
typeLabel: "海缆",
title: cable.userData?.name || "未知海缆",
subtitle: [cable.userData?.owner, cable.userData?.status].filter(Boolean).join(" · ") || "海底光缆系统",
score,
entity: cable,
});
});
getLandingPoints().forEach((point, index) => {
const score = computeSearchScore(
normalizedQuery,
point.userData?.name,
point.userData?.country,
point.userData?.status,
point.userData?.cableNames,
"登陆点 landing point",
);
if (score < 0) return;
results.push({
id: `landing:${point.uuid || index}`,
kind: "landing_point",
icon: "location_on",
typeLabel: "登陆点",
title: point.userData?.name || "未知登陆点",
subtitle:
[point.userData?.country, Array.isArray(point.userData?.cableNames) ? `${point.userData.cableNames.length} 条海缆` : ""]
.filter(Boolean)
.join(" · ") || "海缆登陆点",
score,
entity: point,
});
});
getSatelliteData().forEach((satellite, index) => {
const props = satellite?.properties;
const score = computeSearchScore(
normalizedQuery,
props?.name,
props?.norad_cat_id,
props?.inclination,
"卫星 satellite norad",
);
if (score < 0) return;
results.push({
id: `sat:${props?.norad_cat_id || index}`,
kind: "satellite",
icon: "satellite_alt",
typeLabel: "卫星",
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
score,
entity: { index },
});
});
getBGPAnomalyMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.prefix,
marker.userData?.city,
marker.userData?.country,
marker.userData?.anomaly_type,
marker.userData?.incident_type,
marker.userData?.origin_asn,
marker.userData?.new_origin_asn,
"bgp 事件 anomaly prefix asn",
);
if (score < 0) return;
results.push({
id: `bgp:${marker.userData?.id || marker.uuid}`,
kind: "bgp",
icon: "hub",
typeLabel: "BGP事件",
title:
formatBGPAnomalyTypeLabel(
marker.userData?.incident_type || marker.userData?.anomaly_type,
) || "BGP 事件",
subtitle:
[
marker.userData?.collector,
marker.userData?.prefix,
formatBGPLocation(marker.userData?.city, marker.userData?.country),
]
.filter(Boolean)
.join(" · ") || "BGP 异常事件",
score,
entity: marker,
});
});
getBGPCollectorMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.city,
marker.userData?.country,
marker.userData?.status,
"bgp collector 观测站",
);
if (score < 0) return;
results.push({
id: `collector:${marker.userData?.collector || marker.uuid}`,
kind: "bgp_collector",
icon: "travel_explore",
typeLabel: "观测站",
title: marker.userData?.collector || "未知观测站",
subtitle:
[
formatBGPLocation(marker.userData?.city, marker.userData?.country),
formatBGPCollectorStatus(marker.userData?.status || "online"),
]
.filter(Boolean)
.join(" · ") || "BGP 观测站",
score,
entity: marker,
});
});
return results
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.title.localeCompare(right.title, "zh-CN");
})
.slice(0, SEARCH_RESULT_LIMIT);
}
async function handleSearchSelection(result) {
if (!result) return;
if (result.kind === "cable") {
await focusSearchCable(result.entity);
return;
}
if (result.kind === "landing_point") {
await focusSearchLandingPoint(result.entity);
return;
}
if (result.kind === "satellite") {
await focusSearchSatellite(result.entity.index);
return;
}
if (result.kind === "bgp" || result.kind === "bgp_collector") {
await focusSearchBGPMarker(result.entity);
}
}
function getBGPStatusText(bgpResult) {
if (bgpResult.totalCount > 0) {
return `${bgpResult.totalCount} 起活跃事件`;
@@ -1042,12 +639,10 @@ function updateBGPHud(bgpResult) {
bgpCollectorEl.textContent = `${bgpResult.collectorCount}`;
}
["bgp-status-summary", "mobile-bgp-status-summary"].forEach((id) => {
const bgpStatusEl = document.getElementById(id);
if (bgpStatusEl) {
bgpStatusEl.textContent = getBGPStatusText(bgpResult);
}
});
const bgpStatusEl = document.getElementById("bgp-status-summary");
if (bgpStatusEl) {
bgpStatusEl.textContent = getBGPStatusText(bgpResult);
}
}
function ensureCruiseConnector() {
@@ -1410,12 +1005,10 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
});
}
["satellite-count", "mobile-satellite-count"].forEach((id) => {
const satelliteCountEl = document.getElementById(id);
if (satelliteCountEl) {
satelliteCountEl.textContent = `${satelliteCount}`;
}
});
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${satelliteCount}`;
}
}
function updateCableToggleUi(enabled) {
@@ -1595,10 +1188,6 @@ export function init() {
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
initTVPanel();
initNewsPanel();
initSearchPanel({
resolveResults: resolveEarthSearchResults,
onSelectResult: handleSearchSelection,
});
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
@@ -1885,9 +1474,6 @@ async function loadData() {
}
const POSITION_UPDATE_FORCE_DELTA = 250;
const SEARCH_RESULT_LIMIT = 28;
const SEARCH_CARD_X_RATIO = 0.68;
const SEARCH_CARD_Y_RATIO = 0.18;
export async function reloadData() {
await loadData();
@@ -1994,9 +1580,9 @@ export async function setSatellitesEnabled(
function setupEventListeners() {
const handleResize = () => onWindowResize();
const handlePointerMove = (event) => onPointerMove(event);
const handlePointerDown = (event) => onPointerDown(event);
const handlePointerUp = (event) => onPointerUp(event);
const handleMouseMove = (event) => onMouseMove(event);
const handleMouseDown = (event) => onMouseDown(event);
const handleMouseUp = () => onMouseUp();
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
@@ -2006,15 +1592,11 @@ function setupEventListeners() {
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
bindListener(window, "pointermove", handlePointerMove);
bindListener(window, "pointerup", handlePointerUp);
bindListener(window, "pointercancel", handlePointerUp);
bindListener(window, "mousemove", handleMouseMove);
bindListener(renderer.domElement, "mousedown", handleMouseDown);
bindListener(window, "mouseup", handleMouseUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
if (renderer?.domElement) {
renderer.domElement.style.touchAction = "none";
}
}
function updateHudScale() {
@@ -2102,9 +1684,6 @@ function onMouseMove(event) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
}
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
isLongDrag = true;
}
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
@@ -2281,100 +1860,6 @@ function onMouseUp() {
document.getElementById("container")?.classList.remove("dragging");
}
function onPointerDown(event) {
if (isEventOnHud(event)) return;
if (event.pointerType !== "touch" && event.button !== 0) return;
if (event.pointerType === "touch") {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
renderer?.domElement?.setPointerCapture?.(event.pointerId);
if (activeTouchPoints.size === 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
pinchGesture = {
distance: getTouchDistance(firstPoint, secondPoint),
startZoom: getZoomLevel(),
};
activeDragPointerId = null;
onMouseUp();
return;
}
}
activeDragPointerId = event.pointerId;
pointerDragDistance = 0;
suppressNextClick = false;
onMouseDown(event);
}
function onPointerMove(event) {
if (event.pointerType === "touch") {
if (activeTouchPoints.has(event.pointerId)) {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
}
if (pinchGesture && activeTouchPoints.size >= 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
const nextDistance = getTouchDistance(firstPoint, secondPoint);
if (pinchGesture.distance > 0) {
const scale = nextDistance / pinchGesture.distance;
setZoomLevel(pinchGesture.startZoom * scale, camera);
suppressNextClick = true;
hideTooltip();
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
onMouseMove(event);
return;
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
}
onMouseMove(event);
}
function onPointerUp(event) {
if (event.pointerType === "touch") {
activeTouchPoints.delete(event.pointerId);
if (activeTouchPoints.size < 2) {
pinchGesture = null;
}
}
if (activeDragPointerId === event.pointerId) {
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
suppressNextClick = true;
isLongDrag = true;
}
activeDragPointerId = null;
pointerDragDistance = 0;
onMouseUp();
}
}
function onMouseLeave() {
hideTooltip();
}
@@ -2383,10 +1868,6 @@ function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
if (suppressNextClick) {
suppressNextClick = false;
return;
}
updatePointerFromEvent(event);

View File

@@ -18,18 +18,17 @@ let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
refreshBtn: document.getElementById("news-refresh"),
openBtn: document.getElementById("news-open-external"),
status: document.getElementById("news-board-status"),
focusLabel: document.getElementById("news-focus-label"),
focusCoords: document.getElementById("news-focus-coords"),
sourceCount: document.getElementById("news-source-count"),
regionChip: document.getElementById("news-region-chip"),
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
board: document.getElementById("news-board-list"),
empty: document.getElementById("news-board-empty"),
feedAnchor: document.getElementById("news-feed-anchor"),
};
}
@@ -82,33 +81,18 @@ function renderPayload(nextPayload) {
openBtn,
feedAnchor,
} = getElements();
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
return;
}
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
return;
}
} else {
return;
}
}
if (regionChip) {
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
}
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page does not show the compact chip row.
} else if (!regionChip) {
return;
}
focusLabel.textContent = focus.label || "全球焦点";
regionChip.textContent = focus.region || "global";
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
@@ -291,6 +275,8 @@ export function initNewsPanel() {
if (initialized) return;
initialized = true;
const { refreshBtn, openBtn } = getElements();
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
@@ -301,22 +287,16 @@ export function initNewsPanel() {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon);
showStatusMessage("态势新闻已刷新", "info");
} catch {
showStatusMessage("态势新闻刷新失败", "error");
}
});
["news-open-external", "mobile-news-open-external"].forEach((id) => {
const openBtn = document.getElementById(id);
openBtn?.addEventListener("click", openCurrentSourceHomepage);
});
openBtn?.addEventListener("click", openCurrentSourceHomepage);
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}

View File

@@ -1,282 +0,0 @@
let initialized = false;
let resolveResultsFn = null;
let onSelectResultFn = null;
let currentResults = [];
let activeIndex = -1;
let searchTimerId = null;
let isOpen = false;
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
modal: document.getElementById("search-modal"),
backdrop: document.getElementById("search-backdrop"),
input: document.getElementById(
isMobile ? "mobile-earth-search-input" : "earth-search-input",
),
clear: document.getElementById(
isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
),
meta: document.getElementById(
isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
),
results: document.getElementById(
isMobile ? "mobile-earth-search-results" : "earth-search-results",
),
empty: document.getElementById(
isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
),
close: document.getElementById("search-close"),
};
}
function setMeta(text) {
const { meta } = getElements();
if (meta) meta.textContent = text;
}
function updateEmptyState(query) {
const { empty } = getElements();
if (!empty) return;
if (!query) {
empty.textContent = "支持搜索海缆、登陆点、卫星、BGP 事件与观测站。";
return;
}
empty.textContent = "未找到匹配对象可尝试名称、地点、NORAD、ASN、前缀等关键词。";
}
function renderResults(query) {
const { results, empty } = getElements();
if (!results || !empty) return;
results.innerHTML = "";
const hasResults = currentResults.length > 0;
empty.hidden = hasResults;
updateEmptyState(query);
if (!hasResults) return;
currentResults.forEach((result, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "earth-search-result";
button.setAttribute("role", "option");
button.dataset.index = String(index);
button.innerHTML = `
<span class="earth-search-result-icon" aria-hidden="true">
<span class="material-symbols-rounded">${escapeHtml(result.icon || "search")}</span>
</span>
<span class="earth-search-result-copy">
<span class="earth-search-result-title">${escapeHtml(result.title)}</span>
<span class="earth-search-result-subtitle">${escapeHtml(result.subtitle || "")}</span>
</span>
<span class="earth-search-result-type">${escapeHtml(result.typeLabel || "")}</span>
`;
button.addEventListener("click", async () => {
await selectResult(index);
});
results.appendChild(button);
});
syncActiveResult();
}
function syncActiveResult() {
const { results } = getElements();
if (!results) return;
Array.from(results.children).forEach((node, index) => {
node.classList.toggle("is-active", index === activeIndex);
});
}
function moveActiveResult(delta) {
if (currentResults.length === 0) return;
activeIndex =
((activeIndex < 0 ? 0 : activeIndex) + delta + currentResults.length) %
currentResults.length;
syncActiveResult();
const { results } = getElements();
const activeNode = results?.children?.[activeIndex];
activeNode?.scrollIntoView({ block: "nearest" });
}
async function selectResult(index) {
const result = currentResults[index];
if (!result || typeof onSelectResultFn !== "function") return;
closeSearchPanel();
try {
await onSelectResultFn(result);
} catch (error) {
console.error("Search selection failed:", error);
}
}
async function runSearch() {
const { input, clear } = getElements();
if (!input) return;
const query = input.value.trim();
if (clear) {
clear.hidden = query.length === 0;
}
if (!query) {
currentResults = [];
activeIndex = -1;
setMeta("输入关键词以搜索当前地球对象");
renderResults("");
return;
}
setMeta("正在检索…");
try {
const nextResults = await resolveResultsFn?.(query);
currentResults = Array.isArray(nextResults) ? nextResults : [];
activeIndex = currentResults.length > 0 ? 0 : -1;
setMeta(`找到 ${currentResults.length} 个结果`);
renderResults(query);
} catch (error) {
console.error("Search failed:", error);
currentResults = [];
activeIndex = -1;
setMeta("搜索失败");
renderResults(query);
}
}
function scheduleSearch() {
if (searchTimerId) {
clearTimeout(searchTimerId);
}
searchTimerId = window.setTimeout(() => {
searchTimerId = null;
runSearch();
}, 120);
}
function handleKeydown(event) {
const { modal, input } = getElements();
const isMobile = document.body.classList.contains("layout-mode-mobile");
if (!isMobile && !modal?.classList.contains("is-open")) return;
if (event.key === "Escape") {
event.preventDefault();
closeSearchPanel();
return;
}
if (event.target !== input) return;
if (event.key === "ArrowDown") {
event.preventDefault();
moveActiveResult(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveActiveResult(-1);
} else if (event.key === "Enter" && activeIndex >= 0) {
event.preventDefault();
selectResult(activeIndex).catch((error) => {
console.warn("Selecting search result failed:", error);
});
}
}
export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
resolveResultsFn = resolveResults;
onSelectResultFn = onSelectResult;
if (initialized) return;
initialized = true;
const inputs = ["earth-search-input", "mobile-earth-search-input"]
.map((id) => document.getElementById(id))
.filter((node) => node instanceof HTMLInputElement);
const clears = ["earth-search-clear", "mobile-earth-search-clear"]
.map((id) => document.getElementById(id))
.filter((node) => node instanceof HTMLButtonElement);
const close = document.getElementById("search-close");
const backdrop = document.getElementById("search-backdrop");
inputs.forEach((input) => {
input.addEventListener("input", scheduleSearch);
input.addEventListener("keydown", handleKeydown);
});
clears.forEach((clear) => {
clear.addEventListener("click", () => {
const { input } = getElements();
if (!input) return;
input.value = "";
input.focus();
runSearch().catch((error) => {
console.warn("Clearing search failed:", error);
});
});
});
close?.addEventListener("click", () => {
closeSearchPanel();
});
backdrop?.addEventListener("click", () => {
closeSearchPanel();
});
document.addEventListener("keydown", handleKeydown);
}
export function openSearchPanel() {
const { modal, input } = getElements();
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
if (isOpen) return;
isOpen = true;
document.body.classList.add("earth-search-open");
modal?.classList.add("is-open");
modal?.setAttribute("aria-hidden", "false");
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: true } }),
);
window.setTimeout(() => {
input?.focus();
input?.select();
runSearch().catch((error) => {
console.warn("Running search failed:", error);
});
}, 16);
}
export function focusSearchInput({ select = false } = {}) {
const { input } = getElements();
if (!(input instanceof HTMLInputElement)) return;
input.focus();
if (select) {
input.select();
}
}
export function refreshSearchResults() {
return runSearch();
}
export function closeSearchPanel() {
const { modal } = getElements();
if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
if (!isOpen) return;
isOpen = false;
document.body.classList.remove("earth-search-open");
modal?.classList.remove("is-open");
modal?.setAttribute("aria-hidden", "true");
window.dispatchEvent(
new CustomEvent("earth:search-open-change", { detail: { open: false } }),
);
}
export function isSearchPanelOpen() {
return isOpen;
}

View File

@@ -56,22 +56,21 @@ const HLS_RETRY_CONFIG = {
};
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
select: document.getElementById("tv-source-select"),
title: document.getElementById("tv-source-title"),
meta: document.getElementById("tv-source-meta"),
catalog: document.getElementById("tv-source-catalog"),
status: document.getElementById("tv-source-status"),
notes: document.getElementById("tv-source-notes"),
iframe: document.getElementById("tv-iframe"),
video: document.getElementById("tv-video"),
empty: document.getElementById("tv-empty-state"),
refreshBtn: document.getElementById("tv-refresh"),
openBtn: document.getElementById("tv-open-external"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
@@ -352,7 +351,6 @@ function setPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
mediaPanel?.setVisible(visible);
document.body.classList.toggle("earth-media-open", visible);
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
@@ -1073,16 +1071,12 @@ export function initTVPanel() {
showStatusMessage("已切换到态势新闻", "info");
});
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((selectEl) => {
selectEl?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
});
select?.addEventListener("change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) return;
currentSourceId = target.value;
renderSource(findSourceById(currentSourceId));
});
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
@@ -1090,13 +1084,9 @@ export function initTVPanel() {
setMetaCollapsed(isNowCollapsed);
});
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((refreshEl) => {
refreshEl?.addEventListener("click", () => {
refreshTVPanel();
});
});
refreshBtn?.addEventListener("click", () => {
refreshTVPanel();
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
@@ -1105,32 +1095,24 @@ export function initTVPanel() {
setActiveTab("news");
});
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((iframeEl) => {
iframeEl?.addEventListener("load", () => {
if (iframeEl.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
});
iframe?.addEventListener("load", () => {
if (iframe.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
});
[video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((videoEl) => {
videoEl?.addEventListener("loadedmetadata", () => {
if (videoEl.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
video?.addEventListener("loadedmetadata", () => {
if (video.hidden) return;
clearSourceFailed(currentSourceId);
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
});
videoEl?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
});
video?.addEventListener("error", () => {
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}
});
setupResizeHandle();
syncPanelActiveTab("live");

View File

@@ -19,15 +19,6 @@ function getElement(id) {
return document.getElementById(id);
}
function setTextTargets(ids, value) {
ids.forEach((id) => {
const element = getElement(id);
if (element) {
element.textContent = value;
}
});
}
function setElementDisplay(element, visible, displayValue = "block") {
if (!element) return;
element.style.display = visible ? displayValue : "none";
@@ -180,13 +171,27 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
setTextTargets(["cable-count", "mobile-cable-count"], String(stats.cableCount || 0));
setTextTargets(["landing-point-count", "mobile-landing-point-count"], String(stats.landingPointCount || 0));
setTextTargets(["bgp-anomaly-count", "mobile-bgp-anomaly-count"], String(stats.bgpAnomalyCount || 0));
setTextTargets(["bgp-collector-count"], String(stats.bgpCollectorCount || 0));
setTextTargets(["bgp-status-summary", "mobile-bgp-status-summary"], stats.bgpStatusSummary || "-");
setTextTargets(["terrain-status"], stats.terrainOn ? "开启" : "关闭");
setTextTargets(["texture-quality"], stats.textureQuality || "8K 卫星图");
const cableCountEl = getElement("cable-count");
const landingPointCountEl = getElement("landing-point-count");
const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
const bgpCollectorCountEl = getElement("bgp-collector-count");
const bgpStatusSummaryEl = getElement("bgp-status-summary");
const terrainStatusEl = getElement("terrain-status");
const textureQualityEl = getElement("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl)
landingPointCountEl.textContent = stats.landingPointCount || 0;
if (bgpAnomalyCountEl)
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (bgpCollectorCountEl)
bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
if (bgpStatusSummaryEl)
bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl)
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
}
// Show/hide loading via status message

View File

@@ -21,5 +21,5 @@
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.tooling.json" }]
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -59,7 +59,6 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
@@ -372,41 +371,6 @@ log_success() {
log_line "done" "$GREEN" "$1"
}
get_recommended_lan_ipv4() {
local candidate
while read -r candidate; do
[ -z "$candidate" ] && continue
case "$candidate" in
127.*|169.254.*|172.17.*|172.18.*|198.18.*|198.19.*|10.255.*)
continue
;;
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
printf "%s" "$candidate"
return 0
;;
esac
done <<EOF
$(hostname -I 2>/dev/null | tr ' ' '\n')
EOF
return 1
}
log_lan_access_notes() {
local frontend_port="$1"
local backend_port="$2"
local recommended_lan_ip=""
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}"
log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health"
else
log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}"
log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health"
fi
}
print_splash() {
clear_wait_spinner
printf "%b" "$CYAN"
@@ -802,8 +766,8 @@ ensure_frontend_deps() {
cd "$SCRIPT_DIR/frontend"
set_wait_detail "检查 Vite Bun 入口是否已安装"
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
set_wait_detail "检查 vite 是否已安装"
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
if ! run_with_retry \
@@ -816,9 +780,9 @@ ensure_frontend_deps() {
fi
fi
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
close_wait_session_context "$owns_wait_session"
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
log_error "前端依赖安装失败,未找到 vite"
exit 1
fi
@@ -1360,15 +1324,15 @@ cleanup_frontend_processes() {
rm -f "$FRONTEND_PID_FILE"
fi
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true
pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
}
start_frontend_with_retry() {
local frontend_port="$1"
local frontend_port_requested="${2:-0}"
local frontend_lan_enabled="${3:-0}"
local retry=1
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
@@ -1378,13 +1342,7 @@ start_frontend_with_retry() {
fi
cd "$SCRIPT_DIR/frontend"
: > /tmp/planet_frontend.log
local -a frontend_args
frontend_args=("$FRONTEND_VITE_ENTRY")
if [ "$frontend_lan_enabled" -eq 1 ]; then
frontend_args+=(--host 0.0.0.0)
fi
frontend_args+=(--port "$frontend_port" --strictPort)
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 &
FRONTEND_PID=$!
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
@@ -1410,7 +1368,6 @@ start_frontend_with_retry() {
start_frontend_service() {
local frontend_port="$1"
local frontend_port_requested="$2"
local frontend_lan_enabled="${3:-0}"
if [ "$frontend_port_requested" -eq 1 ]; then
kill_port_if_requested "$frontend_port" "前端"
@@ -1422,12 +1379,8 @@ start_frontend_service() {
log_success "前端依赖已就绪"
start_wait_session "启动前端服务"
if [ "$frontend_lan_enabled" -eq 1 ]; then
set_wait_detail "启动 Vite 开发服务器(局域网开放)"
else
set_wait_detail "启动 Vite 开发服务器"
fi
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
set_wait_detail "启动 Vite 开发服务器"
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
stop_wait_session
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}"
tail -10 /tmp/planet_frontend.log
@@ -1446,7 +1399,6 @@ parse_service_args() {
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
DATABASE_REQUESTED=0
FRONTEND_LAN_ENABLED=0
while [ "$#" -gt 0 ]; do
case "$1" in
@@ -1481,10 +1433,6 @@ parse_service_args() {
DATABASE_REQUESTED=1
shift 1
;;
--allow-lan)
FRONTEND_LAN_ENABLED=1
shift 1
;;
*)
log_error "未知参数: $1"
exit 1
@@ -1531,7 +1479,7 @@ stop_ai_provider_service() {
}
stop_frontend_service() {
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
cleanup_frontend_processes "$FRONTEND_PORT"
@@ -1659,16 +1607,13 @@ start() {
print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
}
stop() {
@@ -1688,7 +1633,7 @@ restart() {
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
sleep 1
start "$@"
start
return 0
fi
@@ -1713,7 +1658,7 @@ restart() {
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
stop_frontend_service
sleep 1
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
start_frontend_service "$FRONTEND_PORT" 1
fi
echo ""
@@ -1729,9 +1674,6 @@ restart() {
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
log_note "前端: http://localhost:${FRONTEND_PORT}"
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
fi
}
@@ -1812,9 +1754,9 @@ case "$1" in
;;
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"

View File

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

View File

@@ -0,0 +1,134 @@
param(
[string]$BaseUrl = "http://localhost:8000",
[string]$Username,
[string]$Password,
[int]$ReadMessageCount = 5
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) {
throw "Missing username/password. Pass -Username and -Password."
}
Add-Type -AssemblyName System.Net.Http
function Read-WebSocketMessage {
param(
[System.Net.WebSockets.ClientWebSocket]$Socket
)
$buffer = New-Object byte[] 8192
$segment = [System.ArraySegment[byte]]::new($buffer)
$builder = New-Object System.Text.StringBuilder
do {
$result = $Socket.ReceiveAsync($segment, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
return $null
}
$builder.Append([System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)) | Out-Null
} while (-not $result.EndOfMessage)
return $builder.ToString()
}
$loginUri = "$BaseUrl/api/v1/auth/login"
$loginBody = "username=$([uri]::EscapeDataString($Username))&password=$([uri]::EscapeDataString($Password))"
Write-Host "Requesting access token from $loginUri ..."
$tokenResponse = Invoke-RestMethod `
-Method Post `
-Uri $loginUri `
-ContentType "application/x-www-form-urlencoded" `
-Body $loginBody
if (-not $tokenResponse.access_token) {
throw "Login succeeded but access_token missing."
}
$accessToken = [string]$tokenResponse.access_token
Write-Host "Token acquired. Expires in: $($tokenResponse.expires_in)"
$wsBase = $BaseUrl -replace "^http", "ws"
$wsUri = "$wsBase/ws?token=$accessToken"
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
$socket.Options.KeepAliveInterval = [TimeSpan]::FromSeconds(20)
Write-Host "Connecting WebSocket: $wsUri"
$socket.ConnectAsync([Uri]$wsUri, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
Write-Host "WebSocket connected."
try {
$initial = Read-WebSocketMessage -Socket $socket
if ($null -ne $initial) {
Write-Host ""
Write-Host "=== connection_established ==="
Write-Host $initial
}
$subscribeFrame = @{
type = "subscribe"
data = @{
channels = @("ue_scene")
}
} | ConvertTo-Json -Depth 8 -Compress
$subscribeBytes = [System.Text.Encoding]::UTF8.GetBytes($subscribeFrame)
$subscribeSegment = [System.ArraySegment[byte]]::new($subscribeBytes)
$socket.SendAsync(
$subscribeSegment,
[System.Net.WebSockets.WebSocketMessageType]::Text,
$true,
[Threading.CancellationToken]::None
).GetAwaiter().GetResult()
$subAck = Read-WebSocketMessage -Socket $socket
if ($null -ne $subAck) {
Write-Host ""
Write-Host "=== subscription_confirmed ==="
Write-Host $subAck
}
$syncFrame = @{
type = "sync_request"
data = @{
channel = "ue_scene"
reason = "initial_connect"
last_sequence = $null
}
} | ConvertTo-Json -Depth 8 -Compress
$syncBytes = [System.Text.Encoding]::UTF8.GetBytes($syncFrame)
$syncSegment = [System.ArraySegment[byte]]::new($syncBytes)
$socket.SendAsync(
$syncSegment,
[System.Net.WebSockets.WebSocketMessageType]::Text,
$true,
[Threading.CancellationToken]::None
).GetAwaiter().GetResult()
for ($i = 1; $i -le $ReadMessageCount; $i++) {
$message = Read-WebSocketMessage -Socket $socket
if ($null -eq $message) {
break
}
Write-Host ""
Write-Host "=== message $i ==="
Write-Host $message
}
}
finally {
if ($socket.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
$socket.CloseAsync(
[System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure,
"done",
[Threading.CancellationToken]::None
).GetAwaiter().GetResult()
}
$socket.Dispose()
}

View File

@@ -0,0 +1,264 @@
# BP_GlobeController 接 `ue_scene` 数据接口指南
## 这份文档解决什么问题
这份文档专门讲:
1. 后端 `ue_scene` 已经准备好之后,如何先在本机验证接口
2. UE 展示端接接口时,消息应该怎么走
3. `BP_GlobeController` 后续该预留哪些函数和状态
适合当前阶段:
- 你已经有 `Earth_Globe`
- 你已经有一个能展示地球的 Demo
- 现在要开始把后端的 `ue_scene` 数据接进来
## 当前后端入口
后端 WebSocket 入口在:
- [backend/app/api/v1/websocket.py](/D:/work/planet/planet/backend/app/api/v1/websocket.py:1)
`ue_scene` 数据构建在:
- [backend/app/core/websocket/ue_scene.py](/D:/work/planet/planet/backend/app/core/websocket/ue_scene.py:1)
协议草案在:
- [docs/ue-ndisplay-websocket-protocol.md](/D:/work/planet/planet/docs/ue-ndisplay-websocket-protocol.md:1)
## 先不要直接进 UE
建议先做一件事:
先在本机验证这条链路是通的:
1. 登录后端拿 `access_token`
2. 用这个 token 连接 `/ws`
3. 订阅 `ue_scene`
4. 发送 `sync_request`
5. 确认后端回了 `full` 快照
这一步跑通后,再把同样的消息流程搬到 UE。
## 本机验证脚本
仓库里已经补了测试脚本:
- [scripts/test_ue_scene_ws.ps1](/D:/work/planet/planet/scripts/test_ue_scene_ws.ps1)
### 用法
```powershell
powershell -ExecutionPolicy Bypass -File scripts/test_ue_scene_ws.ps1 `
-BaseUrl http://localhost:8000 `
-Username 你的用户名 `
-Password 你的密码 `
-ReadMessageCount 3
```
### 预期输出
你应该能看到这些消息:
1. `connection_established`
2. `subscription_confirmed`
3. 至少一条 `data_frame`
其中第一条 `data_frame` 里:
- `channel = ue_scene`
- `update_type = full`
如果这一步失败,不要先去 UE 里找问题。
## 连接顺序
UE 主节点后续接接口时,顺序就是下面这样:
1. 登录拿 token
2. 连接 `ws://<host>:8000/ws?token=<access_token>`
3. 收到 `connection_established`
4.`subscribe`
5.`sync_request`
6.`data_frame`
7. 应用 `full`
8. 后续继续应用 `incremental`
## WebSocket 地址
```text
ws://localhost:8000/ws?token=<access_token>
```
## `subscribe` 消息
```json
{
"type": "subscribe",
"data": {
"channels": ["ue_scene"]
}
}
```
## `sync_request` 消息
```json
{
"type": "sync_request",
"data": {
"channel": "ue_scene",
"reason": "initial_connect",
"last_sequence": null
}
}
```
## 你在 UE 里要接收的重点字段
### 1. `data.update_type`
可能是:
- `full`
- `incremental`
### 2. `data.sequence`
每次场景更新都会递增。
UE 主节点要保存自己最近一次成功应用的 `sequence`
### 3. `data.payload.layers`
`full` 快照时,场景实体都在这里。
重点层包括:
- `satellites`
- `supercomputers`
- `gpu_clusters`
- `submarine_cables`
- `landing_points`
- `alerts`
### 4. `data.changes`
`incremental` 更新时,变化都在这里。
每一层会包含:
- `added`
- `updated`
- `removed`
## `BP_GlobeController` 建议先预留的变量
### `LastSequence`
- 类型:整数
- 用途:记录最近一次成功应用的序列号
### `SceneConnected`
- 类型:布尔
- 用途:当前是否已连上后端 WebSocket
### `PendingFullSync`
- 类型:布尔
- 用途:是否正在等待全量快照
### `CurrentSceneHash`
- 类型:字符串
- 用途:记录当前已应用场景的 `state_hash`
## `BP_GlobeController` 建议先预留的函数
### `ConnectWebSocket`
负责:
- 建立连接
- 进入已连接状态
### `SendSubscribe`
负责:
- 订阅 `ue_scene`
### `SendSyncRequest`
负责:
- 请求 `full` 快照
### `HandleMessage`
负责:
- 解析服务端返回的原始 JSON
### `ApplyFullScene`
负责:
- 清理旧场景数据
-`layers` 重建当前场景
### `ApplyIncrementalScene`
负责:
- 处理 `added`
- 处理 `updated`
- 处理 `removed`
### `GeoToWorld`
负责:
-`lat/lng/alt` 转成球面世界坐标
## 第一个可落地目标
不要一上来就把所有层都接进来。
建议第一阶段只做:
1. 连接成功
2. 收到 `full`
3. 先只解析 `gpu_clusters`
4. 在地球上生成几个测试点位
这样最容易判断问题在哪:
- 是连接没通
- 还是 JSON 没解析对
- 还是地理坐标映射有问题
## 当前阶段建议
对于你现在这个 Demo建议下一步按这个顺序
1. 先跑 [scripts/test_ue_scene_ws.ps1](/D:/work/planet/planet/scripts/test_ue_scene_ws.ps1:1)
2. 确认后端确实回 `ue_scene`
3. 再在 UE 里接登录和 WebSocket
4. 先只落 `ApplyFullScene`
5. 最后再接 `ApplyIncrementalScene`
## 一个现实提醒
UE 原生 `WebSockets` 插件更适合 C++ 层接入,不是最友好的纯蓝图体验。
所以如果你下一步要我继续带着落地,我建议走这条路线:
1. 先把协议和测试跑通
2. 再补一个 UE 侧最小 C++ WebSocket 接收层
3. 最后把消息转给 `BP_GlobeController`
这样会比纯蓝图硬接稳定很多。

View File

@@ -0,0 +1,22 @@
using UnrealBuildTool;
public class Planet : ModuleRules
{
public Planet(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(
new[]
{
"Core",
"CoreUObject",
"Engine",
"HTTP",
"Json",
"JsonUtilities",
"WebSockets",
}
);
}
}

View File

@@ -0,0 +1,677 @@
#include "UeSceneWebSocketClient.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "HttpModule.h"
#include "JsonObjectConverter.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "WebSocketsModule.h"
namespace
{
constexpr double GeoPointMergeTolerance = 1e-4;
bool TryParseGeoPoint(const TSharedPtr<FJsonObject>& PointObject, FVector& OutGeoPoint)
{
if (!PointObject.IsValid())
{
return false;
}
double Latitude = 0.0;
double Longitude = 0.0;
double Altitude = 0.0;
if (!PointObject->TryGetNumberField(TEXT("lat"), Latitude)
|| !PointObject->TryGetNumberField(TEXT("lng"), Longitude))
{
return false;
}
PointObject->TryGetNumberField(TEXT("alt"), Altitude);
OutGeoPoint = FVector(Longitude, Latitude, Altitude);
return true;
}
FString ResolveCableName(const TSharedPtr<FJsonObject>& ItemObject)
{
FString CableName;
const TSharedPtr<FJsonObject>* LabelsObject = nullptr;
if (ItemObject->TryGetObjectField(TEXT("labels"), LabelsObject)
&& LabelsObject != nullptr
&& LabelsObject->IsValid())
{
(*LabelsObject)->TryGetStringField(TEXT("title"), CableName);
}
if (CableName.IsEmpty())
{
ItemObject->TryGetStringField(TEXT("id"), CableName);
}
return CableName;
}
bool AreGeoPointsConnected(const FVector& A, const FVector& B)
{
return FMath::Abs(A.X - B.X) <= GeoPointMergeTolerance
&& FMath::Abs(A.Y - B.Y) <= GeoPointMergeTolerance
&& FMath::Abs(A.Z - B.Z) <= GeoPointMergeTolerance;
}
void AppendMergedSegment(
TArray<TArray<FVector>>& MergedSegments,
const TArray<FVector>& IncomingSegment
)
{
if (IncomingSegment.Num() < 2)
{
return;
}
if (MergedSegments.Num() == 0)
{
MergedSegments.Add(IncomingSegment);
return;
}
TArray<FVector>& LastMergedSegment = MergedSegments.Last();
if (LastMergedSegment.Num() == 0)
{
LastMergedSegment = IncomingSegment;
return;
}
if (AreGeoPointsConnected(LastMergedSegment.Last(), IncomingSegment[0]))
{
for (int32 Index = 1; Index < IncomingSegment.Num(); ++Index)
{
LastMergedSegment.Add(IncomingSegment[Index]);
}
return;
}
MergedSegments.Add(IncomingSegment);
}
}
UUeSceneWebSocketClient::UUeSceneWebSocketClient()
{
PrimaryComponentTick.bCanEverTick = false;
}
void UUeSceneWebSocketClient::BeginPlay()
{
Super::BeginPlay();
}
void UUeSceneWebSocketClient::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
DisconnectFromBackend();
Super::EndPlay(EndPlayReason);
}
void UUeSceneWebSocketClient::ConnectToBackend()
{
if (Username.IsEmpty() || Password.IsEmpty())
{
EmitError(TEXT("Username / Password 不能为空。"));
return;
}
RequestAccessToken();
}
void UUeSceneWebSocketClient::DisconnectFromBackend()
{
if (Socket.IsValid())
{
Socket->Close();
Socket.Reset();
}
PendingFullScene.Reset();
OnConnectedChanged.Broadcast(false);
}
void UUeSceneWebSocketClient::SendSubscribe()
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetStringField(TEXT("type"), TEXT("subscribe"));
TSharedRef<FJsonObject> Data = MakeShared<FJsonObject>();
TArray<TSharedPtr<FJsonValue>> Channels;
for (const FString& Channel : DefaultChannels)
{
Channels.Add(MakeShared<FJsonValueString>(Channel));
}
Data->SetArrayField(TEXT("channels"), Channels);
Root->SetObjectField(TEXT("data"), Data);
FString Payload;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Payload);
FJsonSerializer::Serialize(Root, Writer);
SendJsonString(Payload);
}
void UUeSceneWebSocketClient::SendInitialSyncRequest()
{
SendSyncRequest(TEXT("initial_connect"), INDEX_NONE);
}
void UUeSceneWebSocketClient::SendSyncRequest(const FString& Reason, int32 InLastSequence)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetStringField(TEXT("type"), TEXT("sync_request"));
TSharedRef<FJsonObject> Data = MakeShared<FJsonObject>();
Data->SetStringField(TEXT("channel"), TEXT("ue_scene"));
Data->SetStringField(TEXT("reason"), Reason);
if (InLastSequence >= 0)
{
Data->SetNumberField(TEXT("last_sequence"), InLastSequence);
}
Root->SetObjectField(TEXT("data"), Data);
FString Payload;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Payload);
FJsonSerializer::Serialize(Root, Writer);
SendJsonString(Payload);
}
void UUeSceneWebSocketClient::RequestAccessToken()
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(BuildHttpUrl(TEXT("/api/v1/auth/login")));
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("Content-Type"), TEXT("application/x-www-form-urlencoded"));
Request->SetContentAsString(
FString::Printf(TEXT("username=%s&password=%s"), *Username, *Password)
);
Request->OnProcessRequestComplete().BindUObject(
this, &UUeSceneWebSocketClient::HandleLoginResponse
);
Request->ProcessRequest();
}
void UUeSceneWebSocketClient::HandleLoginResponse(
FHttpRequestPtr Request,
FHttpResponsePtr Response,
bool bSucceeded
)
{
if (!bSucceeded || !Response.IsValid())
{
EmitError(TEXT("登录请求失败。"));
return;
}
if (Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300)
{
EmitError(FString::Printf(TEXT("登录失败HTTP %d"), Response->GetResponseCode()));
return;
}
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString());
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
EmitError(TEXT("登录响应 JSON 解析失败。"));
return;
}
AccessToken = Root->GetStringField(TEXT("access_token"));
if (AccessToken.IsEmpty())
{
EmitError(TEXT("登录响应里没有 access_token。"));
return;
}
OpenWebSocket();
}
void UUeSceneWebSocketClient::OpenWebSocket()
{
if (!FModuleManager::Get().IsModuleLoaded(TEXT("WebSockets")))
{
FModuleManager::Get().LoadModule(TEXT("WebSockets"));
}
Socket = FWebSocketsModule::Get().CreateWebSocket(BuildWsUrl());
BindSocketEvents();
Socket->Connect();
}
void UUeSceneWebSocketClient::BindSocketEvents()
{
if (!Socket.IsValid())
{
return;
}
Socket->OnConnected().AddLambda([this]()
{
OnConnectedChanged.Broadcast(true);
});
Socket->OnConnectionError().AddLambda([this](const FString& Error)
{
EmitError(FString::Printf(TEXT("WebSocket 连接失败: %s"), *Error));
});
Socket->OnClosed().AddLambda([this](int32 StatusCode, const FString& Reason, bool bWasClean)
{
OnConnectedChanged.Broadcast(false);
});
Socket->OnMessage().AddLambda([this](const FString& Message)
{
OnRawMessage.Broadcast(Message);
TryBroadcastSceneFrame(Message);
});
}
void UUeSceneWebSocketClient::EmitError(const FString& ErrorMessage)
{
OnError.Broadcast(ErrorMessage);
}
void UUeSceneWebSocketClient::TryBroadcastSceneFrame(const FString& Message)
{
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Message);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
return;
}
FString Type;
if (!Root->TryGetStringField(TEXT("type"), Type) || Type != TEXT("data_frame"))
{
return;
}
FString Channel;
if (!Root->TryGetStringField(TEXT("channel"), Channel) || Channel != TEXT("ue_scene"))
{
return;
}
const TSharedPtr<FJsonObject>* DataObject = nullptr;
if (!Root->TryGetObjectField(TEXT("data"), DataObject) || DataObject == nullptr || !DataObject->IsValid())
{
return;
}
TSharedPtr<FJsonObject> EffectiveDataObject = *DataObject;
FString UpdateType = TEXT("unknown");
EffectiveDataObject->TryGetStringField(TEXT("update_type"), UpdateType);
int32 Sequence = 0;
EffectiveDataObject->TryGetNumberField(TEXT("sequence"), Sequence);
int32 TotalRecords = 0;
const TSharedPtr<FJsonObject>* PayloadObject = nullptr;
if (EffectiveDataObject->TryGetObjectField(TEXT("payload"), PayloadObject) && PayloadObject != nullptr && PayloadObject->IsValid())
{
const TSharedPtr<FJsonObject>* MetaObject = nullptr;
if ((*PayloadObject)->TryGetObjectField(TEXT("meta"), MetaObject) && MetaObject != nullptr && MetaObject->IsValid())
{
(*MetaObject)->TryGetNumberField(TEXT("total_records"), TotalRecords);
}
}
if (UpdateType == TEXT("full"))
{
TSharedPtr<FJsonObject> AssembledDataObject;
if (!TryAssembleFullScene(EffectiveDataObject, Sequence, TotalRecords, AssembledDataObject))
{
return;
}
if (AssembledDataObject.IsValid())
{
EffectiveDataObject = AssembledDataObject;
}
}
LastSequence = Sequence;
OnSceneFrame.Broadcast(UpdateType, Sequence, TotalRecords);
BroadcastScenePayload(UpdateType, Sequence, EffectiveDataObject);
}
bool UUeSceneWebSocketClient::TryAssembleFullScene(
const TSharedPtr<FJsonObject>& DataObject,
int32 Sequence,
int32 TotalRecords,
TSharedPtr<FJsonObject>& OutAssembledDataObject
)
{
OutAssembledDataObject = nullptr;
const TSharedPtr<FJsonObject>* PayloadObject = nullptr;
if (!DataObject->TryGetObjectField(TEXT("payload"), PayloadObject) || PayloadObject == nullptr || !PayloadObject->IsValid())
{
EmitError(TEXT("Full scene payload is missing."));
return false;
}
const TSharedPtr<FJsonObject>* MetaObject = nullptr;
if (!(*PayloadObject)->TryGetObjectField(TEXT("meta"), MetaObject) || MetaObject == nullptr || !MetaObject->IsValid())
{
EmitError(TEXT("Full scene meta is missing."));
return false;
}
bool bChunked = false;
(*MetaObject)->TryGetBoolField(TEXT("chunked"), bChunked);
if (!bChunked)
{
OutAssembledDataObject = DataObject;
PendingFullScene.Reset();
return true;
}
int32 TransportChunkIndex = 0;
int32 TransportChunkCount = 0;
int32 LayerChunkIndex = 0;
int32 LayerChunkCount = 0;
FString LayerName;
(*MetaObject)->TryGetNumberField(TEXT("transport_chunk_index"), TransportChunkIndex);
(*MetaObject)->TryGetNumberField(TEXT("transport_chunk_count"), TransportChunkCount);
(*MetaObject)->TryGetNumberField(TEXT("layer_chunk_index"), LayerChunkIndex);
(*MetaObject)->TryGetNumberField(TEXT("layer_chunk_count"), LayerChunkCount);
(*MetaObject)->TryGetStringField(TEXT("layer_name"), LayerName);
if (TransportChunkIndex <= 0 || TransportChunkCount <= 0 || LayerChunkIndex <= 0 || LayerChunkCount <= 0 || LayerName.IsEmpty())
{
EmitError(TEXT("Chunked full scene metadata is incomplete."));
PendingFullScene.Reset();
return false;
}
if (PendingFullScene.Sequence != Sequence)
{
PendingFullScene.Reset();
PendingFullScene.Sequence = Sequence;
PendingFullScene.ExpectedTransportChunkCount = TransportChunkCount;
PendingFullScene.TotalRecords = TotalRecords;
PendingFullScene.DisplayProfile = DataObject->GetObjectField(TEXT("display_profile"));
PendingFullScene.CameraState = DataObject->GetObjectField(TEXT("camera_state"));
PendingFullScene.Meta = MakeShared<FJsonObject>(*MetaObject->Get());
DataObject->TryGetStringField(TEXT("cluster_time"), PendingFullScene.ClusterTime);
}
if (PendingFullScene.Sequence != Sequence || PendingFullScene.ExpectedTransportChunkCount != TransportChunkCount)
{
EmitError(TEXT("Chunked full scene sequence mismatch."));
PendingFullScene.Reset();
return false;
}
if (PendingFullScene.ReceivedTransportChunkIndices.Contains(TransportChunkIndex))
{
return false;
}
PendingFullScene.ReceivedTransportChunkIndices.Add(TransportChunkIndex);
const TSharedPtr<FJsonObject>* LayersObject = nullptr;
if (!(*PayloadObject)->TryGetObjectField(TEXT("layers"), LayersObject) || LayersObject == nullptr || !LayersObject->IsValid())
{
EmitError(TEXT("Chunked full scene layers are missing."));
PendingFullScene.Reset();
return false;
}
const TSharedPtr<FJsonObject>* LayerDataObject = nullptr;
if (!(*LayersObject)->TryGetObjectField(LayerName, LayerDataObject) || LayerDataObject == nullptr || !LayerDataObject->IsValid())
{
EmitError(TEXT("Chunked full scene layer payload is missing."));
PendingFullScene.Reset();
return false;
}
FLayerChunkAccumulator& LayerAccumulator = PendingFullScene.Layers.FindOrAdd(LayerName);
if (LayerAccumulator.ExpectedChunkCount == 0)
{
LayerAccumulator.ExpectedChunkCount = LayerChunkCount;
LayerDataObject->Get()->TryGetNumberField(TEXT("revision"), LayerAccumulator.Revision);
}
else if (LayerAccumulator.ExpectedChunkCount != LayerChunkCount)
{
EmitError(TEXT("Layer chunk count changed while assembling full scene."));
PendingFullScene.Reset();
return false;
}
if (LayerAccumulator.ReceivedChunkIndices.Contains(LayerChunkIndex))
{
return false;
}
const TArray<TSharedPtr<FJsonValue>>* LayerItems = nullptr;
if (!LayerDataObject->Get()->TryGetArrayField(TEXT("items"), LayerItems) || LayerItems == nullptr)
{
EmitError(TEXT("Chunked full scene items are missing."));
PendingFullScene.Reset();
return false;
}
LayerAccumulator.ReceivedChunkIndices.Add(LayerChunkIndex);
LayerAccumulator.Items.Append(*LayerItems);
if (PendingFullScene.ReceivedTransportChunkIndices.Num() != PendingFullScene.ExpectedTransportChunkCount)
{
return false;
}
for (const TPair<FString, FLayerChunkAccumulator>& LayerPair : PendingFullScene.Layers)
{
if (LayerPair.Value.ReceivedChunkIndices.Num() != LayerPair.Value.ExpectedChunkCount)
{
return false;
}
}
TSharedRef<FJsonObject> AssembledDataObject = MakeShared<FJsonObject>();
AssembledDataObject->SetStringField(TEXT("update_type"), TEXT("full"));
AssembledDataObject->SetNumberField(TEXT("sequence"), Sequence);
AssembledDataObject->SetStringField(TEXT("cluster_time"), PendingFullScene.ClusterTime);
if (PendingFullScene.DisplayProfile.IsValid())
{
AssembledDataObject->SetObjectField(TEXT("display_profile"), PendingFullScene.DisplayProfile);
}
if (PendingFullScene.CameraState.IsValid())
{
AssembledDataObject->SetObjectField(TEXT("camera_state"), PendingFullScene.CameraState);
}
TSharedRef<FJsonObject> AssembledPayloadObject = MakeShared<FJsonObject>();
TSharedRef<FJsonObject> AssembledMetaObject = PendingFullScene.Meta.IsValid()
? MakeShared<FJsonObject>(*PendingFullScene.Meta.Get())
: MakeShared<FJsonObject>();
AssembledMetaObject->SetBoolField(TEXT("chunked"), false);
AssembledMetaObject->RemoveField(TEXT("layer_name"));
AssembledMetaObject->RemoveField(TEXT("layer_chunk_index"));
AssembledMetaObject->RemoveField(TEXT("layer_chunk_count"));
AssembledMetaObject->RemoveField(TEXT("transport_chunk_index"));
AssembledMetaObject->RemoveField(TEXT("transport_chunk_count"));
AssembledPayloadObject->SetObjectField(TEXT("meta"), AssembledMetaObject);
TSharedRef<FJsonObject> AssembledLayersObject = MakeShared<FJsonObject>();
for (TPair<FString, FLayerChunkAccumulator>& LayerPair : PendingFullScene.Layers)
{
TSharedRef<FJsonObject> LayerObject = MakeShared<FJsonObject>();
LayerObject->SetNumberField(TEXT("revision"), LayerPair.Value.Revision);
LayerObject->SetArrayField(TEXT("items"), LayerPair.Value.Items);
AssembledLayersObject->SetObjectField(LayerPair.Key, LayerObject);
}
AssembledPayloadObject->SetObjectField(TEXT("layers"), AssembledLayersObject);
AssembledDataObject->SetObjectField(TEXT("payload"), AssembledPayloadObject);
OutAssembledDataObject = AssembledDataObject;
PendingFullScene.Reset();
return true;
}
void UUeSceneWebSocketClient::BroadcastScenePayload(
const FString& UpdateType,
int32 Sequence,
const TSharedPtr<FJsonObject>& DataObject
)
{
FString PayloadJson;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&PayloadJson);
if (FJsonSerializer::Serialize(DataObject.ToSharedRef(), Writer))
{
OnScenePayload.Broadcast(UpdateType, Sequence, PayloadJson);
}
BroadcastLegacySubmarineCableEvents(DataObject);
}
void UUeSceneWebSocketClient::BroadcastLegacySubmarineCableEvents(
const TSharedPtr<FJsonObject>& DataObject
)
{
if (!DataObject.IsValid())
{
return;
}
const TSharedPtr<FJsonObject>* PayloadObject = nullptr;
if (!DataObject->TryGetObjectField(TEXT("payload"), PayloadObject)
|| PayloadObject == nullptr
|| !PayloadObject->IsValid())
{
return;
}
const TSharedPtr<FJsonObject>* LayersObject = nullptr;
if (!(*PayloadObject)->TryGetObjectField(TEXT("layers"), LayersObject)
|| LayersObject == nullptr
|| !LayersObject->IsValid())
{
return;
}
const TSharedPtr<FJsonObject>* CableLayerObject = nullptr;
if (!(*LayersObject)->TryGetObjectField(TEXT("submarine_cables"), CableLayerObject)
|| CableLayerObject == nullptr
|| !CableLayerObject->IsValid())
{
return;
}
const TArray<TSharedPtr<FJsonValue>>* CableItems = nullptr;
if (!(*CableLayerObject)->TryGetArrayField(TEXT("items"), CableItems) || CableItems == nullptr)
{
return;
}
for (const TSharedPtr<FJsonValue>& CableItemValue : *CableItems)
{
const TSharedPtr<FJsonObject> CableItemObject = CableItemValue.IsValid()
? CableItemValue->AsObject()
: nullptr;
if (!CableItemObject.IsValid())
{
continue;
}
const FString CableName = ResolveCableName(CableItemObject);
const TSharedPtr<FJsonObject>* GeoObject = nullptr;
if (!CableItemObject->TryGetObjectField(TEXT("geo"), GeoObject)
|| GeoObject == nullptr
|| !GeoObject->IsValid())
{
continue;
}
const TArray<TSharedPtr<FJsonValue>>* SegmentArray = nullptr;
if ((*GeoObject)->TryGetArrayField(TEXT("segments"), SegmentArray) && SegmentArray != nullptr)
{
TArray<TArray<FVector>> MergedSegments;
for (const TSharedPtr<FJsonValue>& SegmentValue : *SegmentArray)
{
const TArray<TSharedPtr<FJsonValue>>* SegmentPoints = nullptr;
if (!SegmentValue.IsValid() || !SegmentValue->TryGetArray(SegmentPoints) || SegmentPoints == nullptr)
{
continue;
}
TArray<FVector> GeoPoints;
GeoPoints.Reserve(SegmentPoints->Num());
for (const TSharedPtr<FJsonValue>& PointValue : *SegmentPoints)
{
FVector GeoPoint = FVector::ZeroVector;
if (PointValue.IsValid() && TryParseGeoPoint(PointValue->AsObject(), GeoPoint))
{
GeoPoints.Add(GeoPoint);
}
}
AppendMergedSegment(MergedSegments, GeoPoints);
}
for (const TArray<FVector>& MergedSegment : MergedSegments)
{
if (MergedSegment.Num() >= 2)
{
OnSubmarineCableReceived.Broadcast(CableName, MergedSegment);
}
}
continue;
}
const TArray<TSharedPtr<FJsonValue>>* PathArray = nullptr;
if (!(*GeoObject)->TryGetArrayField(TEXT("path"), PathArray) || PathArray == nullptr)
{
continue;
}
TArray<FVector> GeoPoints;
GeoPoints.Reserve(PathArray->Num());
for (const TSharedPtr<FJsonValue>& PointValue : *PathArray)
{
FVector GeoPoint = FVector::ZeroVector;
if (PointValue.IsValid() && TryParseGeoPoint(PointValue->AsObject(), GeoPoint))
{
GeoPoints.Add(GeoPoint);
}
}
if (GeoPoints.Num() >= 2)
{
OnSubmarineCableReceived.Broadcast(CableName, GeoPoints);
}
}
}
FString UUeSceneWebSocketClient::BuildWsUrl() const
{
FString WsBase = BackendBaseUrl;
WsBase = WsBase.Replace(TEXT("http://"), TEXT("ws://"));
WsBase = WsBase.Replace(TEXT("https://"), TEXT("wss://"));
return FString::Printf(TEXT("%s/ws?token=%s"), *WsBase, *AccessToken);
}
FString UUeSceneWebSocketClient::BuildHttpUrl(const FString& Path) const
{
return FString::Printf(TEXT("%s%s"), *BackendBaseUrl, *Path);
}
void UUeSceneWebSocketClient::SendJsonString(const FString& JsonPayload)
{
if (!Socket.IsValid() || !Socket->IsConnected())
{
EmitError(TEXT("WebSocket 尚未连接。"));
return;
}
Socket->Send(JsonPayload);
}

View File

@@ -0,0 +1,159 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "IWebSocket.h"
#include "UeSceneWebSocketClient.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUeSceneRawMessageEvent, const FString&, Message);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUeSceneConnectedEvent, bool, bConnected);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUeSceneErrorEvent, const FString&, ErrorMessage);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
FUeSceneFrameEvent,
const FString&,
UpdateType,
int32,
Sequence,
int32,
TotalRecords
);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
FUeScenePayloadEvent,
const FString&,
UpdateType,
int32,
Sequence,
const FString&,
PayloadJson
);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
FUeSceneSubmarineCableEvent,
const FString&,
CableName,
const TArray<FVector>&,
GeoPoints
);
UCLASS(ClassGroup=(Planet), meta=(BlueprintSpawnableComponent))
class PLANET_API UUeSceneWebSocketClient : public UActorComponent
{
GENERATED_BODY()
public:
UUeSceneWebSocketClient();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
FString BackendBaseUrl = TEXT("http://127.0.0.1:8000");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
FString Username;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
FString Password;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
TArray<FString> DefaultChannels = {TEXT("ue_scene")};
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
int32 LastSequence = INDEX_NONE;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneConnectedEvent OnConnectedChanged;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneRawMessageEvent OnRawMessage;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneErrorEvent OnError;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneFrameEvent OnSceneFrame;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeScenePayloadEvent OnScenePayload;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend|Legacy")
FUeSceneSubmarineCableEvent OnSubmarineCableReceived;
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void ConnectToBackend();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void DisconnectFromBackend();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void SendSubscribe();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void SendInitialSyncRequest();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void SendSyncRequest(const FString& Reason, int32 InLastSequence);
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
struct FLayerChunkAccumulator
{
int32 Revision = 0;
int32 ExpectedChunkCount = 0;
TSet<int32> ReceivedChunkIndices;
TArray<TSharedPtr<FJsonValue>> Items;
};
struct FFullSceneAssemblyState
{
int32 Sequence = INDEX_NONE;
int32 ExpectedTransportChunkCount = 0;
int32 TotalRecords = 0;
FString ClusterTime;
TSharedPtr<FJsonObject> DisplayProfile;
TSharedPtr<FJsonObject> CameraState;
TSharedPtr<FJsonObject> Meta;
TMap<FString, FLayerChunkAccumulator> Layers;
TSet<int32> ReceivedTransportChunkIndices;
void Reset()
{
Sequence = INDEX_NONE;
ExpectedTransportChunkCount = 0;
TotalRecords = 0;
ClusterTime.Reset();
DisplayProfile.Reset();
CameraState.Reset();
Meta.Reset();
Layers.Reset();
ReceivedTransportChunkIndices.Reset();
}
};
FString AccessToken;
TSharedPtr<IWebSocket> Socket;
FFullSceneAssemblyState PendingFullScene;
void RequestAccessToken();
void HandleLoginResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bSucceeded);
void OpenWebSocket();
void BindSocketEvents();
void EmitError(const FString& ErrorMessage);
void TryBroadcastSceneFrame(const FString& Message);
bool TryAssembleFullScene(
const TSharedPtr<FJsonObject>& DataObject,
int32 Sequence,
int32 TotalRecords,
TSharedPtr<FJsonObject>& OutAssembledDataObject
);
void BroadcastScenePayload(
const FString& UpdateType,
int32 Sequence,
const TSharedPtr<FJsonObject>& DataObject
);
void BroadcastLegacySubmarineCableEvents(const TSharedPtr<FJsonObject>& DataObject);
FString BuildWsUrl() const;
FString BuildHttpUrl(const FString& Path) const;
void SendJsonString(const FString& JsonPayload);
};

View File

@@ -0,0 +1,189 @@
# UE5.7 连接 `ue_scene` 数据接口实操
## 目标
把当前后端的 `ue_scene` WebSocket 数据接到 UE5.7 工程里。
当前后端入口:
- [backend/app/api/v1/websocket.py](/D:/work/planet/planet/backend/app/api/v1/websocket.py:1)
- [backend/app/core/websocket/ue_scene.py](/D:/work/planet/planet/backend/app/core/websocket/ue_scene.py:1)
本次补的 UE 侧最小 C++ 文件:
- [Planet.Build.cs](/D:/work/planet/planet/unreal/Source/Planet/Planet.Build.cs:1)
- [UeSceneWebSocketClient.h](/D:/work/planet/planet/unreal/Source/Planet/Public/UeSceneWebSocketClient.h:1)
- [UeSceneWebSocketClient.cpp](/D:/work/planet/planet/unreal/Source/Planet/Private/UeSceneWebSocketClient.cpp:1)
## 这一步先做什么
先做最小闭环:
1. UE 登录后端拿 token
2. UE 连接 `/ws`
3. UE 订阅 `ue_scene`
4. UE 发送 `sync_request`
5. UE 收到原始 JSON
先不急着做完整场景生成。
## 第 1 步:把代码放进你的 UE5.7 工程
你的真实 UE 工程里应该有:
```text
YourProject/
Source/
YourProject/
```
把这里的示例代码内容按模块名改进去:
- `Planet.Build.cs`
- `UeSceneWebSocketClient.h`
- `UeSceneWebSocketClient.cpp`
如果你的工程模块名不是 `Planet`,要把:
- `PLANET_API`
改成你自己的模块导出宏。
## 第 2 步:启用插件
在 UE5.7 编辑器里启用:
- `WebSockets`
- `HTTP`
- `Json Blueprint Utilities`(可选)
启用后重启编辑器。
## 第 3 步:重新生成并编译工程
如果你是 Visual Studio 工作流:
1. 右键 `.uproject`
2. 选择“生成 Visual Studio 项目文件”
3. 打开 `.sln`
4. 编译 `Development Editor`
## 第 4 步:把组件挂到 `BP_GlobeController`
编译成功后:
1. 打开 `BP_GlobeController`
2. 点击 `添加`
3. 搜索:
- `Ue Scene Web Socket Client`
4. 把这个组件挂进去
## 第 5 步:填写后端参数
选中这个组件,在 `细节` 面板里填写:
- `Backend Base Url``http://127.0.0.1:8000`
- `Username`:你的后端用户名
- `Password`:你的后端密码
默认频道已经是:
- `ue_scene`
## 第 6 步:在蓝图里接事件
`BP_GlobeController` 里接这几个事件:
### `On Connected Changed`
连接成功后:
1.`Send Subscribe`
2. 再调 `Send Initial Sync Request`
### `On Raw Message`
先不要急着解析全部结构。
第一步只做:
1. 把原始 JSON 打印出来
2. 或保存到字符串变量
这样先确认 UE 确实收到后端消息。
### `On Error`
把错误字符串打印到屏幕和日志里。
## 蓝图推荐连法
### 开始游戏时
1. `开始游戏时`
2. 调组件的 `Connect To Backend`
### 连接成功后
1. `On Connected Changed`
2. `分支`
3. 如果 `bConnected = true`
4.`Send Subscribe`
5.`Send Initial Sync Request`
### 收消息后
1. `On Raw Message`
2. `打印字符串`
## 第 7 步:先验证消息类型
你应该先在屏幕或输出日志里确认这些消息已经进来了:
1. `connection_established`
2. `subscription_confirmed`
3. `data_frame`
其中 `data_frame` 的第一条应是:
- `channel = ue_scene`
- `update_type = full`
## 第 8 步:再接 JSON 解析
当前这一步不建议纯蓝图手搓整个 JSON 结构。
更稳的是:
1. 先在 C++ 里加一个简单解析层
2. 只抽出 `type`
3. 只抽出 `data.update_type`
4. 只抽出 `payload.layers.gpu_clusters.items`
先把 `gpu_clusters` 画出来,再扩到别的层。
## 当前阶段建议
先完成这 3 件事就够了:
1. UE 能成功连接后端
2. UE 能收到 `full` 快照
3. UE 能把原始 JSON 打印出来
做到这一步,我们再继续补:
- JSON 结构体
- `ApplyFullScene`
- `ApplyIncrementalScene`
- `GeoToWorld`
## 一句最重要的提醒
现在先别在 UE 里同时做:
- WebSocket 连接
- 全量 JSON 解析
- 点位生成
- 海缆生成
一步一步来,先确认“连通”,再做“可视化”。这样最不容易卡死。

2
uv.lock generated
View File

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