Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73fa1ea6d | ||
|
|
5b623a6385 |
@@ -1,6 +1,7 @@
|
||||
"""WebSocket API endpoints"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
@@ -11,20 +12,9 @@ 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]:
|
||||
@@ -60,12 +50,18 @@ 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": SUPPORTED_CHANNELS,
|
||||
"supported_channels": [
|
||||
"gpu_clusters",
|
||||
"submarine_cables",
|
||||
"ixp_nodes",
|
||||
"alerts",
|
||||
"dashboard",
|
||||
"datasource_tasks",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -78,79 +74,26 @@ 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":
|
||||
requested_channels = data.get("data", {}).get("channels", [])
|
||||
channels = manager.subscribe(websocket, requested_channels)
|
||||
channels = data.get("data", {}).get("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",
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"data": {
|
||||
"target": data.get("data", {}).get("target"),
|
||||
"command": data.get("data", {}).get("command"),
|
||||
"accepted": True,
|
||||
},
|
||||
}
|
||||
{"type": "control_acknowledged", "data": {"received": True}}
|
||||
)
|
||||
else:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "ack",
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"data": {"received": True},
|
||||
}
|
||||
)
|
||||
await websocket.send_json({"type": "ack", "data": {"received": True}})
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"data": {"action": "ping"},
|
||||
}
|
||||
)
|
||||
await websocket.send_json({"type": "heartbeat", "data": {"action": "ping"}})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Data broadcaster for WebSocket connections."""
|
||||
"""Data broadcaster for WebSocket connections"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
from app.db.session import async_session_factory
|
||||
|
||||
|
||||
|
||||
@@ -46,30 +45,6 @@ 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(
|
||||
@@ -100,7 +75,7 @@ class DataBroadcaster:
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"payload": data,
|
||||
},
|
||||
channel=channel,
|
||||
channel=channel if channel in manager.active_connections else "all",
|
||||
)
|
||||
|
||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||
@@ -120,7 +95,6 @@ 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"""
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""WebSocket connection manager with channel subscriptions."""
|
||||
|
||||
from typing import Dict, Optional, Set
|
||||
"""WebSocket Connection Manager"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict, Set, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import WebSocket
|
||||
import redis.asyncio as redis
|
||||
|
||||
@@ -9,25 +11,17 @@ from app.core.config import settings
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manage user connections and channel subscriptions."""
|
||||
"""Manages WebSocket 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]] = {}
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
@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:
|
||||
async def connect(self, websocket: WebSocket, user_id: str):
|
||||
await websocket.accept()
|
||||
self.user_connections.setdefault(user_id, set()).add(websocket)
|
||||
self.socket_users[websocket] = user_id
|
||||
self.socket_channels.setdefault(websocket, set())
|
||||
if user_id not in self.active_connections:
|
||||
self.active_connections[user_id] = set()
|
||||
self.active_connections[user_id].add(websocket)
|
||||
|
||||
if self.redis_client is None:
|
||||
redis_url = settings.REDIS_URL
|
||||
@@ -41,69 +35,32 @@ class ConnectionManager:
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
def disconnect(self, websocket: WebSocket, user_id: str) -> None:
|
||||
self.unsubscribe(websocket, list(self.socket_channels.get(websocket, set())))
|
||||
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]
|
||||
|
||||
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 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
|
||||
|
||||
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:
|
||||
async def broadcast(self, message: dict, channel: str = "all"):
|
||||
if channel == "all":
|
||||
targets = list(self.socket_users.keys())
|
||||
for user_id in self.active_connections:
|
||||
await self.send_personal_message(message, user_id)
|
||||
else:
|
||||
targets = list(self.channel_connections.get(channel, set()))
|
||||
await self.send_personal_message(message, channel)
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
@@ -1,562 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,150 +0,0 @@
|
||||
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"}]
|
||||
@@ -8,6 +8,41 @@ 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
|
||||
- 抽屉把手区域缩小至 36px(collapsed 时仅露出把手,不遮挡地球操作区)
|
||||
- 抽屉定期弹跳动画提示用户可上拉,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
|
||||
@@ -65,8 +100,6 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
## [0.31.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
@@ -103,6 +136,8 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
||||
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
||||
@@ -116,8 +151,6 @@ This project follows the repository versioning rule:
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
## [0.30.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
当前重点入口:
|
||||
|
||||
- [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)
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
# 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 事件并入统一场景层
|
||||
- 为控制命令增加真正的状态持久化与回执
|
||||
- 为主节点断线重连补状态恢复策略
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.33.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.35.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 图层注册表与启动任务框架,修复旋转/巡航切换、卫星地形遮挡与日夜关闭照明回归 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.33.0",
|
||||
"version": "0.35.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -25,8 +25,8 @@
|
||||
"vite": "^5.0.10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
: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));
|
||||
@@ -72,6 +76,10 @@ body.earth-page {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.earth-app canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.earth-app.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -133,3 +133,20 @@
|
||||
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
@@ -367,3 +367,39 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -317,3 +317,29 @@
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -138,3 +138,24 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,14 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -285,6 +285,27 @@
|
||||
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: "";
|
||||
|
||||
@@ -156,14 +156,22 @@
|
||||
<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="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<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="搜索">
|
||||
<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="1" style="--orb-delay: 0.18s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
|
||||
<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>
|
||||
@@ -174,7 +182,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" 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>
|
||||
@@ -182,7 +190,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="4" 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>
|
||||
@@ -190,7 +198,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="4" style="--orb-delay: 0.72s;">
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" 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>
|
||||
@@ -203,7 +211,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="5" style="--orb-delay: 0.9s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" 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>
|
||||
@@ -211,7 +219,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" 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>
|
||||
@@ -219,7 +227,7 @@
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="8" 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>
|
||||
@@ -412,6 +420,295 @@
|
||||
|
||||
<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="设置">
|
||||
|
||||
695
frontend/public/earth/js/controls.js
vendored
695
frontend/public/earth/js/controls.js
vendored
@@ -26,12 +26,19 @@ import {
|
||||
} from "./satellites.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { ensureTVPanelReady } from "./tv.js";
|
||||
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } 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,
|
||||
@@ -85,6 +92,436 @@ 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)
|
||||
@@ -281,20 +718,23 @@ function persistEarthSettings() {
|
||||
}
|
||||
|
||||
function syncDefaultEarthZoomUi(nextZoom) {
|
||||
const slider = document.getElementById("default-earth-size-slider");
|
||||
const value = document.getElementById("default-earth-size-value");
|
||||
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 zoomValue = document.getElementById("zoom-value");
|
||||
const tooltipText = getZoomResetTooltipText(nextZoom);
|
||||
|
||||
if (slider instanceof HTMLInputElement) {
|
||||
sliders.forEach((slider) => {
|
||||
if (!(slider instanceof HTMLInputElement)) return;
|
||||
slider.min = CONFIG.minZoom.toString();
|
||||
slider.max = CONFIG.maxZoom.toString();
|
||||
slider.step = DEFAULT_EARTH_ZOOM_STEP.toString();
|
||||
slider.value = nextZoom.toFixed(2);
|
||||
}
|
||||
if (value) {
|
||||
value.textContent = formatZoomPercent(nextZoom);
|
||||
}
|
||||
});
|
||||
values.forEach((value) => {
|
||||
if (value instanceof HTMLElement) {
|
||||
value.textContent = formatZoomPercent(nextZoom);
|
||||
}
|
||||
});
|
||||
if (zoomValue instanceof HTMLElement) {
|
||||
zoomValue.title = tooltipText;
|
||||
const tooltip = zoomValue.querySelector(".tooltip");
|
||||
@@ -331,14 +771,16 @@ async function applyEarthSettings(settings) {
|
||||
});
|
||||
|
||||
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity);
|
||||
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)}%`;
|
||||
}
|
||||
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)}%`;
|
||||
}
|
||||
});
|
||||
|
||||
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true });
|
||||
|
||||
@@ -376,6 +818,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
|
||||
|
||||
if (!enabled) {
|
||||
applyTerrainUiState(button, false);
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage("地形已隐藏", "info");
|
||||
@@ -398,6 +841,7 @@ 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");
|
||||
@@ -406,6 +850,7 @@ 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");
|
||||
@@ -428,11 +873,14 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
if (!enabled && !silent) {
|
||||
showStatusMessage("卫星已隐藏", "info");
|
||||
} else if (enabled) {
|
||||
const satelliteCountEl = document.getElementById("satellite-count");
|
||||
if (satelliteCountEl) {
|
||||
satelliteCountEl.textContent = `${getSatelliteCount()} 颗`;
|
||||
}
|
||||
["satellite-count", "mobile-satellite-count"].forEach((id) => {
|
||||
const satelliteCountEl = document.getElementById(id);
|
||||
if (satelliteCountEl) {
|
||||
satelliteCountEl.textContent = `${getSatelliteCount()} 颗`;
|
||||
}
|
||||
});
|
||||
}
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
@@ -442,6 +890,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
loading: false,
|
||||
tooltip: "显示卫星",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
return false;
|
||||
}
|
||||
@@ -461,6 +910,7 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
|
||||
if (bgpCountEl) {
|
||||
bgpCountEl.textContent = `${getBGPCount()} 条`;
|
||||
}
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
|
||||
@@ -474,6 +924,7 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
|
||||
active: enabled,
|
||||
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
|
||||
@@ -485,10 +936,12 @@ 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();
|
||||
}
|
||||
@@ -660,6 +1113,7 @@ function registerLayerDefinition(definition, options = {}) {
|
||||
if (row && layerPanelInitialized) {
|
||||
bindLayerButton(row, normalizedDefinition);
|
||||
}
|
||||
renderMobileLayerCards();
|
||||
return normalizedDefinition;
|
||||
}
|
||||
|
||||
@@ -748,6 +1202,15 @@ 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();
|
||||
@@ -891,6 +1354,10 @@ 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");
|
||||
@@ -900,7 +1367,9 @@ 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");
|
||||
@@ -917,11 +1386,15 @@ 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) {
|
||||
@@ -941,6 +1414,10 @@ 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);
|
||||
@@ -973,10 +1450,11 @@ function syncAllHudPanelToggles() {
|
||||
}
|
||||
|
||||
function syncDayNightToggle(enabled) {
|
||||
const input = document.getElementById("toggle-daynight");
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = enabled;
|
||||
}
|
||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyDayNightEnabled(enabled, { persist = true } = {}) {
|
||||
@@ -1028,24 +1506,29 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
|
||||
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
|
||||
const defaultEarthSizeSlider = document.getElementById("default-earth-size-slider");
|
||||
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 rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
|
||||
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||
const safeOpacity = Math.round(nextOpacity * 100);
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
terrainOpacitySlider.value = nextOpacity.toFixed(2);
|
||||
}
|
||||
if (terrainOpacityValue) {
|
||||
terrainOpacityValue.textContent = `${safeOpacity}%`;
|
||||
}
|
||||
terrainOpacitySliders.forEach((slider) => {
|
||||
if (slider instanceof HTMLInputElement) {
|
||||
slider.value = nextOpacity.toFixed(2);
|
||||
}
|
||||
});
|
||||
terrainOpacityValues.forEach((value) => {
|
||||
if (value instanceof HTMLElement) {
|
||||
value.textContent = `${safeOpacity}%`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
syncTerrainOpacityUi(getTerrainOpacity());
|
||||
syncDefaultEarthZoomUi(defaultEarthZoom);
|
||||
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
terrainOpacitySliders.forEach((terrainOpacitySlider) => {
|
||||
if (!(terrainOpacitySlider instanceof HTMLInputElement)) return;
|
||||
bindListener(terrainOpacitySlider, "input", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
@@ -1056,9 +1539,10 @@ function setupSettingsControls() {
|
||||
syncTerrainOpacityUi(appliedOpacity);
|
||||
persistEarthSettings();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (defaultEarthSizeSlider instanceof HTMLInputElement) {
|
||||
defaultEarthSizeSliders.forEach((defaultEarthSizeSlider) => {
|
||||
if (!(defaultEarthSizeSlider instanceof HTMLInputElement)) return;
|
||||
bindListener(defaultEarthSizeSlider, "input", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
@@ -1068,7 +1552,7 @@ function setupSettingsControls() {
|
||||
{ persist: true, applyToCurrentView: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rotationModeButtons.forEach((button) => {
|
||||
bindListener(button, "click", (event) => {
|
||||
@@ -1080,12 +1564,17 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
const dayNightToggle = document.getElementById("toggle-daynight");
|
||||
if (dayNightToggle instanceof HTMLInputElement) {
|
||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
|
||||
if (!(dayNightToggle instanceof HTMLInputElement)) return;
|
||||
bindListener(dayNightToggle, "change", () => {
|
||||
applyDayNightEnabled(dayNightToggle.checked);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
|
||||
bindListener(mobileSettingsReset, "click", () => {
|
||||
resetEarthSettings();
|
||||
});
|
||||
|
||||
captureEarthSettingsDefaults();
|
||||
applyEarthSettings(loadEarthSettings());
|
||||
@@ -1103,6 +1592,10 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -1230,19 +1723,32 @@ 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 = () => {
|
||||
const stopDragging = (event) => {
|
||||
if (
|
||||
event &&
|
||||
activePointerId !== null &&
|
||||
"pointerId" in event &&
|
||||
event.pointerId !== activePointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -1262,8 +1768,11 @@ 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();
|
||||
@@ -1298,9 +1807,9 @@ function setupDraggableHudPanels() {
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
bindListener(handle, "pointermove", onMove);
|
||||
bindListener(handle, "pointerup", stopDragging);
|
||||
bindListener(handle, "pointercancel", stopDragging);
|
||||
bindListener(window, "pointermove", onMove, { passive: false });
|
||||
bindListener(window, "pointerup", stopDragging);
|
||||
bindListener(window, "pointercancel", stopDragging);
|
||||
bindListener(handle, "lostpointercapture", stopDragging);
|
||||
});
|
||||
|
||||
@@ -1393,6 +1902,7 @@ export function setupControls(camera, renderer, scene, earth) {
|
||||
resetCleanup();
|
||||
activeCamera = camera;
|
||||
earthObj = earth;
|
||||
applyResponsiveLayout();
|
||||
setupZoomControls(camera);
|
||||
setupWheelZoom(camera, renderer);
|
||||
setupRotateControls(camera, earth);
|
||||
@@ -1400,6 +1910,21 @@ 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) {
|
||||
@@ -1785,6 +2310,7 @@ 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");
|
||||
@@ -1795,9 +2321,30 @@ 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", () => {
|
||||
showStatusMessage("搜索功能待开发", "info");
|
||||
if (isMobileLayout()) {
|
||||
setMobileDrawerState({ open: true, card: "search" });
|
||||
return;
|
||||
}
|
||||
closeTransientMobileOverlays({ except: "search" });
|
||||
openSearchPanel();
|
||||
});
|
||||
|
||||
bindListener(terrainBtn, "pointerenter", () => {
|
||||
@@ -1820,6 +2367,15 @@ 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) =>
|
||||
@@ -1850,6 +2406,7 @@ function setupTerrainControls() {
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
applyResponsiveLayout();
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
@@ -1857,11 +2414,21 @@ 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;
|
||||
@@ -1969,9 +2536,12 @@ 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"));
|
||||
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter(
|
||||
(orb) => getComputedStyle(orb).display !== "none",
|
||||
);
|
||||
if (orbs.length === 0) return;
|
||||
|
||||
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
|
||||
@@ -2055,6 +2625,7 @@ function setupToolbarHubCluster() {
|
||||
};
|
||||
|
||||
const scheduleCollapse = () => {
|
||||
if (hubPinnedOpen) return;
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
collapseTimer = window.setTimeout(() => {
|
||||
setExpanded(false);
|
||||
@@ -2093,6 +2664,19 @@ 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 = [];
|
||||
@@ -2152,6 +2736,7 @@ function setupToolbarHubCluster() {
|
||||
};
|
||||
|
||||
bindListener(document, "mousemove", (event) => {
|
||||
if (hubPinnedOpen) return;
|
||||
if (!cluster.classList.contains("is-expanded")) return;
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
@@ -2187,6 +2772,24 @@ 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() {
|
||||
|
||||
@@ -4,6 +4,189 @@ 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: '🛥️',
|
||||
@@ -18,6 +201,18 @@ 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: '卫星详情',
|
||||
@@ -114,19 +309,31 @@ 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 = () => {
|
||||
const stopDragging = (event) => {
|
||||
if (
|
||||
event &&
|
||||
activePointerId !== null &&
|
||||
"pointerId" in event &&
|
||||
event.pointerId !== activePointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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(
|
||||
@@ -143,7 +350,9 @@ 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();
|
||||
@@ -159,9 +368,9 @@ function setupInfoCardDrag(panel) {
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', stopDragging);
|
||||
handle.addEventListener('pointercancel', stopDragging);
|
||||
window.addEventListener('pointermove', onMove, { passive: false });
|
||||
window.addEventListener('pointerup', stopDragging);
|
||||
window.addEventListener('pointercancel', stopDragging);
|
||||
handle.addEventListener('lostpointercapture', stopDragging);
|
||||
}
|
||||
|
||||
@@ -240,6 +449,13 @@ 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;
|
||||
@@ -284,11 +500,19 @@ 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()
|
||||
@@ -308,6 +532,51 @@ 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;
|
||||
@@ -347,6 +616,15 @@ 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;
|
||||
}
|
||||
|
||||
@@ -62,17 +62,18 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
|
||||
function syncCurrentLabel(mode) {
|
||||
const labelEl = document.getElementById("legend-current-label");
|
||||
if (!labelEl) return;
|
||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const listEl = document.querySelector("#legend .legend-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const items = legendItemsByMode[mode] || [];
|
||||
listEl.innerHTML = items
|
||||
const html = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="legend-item">
|
||||
@@ -81,4 +82,14 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
toggleSatellites,
|
||||
getShowSatellites,
|
||||
getSatelliteLegendItems,
|
||||
getSatelliteData,
|
||||
setSelectedSatelliteLegend,
|
||||
clearSelectedSatelliteLegend,
|
||||
getSatelliteCount,
|
||||
@@ -136,6 +137,7 @@ import {
|
||||
applyImmediateView,
|
||||
focusEarthView,
|
||||
getZoomLevel,
|
||||
setZoomLevel,
|
||||
teardownControls,
|
||||
} from "./controls.js";
|
||||
import {
|
||||
@@ -162,6 +164,7 @@ 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;
|
||||
@@ -204,6 +207,11 @@ 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();
|
||||
@@ -224,6 +232,7 @@ 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 *",
|
||||
@@ -237,6 +246,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#earth-stats *",
|
||||
"#media-panel",
|
||||
"#media-panel *",
|
||||
"#mobile-drawer-shell",
|
||||
"#mobile-drawer-shell *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -274,6 +285,13 @@ 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)) {
|
||||
@@ -609,6 +627,391 @@ 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} 起活跃事件`;
|
||||
@@ -639,10 +1042,12 @@ function updateBGPHud(bgpResult) {
|
||||
bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`;
|
||||
}
|
||||
|
||||
const bgpStatusEl = document.getElementById("bgp-status-summary");
|
||||
if (bgpStatusEl) {
|
||||
bgpStatusEl.textContent = getBGPStatusText(bgpResult);
|
||||
}
|
||||
["bgp-status-summary", "mobile-bgp-status-summary"].forEach((id) => {
|
||||
const bgpStatusEl = document.getElementById(id);
|
||||
if (bgpStatusEl) {
|
||||
bgpStatusEl.textContent = getBGPStatusText(bgpResult);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCruiseConnector() {
|
||||
@@ -1005,10 +1410,12 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
|
||||
});
|
||||
}
|
||||
|
||||
const satelliteCountEl = document.getElementById("satellite-count");
|
||||
if (satelliteCountEl) {
|
||||
satelliteCountEl.textContent = `${satelliteCount} 颗`;
|
||||
}
|
||||
["satellite-count", "mobile-satellite-count"].forEach((id) => {
|
||||
const satelliteCountEl = document.getElementById(id);
|
||||
if (satelliteCountEl) {
|
||||
satelliteCountEl.textContent = `${satelliteCount} 颗`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCableToggleUi(enabled) {
|
||||
@@ -1188,6 +1595,10 @@ export function init() {
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
initSearchPanel({
|
||||
resolveResults: resolveEarthSearchResults,
|
||||
onSelectResult: handleSearchSelection,
|
||||
});
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
@@ -1474,6 +1885,9 @@ 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();
|
||||
@@ -1580,9 +1994,9 @@ export async function setSatellitesEnabled(
|
||||
|
||||
function setupEventListeners() {
|
||||
const handleResize = () => onWindowResize();
|
||||
const handleMouseMove = (event) => onMouseMove(event);
|
||||
const handleMouseDown = (event) => onMouseDown(event);
|
||||
const handleMouseUp = () => onMouseUp();
|
||||
const handlePointerMove = (event) => onPointerMove(event);
|
||||
const handlePointerDown = (event) => onPointerDown(event);
|
||||
const handlePointerUp = (event) => onPointerUp(event);
|
||||
const handleMouseLeave = () => onMouseLeave();
|
||||
const handleClick = (event) => onClick(event);
|
||||
const handlePageHide = () => destroy();
|
||||
@@ -1592,11 +2006,15 @@ function setupEventListeners() {
|
||||
bindListener(window, "pagehide", handlePageHide);
|
||||
bindListener(window, "beforeunload", handlePageHide);
|
||||
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
|
||||
bindListener(window, "mousemove", handleMouseMove);
|
||||
bindListener(renderer.domElement, "mousedown", handleMouseDown);
|
||||
bindListener(window, "mouseup", handleMouseUp);
|
||||
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
|
||||
bindListener(window, "pointermove", handlePointerMove);
|
||||
bindListener(window, "pointerup", handlePointerUp);
|
||||
bindListener(window, "pointercancel", handlePointerUp);
|
||||
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
|
||||
bindListener(renderer.domElement, "click", handleClick);
|
||||
if (renderer?.domElement) {
|
||||
renderer.domElement.style.touchAction = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHudScale() {
|
||||
@@ -1684,6 +2102,9 @@ 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;
|
||||
@@ -1860,6 +2281,100 @@ 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();
|
||||
}
|
||||
@@ -1868,6 +2383,10 @@ function onClick(event) {
|
||||
const earth = getEarth();
|
||||
if (!earth) return;
|
||||
if (isEventOnHud(event)) return;
|
||||
if (suppressNextClick) {
|
||||
suppressNextClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
updatePointerFromEvent(event);
|
||||
|
||||
|
||||
@@ -18,17 +18,18 @@ let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
return {
|
||||
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"),
|
||||
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"),
|
||||
regionChip: document.getElementById("news-region-chip"),
|
||||
board: document.getElementById("news-board-list"),
|
||||
empty: document.getElementById("news-board-empty"),
|
||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,18 +82,33 @@ 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")}`;
|
||||
@@ -275,8 +291,6 @@ export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
@@ -287,16 +301,22 @@ export function initNewsPanel() {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
["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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
["news-open-external", "mobile-news-open-external"].forEach((id) => {
|
||||
const openBtn = document.getElementById(id);
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
});
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
|
||||
282
frontend/public/earth/js/search.js
Normal file
282
frontend/public/earth/js/search.js
Normal file
@@ -0,0 +1,282 @@
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -56,21 +56,22 @@ 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("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"),
|
||||
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"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("tv-meta-toggle"),
|
||||
liveHeaderControls: document.getElementById("tv-header-controls-live"),
|
||||
@@ -351,6 +352,7 @@ 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", {
|
||||
@@ -1071,12 +1073,16 @@ export function initTVPanel() {
|
||||
showStatusMessage("已切换到态势新闻", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLSelectElement)) return;
|
||||
currentSourceId = target.value;
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
[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));
|
||||
});
|
||||
});
|
||||
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
@@ -1084,9 +1090,13 @@ export function initTVPanel() {
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", () => {
|
||||
refreshTVPanel();
|
||||
});
|
||||
[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();
|
||||
});
|
||||
});
|
||||
|
||||
liveTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("live");
|
||||
@@ -1095,24 +1105,32 @@ export function initTVPanel() {
|
||||
setActiveTab("news");
|
||||
});
|
||||
|
||||
iframe?.addEventListener("load", () => {
|
||||
if (iframe.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
[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);
|
||||
});
|
||||
});
|
||||
|
||||
video?.addEventListener("loadedmetadata", () => {
|
||||
if (video.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
[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("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
videoEl?.addEventListener("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setupResizeHandle();
|
||||
syncPanelActiveTab("live");
|
||||
|
||||
@@ -19,6 +19,15 @@ 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";
|
||||
@@ -171,27 +180,13 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
||||
|
||||
// Update earth stats
|
||||
export function updateEarthStats(stats) {
|
||||
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 卫星图";
|
||||
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 卫星图");
|
||||
}
|
||||
|
||||
// Show/hide loading via status message
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
"references": [{ "path": "./tsconfig.tooling.json" }]
|
||||
}
|
||||
|
||||
92
planet.sh
92
planet.sh
@@ -59,6 +59,7 @@ 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}"
|
||||
@@ -371,6 +372,41 @@ 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"
|
||||
@@ -766,8 +802,8 @@ ensure_frontend_deps() {
|
||||
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
|
||||
set_wait_detail "检查 vite 是否已安装"
|
||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||
set_wait_detail "检查 Vite Bun 入口是否已安装"
|
||||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||
log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})"
|
||||
set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install"
|
||||
if ! run_with_retry \
|
||||
@@ -780,9 +816,9 @@ ensure_frontend_deps() {
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||
if [ ! -f "$FRONTEND_VITE_ENTRY" ]; then
|
||||
close_wait_session_context "$owns_wait_session"
|
||||
log_error "前端依赖安装失败,未找到 vite"
|
||||
log_error "前端依赖安装失败,未找到 Vite Bun 入口"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1324,15 +1360,15 @@ cleanup_frontend_processes() {
|
||||
rm -f "$FRONTEND_PID_FILE"
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1342,7 +1378,13 @@ start_frontend_with_retry() {
|
||||
fi
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
: > /tmp/planet_frontend.log
|
||||
nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 &
|
||||
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 &
|
||||
FRONTEND_PID=$!
|
||||
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
|
||||
|
||||
@@ -1368,6 +1410,7 @@ 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" "前端"
|
||||
@@ -1379,8 +1422,12 @@ start_frontend_service() {
|
||||
log_success "前端依赖已就绪"
|
||||
|
||||
start_wait_session "启动前端服务"
|
||||
set_wait_detail "启动 Vite 开发服务器"
|
||||
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then
|
||||
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
|
||||
stop_wait_session
|
||||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||
tail -10 /tmp/planet_frontend.log
|
||||
@@ -1399,6 +1446,7 @@ 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
|
||||
@@ -1433,6 +1481,10 @@ parse_service_args() {
|
||||
DATABASE_REQUESTED=1
|
||||
shift 1
|
||||
;;
|
||||
--allow-lan)
|
||||
FRONTEND_LAN_ENABLED=1
|
||||
shift 1
|
||||
;;
|
||||
*)
|
||||
log_error "未知参数: $1"
|
||||
exit 1
|
||||
@@ -1479,7 +1531,7 @@ stop_ai_provider_service() {
|
||||
}
|
||||
|
||||
stop_frontend_service() {
|
||||
if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
|
||||
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/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"
|
||||
@@ -1607,13 +1659,16 @@ start() {
|
||||
print_splash
|
||||
|
||||
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
||||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
|
||||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
|
||||
|
||||
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() {
|
||||
@@ -1633,7 +1688,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
|
||||
|
||||
@@ -1658,7 +1713,7 @@ restart() {
|
||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||
stop_frontend_service
|
||||
sleep 1
|
||||
start_frontend_service "$FRONTEND_PORT" 1
|
||||
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -1674,6 +1729,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -1754,9 +1812,9 @@ case "$1" in
|
||||
;;
|
||||
*)
|
||||
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
||||
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
|
||||
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan"
|
||||
log_note "stop 停止服务"
|
||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
|
||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan"
|
||||
log_note "createuser 交互创建用户"
|
||||
log_note "health 检查健康状态"
|
||||
log_note "log 查看日志"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.33.0"
|
||||
version = "0.35.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
# 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`
|
||||
|
||||
这样会比纯蓝图硬接稳定很多。
|
||||
@@ -1,22 +0,0 @@
|
||||
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",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
#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);
|
||||
};
|
||||
@@ -1,189 +0,0 @@
|
||||
# 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 解析
|
||||
- 点位生成
|
||||
- 海缆生成
|
||||
|
||||
一步一步来,先确认“连通”,再做“可视化”。这样最不容易卡死。
|
||||
Reference in New Issue
Block a user