Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195a8bf71c | ||
|
|
987c378f99 | ||
|
|
67f82dc41c | ||
|
|
abe04030fb |
114
README.md
114
README.md
@@ -227,6 +227,120 @@ bun run build
|
||||
|
||||
启动服务后访问: `http://localhost:8000/docs`
|
||||
|
||||
## WSL / Windows 局域网访问
|
||||
|
||||
如果服务运行在 WSL 中,而你希望:
|
||||
|
||||
- Windows 本机浏览器访问开发服务
|
||||
- 同一局域网内的手机或其他电脑访问开发服务
|
||||
|
||||
推荐按下面顺序排查和配置。
|
||||
|
||||
### 1. 在 WSL 中启动服务
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
|
||||
|
||||
### 2. 先确认 WSL 内部服务正常
|
||||
|
||||
在 WSL 中执行:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `3000` 返回前端 HTML
|
||||
- `8000/health` 返回健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
|
||||
|
||||
如果这一步不通,先不要继续做 Windows 转发。
|
||||
|
||||
### 3. 在 Windows 本机验证 localhost 直通
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
||||
|
||||
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
|
||||
|
||||
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
|
||||
|
||||
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
```
|
||||
|
||||
再放行 Windows 防火墙:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
```
|
||||
|
||||
检查转发规则是否生效:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy show all
|
||||
```
|
||||
|
||||
预期能看到:
|
||||
|
||||
- `0.0.0.0:3000 -> 127.0.0.1:3000`
|
||||
- `0.0.0.0:8000 -> 127.0.0.1:8000`
|
||||
|
||||
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
ipconfig
|
||||
```
|
||||
|
||||
找到当前联网网卡的 IPv4 地址,例如 `192.168.8.228`。
|
||||
|
||||
局域网其他设备可访问:
|
||||
|
||||
- `http://<Windows局域网IP>:3000/earth`
|
||||
- `http://<Windows局域网IP>:3000/admin`
|
||||
|
||||
例如:
|
||||
|
||||
- `http://192.168.8.228:3000/earth`
|
||||
|
||||
### 6. 常见现象与判断
|
||||
|
||||
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
|
||||
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||
|
||||
### 7. 本项目一次性验证顺序
|
||||
|
||||
建议固定按这个顺序验证:
|
||||
|
||||
1. WSL 中执行 `curl http://localhost:3000`
|
||||
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||
3. Windows 中执行 `curl http://localhost:3000`
|
||||
4. Windows 中执行 `curl http://localhost:8000/health`
|
||||
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
||||
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
||||
|
||||
## 启动容错参数
|
||||
|
||||
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
||||
|
||||
8
TODO.md
8
TODO.md
@@ -24,3 +24,11 @@
|
||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
||||
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
||||
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
|
||||
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败
|
||||
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
||||
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
|
||||
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.countries import get_country_centroid
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
@@ -363,6 +364,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _parse_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||
("el capitan", 37.6819, -121.7681),
|
||||
("livermore", 37.6819, -121.7681),
|
||||
("llnl", 37.6819, -121.7681),
|
||||
("lawrence livermore", 37.6819, -121.7681),
|
||||
("frontier", 35.9319, -84.3107),
|
||||
("oak ridge", 35.9319, -84.3107),
|
||||
("ornl", 35.9319, -84.3107),
|
||||
("aurora", 41.7130, -87.9820),
|
||||
("argonne", 41.7130, -87.9820),
|
||||
("anl", 41.7130, -87.9820),
|
||||
("fugaku", 34.6953, 135.1974),
|
||||
("kobe", 34.6953, 135.1974),
|
||||
("riken", 34.6953, 135.1974),
|
||||
("summit", 35.9319, -84.3107),
|
||||
("leonardo", 44.4949, 11.3426),
|
||||
("bologna", 44.4949, 11.3426),
|
||||
("alps", 46.0037, 8.9511),
|
||||
("lugano", 46.0037, 8.9511),
|
||||
("sunway taihulight", 31.4912, 120.3119),
|
||||
("wuxi", 31.4912, 120.3119),
|
||||
("tianhe-2", 23.1291, 113.2644),
|
||||
("tianhe-2a", 23.1291, 113.2644),
|
||||
("guangzhou", 23.1291, 113.2644),
|
||||
("colossus", 35.1495, -90.0490),
|
||||
("memphis", 35.1495, -90.0490),
|
||||
("xai", 35.1495, -90.0490),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hint_text(*parts: Any) -> str:
|
||||
return " ".join(
|
||||
str(part).strip().lower()
|
||||
for part in parts
|
||||
if part not in (None, "")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compute_center_coordinates(
|
||||
record: CollectedData,
|
||||
metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "precise",
|
||||
"geography_mode": "source_coordinates",
|
||||
"is_estimated": False,
|
||||
"estimated_reason": None,
|
||||
}
|
||||
|
||||
hint_text = _normalize_hint_text(
|
||||
record.name,
|
||||
get_record_field(record, "city"),
|
||||
get_record_field(record, "country"),
|
||||
metadata.get("site"),
|
||||
metadata.get("organization"),
|
||||
metadata.get("operator"),
|
||||
)
|
||||
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||
if needle in hint_text:
|
||||
return {
|
||||
"latitude": resolved_latitude,
|
||||
"longitude": resolved_longitude,
|
||||
"location_precision": "estimated_site",
|
||||
"geography_mode": "site_hint",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": f"Matched known site hint: {needle}",
|
||||
}
|
||||
|
||||
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||
if centroid:
|
||||
return {
|
||||
"latitude": centroid.get("latitude"),
|
||||
"longitude": centroid.get("longitude"),
|
||||
"location_precision": "estimated_country",
|
||||
"geography_mode": "country_centroid",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "Estimated from country centroid",
|
||||
}
|
||||
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "unknown",
|
||||
"geography_mode": "unknown",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "No resolvable location hints",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||
if capacity_value is None:
|
||||
return "unknown"
|
||||
|
||||
unit = str(capacity_unit or "").strip().lower()
|
||||
if unit in {"pflop/s", "pflops", "pflop"}:
|
||||
normalized_tflops = capacity_value * 1000
|
||||
elif unit in {"gflop/s", "gflops", "gflop"}:
|
||||
normalized_tflops = capacity_value / 1000
|
||||
else:
|
||||
normalized_tflops = capacity_value
|
||||
|
||||
if normalized_tflops >= 1_000_000:
|
||||
return "exascale"
|
||||
if normalized_tflops >= 100_000:
|
||||
return "ultra"
|
||||
if normalized_tflops >= 10_000:
|
||||
return "large"
|
||||
if normalized_tflops > 0:
|
||||
return "regional"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||
latitude = coordinate_info.get("latitude")
|
||||
longitude = coordinate_info.get("longitude")
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
|
||||
if site_type == "supercomputer":
|
||||
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||
capacity_unit = "GFlops"
|
||||
else:
|
||||
capacity_value = _parse_float(get_record_field(record, "value"))
|
||||
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
|
||||
|
||||
vendor = (
|
||||
metadata.get("manufacturer")
|
||||
or metadata.get("vendor")
|
||||
or metadata.get("gpu_type")
|
||||
)
|
||||
operator = (
|
||||
metadata.get("organization")
|
||||
or metadata.get("operator")
|
||||
or metadata.get("owner")
|
||||
)
|
||||
rank = metadata.get("rank")
|
||||
if rank in (None, "") and site_type == "supercomputer":
|
||||
rank = get_record_field(record, "rank")
|
||||
|
||||
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": record.id,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [longitude or 0, latitude or 0],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"site_type": site_type,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"operator": operator,
|
||||
"vendor": vendor,
|
||||
"capacity_value": capacity_value,
|
||||
"capacity_unit": capacity_unit,
|
||||
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
|
||||
"rank": rank,
|
||||
"gpu_count": metadata.get("gpu_count"),
|
||||
"gpu_type": metadata.get("gpu_type"),
|
||||
"cores": get_record_field(record, "cores"),
|
||||
"power": get_record_field(record, "power"),
|
||||
"source": record.source,
|
||||
"updated_at": updated_at,
|
||||
"status": "observed",
|
||||
"location_precision": coordinate_info.get("location_precision"),
|
||||
"geography_mode": coordinate_info.get("geography_mode"),
|
||||
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||
"data_type": "compute_center",
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_bgp_anomalies_to_geojson(
|
||||
records: List[BGPAnomaly],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
@@ -975,6 +1185,53 @@ async def get_gpu_clusters_geojson(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/compute-centers")
|
||||
async def get_compute_centers_geojson(
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取统一算力中心 GeoJSON 数据"""
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
["top500", "epoch_ai_gpu"],
|
||||
)
|
||||
records = _filter_known_records(
|
||||
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
|
||||
)
|
||||
if limit is not None:
|
||||
records = records[:limit]
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
},
|
||||
}
|
||||
|
||||
geojson = convert_compute_centers_to_geojson(records)
|
||||
features = geojson.get("features", [])
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(features),
|
||||
"stats": {
|
||||
"total": len(features),
|
||||
"supercomputers": sum(
|
||||
1 for feature in features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_clusters": sum(
|
||||
1 for feature in features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/bgp-anomalies")
|
||||
async def get_bgp_anomalies_geojson(
|
||||
severity: Optional[str] = Query(None),
|
||||
|
||||
217
backend/tests/test_visualization_compute_centers.py
Normal file
217
backend/tests/test_visualization_compute_centers.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
|
||||
def _build_record(
|
||||
*,
|
||||
record_id: int,
|
||||
source: str,
|
||||
data_type: str,
|
||||
name: str,
|
||||
country: str,
|
||||
city: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
metadata: dict,
|
||||
):
|
||||
return CollectedData(
|
||||
id=record_id,
|
||||
source=source,
|
||||
data_type=data_type,
|
||||
source_id=f"{source}-{record_id}",
|
||||
name=name,
|
||||
extra_data={
|
||||
"country": country,
|
||||
"city": city,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
**metadata,
|
||||
},
|
||||
collected_at=datetime(2026, 4, 22, tzinfo=timezone.utc),
|
||||
reference_date=datetime(2026, 4, 21, tzinfo=timezone.utc),
|
||||
is_current=True,
|
||||
)
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_unifies_sources():
|
||||
top500_record = _build_record(
|
||||
record_id=1,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={
|
||||
"rank": 1,
|
||||
"manufacturer": "HPE",
|
||||
"organization": "ORNL",
|
||||
"rmax": 1102000.0,
|
||||
"cores": 8730112,
|
||||
"power": 21510.0,
|
||||
},
|
||||
)
|
||||
gpu_record = _build_record(
|
||||
record_id=2,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Colossus",
|
||||
country="United States",
|
||||
city="Memphis",
|
||||
latitude=35.15,
|
||||
longitude=-90.05,
|
||||
metadata={
|
||||
"organization": "xAI",
|
||||
"gpu_type": "H100",
|
||||
"gpu_count": 100000,
|
||||
"value": "20000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([top500_record, gpu_record])
|
||||
|
||||
assert payload["type"] == "FeatureCollection"
|
||||
assert len(payload["features"]) == 2
|
||||
|
||||
supercomputer_feature = payload["features"][0]
|
||||
assert supercomputer_feature["properties"]["site_type"] == "supercomputer"
|
||||
assert supercomputer_feature["properties"]["capacity_unit"] == "GFlops"
|
||||
assert supercomputer_feature["properties"]["capacity_band"] == "exascale"
|
||||
assert supercomputer_feature["properties"]["operator"] == "ORNL"
|
||||
assert supercomputer_feature["properties"]["location_precision"] == "precise"
|
||||
assert supercomputer_feature["properties"]["is_estimated"] is False
|
||||
|
||||
gpu_feature = payload["features"][1]
|
||||
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
|
||||
assert gpu_feature["properties"]["vendor"] == "H100"
|
||||
assert gpu_feature["properties"]["gpu_count"] == 100000
|
||||
assert gpu_feature["properties"]["capacity_band"] == "large"
|
||||
assert gpu_feature["properties"]["location_precision"] == "precise"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
||||
hinted_record = _build_record(
|
||||
record_id=3,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Oak Ridge National Laboratory",
|
||||
"rmax": 1102000.0,
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([hinted_record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||
assert coords[0] == pytest.approx(-84.3107)
|
||||
assert coords[1] == pytest.approx(35.9319)
|
||||
assert payload["features"][0]["properties"]["is_estimated"] is True
|
||||
assert payload["features"][0]["properties"]["location_precision"] == "estimated_site"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
|
||||
centroid_record = _build_record(
|
||||
record_id=4,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Unknown Cluster",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Unknown Operator",
|
||||
"value": "10000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([centroid_record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
props = payload["features"][0]["properties"]
|
||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||
assert coords[0] == pytest.approx(-98.5795)
|
||||
assert coords[1] == pytest.approx(39.8283)
|
||||
assert props["is_estimated"] is True
|
||||
assert props["location_precision"] == "estimated_country"
|
||||
assert props["geography_mode"] == "country_centroid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_centers_geojson_endpoint_returns_stats():
|
||||
records = [
|
||||
_build_record(
|
||||
record_id=1,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={"rank": 1, "rmax": 1102000.0},
|
||||
),
|
||||
_build_record(
|
||||
record_id=2,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Colossus",
|
||||
country="United States",
|
||||
city="Memphis",
|
||||
latitude=35.15,
|
||||
longitude=-90.05,
|
||||
metadata={"value": "20000", "unit": "TFlop/s"},
|
||||
),
|
||||
]
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _ScalarResult(records)
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/compute-centers")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 2
|
||||
assert data["stats"]["supercomputers"] == 1
|
||||
assert data["stats"]["gpu_clusters"] == 1
|
||||
assert data["features"][0]["properties"]["data_type"] == "compute_center"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -8,6 +8,69 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.37.2] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
|
||||
|
||||
### 🔧 Improvements
|
||||
- 经纬线正式接入 Earth layer registry,复用现有图层切换、移动端图层卡片与设置持久化流
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.37.1] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
|
||||
|
||||
### 🔧 Improvements
|
||||
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.37.0] — 2026-04-23
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 连线系统正式从巡航里解耦成通用 callout connector:桌面端和移动端统一支持对象级锚点、四边切换与临界区边缘滑动
|
||||
- BGP 巡航展示继续收口为稳定的“先定位卡片、再连真实锚点、再展示卡片”链路,移动端 popup 与桌面 info panel 的路线规则统一
|
||||
|
||||
### 🔧 Improvements
|
||||
- connector 配置从 `CRUISE_CONFIG` 拆到独立 `CONNECTOR_CONFIG`,默认类名、动画名和实例命名也全部去 cruise 语义
|
||||
- 移动端 popup 增加更稳定的 dock/obstacle 处理,拖动卡片时连线起终点会持续按几何关系自适应刷新
|
||||
- Earth 多个图层与控制逻辑继续收口,补充算力中心/BGP 风格对齐、layer panel 与相关交互细节调整
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复巡航模式下终点只像“视觉锚点”而不是真实绑定对象的问题,卡片拖动后终点现在会跟随
|
||||
- 修复移动端与桌面端多类连线路线异常:压线、反向、临界区折返、起点遮挡事件点等问题
|
||||
- 修复对象矩形临界区内连线仍强制中点到中点导致路线像“先钻进 source 内部”再出去的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
## [0.36.0] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新增统一“算力中心”图层:接入超算与 GPU 集群,支持搜索、统计、图例、详情卡与独立图层开关
|
||||
- 算力中心支持精确位置与估算位置两种状态,估算点会以问号角标区分,避免数据不全时整批节点在地图上消失
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth 详情卡拖拽与地球拖拽交互继续收口,减少拖动卡片和旋转地球时的选中文本与 pointer 竞争
|
||||
- `planet.sh` 改为通过独立脚本计算 AI Provider 依赖指纹,降低与根仓库依赖版本文件的无关耦合
|
||||
- README 补充 WSL / Windows 局域网访问排查与转发配置说明,便于开发环境联调
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Earth 算力中心图层在无原始坐标时无法显示的问题,支持站点提示和国家级估算回退
|
||||
- 修复信息卡拖拽事件可能被卡片级 stopPropagation 吞掉,导致拖拽流中断的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.35.1] — 2026-04-22
|
||||
|
||||
### ✨ Highlights
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
当前重点入口:
|
||||
|
||||
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-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)
|
||||
|
||||
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
372
docs/plans/earth-compute-center-bgp-style-plan.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# Earth Compute Center BGP-Style Plan
|
||||
|
||||
## Goal
|
||||
|
||||
这份文档定义如何按照 BGP 模块的产品方式,把“算力中心”提升为 Earth 上的一级能力。
|
||||
|
||||
这里的“按 BGP 方式”指的是:
|
||||
|
||||
- 有独立的数据语义和接口入口
|
||||
- 有独立的 Earth 图层与图例
|
||||
- 有独立的 hover / click / 选中态 / 详情卡
|
||||
- 有独立的统计口径与后续专题页扩展空间
|
||||
|
||||
这里的“按 BGP 方式”不指:
|
||||
|
||||
- 机械复制 BGP 的 anomaly / incident / collector 三层事件模型
|
||||
- 为静态算力设施强行引入不必要的复杂告警语义
|
||||
|
||||
算力中心本质上更接近“长期基础设施分布层”,不是“高频动态异常层”。
|
||||
因此应该复用 BGP 的模块化方法,而不是照搬 BGP 的事件结构。
|
||||
|
||||
## Why
|
||||
|
||||
当前仓库里已经有算力相关基础:
|
||||
|
||||
- 后端已有 `top500` 和 `epoch_ai_gpu` 数据采集
|
||||
- 可视化接口已有 `/api/v1/visualization/geo/supercomputers` 和 `/api/v1/visualization/geo/gpu-clusters`
|
||||
- Earth 信息卡已对 `supercomputer` 和 `gpu_cluster` 做了基础类型兼容
|
||||
|
||||
但当前能力还停留在“数据可取到”的阶段,没有形成像 BGP 那样完整的可视化模块:
|
||||
|
||||
- Earth 缺少独立的算力图层加载模块
|
||||
- 缺少算力 marker 体系和视觉层级
|
||||
- 缺少算力图例、统计、开关和搜索接入
|
||||
- 缺少与海缆、BGP、卫星的关系表达
|
||||
- 缺少算力专题页和后续告警/研判扩展入口
|
||||
|
||||
所以当前真正的缺口不是“有没有数据”,而是“有没有产品级模块”。
|
||||
|
||||
## Core Principle
|
||||
|
||||
算力中心应当采用和 BGP 一致的模块化分层:
|
||||
|
||||
1. 数据层:稳定的数据契约和 GeoJSON 输出
|
||||
2. 渲染层:独立的 Earth 图层、marker 和视觉状态管理
|
||||
3. 交互层:hover、click、锁定态、详情卡、图例和统计
|
||||
4. 扩展层:后续专题页、关系分析、告警和 AI 研判
|
||||
|
||||
但语义上必须保持算力中心自身的特点:
|
||||
|
||||
- `site / center` 是主对象,不是事件
|
||||
- `capacity / rank / vendor / operator / status` 是主信息,不是异常严重度
|
||||
- `distribution / concentration / dependency` 是后续分析方向,不是第一阶段必须项
|
||||
|
||||
## Recommended Scope
|
||||
|
||||
第一版“算力中心”建议统一承载两类对象:
|
||||
|
||||
- `supercomputer`
|
||||
- `gpu_cluster`
|
||||
|
||||
并在 Earth 上收口为一个主题层:`compute_centers`
|
||||
|
||||
这样做有几个好处:
|
||||
|
||||
- 用户看到的是统一的“算力基础设施”语义,而不是零散数据源
|
||||
- 后端仍可保留 `top500` 和 `epoch_ai_gpu` 的来源差异
|
||||
- 前端可以在一个图层里再细分两种 marker 语言
|
||||
|
||||
## Current Gap
|
||||
|
||||
和 BGP 对比,当前差距主要在下面几层。
|
||||
|
||||
### 1. Data Contract Gap
|
||||
|
||||
现在的算力 GeoJSON 还是通用 `collected_data` 输出思路,字段较轻:
|
||||
|
||||
- `gpu_cluster` 只有基础名称和地点
|
||||
- `supercomputer` 只暴露一部分性能字段
|
||||
- 缺少统一的 `site_type / operator / capacity_band / source / updated_at / confidence`
|
||||
- 缺少统一的算力层聚合出口
|
||||
|
||||
### 2. Earth Rendering Gap
|
||||
|
||||
当前 Earth 里没有类似 `bgp.js` 的算力模块:
|
||||
|
||||
- `constants.js` 没有算力 API 路径和视觉配置
|
||||
- `main.js` 没有算力加载、拾取、状态同步和 HUD 更新
|
||||
- `controls.js` 没有算力图层开关和启动加载优先级
|
||||
- `layer-startup-tasks.js` 没有算力启动任务
|
||||
- `legend.js` / `ui.js` 没有算力统计与图例模式
|
||||
|
||||
### 3. Interaction Gap
|
||||
|
||||
虽然 `info-card.js` 支持基础字段,但还没有形成 BGP 那种完整交互链路:
|
||||
|
||||
- 没有 hover / selected / dimmed 的视觉状态
|
||||
- 没有算力对象专属 tooltip 与摘要文案
|
||||
- 没有锁定后与其他基础设施的联动高亮
|
||||
- 没有搜索、统计卡和详情组织方式
|
||||
|
||||
### 4. Product Expansion Gap
|
||||
|
||||
当前还没有“算力中心”专题页与分析语义:
|
||||
|
||||
- 没有全球分布/国家聚合/厂商聚合视图
|
||||
- 没有算力与海缆/BGP/区域的关系表达
|
||||
- 没有 AI brief / assessment 的后续落点
|
||||
|
||||
## Architecture Direction
|
||||
|
||||
推荐把算力中心做成“BGP 同级能力”,但采用更适合静态基础设施的结构。
|
||||
|
||||
### Backend
|
||||
|
||||
建议新增统一聚合接口,例如:
|
||||
|
||||
- `/api/v1/visualization/geo/compute-centers`
|
||||
|
||||
它的职责是把:
|
||||
|
||||
- `top500`
|
||||
- `epoch_ai_gpu`
|
||||
|
||||
统一转换成一个主题层输出,同时保留对象细分类型:
|
||||
|
||||
- `site_type: supercomputer | gpu_cluster`
|
||||
|
||||
建议统一字段至少包括:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `site_type`
|
||||
- `country`
|
||||
- `city`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- `operator`
|
||||
- `vendor`
|
||||
- `capacity_value`
|
||||
- `capacity_unit`
|
||||
- `capacity_band`
|
||||
- `rank`
|
||||
- `source`
|
||||
- `updated_at`
|
||||
- `location_precision`
|
||||
- `geography_mode`
|
||||
- `is_estimated`
|
||||
- `estimated_reason`
|
||||
- `metadata`
|
||||
|
||||
这里建议优先做“统一聚合出口”,而不是一开始就新增独立数据库表。
|
||||
|
||||
原因:
|
||||
|
||||
- 当前源数据更新频率低,先复用 `collected_data` 成本更低
|
||||
- 可以先把 Earth 产品体验做完整
|
||||
- 如果后续要做历史趋势、关系推断、告警,再评估是否拆成独立模型
|
||||
|
||||
### Frontend Earth
|
||||
|
||||
建议新增独立模块,例如:
|
||||
|
||||
- `frontend/public/earth/js/compute-centers.js`
|
||||
|
||||
职责参照 `bgp.js`:
|
||||
|
||||
- 拉取算力中心 GeoJSON
|
||||
- 创建 marker
|
||||
- 管理 hover / selected / dimmed 状态
|
||||
- 输出图例项
|
||||
- 输出统计摘要
|
||||
- 提供 overlay 和详情格式化辅助函数
|
||||
|
||||
推荐视觉分层:
|
||||
|
||||
1. `supercomputer` 用更稳定、更规整的设施型符号
|
||||
2. `gpu_cluster` 用更活跃、更现代的密度型符号
|
||||
3. 选中态通过 halo / ring / related infrastructure highlight 表达
|
||||
|
||||
视觉上应避免把算力中心做成“BGP 事件点”那种高频脉冲风格。
|
||||
它应该更像长期存在的高价值设施。
|
||||
|
||||
## Phases
|
||||
|
||||
## Phase 1: Unified Earth Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 先把算力中心做成 Earth 上可用、可点、可解释的一级图层
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增统一算力 GeoJSON 接口
|
||||
- 新增 `compute-centers.js`
|
||||
- 在 `constants.js` 增加 API 路径和视觉配置
|
||||
- 在 `controls.js` 增加算力图层开关与启动元数据
|
||||
- 在 `layer-startup-tasks.js` 增加算力启动加载任务
|
||||
- 在 `main.js` 接入算力拾取、hover、click、锁定态和 HUD 统计
|
||||
- 在 `ui.js` / `legend.js` / `index.html` 增加算力统计与图例入口
|
||||
- 在 `info-card.js` 提升算力详情字段组织
|
||||
- 对无法精确定位、但可按国家或弱线索推测的大概位置,仍然生成地图点位
|
||||
- 这类对象必须带显式“估算位置”状态,例如图标问号角标与详情说明
|
||||
|
||||
完成标准:
|
||||
|
||||
- Earth 上能独立显示/隐藏算力中心
|
||||
- 两类对象有可区分的视觉表达
|
||||
- hover / click / 详情卡 / 图例 / 统计全部打通
|
||||
- 精确位置与估算位置在图标或文案上可区分,不会误导为同一精度
|
||||
- 不干扰现有海缆、卫星、BGP 的交互链路
|
||||
|
||||
## Phase 2: Relationship Layer
|
||||
|
||||
目标:
|
||||
|
||||
- 让算力中心不只是“点”,而是和其他基础设施产生上下文关系
|
||||
|
||||
工作项:
|
||||
|
||||
- 建立算力中心与国家/区域聚合摘要
|
||||
- 增加与附近海缆登陆点的关系提示
|
||||
- 增加与 BGP 事件/观测范围的空间邻近提示
|
||||
- 增加与卫星覆盖或区域连通性的实验性提示
|
||||
|
||||
完成标准:
|
||||
|
||||
- 点击算力中心时,用户能看到“它和哪些基础设施相关”
|
||||
- 信息表达以辅助判断为主,不做夸张推断
|
||||
|
||||
## Phase 3: Compute Center Observatory
|
||||
|
||||
目标:
|
||||
|
||||
- 把算力中心从 Earth 图层扩展成独立专题观测能力
|
||||
|
||||
工作项:
|
||||
|
||||
- 新增算力中心专题页
|
||||
- 提供国家/厂商/类型/容量分布统计
|
||||
- 支持列表、筛选、详情和历史快照
|
||||
- 预留 AI brief / assessment 入口
|
||||
|
||||
完成标准:
|
||||
|
||||
- 算力中心不再只是 Earth 上的视觉点位
|
||||
- 能作为独立业务上下文进入日常观察与研判
|
||||
|
||||
## Phase 4: Alerts And Assessment
|
||||
|
||||
目标:
|
||||
|
||||
- 在不滥造“假动态告警”的前提下,引入真正有价值的变化感知
|
||||
|
||||
候选方向:
|
||||
|
||||
- 新增大规模算力中心
|
||||
- 既有中心容量显著变化
|
||||
- 国家/区域集中度显著变化
|
||||
- 高价值中心与关键网络基础设施关系变化
|
||||
|
||||
完成标准:
|
||||
|
||||
- 告警来自可解释的结构变化
|
||||
- 不把静态数据硬做成噪声式实时事件流
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
建议按下面顺序推进:
|
||||
|
||||
1. 先统一 GeoJSON 契约
|
||||
2. 再做 Earth 独立模块和图层开关
|
||||
3. 再补详情卡、图例和统计
|
||||
4. 最后才做关系层和专题页
|
||||
|
||||
这样可以避免一开始把范围摊得过大。
|
||||
|
||||
## Unknown Location Strategy
|
||||
|
||||
由于部分算力数据源不会直接提供经纬度,未知位置补全不能只依赖“继续找 API 字段”。
|
||||
更稳妥的方式是做成一条分层富化链路,而不是单一猜测规则。
|
||||
|
||||
推荐按下面优先级推进:
|
||||
|
||||
1. 直接源信息
|
||||
|
||||
- 源记录显式给出 `latitude / longitude`
|
||||
- 源记录给出 `city / region / facility / campus / operator`
|
||||
- 源页面详情、内嵌 JSON、结构化元数据、新闻稿链接里能抽出地点线索
|
||||
|
||||
2. 名称与机构归一化
|
||||
|
||||
- 建立 `canonical_name / aliases / operator / facility` 归一化表
|
||||
- 把 `cluster name`、`operator`、`campus name` 归一到同一个实体
|
||||
- 优先解决同一对象多写法导致的命中失败,而不是先扩大猜测范围
|
||||
|
||||
3. 本地位置注册表
|
||||
|
||||
- 用仓库内可维护的 registry 保存高价值对象的位置知识
|
||||
- 每条记录至少包含:`canonical_name`、`aliases`、`operator`、`country`、`region`、`city`、`lat`、`lon`、`confidence`、`source_note`
|
||||
- 转换层优先读取 registry,避免地点知识长期散落在转换代码里
|
||||
|
||||
4. 分层回退定位
|
||||
|
||||
- `precise`
|
||||
- `estimated_site`
|
||||
- `estimated_city`
|
||||
- `estimated_region`
|
||||
- `estimated_national_hub`
|
||||
- `estimated_country`
|
||||
|
||||
这里建议把“国家内主要算力城市”作为国家质心之前的一层。
|
||||
例如没有美国精确位置时,优先考虑已知的主要算力/数据中心城市候选,而不是直接落在几何质心。
|
||||
|
||||
5. 候选证据富化
|
||||
|
||||
- 如果源 API 无地点信息,可以允许采集链路读取公开辅助证据
|
||||
- 例如机构官网、数据中心介绍页、新闻稿、百科型页面、公开 PDF
|
||||
- 但只提取“地点线索”,不把外部页面上的经纬度当真值直接写回
|
||||
|
||||
6. 人工校验闭环
|
||||
|
||||
- 对高价值且仍然未知的对象输出待核验清单
|
||||
- 把人工确认结果回写到位置注册表
|
||||
- 后续采集继续优先复用这层人工确认结果
|
||||
|
||||
### Additional Solution Paths
|
||||
|
||||
除了静态映射表,还可以考虑下面这些办法:
|
||||
|
||||
- 基于国家和运营方建立“主要园区候选集”,用稳定散列把同国未知节点分散到若干可信城市,而不是全部压到一个点
|
||||
- 基于数据中心/云厂商公开 region 列表建立 `operator -> city set` 候选映射,用于云 GPU 集群类对象
|
||||
- 把“估算依据”结构化,例如 `matched_alias`、`matched_operator`、`matched_city_text`、`fallback_country_hub`
|
||||
- 给位置补全增加 `last_verified_at`,便于后续按时间重新校验老旧映射
|
||||
- 单独维护“不可可靠定位”状态;这类对象仍可在国家级聚合统计中出现,但可以允许用户在地图上过滤掉
|
||||
- 后续如果你们愿意投入更多,可把这条链路做成小型 enrichment pipeline,而不是仅在 API 转换时临时判断
|
||||
|
||||
## Non-Goals
|
||||
|
||||
第一阶段不建议做这些内容:
|
||||
|
||||
- 不复制 BGP 巡航模式到算力中心
|
||||
- 不先做复杂实时 websocket 推送
|
||||
- 不先引入独立 `compute_center_incident` 一类模型
|
||||
- 不先做全量 AI 分析面板
|
||||
|
||||
原因是算力中心的第一需求是“被看清楚”,不是“被实时播报”。
|
||||
但“被看清楚”不等于“只显示精确坐标对象”。
|
||||
对于没有精确经纬度、但能推测到国家或区域级位置的算力中心,应优先以上图并标注估算状态的方式处理,而不是直接在地图上消失。
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- 后端存在统一的算力中心 GeoJSON 出口
|
||||
- Earth 有独立算力图层模块,而不是散落在 `main.js`
|
||||
- 页面上有清晰的算力开关、图例和统计
|
||||
- `supercomputer` 和 `gpu_cluster` 在视觉和详情上都可区分
|
||||
- 估算位置对象在地图和详情中都有明确状态提示
|
||||
- 现有 BGP / 海缆 / 卫星功能无回归
|
||||
- 代码结构上为后续专题页和关系分析留出了明确扩展点
|
||||
|
||||
## Summary
|
||||
|
||||
这项工作的本质不是“再多画几个点”。
|
||||
|
||||
它应该把算力中心从已有数据源,升级成与 BGP 同级的 Earth 观测主题:
|
||||
|
||||
- 有独立语义
|
||||
- 有独立图层
|
||||
- 有独立交互
|
||||
- 有后续分析扩展能力
|
||||
|
||||
推荐先完成 Phase 1,把算力中心做成真正可用的 Earth 一级模块,再继续推进关系层和专题页。
|
||||
@@ -16,12 +16,16 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.35.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.37.2`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
|
||||
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh` 在 `uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
|
||||
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector,统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
|
||||
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
|
||||
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
|
||||
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
|
||||
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.35.1",
|
||||
"version": "0.37.2",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -70,6 +70,13 @@ body.earth-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.is-globe-dragging,
|
||||
body.earth-page.is-globe-dragging,
|
||||
body.earth-page.is-globe-dragging * {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
.earth-app {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
display: flex;
|
||||
position: fixed;
|
||||
z-index: 260;
|
||||
overflow: visible;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 10px 12px;
|
||||
@@ -304,6 +305,15 @@
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-mobile-popup.earth-mobile-popup--anchor-stable {
|
||||
transform: none;
|
||||
transition: opacity 0.17s ease;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .earth-mobile-popup.earth-mobile-popup--anchor-stable.is-visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-mobile-popup-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
@@ -342,6 +352,42 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-popup-dock {
|
||||
--earth-mobile-popup-dock-size: 14px;
|
||||
--earth-mobile-popup-dock-offset: calc(var(--earth-mobile-popup-dock-size) * -0.5);
|
||||
position: absolute;
|
||||
top: 32%;
|
||||
width: var(--earth-mobile-popup-dock-size);
|
||||
height: var(--earth-mobile-popup-dock-size);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-mobile-popup[data-dock-side="left"] .earth-mobile-popup-dock {
|
||||
left: var(--earth-mobile-popup-dock-offset);
|
||||
}
|
||||
|
||||
.earth-mobile-popup[data-dock-side="right"] .earth-mobile-popup-dock {
|
||||
right: var(--earth-mobile-popup-dock-offset);
|
||||
}
|
||||
|
||||
.earth-mobile-popup[data-dock-side="top"] .earth-mobile-popup-dock {
|
||||
top: var(--earth-mobile-popup-dock-offset);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.earth-mobile-popup[data-dock-side="bottom"] .earth-mobile-popup-dock {
|
||||
top: auto;
|
||||
bottom: var(--earth-mobile-popup-dock-offset);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* ── Mobile drawer ───────────────────────────────────────────── */
|
||||
|
||||
.earth-mobile-drawer-overlay,
|
||||
|
||||
@@ -153,15 +153,28 @@
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
visibility: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.hud-panel-info.is-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.info-card-cruise-link {
|
||||
.hud-panel-info.hud-panel-info--anchor-stable {
|
||||
transform: none;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel-info.hud-panel-info--anchor-stable.is-visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.callout-connector {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -173,7 +186,7 @@
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
.info-card-cruise-link polyline {
|
||||
.callout-connector polyline {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.98);
|
||||
stroke-width: 2.15;
|
||||
@@ -185,7 +198,7 @@
|
||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||
}
|
||||
|
||||
.info-card-cruise-link circle {
|
||||
.callout-connector circle {
|
||||
fill: rgba(255, 255, 255, 0.98);
|
||||
stroke: rgba(7, 16, 32, 0.72);
|
||||
stroke-width: 1.0;
|
||||
@@ -197,29 +210,29 @@
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-visible {
|
||||
.callout-connector.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating polyline {
|
||||
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
.callout-connector.is-animating polyline {
|
||||
animation: calloutConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle {
|
||||
.callout-connector.is-animating circle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle:first-of-type {
|
||||
animation: cruiseConnectorNodeIn 0.14s ease forwards;
|
||||
.callout-connector.is-animating circle:first-of-type {
|
||||
animation: calloutConnectorNodeIn 0.14s ease forwards;
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle:last-of-type {
|
||||
animation: cruiseConnectorNodeIn 0.16s ease forwards;
|
||||
.callout-connector.is-animating circle:last-of-type {
|
||||
animation: calloutConnectorNodeIn 0.16s ease forwards;
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorDraw {
|
||||
@keyframes calloutConnectorDraw {
|
||||
from {
|
||||
stroke-dashoffset: var(--connector-length, 0px);
|
||||
}
|
||||
@@ -228,7 +241,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorNodeIn {
|
||||
@keyframes calloutConnectorNodeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
@@ -290,6 +303,8 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar {
|
||||
@@ -327,6 +342,8 @@
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.18s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.info-card-label:hover {
|
||||
@@ -341,6 +358,8 @@
|
||||
text-align: right;
|
||||
max-width: calc(180px * var(--hud-scale));
|
||||
word-break: break-word;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Type-specific header accent colors */
|
||||
|
||||
@@ -160,6 +160,23 @@
|
||||
.layer-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(5 * (56px * var(--hud-scale)));
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.layer-panel-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
@@ -169,6 +186,7 @@
|
||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
transition: background 0.14s ease;
|
||||
min-height: calc(56px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.layer-row:last-child {
|
||||
@@ -343,3 +361,8 @@
|
||||
max-height: min(52vh, 460px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .layer-panel-list {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function applyInitialEarthViewportMode() {
|
||||
var width = window.innerWidth;
|
||||
var height = window.innerHeight;
|
||||
var mode = "desktop";
|
||||
|
||||
if (width <= 820) {
|
||||
mode = "mobile";
|
||||
} else if (width <= 1080 || height <= 760) {
|
||||
mode = "compact";
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle("layout-mode-mobile", mode === "mobile");
|
||||
document.documentElement.classList.toggle("layout-mode-compact", mode === "compact");
|
||||
document.documentElement.dataset.earthLayoutMode = mode;
|
||||
})();
|
||||
|
||||
(function applyInitialHudScale() {
|
||||
var referenceWidth = 1920;
|
||||
var referenceHeight = 1080;
|
||||
@@ -102,6 +118,16 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
|
||||
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">经纬线</span>
|
||||
<span class="layer-row-meta">Graticule</span>
|
||||
</div>
|
||||
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
@@ -132,6 +158,16 @@
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="算力中心 compute centers">
|
||||
<span class="material-symbols-rounded layer-row-icon">memory</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">算力中心</span>
|
||||
<span class="layer-row-meta">Compute Centers</span>
|
||||
</div>
|
||||
<button id="toggle-compute-centers" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换算力中心显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="bgp观测 routing signals">
|
||||
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
||||
<div class="layer-row-copy">
|
||||
@@ -293,6 +329,10 @@
|
||||
<span class="stat-num" id="satellite-count" data-earth-stat="satellite-count">—</span>
|
||||
<span class="stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="compute-center-count" data-earth-stat="compute-center-count">—</span>
|
||||
<span class="stat-label">算力中心</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-anomaly-count" data-earth-stat="bgp-anomaly-count">—</span>
|
||||
<span class="stat-label">BGP 事件</span>
|
||||
@@ -421,6 +461,7 @@
|
||||
<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 id="earth-mobile-popup-dock" class="earth-mobile-popup-dock" aria-hidden="true"></span>
|
||||
<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>
|
||||
@@ -456,7 +497,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -475,7 +516,7 @@
|
||||
</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 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">
|
||||
@@ -497,6 +538,10 @@
|
||||
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count">—</span>
|
||||
<span class="earth-mobile-stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stat-card">
|
||||
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count">—</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" data-earth-stat="bgp-anomaly-count">—</span>
|
||||
<span class="earth-mobile-stat-label">BGP 事件</span>
|
||||
@@ -666,7 +711,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
|
||||
<div class="earth-mobile-detail-empty">点击海缆、BGP 事件或卫星后在这里查看详情。</div>
|
||||
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -697,7 +742,7 @@
|
||||
inputmode="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="搜索海缆、登陆点、卫星、BGP 事件..."
|
||||
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>
|
||||
@@ -705,7 +750,7 @@
|
||||
</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 id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,73 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||
import { createElbowConnectorPoints } from "./callout-connector.js";
|
||||
import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||
import {
|
||||
computeNearestPerimeterAnchor,
|
||||
createConnectorPath,
|
||||
resolveConnectorAnchor,
|
||||
} from "./callout-connector.js";
|
||||
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
||||
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
||||
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
|
||||
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
||||
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18;
|
||||
const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
|
||||
const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
|
||||
const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17;
|
||||
const CRUISE_MOBILE_POPUP_MARGIN_PX = 14;
|
||||
const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52;
|
||||
const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16;
|
||||
const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12;
|
||||
const CRUISE_MARKER_SCREEN_PADDING_PX = 4;
|
||||
|
||||
function getDockAxisOffsets(dockSide, gapPx) {
|
||||
return {
|
||||
offsetX:
|
||||
dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0,
|
||||
offsetY:
|
||||
dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getObstaclePaddingBySide(side, paddingPx) {
|
||||
if (side === "left") {
|
||||
return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx };
|
||||
}
|
||||
if (side === "right") {
|
||||
return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx };
|
||||
}
|
||||
if (side === "top") {
|
||||
return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx };
|
||||
}
|
||||
return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 };
|
||||
}
|
||||
|
||||
const scratchMarkerWorldScale = new THREE.Vector3();
|
||||
const scratchCameraQuaternion = new THREE.Quaternion();
|
||||
const scratchCameraRight = new THREE.Vector3();
|
||||
const scratchCameraUp = new THREE.Vector3();
|
||||
const scratchMarkerRightPoint = new THREE.Vector3();
|
||||
const scratchMarkerLeftPoint = new THREE.Vector3();
|
||||
const scratchMarkerTopPoint = new THREE.Vector3();
|
||||
const scratchMarkerBottomPoint = new THREE.Vector3();
|
||||
|
||||
function projectWorldToScreen(point, camera) {
|
||||
if (!point || !camera) return null;
|
||||
const projected = point.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getMarkerTimestamp(marker) {
|
||||
const rawValue = marker?.userData?.created_at_raw;
|
||||
@@ -53,14 +109,78 @@ export function createBGPCruiseAdapter({
|
||||
if (!marker || !camera) return null;
|
||||
scratchBGPWorldPosition.copy(marker.position);
|
||||
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
||||
const projected = scratchBGPWorldPosition.clone().project(camera);
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||
return projectWorldToScreen(scratchBGPWorldPosition, camera);
|
||||
}
|
||||
|
||||
function getVisibleMobilePopup() {
|
||||
const mobilePopup = document.getElementById("earth-mobile-popup");
|
||||
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
|
||||
? mobilePopup
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVisibleInfoPanel() {
|
||||
const infoPanel = document.getElementById("info-panel");
|
||||
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
|
||||
? infoPanel
|
||||
: null;
|
||||
}
|
||||
|
||||
function getMarkerScreenRect(marker) {
|
||||
const center = getMarkerScreenCoords(marker);
|
||||
if (!center || !camera || !marker) return null;
|
||||
|
||||
marker.getWorldScale(scratchMarkerWorldScale);
|
||||
const worldWidth = Math.max(
|
||||
0.0001,
|
||||
Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x,
|
||||
);
|
||||
const worldHeight = Math.max(
|
||||
0.0001,
|
||||
Number(scratchMarkerWorldScale.y || worldWidth),
|
||||
);
|
||||
|
||||
camera.getWorldQuaternion(scratchCameraQuaternion);
|
||||
scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||
scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize();
|
||||
|
||||
scratchMarkerRightPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraRight, worldWidth * 0.5);
|
||||
scratchMarkerLeftPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraRight, -worldWidth * 0.5);
|
||||
scratchMarkerTopPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraUp, worldHeight * 0.5);
|
||||
scratchMarkerBottomPoint
|
||||
.copy(scratchBGPWorldPosition)
|
||||
.addScaledVector(scratchCameraUp, -worldHeight * 0.5);
|
||||
|
||||
const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera);
|
||||
const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera);
|
||||
const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera);
|
||||
const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera);
|
||||
if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const halfWidth = Math.max(
|
||||
Math.abs(rightPoint.x - center.x),
|
||||
Math.abs(leftPoint.x - center.x),
|
||||
1,
|
||||
);
|
||||
const halfHeight = Math.max(
|
||||
Math.abs(topPoint.y - center.y),
|
||||
Math.abs(bottomPoint.y - center.y),
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||
y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX,
|
||||
width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||
height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +188,53 @@ export function createBGPCruiseAdapter({
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const safeBottom =
|
||||
parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
|
||||
) || 0;
|
||||
const estimatedCardWidth = Math.min(
|
||||
CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
|
||||
window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2,
|
||||
);
|
||||
const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX;
|
||||
const topBound = Math.max(
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO,
|
||||
window.innerHeight -
|
||||
CRUISE_MOBILE_DRAWER_CLEARANCE_PX -
|
||||
safeBottom -
|
||||
estimatedCardHeight -
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
),
|
||||
);
|
||||
const rightSlotLeft = Math.max(
|
||||
CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX,
|
||||
);
|
||||
const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX;
|
||||
const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5;
|
||||
const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5;
|
||||
const rightClearance = rightSlotLeft - markerCoords.x;
|
||||
const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth);
|
||||
const rightCost =
|
||||
Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||
Math.abs(rightSlotCenterX - markerCoords.x);
|
||||
const leftCost =
|
||||
Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
|
||||
Math.abs(markerCoords.x - leftSlotCenterX);
|
||||
const placeOnRight = rightCost <= leftCost;
|
||||
const left = placeOnRight ? rightSlotLeft : leftSlotLeft;
|
||||
return {
|
||||
x: left,
|
||||
y: topBound,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
dockSide: placeOnRight ? "left" : "right",
|
||||
};
|
||||
}
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
@@ -94,38 +261,148 @@ export function createBGPCruiseAdapter({
|
||||
Math.max(margin, y),
|
||||
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
|
||||
);
|
||||
const anchorY = clampedY + Math.max(
|
||||
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
|
||||
estimatedCardHeight * 0.18,
|
||||
);
|
||||
|
||||
return {
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: estimatedCardWidth,
|
||||
height: estimatedCardHeight,
|
||||
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
|
||||
anchorY,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardAnchorTarget() {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (
|
||||
document.body.classList.contains("layout-mode-mobile") &&
|
||||
mobilePopup
|
||||
) {
|
||||
const dockSide = mobilePopup.dataset.dockSide || "left";
|
||||
const { offsetX, offsetY } = getDockAxisOffsets(
|
||||
dockSide,
|
||||
CONNECTOR_CONFIG.panelGapPx,
|
||||
);
|
||||
return {
|
||||
element: mobilePopup,
|
||||
side: dockSide,
|
||||
alignRatio: 0.5,
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCardObstacleTarget(fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
if (
|
||||
document.body.classList.contains("layout-mode-mobile") &&
|
||||
mobilePopup
|
||||
) {
|
||||
const side = mobilePopup.dataset.dockSide || "left";
|
||||
return {
|
||||
element: mobilePopup,
|
||||
padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX),
|
||||
};
|
||||
}
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
if (infoPanel) {
|
||||
return {
|
||||
element: infoPanel,
|
||||
padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX),
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackPlacement) {
|
||||
return {
|
||||
x: fallbackPlacement.x,
|
||||
y: fallbackPlacement.y,
|
||||
width: fallbackPlacement.width ?? 0,
|
||||
height: fallbackPlacement.height ?? 0,
|
||||
padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
const popupRect =
|
||||
mobilePopup
|
||||
? mobilePopup.getBoundingClientRect()
|
||||
: fallbackPlacement
|
||||
? {
|
||||
left: fallbackPlacement.x,
|
||||
top: fallbackPlacement.y,
|
||||
right: fallbackPlacement.x + (fallbackPlacement.width ?? 0),
|
||||
bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0),
|
||||
}
|
||||
: null;
|
||||
if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left";
|
||||
|
||||
return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left";
|
||||
}
|
||||
|
||||
function syncMobileDockSide(markerCoords, fallbackPlacement = null) {
|
||||
const mobilePopup = getVisibleMobilePopup();
|
||||
const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement);
|
||||
if (mobilePopup) {
|
||||
mobilePopup.dataset.dockSide = dockSide;
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectorPath(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
if (!markerCoords || !targetCardCoords) return null;
|
||||
const markerRect = getMarkerScreenRect(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
return createElbowConnectorPoints(
|
||||
markerCoords,
|
||||
{
|
||||
x: targetCardCoords.anchorX,
|
||||
y: targetCardCoords.anchorY,
|
||||
},
|
||||
{
|
||||
if (document.body.classList.contains("layout-mode-mobile")) {
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
syncMobileDockSide(markerCoords, targetCardCoords);
|
||||
const cardAnchorTarget = getCardAnchorTarget();
|
||||
const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget);
|
||||
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||
if (!cardAnchorCoords) return null;
|
||||
|
||||
return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, {
|
||||
routingMode: "adaptive",
|
||||
sourceRect: markerRect,
|
||||
targetAnchor: cardAnchorTarget ?? cardAnchorCoords,
|
||||
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||
startFrom: "source",
|
||||
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
|
||||
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
|
||||
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
|
||||
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
});
|
||||
}
|
||||
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
|
||||
if (!targetCardCoords) return null;
|
||||
|
||||
const infoPanel = getVisibleInfoPanel();
|
||||
const desktopTarget =
|
||||
infoPanel
|
||||
? infoPanel
|
||||
: {
|
||||
x: targetCardCoords.x,
|
||||
y: targetCardCoords.y,
|
||||
width: targetCardCoords.width,
|
||||
height: targetCardCoords.height,
|
||||
};
|
||||
|
||||
return createConnectorPath(
|
||||
markerCoords,
|
||||
desktopTarget,
|
||||
{
|
||||
routingMode: "adaptive",
|
||||
sourceRect: markerRect,
|
||||
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
|
||||
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
|
||||
startFrom: "source",
|
||||
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
|
||||
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -166,7 +443,6 @@ export function createBGPCruiseAdapter({
|
||||
async focusMarker(marker, { interrupt = false } = {}) {
|
||||
if (!marker) return;
|
||||
currentMarkerId = marker.userData?.id || null;
|
||||
cardPlacement = getCardScreenCoords(marker);
|
||||
setMarkerLocked(marker);
|
||||
showMarkerOverlay(marker);
|
||||
|
||||
@@ -179,10 +455,31 @@ export function createBGPCruiseAdapter({
|
||||
: CRUISE_CONFIG.focusDurationMs,
|
||||
suppressStatus: true,
|
||||
});
|
||||
|
||||
cardPlacement = getCardScreenCoords(marker);
|
||||
},
|
||||
async presentMarker(marker, { context }) {
|
||||
if (!marker) return false;
|
||||
|
||||
const showCruiseMarkerInfo = ({ reveal = true } = {}) =>
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
reveal,
|
||||
anchorStable: true,
|
||||
dockSide: cardPlacement?.dockSide,
|
||||
});
|
||||
|
||||
showCruiseMarkerInfo({ reveal: false });
|
||||
await context.nextFrame();
|
||||
if (!context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
@@ -211,18 +508,10 @@ export function createBGPCruiseAdapter({
|
||||
return false;
|
||||
}
|
||||
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
});
|
||||
showCruiseMarkerInfo();
|
||||
await context.nextFrame();
|
||||
if (!isInfoVisible()) {
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
});
|
||||
showCruiseMarkerInfo();
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const bgpGroup = new THREE.Group();
|
||||
const bgpOverlayGroup = new THREE.Group();
|
||||
@@ -280,37 +280,23 @@ function blendHexColors(fromHex, toHex, ratio) {
|
||||
function getCollectorDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
marker.getWorldPosition(collectorWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset;
|
||||
const referenceFovRad = (75 * Math.PI) / 180;
|
||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
||||
const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6);
|
||||
const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9);
|
||||
const worldPerPixel =
|
||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
|
||||
});
|
||||
}
|
||||
|
||||
function getEventDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
|
||||
|
||||
marker.getWorldPosition(collectorWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
|
||||
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset;
|
||||
const referenceFovRad = (75 * Math.PI) / 180;
|
||||
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
|
||||
const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7);
|
||||
const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9);
|
||||
const worldPerPixel =
|
||||
distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: BGP_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
|
||||
max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
|
||||
});
|
||||
}
|
||||
|
||||
function orientCollectorMarkerToSurface(marker, position) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CABLE_STATE,
|
||||
CABLE_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { showInfoCard } from "./info-card.js";
|
||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||
@@ -21,7 +21,6 @@ let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
let landingPointGeometry = null;
|
||||
const landingPointWorldPosition = new THREE.Vector3();
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -33,24 +32,13 @@ function getLandingPointDistanceScale(point, camera) {
|
||||
!camera ||
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
|
||||
) return 1;
|
||||
point.getWorldPosition(landingPointWorldPosition);
|
||||
const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition);
|
||||
const referenceDistance =
|
||||
CONFIG.defaultCameraZ -
|
||||
CONFIG.earthRadius +
|
||||
CABLE_CONFIG.landingPoint.altitudeOffset;
|
||||
const referenceFovDeg =
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75;
|
||||
const referenceFovRad = (referenceFovDeg * Math.PI) / 180;
|
||||
const cameraFovRad =
|
||||
(((camera.fov || referenceFovDeg)) * Math.PI) / 180;
|
||||
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
|
||||
return clamp(
|
||||
worldPerPixel / referenceWorldPerPixel,
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
);
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
|
||||
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
|
||||
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
|
||||
@@ -1,13 +1,316 @@
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
|
||||
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
|
||||
const DEFAULT_CLASS_NAME = "callout-connector";
|
||||
const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
|
||||
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
|
||||
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
|
||||
|
||||
function createSvgElement(tagName) {
|
||||
return document.createElementNS(SVG_NS, tagName);
|
||||
}
|
||||
|
||||
function resolveElementAnchorSide(side) {
|
||||
switch (side) {
|
||||
case "right":
|
||||
case "top":
|
||||
case "bottom":
|
||||
case "left":
|
||||
return side;
|
||||
default:
|
||||
return "left";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveElementAnchorAlignRatio(ratio) {
|
||||
if (!Number.isFinite(ratio)) return 0.5;
|
||||
return Math.min(Math.max(ratio, 0), 1);
|
||||
}
|
||||
|
||||
function resolveAnchorElement(target) {
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (target?.element instanceof HTMLElement) return target.element;
|
||||
if (typeof target?.selector === "string") {
|
||||
const matched = document.querySelector(target.selector);
|
||||
return matched instanceof HTMLElement ? matched : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRectElement(target) {
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (target?.element instanceof HTMLElement) return target.element;
|
||||
if (typeof target?.selector === "string") {
|
||||
const matched = document.querySelector(target.selector);
|
||||
return matched instanceof HTMLElement ? matched : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFiniteRect(rect) {
|
||||
return (
|
||||
rect &&
|
||||
Number.isFinite(rect.left) &&
|
||||
Number.isFinite(rect.top) &&
|
||||
Number.isFinite(rect.right) &&
|
||||
Number.isFinite(rect.bottom)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRect(rect) {
|
||||
if (!rect) return null;
|
||||
const left = Number(rect.left);
|
||||
const top = Number(rect.top);
|
||||
const right = Number(rect.right);
|
||||
const bottom = Number(rect.bottom);
|
||||
if (
|
||||
!Number.isFinite(left) ||
|
||||
!Number.isFinite(top) ||
|
||||
!Number.isFinite(right) ||
|
||||
!Number.isFinite(bottom)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: Math.min(left, right),
|
||||
top: Math.min(top, bottom),
|
||||
right: Math.max(left, right),
|
||||
bottom: Math.max(top, bottom),
|
||||
};
|
||||
}
|
||||
|
||||
function expandRect(rect, padding = 0) {
|
||||
const normalized = normalizeRect(rect);
|
||||
if (!normalized) return null;
|
||||
if (typeof padding === "object" && padding !== null) {
|
||||
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
|
||||
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
|
||||
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
|
||||
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
|
||||
return {
|
||||
left: normalized.left - leftPadding,
|
||||
top: normalized.top - topPadding,
|
||||
right: normalized.right + rightPadding,
|
||||
bottom: normalized.bottom + bottomPadding,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: normalized.left - padding,
|
||||
top: normalized.top - padding,
|
||||
right: normalized.right + padding,
|
||||
bottom: normalized.bottom + padding,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeSequentialPoints(points) {
|
||||
const nextPoints = [];
|
||||
for (const point of points) {
|
||||
const previous = nextPoints[nextPoints.length - 1];
|
||||
if (
|
||||
previous &&
|
||||
Math.abs(previous.x - point.x) < 0.5 &&
|
||||
Math.abs(previous.y - point.y) < 0.5
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
nextPoints.push(point);
|
||||
}
|
||||
return nextPoints;
|
||||
}
|
||||
|
||||
// Compute the anchor point on the nearest perimeter edge of rect to source.
|
||||
// gap is applied outward from the edge, so the anchor is outside the rect.
|
||||
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
|
||||
const { left, top, right, bottom } = rect;
|
||||
const midX = (left + right) * 0.5;
|
||||
const midY = (top + bottom) * 0.5;
|
||||
|
||||
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
|
||||
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
|
||||
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
|
||||
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
|
||||
|
||||
// Source inside rect: snap to nearest edge midpoint
|
||||
const dLeft = source.x - left;
|
||||
const dRight = right - source.x;
|
||||
const dTop = source.y - top;
|
||||
const dBottom = bottom - source.y;
|
||||
const minD = Math.min(dLeft, dRight, dTop, dBottom);
|
||||
|
||||
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
|
||||
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
|
||||
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
|
||||
return { x: midX, y: bottom + gap, side: "bottom" };
|
||||
}
|
||||
|
||||
export function resolveConnectorObstacleRect(target, options = {}) {
|
||||
if (!target) return null;
|
||||
|
||||
if (typeof target === "function") {
|
||||
return resolveConnectorObstacleRect(target(), options);
|
||||
}
|
||||
|
||||
const resolvedPadding =
|
||||
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
|
||||
? target.padding
|
||||
: options.padding;
|
||||
const padding =
|
||||
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
|
||||
? resolvedPadding
|
||||
: 0;
|
||||
|
||||
if (isFiniteRect(target)) {
|
||||
return expandRect(target, padding);
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isFinite(target.x) &&
|
||||
Number.isFinite(target.y) &&
|
||||
Number.isFinite(target.width) &&
|
||||
Number.isFinite(target.height)
|
||||
) {
|
||||
return expandRect(
|
||||
{
|
||||
left: Number(target.x),
|
||||
top: Number(target.y),
|
||||
right: Number(target.x) + Number(target.width),
|
||||
bottom: Number(target.y) + Number(target.height),
|
||||
},
|
||||
padding,
|
||||
);
|
||||
}
|
||||
|
||||
const element = resolveRectElement(target);
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
return expandRect(element.getBoundingClientRect(), padding);
|
||||
}
|
||||
|
||||
export function resolveConnectorAnchor(target) {
|
||||
if (!target) return null;
|
||||
|
||||
if (typeof target === "function") {
|
||||
return resolveConnectorAnchor(target());
|
||||
}
|
||||
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
|
||||
return { x: Number(target.x), y: Number(target.y) };
|
||||
}
|
||||
|
||||
const element = resolveAnchorElement(target);
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const side = resolveElementAnchorSide(target.side);
|
||||
const alignRatio = resolveElementAnchorAlignRatio(
|
||||
target.alignRatio ?? target.anchorRatio ?? target.ratio,
|
||||
);
|
||||
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
|
||||
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
|
||||
|
||||
let x = rect.left + rect.width * 0.5;
|
||||
let y = rect.top + rect.height * 0.5;
|
||||
|
||||
if (side === "left") {
|
||||
x = rect.left;
|
||||
y = rect.top + rect.height * alignRatio;
|
||||
} else if (side === "right") {
|
||||
x = rect.right;
|
||||
y = rect.top + rect.height * alignRatio;
|
||||
} else if (side === "top") {
|
||||
x = rect.left + rect.width * alignRatio;
|
||||
y = rect.top;
|
||||
} else if (side === "bottom") {
|
||||
x = rect.left + rect.width * alignRatio;
|
||||
y = rect.bottom;
|
||||
}
|
||||
|
||||
return {
|
||||
x: x + offsetX,
|
||||
y: y + offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConnectorRect(target) {
|
||||
return resolveConnectorObstacleRect(target, { padding: 0 });
|
||||
}
|
||||
|
||||
function createRectSideMidpoint(rect, side, gap = 0) {
|
||||
const normalizedRect = normalizeRect(rect);
|
||||
if (!normalizedRect) return null;
|
||||
|
||||
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
|
||||
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
|
||||
|
||||
if (side === "left") {
|
||||
return { x: normalizedRect.left - gap, y: midpointY, side };
|
||||
}
|
||||
if (side === "right") {
|
||||
return { x: normalizedRect.right + gap, y: midpointY, side };
|
||||
}
|
||||
if (side === "top") {
|
||||
return { x: midpointX, y: normalizedRect.top - gap, side };
|
||||
}
|
||||
if (side === "bottom") {
|
||||
return { x: midpointX, y: normalizedRect.bottom + gap, side };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function createRectEdgeAnchor(rect, side, position, gap = 0) {
|
||||
const normalizedRect = normalizeRect(rect);
|
||||
if (!normalizedRect) return null;
|
||||
|
||||
const x =
|
||||
side === "left"
|
||||
? normalizedRect.left - gap
|
||||
: side === "right"
|
||||
? normalizedRect.right + gap
|
||||
: clamp(
|
||||
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
|
||||
normalizedRect.left,
|
||||
normalizedRect.right,
|
||||
);
|
||||
const y =
|
||||
side === "top"
|
||||
? normalizedRect.top - gap
|
||||
: side === "bottom"
|
||||
? normalizedRect.bottom + gap
|
||||
: clamp(
|
||||
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
|
||||
normalizedRect.top,
|
||||
normalizedRect.bottom,
|
||||
);
|
||||
|
||||
return { x, y, side };
|
||||
}
|
||||
|
||||
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
|
||||
if (!startPoint || !endPoint) return null;
|
||||
const normalizedDirections = directions.filter(Boolean);
|
||||
if (!normalizedDirections.length) {
|
||||
return dedupeSequentialPoints([startPoint, endPoint]);
|
||||
}
|
||||
|
||||
const firstDirection = normalizedDirections[0];
|
||||
const corner =
|
||||
firstDirection === "left" || firstDirection === "right"
|
||||
? { x: endPoint.x, y: startPoint.y }
|
||||
: { x: startPoint.x, y: endPoint.y };
|
||||
|
||||
return dedupeSequentialPoints([startPoint, corner, endPoint]);
|
||||
}
|
||||
|
||||
function resolveSourceAnchorGapPx(sourceGapPx) {
|
||||
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
|
||||
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
|
||||
}
|
||||
|
||||
export function createElbowConnectorPoints(source, target, options = {}) {
|
||||
if (!source || !target) return null;
|
||||
const resolvedSource = resolveConnectorAnchor(source);
|
||||
const resolvedTarget = resolveConnectorAnchor(target);
|
||||
if (!resolvedSource || !resolvedTarget) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
@@ -17,8 +320,8 @@ export function createElbowConnectorPoints(source, target, options = {}) {
|
||||
elbowDropPx = 14,
|
||||
} = options;
|
||||
|
||||
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
|
||||
const targetPoint = { x: Number(target.x), y: Number(target.y) };
|
||||
const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||
const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x) ||
|
||||
!Number.isFinite(sourcePoint.y) ||
|
||||
@@ -53,6 +356,163 @@ export function createElbowConnectorPoints(source, target, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createAdaptiveConnectorPoints(source, target, options = {}) {
|
||||
const resolvedSource = resolveConnectorAnchor(source);
|
||||
if (!resolvedSource) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
sourceGapPx = 12,
|
||||
targetGapPx = 8,
|
||||
obstacleClearancePx = 8,
|
||||
obstacles = [],
|
||||
targetAnchor = null,
|
||||
sourceRect = null,
|
||||
} = options;
|
||||
|
||||
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
||||
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
|
||||
|
||||
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
|
||||
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
|
||||
.filter(Boolean);
|
||||
|
||||
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
|
||||
const resolvedTarget =
|
||||
resolveConnectorAnchor(targetAnchor ?? target) ||
|
||||
(fallbackTargetRect
|
||||
? computeNearestPerimeterAnchor(
|
||||
sp,
|
||||
fallbackTargetRect,
|
||||
Math.max(targetGapPx, obstacleClearancePx + 1),
|
||||
)
|
||||
: null);
|
||||
if (!resolvedTarget) return null;
|
||||
|
||||
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
||||
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
|
||||
|
||||
const primaryObstacle = normalizedObstacles[0] || null;
|
||||
const relationRect = fallbackTargetRect || primaryObstacle;
|
||||
if (!relationRect) return null;
|
||||
|
||||
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
|
||||
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
|
||||
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
|
||||
|
||||
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
|
||||
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
|
||||
const isTargetAbove = relationRect.bottom < sp.y;
|
||||
const isTargetBelow = relationRect.top > sp.y;
|
||||
const isSourceWithinAnchorHorizontalRange =
|
||||
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
|
||||
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
|
||||
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
|
||||
|
||||
let directions = [];
|
||||
let targetSide = null;
|
||||
const isTargetCenterWithinSourceVerticalRange =
|
||||
sourceRelationRect &&
|
||||
targetCenterY >= sourceRelationRect.top &&
|
||||
targetCenterY <= sourceRelationRect.bottom;
|
||||
const isTargetCenterWithinSourceHorizontalRange =
|
||||
sourceRelationRect &&
|
||||
targetCenterX >= sourceRelationRect.left &&
|
||||
targetCenterX <= sourceRelationRect.right;
|
||||
|
||||
if (isRightMidpointLeftOfSource) {
|
||||
targetSide = "right";
|
||||
if (isTargetCenterWithinSourceVerticalRange) {
|
||||
directions = ["left"];
|
||||
} else if (rightMidpoint.y < sp.y) {
|
||||
directions = ["top", "left"];
|
||||
} else if (rightMidpoint.y > sp.y) {
|
||||
directions = ["bottom", "left"];
|
||||
} else {
|
||||
directions = ["left"];
|
||||
}
|
||||
} else if (isLeftMidpointRightOfSource) {
|
||||
targetSide = "left";
|
||||
if (isTargetCenterWithinSourceVerticalRange) {
|
||||
directions = ["right"];
|
||||
} else if (leftMidpoint.y < sp.y) {
|
||||
directions = ["top", "right"];
|
||||
} else if (leftMidpoint.y > sp.y) {
|
||||
directions = ["bottom", "right"];
|
||||
} else {
|
||||
directions = ["right"];
|
||||
}
|
||||
} else if (isSourceWithinAnchorHorizontalRange) {
|
||||
if (isTargetAbove) {
|
||||
targetSide = "bottom";
|
||||
directions = isTargetCenterWithinSourceHorizontalRange
|
||||
? ["top"]
|
||||
: targetCenterX >= sp.x
|
||||
? ["right", "top"]
|
||||
: ["left", "top"];
|
||||
} else if (isTargetBelow) {
|
||||
targetSide = "top";
|
||||
directions = isTargetCenterWithinSourceHorizontalRange
|
||||
? ["bottom"]
|
||||
: targetCenterX >= sp.x
|
||||
? ["right", "bottom"]
|
||||
: ["left", "bottom"];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetSide || !directions.length) {
|
||||
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||
}
|
||||
|
||||
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
|
||||
const targetPoint =
|
||||
resolvedTarget && targetAnchor
|
||||
? end
|
||||
: derivedTargetAnchor || end;
|
||||
|
||||
const sourceSide = directions[0] || null;
|
||||
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
|
||||
const shouldSlideSourceAnchorAlongEdge =
|
||||
(sourceSide === "left" || sourceSide === "right") &&
|
||||
isTargetCenterWithinSourceVerticalRange ||
|
||||
(sourceSide === "top" || sourceSide === "bottom") &&
|
||||
isTargetCenterWithinSourceHorizontalRange;
|
||||
const derivedSourceAnchor =
|
||||
sourceRelationRect && sourceSide
|
||||
? shouldSlideSourceAnchorAlongEdge
|
||||
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
|
||||
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
|
||||
: null;
|
||||
const startPoint = derivedSourceAnchor || sp;
|
||||
|
||||
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
|
||||
if (!pts) {
|
||||
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
||||
}
|
||||
pts = dedupeSequentialPoints(pts);
|
||||
return {
|
||||
points: startFrom === "target" ? pts.slice().reverse() : pts,
|
||||
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
|
||||
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
export function createConnectorPath(source, target, options = {}) {
|
||||
const {
|
||||
routingMode = "simple",
|
||||
} = options;
|
||||
|
||||
if (routingMode === "adaptive") {
|
||||
return createAdaptiveConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
if (routingMode === "simple") {
|
||||
return createElbowConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
return createElbowConnectorPoints(source, target, options);
|
||||
}
|
||||
|
||||
export class CalloutConnector {
|
||||
constructor({
|
||||
container = null,
|
||||
|
||||
375
frontend/public/earth/js/compute-centers.js
Normal file
375
frontend/public/earth/js/compute-centers.js
Normal file
@@ -0,0 +1,375 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
|
||||
const computeCenterGroup = new THREE.Group();
|
||||
const computeCenterMarkers = [];
|
||||
const textureCache = new Map();
|
||||
let showComputeCenters = true;
|
||||
let supercomputerCount = 0;
|
||||
let gpuClusterCount = 0;
|
||||
|
||||
function buildComputeCenterMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
const longitude = Number(coordinates[0]);
|
||||
const latitude = Number(coordinates[1]);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...props,
|
||||
latitude,
|
||||
longitude,
|
||||
displayLatitude: latitude,
|
||||
displayLongitude: longitude,
|
||||
site_type: normalizeSiteType(props.site_type),
|
||||
};
|
||||
}
|
||||
|
||||
function spreadComputeCenterPositions(markers) {
|
||||
const groups = new Map();
|
||||
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
|
||||
|
||||
markers.forEach((marker) => {
|
||||
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key).push(marker);
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
if (group.length <= 1) return;
|
||||
|
||||
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
|
||||
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
|
||||
group.forEach((marker, index) => {
|
||||
const angle = (Math.PI * 2 * index) / group.length;
|
||||
marker.displayLatitude =
|
||||
marker.latitude + Math.sin(angle) * radius * offsetStep;
|
||||
marker.displayLongitude =
|
||||
marker.longitude + Math.cos(angle) * radius * offsetStep;
|
||||
marker.isSpread = true;
|
||||
marker.groupSize = group.length;
|
||||
});
|
||||
});
|
||||
|
||||
markers.forEach((marker) => {
|
||||
if (marker.isSpread) return;
|
||||
marker.displayLatitude = marker.latitude;
|
||||
marker.displayLongitude = marker.longitude;
|
||||
marker.isSpread = false;
|
||||
marker.groupSize = 1;
|
||||
});
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
function createMarkerTexture(siteType, isEstimated = false) {
|
||||
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
|
||||
if (textureCache.has(textureKey)) {
|
||||
return textureCache.get(textureKey);
|
||||
}
|
||||
|
||||
const color =
|
||||
COMPUTE_CENTER_CONFIG.colors[siteType] ||
|
||||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
const centerX = 64;
|
||||
const centerY = 64;
|
||||
const baseFill = color;
|
||||
|
||||
function fillPath(draw, options = {}) {
|
||||
const { fillStyle = color } = options;
|
||||
context.save();
|
||||
context.fillStyle = fillStyle;
|
||||
context.beginPath();
|
||||
draw();
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 128, 128);
|
||||
|
||||
if (siteType === "supercomputer") {
|
||||
fillPath(() => {
|
||||
context.roundRect(40, 42, 48, 30, 7);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.roundRect(58, 74, 12, 8, 3);
|
||||
context.roundRect(50, 84, 28, 5, 2.5);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
} else {
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
|
||||
context.rect(46, 46, 36, 28);
|
||||
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
fillPath(() => {
|
||||
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
|
||||
context.rect(52, 58, 24, 6);
|
||||
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
|
||||
}, {
|
||||
fillStyle: baseFill,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEstimated) {
|
||||
fillPath(() => {
|
||||
context.arc(94, 36, 12, 0, Math.PI * 2);
|
||||
}, {
|
||||
fillStyle: "rgba(15,23,42,0.92)",
|
||||
});
|
||||
context.save();
|
||||
context.fillStyle = "rgba(255,255,255,0.98)";
|
||||
context.font = "bold 18px sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("?", 94, 36);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
textureCache.set(textureKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function normalizeSiteType(siteType) {
|
||||
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
|
||||
}
|
||||
|
||||
function getBaseScale(siteType) {
|
||||
return siteType === "supercomputer"
|
||||
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
|
||||
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
|
||||
}
|
||||
|
||||
function getDistanceScale(marker, camera) {
|
||||
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return getSurfaceMarkerCameraScale(camera, {
|
||||
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
referenceFov: 75,
|
||||
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
|
||||
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
|
||||
});
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
for (let index = group.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = group.children[index];
|
||||
child.material?.dispose?.();
|
||||
group.remove(child);
|
||||
}
|
||||
}
|
||||
|
||||
function createComputeCenterMarker(markerData) {
|
||||
const siteType = markerData.site_type;
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
|
||||
});
|
||||
const marker = new THREE.Sprite(material);
|
||||
const baseScale = getBaseScale(siteType);
|
||||
marker.position.copy(
|
||||
latLonToVector3(
|
||||
markerData.displayLatitude,
|
||||
markerData.displayLongitude,
|
||||
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
),
|
||||
);
|
||||
marker.scale.setScalar(baseScale);
|
||||
marker.renderOrder = 8;
|
||||
marker.visible = showComputeCenters;
|
||||
marker.userData = {
|
||||
...markerData,
|
||||
site_type: siteType,
|
||||
type: "compute_center",
|
||||
baseScale,
|
||||
state: "normal",
|
||||
pulseOffset: Math.random() * Math.PI * 2,
|
||||
};
|
||||
computeCenterGroup.add(marker);
|
||||
computeCenterMarkers.push(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function formatComputeCenterTypeLabel(siteType) {
|
||||
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
|
||||
}
|
||||
|
||||
export function formatComputeCenterCapacity(markerData) {
|
||||
const value = markerData?.capacity_value;
|
||||
const unit = markerData?.capacity_unit;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return `${value}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatComputeCenterUpdatedAt(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatComputeCenterLocationPrecision(markerData) {
|
||||
const precision = markerData?.location_precision;
|
||||
if (precision === "precise") return "精确坐标";
|
||||
if (precision === "estimated_site") return "估算位置(站点级)";
|
||||
if (precision === "estimated_country") return "估算位置(国家级)";
|
||||
return "位置未知";
|
||||
}
|
||||
|
||||
export function getComputeCenterLegendItems() {
|
||||
return [
|
||||
{
|
||||
label: "超算中心",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
|
||||
},
|
||||
{
|
||||
label: "GPU 集群",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getComputeCenterMarkers() {
|
||||
return computeCenterMarkers;
|
||||
}
|
||||
|
||||
export function getComputeCenterCount() {
|
||||
return computeCenterMarkers.length;
|
||||
}
|
||||
|
||||
export function getComputeCenterSupercomputerCount() {
|
||||
return supercomputerCount;
|
||||
}
|
||||
|
||||
export function getComputeCenterGPUClusterCount() {
|
||||
return gpuClusterCount;
|
||||
}
|
||||
|
||||
export function getComputeCenterStatusSummary() {
|
||||
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
|
||||
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
|
||||
}
|
||||
|
||||
export function setComputeCenterMarkerState(marker, state = "normal") {
|
||||
if (!marker || marker.userData?.type !== "compute_center") return;
|
||||
marker.userData.state = state;
|
||||
}
|
||||
|
||||
export function clearComputeCenterSelection() {
|
||||
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
|
||||
}
|
||||
|
||||
export function clearComputeCenterData(earth) {
|
||||
computeCenterMarkers.length = 0;
|
||||
supercomputerCount = 0;
|
||||
gpuClusterCount = 0;
|
||||
clearGroup(computeCenterGroup);
|
||||
if (earth && computeCenterGroup.parent === earth) {
|
||||
earth.remove(computeCenterGroup);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleComputeCenters(show) {
|
||||
showComputeCenters = Boolean(show);
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowComputeCenters() {
|
||||
return showComputeCenters;
|
||||
}
|
||||
|
||||
export async function loadComputeCenters(_scene, earth) {
|
||||
const response = await fetch(PATHS.computeCentersApi);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Compute centers HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearComputeCenterData(earth);
|
||||
|
||||
spreadComputeCenterPositions(
|
||||
features
|
||||
.map((feature) => buildComputeCenterMarkerData(feature))
|
||||
.filter(Boolean),
|
||||
)
|
||||
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
|
||||
.forEach((markerData) => {
|
||||
const marker = createComputeCenterMarker(markerData);
|
||||
if (!marker) return;
|
||||
if (marker.userData.site_type === "supercomputer") {
|
||||
supercomputerCount += 1;
|
||||
} else {
|
||||
gpuClusterCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (earth && !computeCenterGroup.parent) {
|
||||
earth.add(computeCenterGroup);
|
||||
}
|
||||
computeCenterGroup.visible = showComputeCenters;
|
||||
|
||||
return {
|
||||
totalCount: computeCenterMarkers.length,
|
||||
supercomputerCount,
|
||||
gpuClusterCount,
|
||||
summary: getComputeCenterStatusSummary(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
|
||||
const now = Date.now();
|
||||
|
||||
computeCenterMarkers.forEach((marker) => {
|
||||
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
|
||||
const state = marker.userData?.state || "normal";
|
||||
const pulse =
|
||||
1 +
|
||||
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
|
||||
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
|
||||
|
||||
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
|
||||
let scaleMultiplier = 1;
|
||||
|
||||
if (isLocked) {
|
||||
opacity = 1;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
|
||||
} else if (state === "hover") {
|
||||
opacity = 0.98;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
|
||||
} else if (hasFocus) {
|
||||
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
|
||||
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
|
||||
}
|
||||
|
||||
const distanceScale = getDistanceScale(marker, camera);
|
||||
marker.material.opacity = showComputeCenters ? opacity : 0;
|
||||
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
|
||||
marker.visible = showComputeCenters;
|
||||
});
|
||||
}
|
||||
@@ -25,12 +25,12 @@ export const CRUISE_CONFIG = {
|
||||
maxPolledEvents: 200,
|
||||
cardAnchorXRatio: 0.68,
|
||||
cardAnchorYRatio: 0.24,
|
||||
linkMarkerGapPx: 18,
|
||||
linkPanelGapPx: 12,
|
||||
linkElbowOffsetPx: 72,
|
||||
linkAnchorHeightRatio: 0.26,
|
||||
linkForcedBendPx: 34,
|
||||
linkElbowDropPx: 24,
|
||||
};
|
||||
|
||||
export const CONNECTOR_CONFIG = {
|
||||
markerGapPx: 18,
|
||||
panelGapPx: 12,
|
||||
obstacleClearancePx: 8,
|
||||
};
|
||||
|
||||
export const HUD_CONFIG = {
|
||||
@@ -156,11 +156,43 @@ export const TERRAIN_CONFIG = {
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||
};
|
||||
|
||||
export const COMPUTE_CENTER_CONFIG = {
|
||||
altitudeOffset: 0.48,
|
||||
maxRenderedMarkers: 300,
|
||||
overlapSpread: {
|
||||
groupPrecision: 4,
|
||||
radius: 1.4,
|
||||
offsetStep: 0.28,
|
||||
},
|
||||
marker: {
|
||||
baseOpacity: 0.88,
|
||||
supercomputerScale: 12,
|
||||
gpuClusterScale: 12,
|
||||
hoverScale: 1.16,
|
||||
lockedScale: 1.22,
|
||||
dimmedScale: 0.82,
|
||||
dimmedOpacity: 0.34,
|
||||
pulseSpeed: 0.0038,
|
||||
pulseAmplitude: 0.03,
|
||||
},
|
||||
colors: {
|
||||
supercomputer: "#38bdf8",
|
||||
gpu_cluster: "#2dd4bf",
|
||||
linked: "#f8fafc",
|
||||
},
|
||||
sizeStabilization: {
|
||||
enabled: true,
|
||||
min: 0.12,
|
||||
max: 3.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Cable colors mapping
|
||||
export const CABLE_COLORS = {
|
||||
'Americas II': 0xff4444,
|
||||
|
||||
316
frontend/public/earth/js/controls.js
vendored
316
frontend/public/earth/js/controls.js
vendored
@@ -3,7 +3,12 @@
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
|
||||
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||
import { toggleTerrain, setDayNightEnabled } from "./earth.js";
|
||||
import {
|
||||
toggleTerrain,
|
||||
setDayNightEnabled,
|
||||
toggleGridLines,
|
||||
getShowGridLines,
|
||||
} from "./earth.js";
|
||||
import { setCelestialDayNightEnabled } from "./celestial.js";
|
||||
import {
|
||||
ensureTerrainReady,
|
||||
@@ -26,6 +31,11 @@ import {
|
||||
} from "./satellites.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import {
|
||||
toggleComputeCenters,
|
||||
getShowComputeCenters,
|
||||
getComputeCenterCount,
|
||||
} from "./compute-centers.js";
|
||||
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import {
|
||||
@@ -81,7 +91,8 @@ const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
|
||||
const SETTINGS_SHEET_MIN_SCALE = 0.06;
|
||||
const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
|
||||
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
|
||||
let settingsModalTimer = null;
|
||||
let settingsSheetAnimation = null;
|
||||
@@ -90,6 +101,7 @@ let terrainPrefetchStarted = false;
|
||||
let terrainPrefetchScheduled = false;
|
||||
let focusViewAnimationToken = 0;
|
||||
let earthSettingsDefaults = null;
|
||||
let earthSettingsState = null;
|
||||
let layerRegistry = new Map();
|
||||
let layerPanelInitialized = false;
|
||||
let layoutMode = "desktop";
|
||||
@@ -113,6 +125,10 @@ function detectLayoutMode() {
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function getSettingsViewportScope(mode = layoutMode) {
|
||||
return mode === "mobile" ? "mobile" : "desktop";
|
||||
}
|
||||
|
||||
export function getLayoutMode() {
|
||||
return layoutMode;
|
||||
}
|
||||
@@ -220,6 +236,7 @@ function closeTransientMobileOverlays({ except = null } = {}) {
|
||||
}
|
||||
|
||||
function applyResponsiveLayout() {
|
||||
const previousLayoutMode = layoutMode;
|
||||
layoutMode = detectLayoutMode();
|
||||
|
||||
const isMobile = isMobileLayout();
|
||||
@@ -228,6 +245,7 @@ function applyResponsiveLayout() {
|
||||
|
||||
document.documentElement.classList.toggle("layout-mode-mobile", isMobile);
|
||||
document.documentElement.classList.toggle("layout-mode-compact", isCompact);
|
||||
document.documentElement.dataset.earthLayoutMode = layoutMode;
|
||||
document.body.classList.toggle("layout-mode-mobile", isMobile);
|
||||
document.body.classList.toggle("layout-mode-compact", isCompact);
|
||||
container?.classList.toggle("layout-mode-mobile", isMobile);
|
||||
@@ -238,6 +256,16 @@ function applyResponsiveLayout() {
|
||||
mobileDrawerOpen = false;
|
||||
}
|
||||
|
||||
if (previousLayoutMode !== layoutMode) {
|
||||
if (isMobile) {
|
||||
resetDesktopHudPanelsForMobile();
|
||||
}
|
||||
|
||||
if (earthSettingsState) {
|
||||
applyCurrentViewportPanelVisibility({ persist: false });
|
||||
}
|
||||
}
|
||||
|
||||
syncMobileDrawerState();
|
||||
}
|
||||
|
||||
@@ -276,6 +304,10 @@ function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化媒体抽屉失败:", error);
|
||||
});
|
||||
} else if (mobileDrawerCard === "news") {
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化新闻抽屉失败:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,18 +620,19 @@ function canUseLocalStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentSettingsSnapshot() {
|
||||
const panelVisibility = Object.fromEntries(
|
||||
function getCurrentPanelVisibilitySnapshot() {
|
||||
return Object.fromEntries(
|
||||
HUD_PANEL_IDS.map((panelId) => {
|
||||
const panel = document.getElementById(panelId);
|
||||
const visible = !panel?.classList.contains("hud-panel-hidden");
|
||||
return [panelId, visible];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentSharedSettingsSnapshot() {
|
||||
return {
|
||||
rotationMode,
|
||||
panelVisibility,
|
||||
layerVisibility: Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
|
||||
),
|
||||
@@ -611,37 +644,87 @@ function getCurrentSettingsSnapshot() {
|
||||
|
||||
function captureEarthSettingsDefaults() {
|
||||
if (!earthSettingsDefaults) {
|
||||
earthSettingsDefaults = getCurrentSettingsSnapshot();
|
||||
const panelVisibility = getCurrentPanelVisibilitySnapshot();
|
||||
const shared = getCurrentSharedSettingsSnapshot();
|
||||
earthSettingsDefaults = {
|
||||
version: 2,
|
||||
shared,
|
||||
views: {
|
||||
desktop: {
|
||||
panelVisibility: { ...panelVisibility },
|
||||
},
|
||||
mobile: {
|
||||
panelVisibility: { ...panelVisibility },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return earthSettingsDefaults;
|
||||
}
|
||||
|
||||
function cloneEarthSettings(settings) {
|
||||
return {
|
||||
rotationMode: settings.rotationMode,
|
||||
terrainOpacity: settings.terrainOpacity,
|
||||
dayNightEnabled: settings.dayNightEnabled,
|
||||
defaultEarthZoom: settings.defaultEarthZoom,
|
||||
panelVisibility: { ...(settings.panelVisibility || {}) },
|
||||
layerVisibility: { ...(settings.layerVisibility || {}) },
|
||||
version: 2,
|
||||
shared: {
|
||||
rotationMode: settings.shared.rotationMode,
|
||||
terrainOpacity: settings.shared.terrainOpacity,
|
||||
dayNightEnabled: settings.shared.dayNightEnabled,
|
||||
defaultEarthZoom: settings.shared.defaultEarthZoom,
|
||||
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
|
||||
},
|
||||
views: {
|
||||
desktop: {
|
||||
panelVisibility: {
|
||||
...(settings.views?.desktop?.panelVisibility || {}),
|
||||
},
|
||||
},
|
||||
mobile: {
|
||||
panelVisibility: {
|
||||
...(settings.views?.mobile?.panelVisibility || {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEarthSettings(rawSettings, defaults) {
|
||||
const normalizedPanelVisibility = { ...defaults.panelVisibility };
|
||||
const normalizedLayerVisibility = { ...defaults.layerVisibility };
|
||||
const inputPanelVisibility =
|
||||
rawSettings && typeof rawSettings.panelVisibility === "object"
|
||||
? rawSettings.panelVisibility
|
||||
const normalizedDesktopPanelVisibility = {
|
||||
...defaults.views.desktop.panelVisibility,
|
||||
};
|
||||
const normalizedMobilePanelVisibility = {
|
||||
...defaults.views.mobile.panelVisibility,
|
||||
};
|
||||
const normalizedLayerVisibility = {
|
||||
...defaults.shared.layerVisibility,
|
||||
};
|
||||
const sharedSettings =
|
||||
rawSettings && typeof rawSettings.shared === "object"
|
||||
? rawSettings.shared
|
||||
: rawSettings;
|
||||
const inputDesktopPanelVisibility =
|
||||
rawSettings?.views?.desktop && typeof rawSettings.views.desktop.panelVisibility === "object"
|
||||
? rawSettings.views.desktop.panelVisibility
|
||||
: rawSettings && typeof rawSettings.panelVisibility === "object"
|
||||
? rawSettings.panelVisibility
|
||||
: {};
|
||||
const inputMobilePanelVisibility =
|
||||
rawSettings?.views?.mobile && typeof rawSettings.views.mobile.panelVisibility === "object"
|
||||
? rawSettings.views.mobile.panelVisibility
|
||||
: {};
|
||||
const inputLayerVisibility =
|
||||
rawSettings && typeof rawSettings.layerVisibility === "object"
|
||||
? rawSettings.layerVisibility
|
||||
sharedSettings && typeof sharedSettings.layerVisibility === "object"
|
||||
? sharedSettings.layerVisibility
|
||||
: {};
|
||||
|
||||
Object.entries(inputPanelVisibility).forEach(([panelId, visible]) => {
|
||||
if (panelId in normalizedPanelVisibility) {
|
||||
normalizedPanelVisibility[panelId] = Boolean(visible);
|
||||
Object.entries(inputDesktopPanelVisibility).forEach(([panelId, visible]) => {
|
||||
if (panelId in normalizedDesktopPanelVisibility) {
|
||||
normalizedDesktopPanelVisibility[panelId] = Boolean(visible);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(inputMobilePanelVisibility).forEach(([panelId, visible]) => {
|
||||
if (panelId in normalizedMobilePanelVisibility) {
|
||||
normalizedMobilePanelVisibility[panelId] = Boolean(visible);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -652,26 +735,36 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
});
|
||||
|
||||
const nextRotationMode =
|
||||
rawSettings?.rotationMode === ROTATION_MODE.CRUISE
|
||||
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
|
||||
? ROTATION_MODE.CRUISE
|
||||
: defaults.rotationMode;
|
||||
const nextTerrainOpacity = Number.parseFloat(rawSettings?.terrainOpacity);
|
||||
const nextDayNightEnabled = typeof rawSettings?.dayNightEnabled === "boolean"
|
||||
? rawSettings.dayNightEnabled
|
||||
: defaults.dayNightEnabled;
|
||||
: defaults.shared.rotationMode;
|
||||
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
|
||||
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
|
||||
? sharedSettings.dayNightEnabled
|
||||
: defaults.shared.dayNightEnabled;
|
||||
const nextDefaultEarthZoom = clampEarthZoomLevel(
|
||||
rawSettings?.defaultEarthZoom ?? defaults.defaultEarthZoom,
|
||||
sharedSettings?.defaultEarthZoom ?? defaults.shared.defaultEarthZoom,
|
||||
);
|
||||
|
||||
return {
|
||||
rotationMode: nextRotationMode,
|
||||
panelVisibility: normalizedPanelVisibility,
|
||||
layerVisibility: normalizedLayerVisibility,
|
||||
terrainOpacity: Number.isFinite(nextTerrainOpacity)
|
||||
? nextTerrainOpacity
|
||||
: defaults.terrainOpacity,
|
||||
dayNightEnabled: nextDayNightEnabled,
|
||||
defaultEarthZoom: nextDefaultEarthZoom,
|
||||
version: 2,
|
||||
shared: {
|
||||
rotationMode: nextRotationMode,
|
||||
layerVisibility: normalizedLayerVisibility,
|
||||
terrainOpacity: Number.isFinite(nextTerrainOpacity)
|
||||
? nextTerrainOpacity
|
||||
: defaults.shared.terrainOpacity,
|
||||
dayNightEnabled: nextDayNightEnabled,
|
||||
defaultEarthZoom: nextDefaultEarthZoom,
|
||||
},
|
||||
views: {
|
||||
desktop: {
|
||||
panelVisibility: normalizedDesktopPanelVisibility,
|
||||
},
|
||||
mobile: {
|
||||
panelVisibility: normalizedMobilePanelVisibility,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -696,8 +789,10 @@ function loadEarthSettings() {
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(EARTH_SETTINGS_STORAGE_KEY);
|
||||
if (!rawValue) return defaults;
|
||||
const parsedValue = JSON.parse(rawValue);
|
||||
const legacyRawValue = window.localStorage.getItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
|
||||
const sourceValue = rawValue || legacyRawValue;
|
||||
if (!sourceValue) return defaults;
|
||||
const parsedValue = JSON.parse(sourceValue);
|
||||
return normalizeEarthSettings(parsedValue, defaults);
|
||||
} catch (error) {
|
||||
console.warn("读取 Earth 设置失败,已回退默认值:", error);
|
||||
@@ -705,13 +800,33 @@ function loadEarthSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportPanelVisibility(settings, mode = layoutMode) {
|
||||
const scope = getSettingsViewportScope(mode);
|
||||
return settings?.views?.[scope]?.panelVisibility || {};
|
||||
}
|
||||
|
||||
function syncEarthSettingsStateFromRuntime() {
|
||||
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
|
||||
const nextSettings = earthSettingsState
|
||||
? cloneEarthSettings(earthSettingsState)
|
||||
: defaults;
|
||||
const scope = getSettingsViewportScope();
|
||||
|
||||
nextSettings.shared = getCurrentSharedSettingsSnapshot();
|
||||
nextSettings.views[scope].panelVisibility = getCurrentPanelVisibilitySnapshot();
|
||||
earthSettingsState = nextSettings;
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
function persistEarthSettings() {
|
||||
if (!canUseLocalStorage()) return;
|
||||
try {
|
||||
const nextSettings = syncEarthSettingsStateFromRuntime();
|
||||
window.localStorage.setItem(
|
||||
EARTH_SETTINGS_STORAGE_KEY,
|
||||
JSON.stringify(getCurrentSettingsSnapshot()),
|
||||
JSON.stringify(nextSettings),
|
||||
);
|
||||
window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn("保存 Earth 设置失败:", error);
|
||||
}
|
||||
@@ -762,15 +877,11 @@ function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = tr
|
||||
|
||||
async function applyEarthSettings(settings) {
|
||||
if (!settings) return;
|
||||
earthSettingsState = cloneEarthSettings(settings);
|
||||
|
||||
HUD_PANEL_IDS.forEach((panelId) => {
|
||||
const visible = settings.panelVisibility?.[panelId];
|
||||
if (typeof visible === "boolean") {
|
||||
setHudPanelVisibility(panelId, visible, { persist: false });
|
||||
}
|
||||
});
|
||||
applyCurrentViewportPanelVisibility({ persist: false });
|
||||
|
||||
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity);
|
||||
const appliedOpacity = setTerrainOpacity(settings.shared.terrainOpacity);
|
||||
document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => {
|
||||
if (slider instanceof HTMLInputElement) {
|
||||
slider.value = appliedOpacity.toFixed(2);
|
||||
@@ -782,18 +893,18 @@ async function applyEarthSettings(settings) {
|
||||
}
|
||||
});
|
||||
|
||||
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true });
|
||||
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
|
||||
|
||||
if (typeof settings.dayNightEnabled === "boolean") {
|
||||
applyDayNightEnabled(settings.dayNightEnabled, { persist: false });
|
||||
if (typeof settings.shared.dayNightEnabled === "boolean") {
|
||||
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
|
||||
}
|
||||
|
||||
setDefaultEarthZoom(settings.defaultEarthZoom, {
|
||||
setDefaultEarthZoom(settings.shared.defaultEarthZoom, {
|
||||
persist: false,
|
||||
applyToCurrentView: true,
|
||||
});
|
||||
|
||||
await applyLayerVisibilitySettings(settings.layerVisibility, {
|
||||
await applyLayerVisibilitySettings(settings.shared.layerVisibility, {
|
||||
persist: false,
|
||||
silent: true,
|
||||
});
|
||||
@@ -801,9 +912,11 @@ async function applyEarthSettings(settings) {
|
||||
|
||||
function resetEarthSettings() {
|
||||
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
|
||||
earthSettingsState = cloneEarthSettings(defaults);
|
||||
if (canUseLocalStorage()) {
|
||||
try {
|
||||
window.localStorage.removeItem(EARTH_SETTINGS_STORAGE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn("移除 Earth 设置失败:", error);
|
||||
}
|
||||
@@ -891,6 +1004,20 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
|
||||
}
|
||||
}
|
||||
|
||||
function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
toggleGridLines(enabled);
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
tooltip: enabled ? "隐藏经纬线" : "显示经纬线",
|
||||
});
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
toggleBGP(enabled);
|
||||
@@ -910,6 +1037,22 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
clearSelectionIfHiding(!enabled);
|
||||
toggleComputeCenters(enabled);
|
||||
setLayerButtonState(button, {
|
||||
active: enabled,
|
||||
tooltip: enabled ? "隐藏算力中心" : "显示算力中心",
|
||||
});
|
||||
setEarthStatValue("compute-center-count", `${getComputeCenterCount()} 个`);
|
||||
syncMobileLayerCards();
|
||||
if (persist) persistEarthSettings();
|
||||
if (!silent) {
|
||||
showStatusMessage(enabled ? "算力中心已显示" : "算力中心已隐藏", "info");
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
|
||||
toggleTrails(enabled);
|
||||
setLayerButtonState(button, {
|
||||
@@ -966,6 +1109,22 @@ function getBuiltinLayerDefinitions() {
|
||||
setVisible: (visible, options = {}) =>
|
||||
setTerrainEnabled(getLayerButton("terrain"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "gridLines",
|
||||
buttonId: "toggle-grid-lines",
|
||||
icon: "grid_4x4",
|
||||
label: "经纬线",
|
||||
meta: "Graticule",
|
||||
keywords: "经纬线 graticule 经纬 latitude longitude",
|
||||
defaultActive: true,
|
||||
startupPriority: null,
|
||||
startupMode: "visible",
|
||||
startupLabel: "经纬线",
|
||||
startupMessage: "",
|
||||
getVisible: () => getShowGridLines(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "satellites",
|
||||
buttonId: "toggle-satellites",
|
||||
@@ -1017,6 +1176,22 @@ function getBuiltinLayerDefinitions() {
|
||||
setVisible: (visible, options = {}) =>
|
||||
setCablesLayerEnabled(getLayerButton("cables"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "computeCenters",
|
||||
buttonId: "toggle-compute-centers",
|
||||
icon: "memory",
|
||||
label: "算力中心",
|
||||
meta: "Compute Centers",
|
||||
keywords: "算力中心 compute centers gpu 超算",
|
||||
defaultActive: true,
|
||||
startupPriority: 35,
|
||||
startupMode: "preload",
|
||||
startupLabel: "算力中心",
|
||||
startupMessage: "正在加载算力中心...",
|
||||
getVisible: () => getShowComputeCenters(),
|
||||
setVisible: (visible, options = {}) =>
|
||||
setComputeCentersLayerEnabled(getLayerButton("computeCenters"), visible, options),
|
||||
},
|
||||
{
|
||||
id: "bgp",
|
||||
buttonId: "toggle-bgp",
|
||||
@@ -1406,6 +1581,10 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
const scope = getSettingsViewportScope();
|
||||
if (earthSettingsState?.views?.[scope]?.panelVisibility) {
|
||||
earthSettingsState.views[scope].panelVisibility[panelId] = visible;
|
||||
}
|
||||
if (!visible && activeMobileDrawerId === panelId) {
|
||||
activeMobileDrawerId = null;
|
||||
syncMobileDrawerState();
|
||||
@@ -1414,10 +1593,13 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
|
||||
if (panelId === "media-panel") {
|
||||
updateTVToggleUI(visible);
|
||||
updateNewsToggleUI(visible);
|
||||
if (visible) {
|
||||
if (visible && !isMobileLayout()) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (persist) {
|
||||
@@ -1425,6 +1607,24 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetDesktopHudPanelsForMobile() {
|
||||
document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR).forEach((panel) => {
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
resetPanelInlineLayout(panel);
|
||||
});
|
||||
}
|
||||
|
||||
function applyCurrentViewportPanelVisibility({ persist = false } = {}) {
|
||||
const panelVisibility = getViewportPanelVisibility(earthSettingsState, layoutMode);
|
||||
HUD_PANEL_IDS.forEach((panelId) => {
|
||||
const visible = panelVisibility?.[panelId];
|
||||
if (typeof visible === "boolean") {
|
||||
setHudPanelVisibility(panelId, visible, { persist });
|
||||
}
|
||||
});
|
||||
syncAllHudPanelToggles();
|
||||
}
|
||||
|
||||
function syncSettingsToggle(panelId, visible) {
|
||||
const input = document.querySelector(
|
||||
`[data-settings-panel="${panelId}"]`,
|
||||
@@ -2387,17 +2587,19 @@ function setupTerrainControls() {
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
});
|
||||
|
||||
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
|
||||
const mediaVisible =
|
||||
!isMobileLayout() &&
|
||||
!document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(mediaVisible);
|
||||
if (mediaVisible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
}
|
||||
updateNewsToggleUI(mediaVisible);
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
applyResponsiveLayout();
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { latLonToVector3 } from './utils.js';
|
||||
export let earth = null;
|
||||
export let clouds = null;
|
||||
export let terrain = null;
|
||||
let showGridLines = true;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
@@ -321,6 +322,7 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'latitude', value: lat };
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
latitudeLines.push(line);
|
||||
}
|
||||
@@ -335,11 +337,26 @@ export function createGridLines(scene, earthObj) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const line = new THREE.Line(geometry, gridMaterial);
|
||||
line.userData = { type: 'longitude', value: lon };
|
||||
line.visible = showGridLines;
|
||||
earthObj.add(line);
|
||||
longitudeLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleGridLines(visible) {
|
||||
showGridLines = visible;
|
||||
latitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
longitudeLines.forEach((line) => {
|
||||
line.visible = visible;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowGridLines() {
|
||||
return showGridLines;
|
||||
}
|
||||
|
||||
export function getEarth() {
|
||||
return earth;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function getMobilePopupSubtitle(type, data) {
|
||||
}
|
||||
}
|
||||
|
||||
function positionMobilePopup(popup, touchX, touchY) {
|
||||
function positionMobilePopup(popup, touchX, touchY, options = {}) {
|
||||
const margin = 14;
|
||||
const drawerClearance = 52;
|
||||
const vpW = window.innerWidth;
|
||||
@@ -46,6 +46,14 @@ function positionMobilePopup(popup, touchX, touchY) {
|
||||
const popW = popup.offsetWidth || 200;
|
||||
const popH = popup.offsetHeight || 68;
|
||||
|
||||
if (options.absolute === true) {
|
||||
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
|
||||
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 22;
|
||||
const spaceRight = vpW - touchX;
|
||||
const spaceLeft = touchX;
|
||||
@@ -81,7 +89,7 @@ function positionMobilePopup(popup, touchX, touchY) {
|
||||
|
||||
let popupShowToken = 0;
|
||||
|
||||
function showMobilePopup(type, data, x, y) {
|
||||
function showMobilePopup(type, data, x, y, options = {}) {
|
||||
// Require coordinates — skip if called without position (e.g. from handleCableClick)
|
||||
if (x == null || y == null) return;
|
||||
|
||||
@@ -102,15 +110,19 @@ function showMobilePopup(type, data, x, y) {
|
||||
popupShowToken += 1;
|
||||
const token = popupShowToken;
|
||||
|
||||
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
|
||||
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
|
||||
popup.removeAttribute('hidden');
|
||||
popup.classList.remove('is-visible');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
positionMobilePopup(popup, x, y);
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== popupShowToken) return; // superseded
|
||||
popup.classList.add('is-visible');
|
||||
});
|
||||
positionMobilePopup(popup, x, y, options);
|
||||
if (options.reveal === false) {
|
||||
return;
|
||||
}
|
||||
if (token !== popupShowToken) return; // superseded
|
||||
void popup.getBoundingClientRect();
|
||||
popup.classList.add('is-visible');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,6 +131,8 @@ function hideMobilePopup() {
|
||||
if (!popup) return;
|
||||
popupShowToken += 1; // invalidate any pending show
|
||||
popup.classList.remove('is-visible');
|
||||
popup.classList.remove('earth-mobile-popup--anchor-stable');
|
||||
delete popup.dataset.dockSide;
|
||||
popup.addEventListener('transitionend', () => {
|
||||
if (!popup.classList.contains('is-visible')) {
|
||||
popup.setAttribute('hidden', '');
|
||||
@@ -139,6 +153,19 @@ function ensurePopupClickHandler() {
|
||||
let dragged = false;
|
||||
const DRAG_THRESHOLD = 10;
|
||||
|
||||
const emitDragEvent = (dragging) => {
|
||||
const rect = popup.getBoundingClientRect();
|
||||
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
|
||||
detail: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
popup.addEventListener('pointerdown', (e) => {
|
||||
if (e.button > 0) return;
|
||||
e.stopPropagation();
|
||||
@@ -164,6 +191,7 @@ function ensurePopupClickHandler() {
|
||||
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
emitDragEvent(true);
|
||||
});
|
||||
|
||||
document.addEventListener('pointerup', (e) => {
|
||||
@@ -171,6 +199,7 @@ function ensurePopupClickHandler() {
|
||||
const wasDragged = dragged;
|
||||
dragPointerId = null;
|
||||
dragged = false;
|
||||
emitDragEvent(false);
|
||||
if (!wasDragged) {
|
||||
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
|
||||
}
|
||||
@@ -180,6 +209,7 @@ function ensurePopupClickHandler() {
|
||||
if (e.pointerId === dragPointerId) {
|
||||
dragPointerId = null;
|
||||
dragged = false;
|
||||
emitDragEvent(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -274,15 +304,22 @@ const CARD_CONFIG = {
|
||||
},
|
||||
supercomputer: {
|
||||
icon: '🖥️',
|
||||
title: '超算详情',
|
||||
title: '超算中心详情',
|
||||
className: 'supercomputer',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'site_type_label', label: '类型' },
|
||||
{ key: 'rank', label: '排名' },
|
||||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
||||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
||||
{ key: 'capacity', label: '实测算力' },
|
||||
{ key: 'vendor', label: '厂商' },
|
||||
{ key: 'operator', label: '运营方' },
|
||||
{ key: 'cores', label: '核心数' },
|
||||
{ key: 'power', label: '功耗', unit: 'kW' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'location_precision_label', label: '位置精度' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
},
|
||||
gpu_cluster: {
|
||||
@@ -291,8 +328,17 @@ const CARD_CONFIG = {
|
||||
className: 'gpu_cluster',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'site_type_label', label: '类型' },
|
||||
{ key: 'capacity', label: '估算算力' },
|
||||
{ key: 'gpu_count', label: 'GPU 数量' },
|
||||
{ key: 'gpu_type', label: 'GPU 型号' },
|
||||
{ key: 'vendor', label: '芯片/平台' },
|
||||
{ key: 'operator', label: '运营方' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'location_precision_label', label: '位置精度' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'updated_at', label: '更新时间' }
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -315,6 +361,21 @@ function setupInfoCardDrag(panel) {
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
const emitDragEvent = () => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-drag', {
|
||||
detail: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging: isDragging,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const stopDragging = (event) => {
|
||||
if (
|
||||
event &&
|
||||
@@ -328,6 +389,7 @@ function setupInfoCardDrag(panel) {
|
||||
activePointerId = null;
|
||||
panel.classList.remove('is-dragging');
|
||||
document.body.style.userSelect = '';
|
||||
emitDragEvent();
|
||||
};
|
||||
|
||||
const onMove = (event) => {
|
||||
@@ -346,6 +408,7 @@ function setupInfoCardDrag(panel) {
|
||||
);
|
||||
panel.style.left = `${nextLeft}px`;
|
||||
panel.style.top = `${nextTop}px`;
|
||||
emitDragEvent();
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
@@ -366,11 +429,14 @@ function setupInfoCardDrag(panel) {
|
||||
panel.classList.add('is-dragging');
|
||||
document.body.style.userSelect = 'none';
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
emitDragEvent();
|
||||
});
|
||||
|
||||
window.addEventListener('pointermove', onMove, { passive: false });
|
||||
window.addEventListener('pointerup', stopDragging);
|
||||
window.addEventListener('pointercancel', stopDragging);
|
||||
// Listen in capture phase so card-level stopPropagation used to shield the
|
||||
// globe canvas does not swallow the drag stream before we can reposition.
|
||||
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
|
||||
window.addEventListener('pointerup', stopDragging, { capture: true });
|
||||
window.addEventListener('pointercancel', stopDragging, { capture: true });
|
||||
handle.addEventListener('lostpointercapture', stopDragging);
|
||||
}
|
||||
|
||||
@@ -384,6 +450,8 @@ function mountCard() {
|
||||
panel.id = 'info-panel';
|
||||
panel.className = 'hud-panel hud-panel-info';
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
panel.setAttribute('hidden', '');
|
||||
panel.innerHTML = `
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
@@ -498,8 +566,17 @@ function positionPanel(panel, x, y, options = {}) {
|
||||
function showPanel(x, y, options = {}) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
|
||||
panel.removeAttribute('hidden');
|
||||
panel.setAttribute('aria-hidden', 'false');
|
||||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||
panel.classList.add('is-visible');
|
||||
if (options.reveal === false) {
|
||||
panel.classList.remove('is-visible');
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
panel.classList.add('is-visible');
|
||||
});
|
||||
document.body.classList.add('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||
@@ -508,7 +585,12 @@ function showPanel(x, y, options = {}) {
|
||||
|
||||
function hidePanel() {
|
||||
const panel = getPanel();
|
||||
if (panel) panel.classList.remove('is-visible');
|
||||
if (panel) {
|
||||
panel.classList.remove('is-visible');
|
||||
panel.classList.remove('hud-panel-info--anchor-stable');
|
||||
panel.setAttribute('aria-hidden', 'true');
|
||||
panel.setAttribute('hidden', '');
|
||||
}
|
||||
document.body.classList.remove('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
|
||||
@@ -568,7 +650,7 @@ export function showInfoCard(type, data, options = {}) {
|
||||
// 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);
|
||||
showMobilePopup(type, data, options.x, options.y, options);
|
||||
document.body.classList.add('earth-info-open');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
loadBGPAnomalies,
|
||||
toggleBGP,
|
||||
} from "./bgp.js";
|
||||
import {
|
||||
loadComputeCenters,
|
||||
toggleComputeCenters,
|
||||
} from "./compute-centers.js";
|
||||
|
||||
/**
|
||||
* Layer startup task registry.
|
||||
@@ -70,6 +74,7 @@ function registerBuiltinLayerStartupTasks() {
|
||||
startupTaskRegistry.clear();
|
||||
registerCableStartupTask();
|
||||
registerSatelliteStartupTask();
|
||||
registerComputeCenterStartupTask();
|
||||
registerBGPStartupTask();
|
||||
}
|
||||
|
||||
@@ -171,4 +176,26 @@ function registerBGPStartupTask() {
|
||||
});
|
||||
}
|
||||
|
||||
function registerComputeCenterStartupTask() {
|
||||
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
|
||||
);
|
||||
await context.yieldFrame(12);
|
||||
try {
|
||||
const computeCenterResult = await loadComputeCenters(context.scene, context.earth);
|
||||
if (!context.isCancelled()) {
|
||||
toggleComputeCenters(context.getShowComputeCenters());
|
||||
context.updateComputeCenterHud(computeCenterResult);
|
||||
context.setLegendItems("computeCenters", context.getComputeCenterLegendItems());
|
||||
context.refreshLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
|
||||
}
|
||||
if (context.isCancelled()) return;
|
||||
await context.yieldFrame(16);
|
||||
});
|
||||
}
|
||||
|
||||
registerBuiltinLayerStartupTasks();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
bgp: { title: "BGP" },
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
computeCenters: { title: "算力" },
|
||||
bgp: { title: "BGP" },
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
@@ -11,6 +12,7 @@ let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
computeCenters: [],
|
||||
bgp: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -128,6 +128,22 @@ import {
|
||||
showBGPEventOverlay,
|
||||
showBGPCollectorCoverageOverlay,
|
||||
} from "./bgp.js";
|
||||
import {
|
||||
clearComputeCenterData,
|
||||
clearComputeCenterSelection,
|
||||
formatComputeCenterCapacity,
|
||||
formatComputeCenterLocationPrecision,
|
||||
formatComputeCenterTypeLabel,
|
||||
formatComputeCenterUpdatedAt,
|
||||
getComputeCenterCount,
|
||||
getComputeCenterLegendItems,
|
||||
getComputeCenterMarkers,
|
||||
getShowComputeCenters,
|
||||
loadComputeCenters,
|
||||
setComputeCenterMarkerState,
|
||||
toggleComputeCenters,
|
||||
updateComputeCenterVisualState,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
setupControls,
|
||||
getAutoRotate,
|
||||
@@ -177,6 +193,7 @@ let targetRotation = { x: 0, y: 0 };
|
||||
let inertialVelocity = { x: 0, y: 0 };
|
||||
let hoveredCable = null;
|
||||
let hoveredBGP = null;
|
||||
let hoveredComputeCenter = null;
|
||||
let hoveredSatellite = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let lockedSatellite = null;
|
||||
@@ -205,7 +222,7 @@ let satelliteToggleToken = 0;
|
||||
let satelliteHydrationToken = 0;
|
||||
let sceneLights = null;
|
||||
let cruisePollTimerId = null;
|
||||
let cruiseConnector = null;
|
||||
let calloutConnector = null;
|
||||
let cruiseBGPAdapter = null;
|
||||
let cruiseSequencer = null;
|
||||
let activeDragPointerId = null;
|
||||
@@ -222,6 +239,8 @@ const scratchCableCenter = new THREE.Vector3();
|
||||
const scratchCableDirection = new THREE.Vector3();
|
||||
const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchComputeCenterDirection = new THREE.Vector3();
|
||||
const scratchComputeCenterWorldPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
|
||||
const cleanupFns = [];
|
||||
@@ -234,6 +253,7 @@ 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 GLOBE_DRAGGING_CLASS = "is-globe-dragging";
|
||||
const HUD_INTERACTIVE_SELECTORS = [
|
||||
".earth-left-column",
|
||||
".earth-left-column *",
|
||||
@@ -276,6 +296,17 @@ function isEventOnHud(event) {
|
||||
return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector));
|
||||
}
|
||||
|
||||
function clearDocumentSelection() {
|
||||
const selection = window.getSelection?.();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
|
||||
function setGlobeDraggingUiState(active) {
|
||||
document.body.classList.toggle(GLOBE_DRAGGING_CLASS, active);
|
||||
document.documentElement.classList.toggle(GLOBE_DRAGGING_CLASS, active);
|
||||
}
|
||||
|
||||
function getDragRotationFactor() {
|
||||
const zoom = Math.max(getZoomLevel(), 0.01);
|
||||
const scale = THREE.MathUtils.clamp(
|
||||
@@ -338,6 +369,7 @@ function disposeSceneObject(object) {
|
||||
function clearRuntimeSelection() {
|
||||
hoveredCable = null;
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
hoveredSatellite = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
lockedObject = null;
|
||||
@@ -353,6 +385,7 @@ export function clearLockedObject() {
|
||||
clearAllCableStates();
|
||||
clearCableSelection();
|
||||
clearBGPSelection();
|
||||
clearComputeCenterSelection();
|
||||
clearRelatedSatelliteHighlights();
|
||||
setSatelliteRingState(null, "none", null);
|
||||
clearRuntimeSelection();
|
||||
@@ -388,6 +421,14 @@ function isSameBGPMarker(marker1, marker2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSameComputeCenter(marker1, marker2) {
|
||||
if (!marker1 || !marker2) return false;
|
||||
if (marker1.userData?.type !== "compute_center" || marker2.userData?.type !== "compute_center") {
|
||||
return false;
|
||||
}
|
||||
return marker1.userData?.id === marker2.userData?.id;
|
||||
}
|
||||
|
||||
function getBGPCollectorMarkerByName(collector) {
|
||||
return getBGPCollectorMarkers().find(
|
||||
(marker) => marker.userData?.collector === collector,
|
||||
@@ -407,9 +448,19 @@ function resetTransientBGPStates() {
|
||||
});
|
||||
}
|
||||
|
||||
function resetTransientComputeCenterStates() {
|
||||
getComputeCenterMarkers().forEach((marker) => {
|
||||
if (marker !== lockedObject) {
|
||||
setComputeCenterMarkerState(marker, "normal");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
|
||||
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
|
||||
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
|
||||
@@ -445,6 +496,18 @@ function applyBGPHoverState(marker) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyComputeCenterHoverState(marker) {
|
||||
resetTransientComputeCenterStates();
|
||||
if (!marker) {
|
||||
hoveredComputeCenter = null;
|
||||
return;
|
||||
}
|
||||
hoveredComputeCenter = marker;
|
||||
if (marker !== lockedObject) {
|
||||
setComputeCenterMarkerState(marker, "hover");
|
||||
}
|
||||
}
|
||||
|
||||
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
|
||||
if (bgpAnomalyIntersects.length > 0) {
|
||||
return bgpAnomalyIntersects[0].object;
|
||||
@@ -525,6 +588,40 @@ function getSatelliteBriefHtml(props) {
|
||||
return `<strong>${name}</strong>${id ? `<br>${id}` : ""}`;
|
||||
}
|
||||
|
||||
function showComputeCenterInfo(marker, coords) {
|
||||
const siteType = marker.userData?.site_type === "supercomputer"
|
||||
? "supercomputer"
|
||||
: "gpu_cluster";
|
||||
setLegendMode("computeCenters");
|
||||
showInfoCard(siteType, {
|
||||
name: marker.userData?.name || "-",
|
||||
site_type_label: formatComputeCenterTypeLabel(siteType),
|
||||
rank: marker.userData?.rank ?? "-",
|
||||
capacity: formatComputeCenterCapacity(marker.userData),
|
||||
vendor: marker.userData?.vendor || "-",
|
||||
operator: marker.userData?.operator || "-",
|
||||
gpu_count: marker.userData?.gpu_count ?? "-",
|
||||
gpu_type: marker.userData?.gpu_type || "-",
|
||||
cores: marker.userData?.cores ?? "-",
|
||||
power: marker.userData?.power ?? "-",
|
||||
country: marker.userData?.country || "-",
|
||||
city: marker.userData?.city || "-",
|
||||
location_precision_label: formatComputeCenterLocationPrecision(marker.userData),
|
||||
source: marker.userData?.source || "-",
|
||||
updated_at: formatComputeCenterUpdatedAt(marker.userData?.updated_at),
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function getComputeCenterBriefHtml(marker) {
|
||||
const name = marker.userData?.name || "算力中心";
|
||||
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
|
||||
const location = [marker.userData?.city, marker.userData?.country]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const precision = marker.userData?.is_estimated ? " · 估算位置" : "";
|
||||
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
|
||||
}
|
||||
|
||||
function showBGPInfo(marker, coords) {
|
||||
setLegendMode("bgp");
|
||||
const impactedRegions =
|
||||
@@ -700,6 +797,13 @@ function getBGPFocusCoords(marker) {
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function getComputeCenterFocusCoords(marker) {
|
||||
const lat = marker?.userData?.latitude;
|
||||
const lon = 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, {
|
||||
@@ -847,6 +951,29 @@ async function focusSearchBGPMarker(marker) {
|
||||
}
|
||||
}
|
||||
|
||||
async function focusSearchComputeCenter(marker) {
|
||||
if (!getShowComputeCenters()) {
|
||||
toggleComputeCenters(true);
|
||||
}
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
setAutoRotate(false);
|
||||
|
||||
const coords = getComputeCenterFocusCoords(marker);
|
||||
if (coords) {
|
||||
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.16));
|
||||
}
|
||||
|
||||
setComputeCenterMarkerState(marker, "locked");
|
||||
lockedObject = marker;
|
||||
lockedObjectType = "compute_center";
|
||||
showComputeCenterInfo(marker, getSearchCardCoords());
|
||||
showStatusMessage(
|
||||
`已定位算力中心:${marker.userData?.name || "未知节点"}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEarthSearchResults(query) {
|
||||
const results = [];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
@@ -985,6 +1112,34 @@ function resolveEarthSearchResults(query) {
|
||||
});
|
||||
});
|
||||
|
||||
getComputeCenterMarkers().forEach((marker) => {
|
||||
const score = computeSearchScore(
|
||||
normalizedQuery,
|
||||
marker.userData?.name,
|
||||
marker.userData?.country,
|
||||
marker.userData?.city,
|
||||
marker.userData?.operator,
|
||||
marker.userData?.vendor,
|
||||
marker.userData?.site_type,
|
||||
"算力中心 compute center gpu 超算",
|
||||
);
|
||||
if (score < 0) return;
|
||||
results.push({
|
||||
id: `compute:${marker.userData?.id || marker.uuid}`,
|
||||
kind: "compute_center",
|
||||
icon: "memory",
|
||||
typeLabel: "算力中心",
|
||||
title: marker.userData?.name || "未知算力中心",
|
||||
subtitle: [
|
||||
formatComputeCenterTypeLabel(marker.userData?.site_type),
|
||||
marker.userData?.city,
|
||||
marker.userData?.country,
|
||||
].filter(Boolean).join(" · ") || "算力基础设施",
|
||||
score,
|
||||
entity: marker,
|
||||
});
|
||||
});
|
||||
|
||||
return results
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
@@ -1010,6 +1165,10 @@ async function handleSearchSelection(result) {
|
||||
}
|
||||
if (result.kind === "bgp" || result.kind === "bgp_collector") {
|
||||
await focusSearchBGPMarker(result.entity);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "compute_center") {
|
||||
await focusSearchComputeCenter(result.entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,6 +1182,19 @@ function getBGPStatusText(bgpResult) {
|
||||
return "当前无活跃事件";
|
||||
}
|
||||
|
||||
function updateComputeCenterHud(computeCenterResult) {
|
||||
const computeBtn = document.getElementById("toggle-compute-centers");
|
||||
if (computeBtn) {
|
||||
computeBtn.classList.add("active");
|
||||
const tooltip = computeBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = "隐藏算力中心";
|
||||
}
|
||||
}
|
||||
|
||||
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount} 个`);
|
||||
}
|
||||
|
||||
function updateBGPHud(bgpResult) {
|
||||
const bgpBtn = document.getElementById("toggle-bgp");
|
||||
if (bgpBtn) {
|
||||
@@ -1038,11 +1210,11 @@ function updateBGPHud(bgpResult) {
|
||||
setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult));
|
||||
}
|
||||
|
||||
function ensureCruiseConnector() {
|
||||
if (!cruiseConnector) {
|
||||
cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" });
|
||||
function ensureCalloutConnector() {
|
||||
if (!calloutConnector) {
|
||||
calloutConnector = new CalloutConnector({ className: "callout-connector" });
|
||||
}
|
||||
return cruiseConnector;
|
||||
return calloutConnector;
|
||||
}
|
||||
|
||||
function ensureBGPCruiseAdapter() {
|
||||
@@ -1051,7 +1223,7 @@ function ensureBGPCruiseAdapter() {
|
||||
cruiseBGPAdapter = createBGPCruiseAdapter({
|
||||
camera,
|
||||
getMarkers: () => getBGPAnomalyMarkers(),
|
||||
connector: ensureCruiseConnector(),
|
||||
connector: ensureCalloutConnector(),
|
||||
focusView: (options) => focusEarthView(camera, options),
|
||||
setMarkerLocked: (marker) => {
|
||||
setLegendMode("bgp");
|
||||
@@ -1070,7 +1242,8 @@ function ensureBGPCruiseAdapter() {
|
||||
showMarkerInfo: showBGPInfo,
|
||||
hideInfo: hideInfoCard,
|
||||
isInfoVisible: () =>
|
||||
document.getElementById("info-panel")?.classList.contains("is-visible") === true,
|
||||
document.getElementById("info-panel")?.classList.contains("is-visible") === true ||
|
||||
document.getElementById("earth-mobile-popup")?.classList.contains("is-visible") === true,
|
||||
getLockedObject: () => lockedObject,
|
||||
refreshMarkers: async () => {
|
||||
const bgpResult = await loadBGPAnomalies(scene, getEarth());
|
||||
@@ -1530,6 +1703,7 @@ function updateStatsSummary() {
|
||||
updateEarthStats({
|
||||
cableCount: getCableLines().length,
|
||||
landingPointCount: getLandingPoints().length,
|
||||
computeCenterCount: `${getComputeCenterCount()} 个`,
|
||||
bgpAnomalyCount: `${getBGPCount()} 条`,
|
||||
bgpCollectorCount: `${getBGPCollectorCount()} 个`,
|
||||
bgpStatusSummary: getBGPStatusSummary(),
|
||||
@@ -1605,6 +1779,7 @@ export function init() {
|
||||
initLegend();
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
const earthObj = createEarth(scene);
|
||||
applyImmediateView(earthObj, camera);
|
||||
@@ -1762,6 +1937,7 @@ async function loadData() {
|
||||
clearEarthTexture();
|
||||
clearBGPData(earth);
|
||||
clearCableData(earth);
|
||||
clearComputeCenterData(earth);
|
||||
clearSatelliteData();
|
||||
|
||||
setLoadingMessage("正在初始化...");
|
||||
@@ -1789,9 +1965,12 @@ async function loadData() {
|
||||
yieldFrame,
|
||||
refreshLegend,
|
||||
setLegendItems,
|
||||
getComputeCenterLegendItems,
|
||||
updateCableToggleUi,
|
||||
updateSatelliteToggleUi,
|
||||
updateComputeCenterHud,
|
||||
updateBGPHud,
|
||||
getShowComputeCenters,
|
||||
getShowBGP,
|
||||
getInitialSatelliteLoadLimit,
|
||||
shouldHydrateFullSatelliteSet,
|
||||
@@ -1838,6 +2017,7 @@ async function loadData() {
|
||||
updateSatelliteToggleUi(satellitesEnabled);
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
refreshLegend();
|
||||
setLoading(false);
|
||||
@@ -1976,11 +2156,13 @@ function setupEventListeners() {
|
||||
const handleClick = (event) => onClick(event);
|
||||
const handlePageHide = () => destroy();
|
||||
const handleRotationMode = (event) => handleRotationModeChange(event);
|
||||
const handleInfoCardDrag = () => repositionCruiseConnector();
|
||||
|
||||
bindListener(window, "resize", handleResize);
|
||||
bindListener(window, "pagehide", handlePageHide);
|
||||
bindListener(window, "beforeunload", handlePageHide);
|
||||
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
|
||||
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
|
||||
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
|
||||
bindListener(window, "pointermove", handlePointerMove);
|
||||
bindListener(window, "pointerup", handlePointerUp);
|
||||
@@ -2055,6 +2237,25 @@ function getFrontFacingBGPMarkers(markers) {
|
||||
});
|
||||
}
|
||||
|
||||
function getFrontFacingComputeCenterMarkers(markers) {
|
||||
const earth = getEarth();
|
||||
if (!earth) return markers;
|
||||
|
||||
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
|
||||
|
||||
return markers.filter((marker) => {
|
||||
scratchComputeCenterWorldPosition.copy(marker.position);
|
||||
marker.parent?.localToWorld(scratchComputeCenterWorldPosition);
|
||||
scratchComputeCenterDirection
|
||||
.subVectors(scratchComputeCenterWorldPosition, earth.position)
|
||||
.normalize();
|
||||
return (
|
||||
scratchCameraToEarth.dot(scratchComputeCenterDirection) >
|
||||
SATELLITE_CONFIG.frontFacingDotThreshold
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
const earth = getEarth();
|
||||
if (!earth) return;
|
||||
@@ -2112,6 +2313,12 @@ function onMouseMove(event) {
|
||||
const bgpCollectorIntersects = getShowBGP()
|
||||
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
|
||||
: [];
|
||||
const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers(
|
||||
getComputeCenterMarkers(),
|
||||
);
|
||||
const computeCenterIntersects = getShowComputeCenters()
|
||||
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers)
|
||||
: [];
|
||||
|
||||
let hoveredSat = null;
|
||||
let hoveredSatIndexFromIntersect = null;
|
||||
@@ -2138,6 +2345,16 @@ function onMouseMove(event) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
|
||||
const hoveredComputeCenterMarker =
|
||||
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
|
||||
|
||||
if (
|
||||
hoveredComputeCenter &&
|
||||
!isSameComputeCenter(hoveredComputeCenter, hoveredComputeCenterMarker)
|
||||
) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
|
||||
if (
|
||||
hoveredCable &&
|
||||
(!cableIntersects.length ||
|
||||
@@ -2168,6 +2385,18 @@ function onMouseMove(event) {
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPCollectorBriefHtml(hoveredBGPMarker));
|
||||
}
|
||||
objectTooltipShown = true;
|
||||
} else if (
|
||||
hoveredComputeCenterMarker &&
|
||||
getShowComputeCenters() &&
|
||||
lockedObjectType !== "compute_center"
|
||||
) {
|
||||
applyComputeCenterHoverState(hoveredComputeCenterMarker);
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
getComputeCenterBriefHtml(hoveredComputeCenterMarker),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||
const cable = cableIntersects[0].object;
|
||||
hoveredCable = cable;
|
||||
@@ -2195,8 +2424,11 @@ function onMouseMove(event) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "compute_center" && lockedObject) {
|
||||
applyComputeCenterHoverState(lockedObject);
|
||||
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
hideInfoCard();
|
||||
}
|
||||
|
||||
@@ -2247,12 +2479,16 @@ function onMouseDown(event) {
|
||||
y: earth.rotation.y,
|
||||
};
|
||||
}
|
||||
clearDocumentSelection();
|
||||
setGlobeDraggingUiState(true);
|
||||
document.getElementById("container")?.classList.add("dragging");
|
||||
hideTooltip();
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging = false;
|
||||
setGlobeDraggingUiState(false);
|
||||
clearDocumentSelection();
|
||||
document.getElementById("container")?.classList.remove("dragging");
|
||||
}
|
||||
|
||||
@@ -2260,6 +2496,9 @@ function onPointerDown(event) {
|
||||
if (isEventOnHud(event)) return;
|
||||
if (event.pointerType !== "touch" && event.button !== 0) return;
|
||||
|
||||
renderer?.domElement?.setPointerCapture?.(event.pointerId);
|
||||
clearDocumentSelection();
|
||||
|
||||
if (event.pointerType === "touch") {
|
||||
activeTouchPoints.set(event.pointerId, {
|
||||
clientX: event.clientX,
|
||||
@@ -2286,6 +2525,10 @@ function onPointerDown(event) {
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (activeDragPointerId === event.pointerId && isDragging) {
|
||||
clearDocumentSelection();
|
||||
}
|
||||
|
||||
if (event.pointerType === "touch") {
|
||||
if (activeTouchPoints.has(event.pointerId)) {
|
||||
activeTouchPoints.set(event.pointerId, {
|
||||
@@ -2344,6 +2587,7 @@ function onPointerUp(event) {
|
||||
suppressNextClick = true;
|
||||
isLongDrag = true;
|
||||
}
|
||||
renderer?.domElement?.releasePointerCapture?.(event.pointerId);
|
||||
activeDragPointerId = null;
|
||||
pointerDragDistance = 0;
|
||||
onMouseUp();
|
||||
@@ -2380,6 +2624,11 @@ function onClick(event) {
|
||||
const bgpCollectorIntersects = getShowBGP()
|
||||
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
|
||||
: [];
|
||||
const computeCenterIntersects = getShowComputeCenters()
|
||||
? interactionRaycaster.intersectObjects(
|
||||
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
|
||||
)
|
||||
: [];
|
||||
const satIntersects = getShowSatellites()
|
||||
? interactionRaycaster.intersectObject(getSatellitePoints())
|
||||
: [];
|
||||
@@ -2387,6 +2636,9 @@ function onClick(event) {
|
||||
const clickedBGPMarker = getShowBGP()
|
||||
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
|
||||
: null;
|
||||
const clickedComputeCenterMarker = computeCenterIntersects.length > 0
|
||||
? computeCenterIntersects[0].object
|
||||
: null;
|
||||
|
||||
if (clickedBGPMarker?.userData?.type === "bgp") {
|
||||
interruptCruisePresentation();
|
||||
@@ -2437,6 +2689,23 @@ function onClick(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedComputeCenterMarker?.userData?.type === "compute_center") {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
const clickedMarker = clickedComputeCenterMarker;
|
||||
setComputeCenterMarkerState(clickedMarker, "locked");
|
||||
lockedObject = clickedMarker;
|
||||
lockedObjectType = "compute_center";
|
||||
setAutoRotate(false);
|
||||
showComputeCenterInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
`已选择算力中心: ${clickedMarker.userData?.name || "未知节点"}`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cableIntersects.length > 0 && getShowCables()) {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
@@ -2594,6 +2863,7 @@ function animate() {
|
||||
? cruiseSequencer?.getCurrentItem() ?? null
|
||||
: null;
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
|
||||
|
||||
if (lockedObjectType === "cable" && lockedObject) {
|
||||
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
|
||||
@@ -2641,6 +2911,8 @@ export function destroy() {
|
||||
destroyed = true;
|
||||
currentLoadToken += 1;
|
||||
isDataLoading = false;
|
||||
setGlobeDraggingUiState(false);
|
||||
clearDocumentSelection();
|
||||
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
@@ -2656,6 +2928,7 @@ export function destroy() {
|
||||
clearLockedObject();
|
||||
clearCableData(getEarth());
|
||||
clearBGPData(getEarth());
|
||||
clearComputeCenterData(getEarth());
|
||||
resetSatelliteState();
|
||||
clearUiState();
|
||||
disposeCelestialLayer();
|
||||
|
||||
@@ -48,7 +48,7 @@ function updateEmptyState(query) {
|
||||
const { empty } = getElements();
|
||||
if (!empty) return;
|
||||
if (!query) {
|
||||
empty.textContent = "支持搜索海缆、登陆点、卫星、BGP 事件与观测站。";
|
||||
empty.textContent = "支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。";
|
||||
return;
|
||||
}
|
||||
empty.textContent = "未找到匹配对象,可尝试名称、地点、NORAD、ASN、前缀等关键词。";
|
||||
|
||||
@@ -187,6 +187,7 @@ export function updateZoomDisplay(zoomLevel, distance) {
|
||||
export function updateEarthStats(stats) {
|
||||
setEarthStatValue("cable-count", String(stats.cableCount || 0));
|
||||
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
|
||||
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
|
||||
setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
|
||||
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||
setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as THREE from "three";
|
||||
|
||||
import { CONFIG } from "./constants.js";
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
// Convert latitude/longitude to 3D vector
|
||||
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
|
||||
const phi = (90 - lat) * (Math.PI / 180);
|
||||
@@ -90,3 +94,31 @@ export function calculateDistance(
|
||||
|
||||
return radius * c;
|
||||
}
|
||||
|
||||
export function getSurfaceMarkerCameraScale(camera, options = {}) {
|
||||
if (!camera) return 1;
|
||||
|
||||
const {
|
||||
altitudeOffset = 0,
|
||||
referenceFov = 75,
|
||||
min = 0.12,
|
||||
max = 3.0,
|
||||
} = options;
|
||||
|
||||
const cameraDistanceFromCenter = camera.position.length();
|
||||
const surfaceDistance = Math.max(
|
||||
1,
|
||||
cameraDistanceFromCenter - (CONFIG.earthRadius + altitudeOffset),
|
||||
);
|
||||
const referenceSurfaceDistance = Math.max(
|
||||
1,
|
||||
CONFIG.defaultCameraZ - (CONFIG.earthRadius + altitudeOffset),
|
||||
);
|
||||
const cameraFovRad = (((camera.fov || referenceFov)) * Math.PI) / 180;
|
||||
const referenceFovRad = (referenceFov * Math.PI) / 180;
|
||||
const worldPerPixel = surfaceDistance * Math.tan(cameraFovRad / 2);
|
||||
const referenceWorldPerPixel =
|
||||
referenceSurfaceDistance * Math.tan(referenceFovRad / 2);
|
||||
|
||||
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
|
||||
}
|
||||
|
||||
61
planet.sh
61
planet.sh
@@ -520,12 +520,13 @@ print_database_failure_diagnostics() {
|
||||
compute_ai_provider_build_fingerprint() {
|
||||
(
|
||||
cd "$SCRIPT_DIR" || exit 1
|
||||
tar -cf - \
|
||||
aiprovider \
|
||||
pyproject.toml \
|
||||
uv.lock \
|
||||
docker-compose.yml \
|
||||
docker-compose.simple.yml 2>/dev/null
|
||||
{
|
||||
tar -cf - \
|
||||
aiprovider \
|
||||
docker-compose.yml \
|
||||
docker-compose.simple.yml 2>/dev/null
|
||||
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py"
|
||||
}
|
||||
) | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
@@ -1024,7 +1025,14 @@ start_postgres_service() {
|
||||
|
||||
# Backend lifecycle helpers
|
||||
cleanup_backend_processes() {
|
||||
pkill -f "uvicorn" 2>/dev/null || true
|
||||
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
|
||||
terminate_backend_processes TERM "$backend_port"
|
||||
|
||||
if ! wait_for_port_release "$backend_port"; then
|
||||
terminate_backend_processes KILL "$backend_port"
|
||||
|
||||
wait_for_port_release "$backend_port" || true
|
||||
fi
|
||||
}
|
||||
|
||||
start_backend_with_retry() {
|
||||
@@ -1032,7 +1040,7 @@ start_backend_with_retry() {
|
||||
local retry=1
|
||||
|
||||
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
||||
cleanup_backend_processes
|
||||
cleanup_backend_processes "$backend_port"
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
@@ -1241,6 +1249,39 @@ collect_port_pids() {
|
||||
return 1
|
||||
}
|
||||
|
||||
terminate_process_group() {
|
||||
local signal="$1"
|
||||
local pid="$2"
|
||||
local pgid=""
|
||||
|
||||
[ -n "$pid" ] || return 0
|
||||
kill -0 "$pid" 2>/dev/null || return 0
|
||||
|
||||
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
|
||||
[ -n "$pgid" ] || return 0
|
||||
|
||||
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
terminate_backend_processes() {
|
||||
local signal="$1"
|
||||
local backend_port="$2"
|
||||
local pids=""
|
||||
local pid=""
|
||||
|
||||
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
|
||||
for pid in $pids; do
|
||||
terminate_process_group "$signal" "$pid"
|
||||
terminate_process_tree "$signal" "$pid"
|
||||
done
|
||||
|
||||
pids="$(collect_port_pids "$backend_port" || true)"
|
||||
for pid in $pids; do
|
||||
terminate_process_group "$signal" "$pid"
|
||||
terminate_process_tree "$signal" "$pid"
|
||||
done
|
||||
}
|
||||
|
||||
terminate_process_tree() {
|
||||
local signal="$1"
|
||||
local pid="$2"
|
||||
@@ -1520,8 +1561,8 @@ stop_container_if_running() {
|
||||
}
|
||||
|
||||
stop_backend_service() {
|
||||
if pgrep -f "uvicorn" >/dev/null 2>&1; then
|
||||
cleanup_backend_processes
|
||||
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
|
||||
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
|
||||
log_halt "后端服务已停止"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.35.1"
|
||||
version = "0.37.2"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
186
scripts/compute_aiprovider_dependency_fingerprint.py
Normal file
186
scripts/compute_aiprovider_dependency_fingerprint.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
IMPORT_TO_DISTRIBUTION = {
|
||||
"pydantic_settings": "pydantic-settings",
|
||||
}
|
||||
|
||||
# Some runtime dependencies are referenced indirectly:
|
||||
# - uvicorn is launched by the container command rather than imported.
|
||||
# - python-dotenv is used by pydantic-settings when loading the local .env file.
|
||||
EXTRA_RUNTIME_DISTRIBUTIONS = {
|
||||
"python-dotenv",
|
||||
"uvicorn",
|
||||
}
|
||||
|
||||
|
||||
def normalize_name(value: str) -> str:
|
||||
return value.strip().lower().replace("_", "-").replace(".", "-")
|
||||
|
||||
|
||||
def extract_table(text: str, table_name: str) -> str:
|
||||
pattern = re.compile(
|
||||
rf"(?ms)^\[{re.escape(table_name)}\]\s*$\n(.*?)(?=^\[[^\]]+\]\s*$|\Z)"
|
||||
)
|
||||
match = pattern.search(text)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def extract_string_value(text: str, key: str) -> str | None:
|
||||
match = re.search(rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', text)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def extract_string_list_value(text: str, key: str) -> list[str]:
|
||||
match = re.search(rf"(?ms)^{re.escape(key)}\s*=\s*\[(.*?)\]\s*$", text)
|
||||
if not match:
|
||||
return []
|
||||
return re.findall(r'"([^"]+)"', match.group(1))
|
||||
|
||||
|
||||
def project_dependency_map(pyproject_text: str) -> tuple[str | None, dict[str, str]]:
|
||||
project_block = extract_table(pyproject_text, "project")
|
||||
requires_python = extract_string_value(project_block, "requires-python")
|
||||
dependencies = extract_string_list_value(project_block, "dependencies")
|
||||
mapping: dict[str, str] = {}
|
||||
for spec in dependencies:
|
||||
name = spec.split(";", 1)[0].strip()
|
||||
for marker in ("<", ">", "=", "!", "~"):
|
||||
if marker in name:
|
||||
name = name.split(marker, 1)[0].strip()
|
||||
if "[" in name:
|
||||
name = name.split("[", 1)[0].strip()
|
||||
mapping[normalize_name(name)] = spec.strip()
|
||||
return requires_python, mapping
|
||||
|
||||
|
||||
def collect_runtime_imports(aiprovider_dir: Path) -> set[str]:
|
||||
imports: set[str] = set()
|
||||
stdlib = set(sys.stdlib_module_names)
|
||||
|
||||
for path in sorted(aiprovider_dir.rglob("*.py")):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
imports.add(alias.name.split(".", 1)[0])
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imports.add(node.module.split(".", 1)[0])
|
||||
|
||||
return {
|
||||
name
|
||||
for name in imports
|
||||
if name not in stdlib and name != "aiprovider"
|
||||
}
|
||||
|
||||
|
||||
def resolve_relevant_root_dependencies(
|
||||
pyproject_deps: dict[str, str],
|
||||
imported_modules: set[str],
|
||||
) -> dict[str, str]:
|
||||
relevant_names: set[str] = set(EXTRA_RUNTIME_DISTRIBUTIONS)
|
||||
|
||||
for module_name in imported_modules:
|
||||
mapped_name = IMPORT_TO_DISTRIBUTION.get(module_name, module_name)
|
||||
normalized_name = normalize_name(mapped_name)
|
||||
if normalized_name in pyproject_deps:
|
||||
relevant_names.add(normalized_name)
|
||||
|
||||
return {
|
||||
name: pyproject_deps[name]
|
||||
for name in sorted(relevant_names)
|
||||
if name in pyproject_deps
|
||||
}
|
||||
|
||||
|
||||
def lock_metadata(lock_text: str) -> dict[str, str | None]:
|
||||
return {
|
||||
"version": extract_string_value(lock_text, "version") or re.search(r"(?m)^version\s*=\s*(\d+)\s*$", lock_text).group(1),
|
||||
"revision": extract_string_value(lock_text, "revision") or re.search(r"(?m)^revision\s*=\s*(\d+)\s*$", lock_text).group(1),
|
||||
"requires_python": extract_string_value(lock_text, "requires-python"),
|
||||
}
|
||||
|
||||
|
||||
def lock_package_map(lock_text: str) -> dict[str, dict[str, Any]]:
|
||||
mapping: dict[str, dict[str, Any]] = {}
|
||||
sections = re.split(r"(?m)^\[\[package\]\]\s*$\n?", lock_text)
|
||||
for section in sections[1:]:
|
||||
raw_section = section.strip()
|
||||
name = extract_string_value(raw_section, "name")
|
||||
if not name:
|
||||
continue
|
||||
version = extract_string_value(raw_section, "version")
|
||||
dependency_names = [
|
||||
normalize_name(dep_name)
|
||||
for dep_name in re.findall(r'\{\s*name\s*=\s*"([^"]+)"', raw_section)
|
||||
]
|
||||
mapping[normalize_name(name)] = {
|
||||
"name": name,
|
||||
"version": version,
|
||||
"dependencies": dependency_names,
|
||||
"raw": raw_section,
|
||||
}
|
||||
return mapping
|
||||
|
||||
|
||||
def dependency_closure(
|
||||
lock_packages: dict[str, dict[str, Any]],
|
||||
root_dependencies: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
pending = list(root_dependencies.keys())
|
||||
visited: set[str] = set()
|
||||
resolved: list[dict[str, Any]] = []
|
||||
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
|
||||
package = lock_packages.get(current)
|
||||
if package is None:
|
||||
continue
|
||||
|
||||
resolved.append(package)
|
||||
for dep_name in package.get("dependencies", []):
|
||||
pending.append(dep_name)
|
||||
|
||||
resolved.sort(key=lambda pkg: (normalize_name(pkg["name"]), pkg.get("version", "")))
|
||||
return resolved
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
pyproject_path = repo_root / "pyproject.toml"
|
||||
uv_lock_path = repo_root / "uv.lock"
|
||||
aiprovider_dir = repo_root / "aiprovider"
|
||||
|
||||
pyproject_text = pyproject_path.read_text(encoding="utf-8")
|
||||
uv_lock_text = uv_lock_path.read_text(encoding="utf-8")
|
||||
|
||||
requires_python, pyproject_deps = project_dependency_map(pyproject_text)
|
||||
imported_modules = collect_runtime_imports(aiprovider_dir)
|
||||
relevant_roots = resolve_relevant_root_dependencies(pyproject_deps, imported_modules)
|
||||
relevant_packages = dependency_closure(lock_package_map(uv_lock_text), relevant_roots)
|
||||
|
||||
fingerprint_payload = {
|
||||
"requires_python": requires_python,
|
||||
"relevant_root_dependencies": relevant_roots,
|
||||
"lock": {**lock_metadata(uv_lock_text), "packages": relevant_packages},
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user