Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003a46ac30 | ||
|
|
4b0be4cb76 | ||
|
|
b7647379de | ||
|
|
0f89372d71 | ||
|
|
2b0d4cfc49 |
10
README.md
10
README.md
@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
|
||||
详细文档:
|
||||
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
|
||||
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
@@ -346,7 +346,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
当前推荐参考实现:
|
||||
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import math
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
@@ -23,6 +24,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
|
||||
router = APIRouter()
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
@@ -782,9 +786,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/geo/landing-points")
|
||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
records = await _load_current_collected_data(db, "arcgis_landing_points")
|
||||
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation")
|
||||
cable_records = await _load_current_collected_data(db, "arcgis_cables")
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
[
|
||||
"arcgis_landing_points",
|
||||
"arcgis_cable_landing_relation",
|
||||
"arcgis_cables",
|
||||
],
|
||||
)
|
||||
records = records_by_source.get("arcgis_landing_points", [])
|
||||
relation_records = records_by_source.get(
|
||||
"arcgis_cable_landing_relation",
|
||||
[],
|
||||
)
|
||||
cable_records = records_by_source.get("arcgis_cables", [])
|
||||
|
||||
city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
|
||||
relation_records,
|
||||
@@ -804,6 +819,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Terrain tile upstream error: {exc.response.status_code}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
|
||||
@@ -8,6 +8,89 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.31.0] — 2026-04-21
|
||||
|
||||
## [0.31.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机
|
||||
- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复
|
||||
- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画
|
||||
- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配
|
||||
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题
|
||||
- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题
|
||||
- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点
|
||||
|
||||
---
|
||||
|
||||
## [0.31.1] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 图层开关状态统一成可复用的 `active / loading` 状态机,首次启用地形和卫星时不再像按钮失效
|
||||
- 文档目录重构为 `docs/technical`、`docs/plans`、`docs/deprecated`,并吸收 `.sisyphus/plans` 中有价值的 Earth / 卫星 / UE5 草案
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js),统一按钮 tooltip、`aria-busy`、禁用态和状态文本同步
|
||||
- 地形图层支持 hover/focus 预热与空闲预热,首次点击等待前移,加载中状态持续可见
|
||||
- 卫星图层启用前会立即切换为 `loading` 中间态,请求完成后再切回正常开关表现
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复地形首次加载时通知过早消失、开关仍像关闭状态导致用户误判按钮损坏的问题
|
||||
- 修复卫星接口较慢时按钮没有任何中间态反馈的问题
|
||||
|
||||
---
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增"巡航展示"模式:自动轮播 BGP 异常事件,逐帧追踪连接线位置,支持外部交互立即中断序列(cancel notifier 模式)
|
||||
- 巡航目标事件点高亮显示:hover 外观 + 锁定脉冲动画,并与点击行为统一展示周边受影响卫星与海缆
|
||||
- BGP 事件图标新增填充 W 形波动符号(flap 类型),替换原有难以辨认的贝塞尔细线
|
||||
- 巡航/点击激活时其余卫星自动降饱和度 + 增加透明度以突出焦点;海缆未受影响时同步变暗
|
||||
|
||||
### 🔧 Improvements
|
||||
- 修复巡航轮播期间 BGP 事件 polling 刷新导致标记闪烁消失的问题(clearBGPData 延迟到请求完成后执行)
|
||||
- 点击与巡航锁定颜色统一为 hover 色(0.92, 0.98, 1.0 全透明),移除锁定态脉冲动画
|
||||
- 巡航连接折线转折点从尖角调整为钝角(linkElbowDropPx),提升连线可读性
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
## [0.30.0] — 2026-04-21
|
||||
|
||||
### ✨ Features
|
||||
- Earth 新增真实地形图层:后端代理 Terrarium DEM 瓦片(`/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png`),前端新增 `terrain.js` 负责瓦片拉取、顶点位移与按海拔着色
|
||||
- 设置弹窗新增"地形"分组,支持通过滑块实时调整地形图层透明度
|
||||
|
||||
### 🔧 Improvements
|
||||
- 地形按钮改为异步加载,首次点击显示进度提示并在失败时自动回退
|
||||
- 启动阶段改用 `applyImmediateView` 直接应用初始视角,`showStatusMessage` / `queueStatusMessage` 区分即时与队列态状态消息,加载中不再被临时状态打断
|
||||
- 控制面板抽取 `applyTerrainUiState` / `getViewRotation` 收敛地形切换与视角旋转的重复 UI 同步逻辑
|
||||
|
||||
---
|
||||
|
||||
## [0.29.2] — 2026-04-21
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 继续收口 HUD 交互与设置面板表现,设置弹窗改成更接近从按钮展开的窗口感,同时加入系统级 admin 入口
|
||||
- 修正天球太阳方向与地球受光解耦后的日照逻辑,地表昼夜判断改为按太阳直射点经纬度落到地球贴图坐标
|
||||
|
||||
### 🔧 Improvements
|
||||
- toolbar 进一步收成更贴近 hub 的浅弓形排列,并统一成与 HUD panel 一致的液态玻璃配色与透明度
|
||||
- 设置弹窗与各 HUD panel 继续统一样式、等比缩放和头部基线,设置列表补充系统分组与 admin 跳转
|
||||
- 所有 HUD panel 增加更统一的液态玻璃高光与 hover / press 反馈
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复设置弹窗仍像旧圆角矩形、标题文案重复和从底边直直飞出的动画问题
|
||||
- 修复天球与太阳方向混用显示校准导致中国白天仍落在夜面的日照错误
|
||||
|
||||
---
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
@@ -263,7 +346,7 @@ Released: 2026-04-12
|
||||
|
||||
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
|
||||
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
|
||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/technical/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/technical/earth-news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -349,7 +432,7 @@ Released: 2026-04-10
|
||||
|
||||
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
|
||||
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -387,7 +470,7 @@ Released: 2026-04-10
|
||||
### Improved
|
||||
|
||||
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
|
||||
## 0.24.6
|
||||
|
||||
@@ -404,7 +487,7 @@ Released: 2026-04-10
|
||||
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
|
||||
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
|
||||
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
|
||||
- Improved [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
- Improved [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -510,8 +593,8 @@ Released: 2026-04-09
|
||||
### Added
|
||||
|
||||
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
|
||||
- Added [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
- Added [docs/frontend/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/frontend/plans/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -616,7 +699,7 @@ Released: 2026-04-07
|
||||
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
|
||||
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
||||
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
|
||||
### Improved
|
||||
@@ -742,7 +825,7 @@ Released: 2026-04-02
|
||||
|
||||
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
|
||||
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/earth/prefix-geography-plan.md).
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-prefix-geography-plan.md).
|
||||
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
||||
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
||||
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
||||
@@ -757,7 +840,7 @@ Released: 2026-04-02
|
||||
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
|
||||
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
||||
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -915,7 +998,7 @@ Released: 2026-03-31
|
||||
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md).
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/backend-system-service-control.md).
|
||||
|
||||
### Improved
|
||||
|
||||
|
||||
@@ -15,3 +15,8 @@
|
||||
- 明确写明“已完成”的计划,优先归档
|
||||
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
||||
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
||||
|
||||
补充说明:
|
||||
|
||||
- 一部分归档文档来自外部或临时工作流草案,例如 sisyphus 生成的初稿
|
||||
- 这类文档如果有可用内容,应先吸收到 `docs/plans/` 或 `docs/technical/`,再归档保留来源记录
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||
|
||||
# 地球3D可视化架构重构计划
|
||||
|
||||
## 背景
|
||||
@@ -1,3 +1,5 @@
|
||||
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||
|
||||
# 卫星预测轨道显示功能
|
||||
|
||||
## TL;DR
|
||||
@@ -1,3 +1,5 @@
|
||||
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||
|
||||
# UE5 3D 大屏客户端开发计划
|
||||
|
||||
## 项目概述
|
||||
@@ -1,3 +1,5 @@
|
||||
> Archived note: this document was originally created by sisyphus and later reviewed against the main docs set. Useful content has been absorbed into `docs/plans/` where appropriate.
|
||||
|
||||
# WebGL Instancing 卫星渲染优化计划
|
||||
|
||||
## 背景
|
||||
34
docs/plans/README.md
Normal file
34
docs/plans/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Plans Docs
|
||||
|
||||
这里放“未来实施方案和未完成计划”的文档,重点回答:
|
||||
|
||||
- 我们准备做什么
|
||||
- 为什么要做
|
||||
- 分几期做
|
||||
- 当前差距和下一步是什么
|
||||
|
||||
适合放入这里的内容:
|
||||
|
||||
- Earth / BGP / 地形 / 天球实施方案
|
||||
- AI Playground 发展计划
|
||||
- backend / datasource / agent roadmap
|
||||
- UE5 MVP 方案
|
||||
|
||||
当前重点入口:
|
||||
|
||||
- [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)
|
||||
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
||||
|
||||
不适合放入这里的内容:
|
||||
|
||||
- 当前代码结构说明
|
||||
- 组件现状和实现入口
|
||||
- 已经落地的技术上下文说明
|
||||
|
||||
这些应放入:
|
||||
|
||||
- [docs/technical/README.md](/home/ray/dev/linkong/planet/docs/technical/README.md)
|
||||
@@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r
|
||||
|
||||
Related documents:
|
||||
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/agent-architecture-plan.md)
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/plans/agents-datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/plans/agents-agent-architecture-plan.md)
|
||||
|
||||
|
||||
## Big Picture
|
||||
@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
|
||||
|
||||
## Why This Layer Exists
|
||||
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md):
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-bgp-context.md):
|
||||
|
||||
- incident density is naturally low
|
||||
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
||||
@@ -290,7 +290,7 @@ Each feature should include:
|
||||
|
||||
## Earth Rendering Plan
|
||||
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md).
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-earth-rendering-plan.md).
|
||||
|
||||
### Layer Relationship
|
||||
|
||||
98
docs/plans/earth-predicted-orbit-plan.md
Normal file
98
docs/plans/earth-predicted-orbit-plan.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Earth Predicted Orbit Plan
|
||||
|
||||
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/predicted-orbit.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
在 Earth 中锁定卫星时,显示“预测轨道”而不是只有历史尾迹:
|
||||
|
||||
- 从当前时刻开始
|
||||
- 绕地球一圈
|
||||
- 当前点最亮
|
||||
- 向后沿轨道逐步衰减
|
||||
|
||||
## Current State
|
||||
|
||||
当前已经有:
|
||||
|
||||
- 卫星历史轨迹
|
||||
- 锁定卫星
|
||||
- 轨道高亮与相关联动
|
||||
|
||||
但“预测轨道”仍然不是一套稳定、可验证的单独功能计划。
|
||||
|
||||
## Why It Is Valuable
|
||||
|
||||
预测轨道可以明显提升:
|
||||
|
||||
- 锁定卫星后的空间可读性
|
||||
- 轨道类型辨识
|
||||
- 演示解释力
|
||||
|
||||
相比短历史尾迹,预测轨道更符合用户对“这颗卫星接下来会怎么走”的预期。
|
||||
|
||||
## Scope
|
||||
|
||||
### Phase 1
|
||||
|
||||
- 锁定卫星时显示一整圈预测轨道
|
||||
- 解锁时隐藏
|
||||
- 不替代现有普通轨迹系统
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 根据轨道类型调整采样率
|
||||
- GEO / MEO / LEO 不同密度
|
||||
- 进一步减少 fallback 轨迹的比例
|
||||
|
||||
## Implementation Direction
|
||||
|
||||
### 1. Orbit period
|
||||
|
||||
基于 `meanMotion` 估算轨道周期。
|
||||
|
||||
### 2. Predicted samples
|
||||
|
||||
以固定采样步长从 `now -> now + period` 推算轨迹点。
|
||||
|
||||
### 3. Render object lifecycle
|
||||
|
||||
预测轨道应是一个独立渲染对象:
|
||||
|
||||
- show
|
||||
- update
|
||||
- hide
|
||||
- dispose
|
||||
|
||||
### 4. Visual semantics
|
||||
|
||||
预测轨道不应与普通尾迹混淆:
|
||||
|
||||
- 更稳定
|
||||
- 更完整
|
||||
- 透明度沿轨道衰减
|
||||
- 当前点附近更亮
|
||||
|
||||
## Known Risks
|
||||
|
||||
### 1. TLE propagation gaps
|
||||
|
||||
部分卫星可能出现 SGP4 计算不足,需要 fallback。
|
||||
|
||||
### 2. Multiple orbit lines
|
||||
|
||||
必须确保:
|
||||
|
||||
- 锁定切换前先清旧轨道
|
||||
- 页面隐藏/销毁时清理
|
||||
|
||||
### 3. Performance
|
||||
|
||||
GEO 轨道点数高,采样率需要按轨道类型分层。
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. 锁定单颗卫星时只显示一条预测轨道
|
||||
2. 解锁后轨道立即清除
|
||||
3. 不同轨道类型下点数可控
|
||||
4. 页面切换回来不会闪出旧轨道残留
|
||||
472
docs/plans/earth-real-terrain-plan.md
Normal file
472
docs/plans/earth-real-terrain-plan.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Earth Real Terrain Plan
|
||||
|
||||
## Goal
|
||||
|
||||
将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。
|
||||
|
||||
当前占位实现位于:
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
具体问题:
|
||||
|
||||
- `createTerrain()` 直接对球体顶点应用 `simplex noise`
|
||||
- 没有真实海拔数据来源
|
||||
- 没有分辨率分层
|
||||
- 没有和当前相机/视角配套的性能控制
|
||||
|
||||
## Constraints
|
||||
|
||||
本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎:
|
||||
|
||||
- 地球主体仍然是一个 Three.js sphere
|
||||
- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上
|
||||
- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换
|
||||
- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形
|
||||
|
||||
## Recommended Data Source
|
||||
|
||||
### Primary recommendation
|
||||
|
||||
使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入:
|
||||
|
||||
- Mapzen/AWS `Terrarium` elevation tiles
|
||||
参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
|
||||
原因:
|
||||
|
||||
- 已经是全球瓦片化高程
|
||||
- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe
|
||||
- 编码简单稳定:
|
||||
- `heightMeters = (R * 256 + G + B / 256) - 32768`
|
||||
- 不需要我们先离线拼整球 DEM
|
||||
|
||||
### Data quality upgrade path
|
||||
|
||||
如果后面第一阶段效果确认可用,再逐步升级到底层源:
|
||||
|
||||
- Copernicus DEM GLO-30
|
||||
参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles
|
||||
|
||||
这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。
|
||||
|
||||
## Why Not Replace the Engine
|
||||
|
||||
不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因:
|
||||
|
||||
- 现有 Earth 业务对象都依附当前球面坐标
|
||||
- 切引擎会同时波及:
|
||||
- 海缆绘制
|
||||
- 卫星/轨迹
|
||||
- BGP 标记
|
||||
- HUD 与交互
|
||||
- 这是“重做一页”,不是“给地形层接真实数据”
|
||||
|
||||
所以推荐路线是:
|
||||
|
||||
- 保持当前 sphere globe
|
||||
- 为 sphere 增加真实高度位移层
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
分三期推进。
|
||||
|
||||
### Phase 1 — Global Heightmap Terrain Overlay
|
||||
|
||||
目标:
|
||||
|
||||
- 地形层切换后显示真实海拔起伏
|
||||
- 全球范围可用
|
||||
- 性能可控
|
||||
|
||||
做法:
|
||||
|
||||
1. 新增 terrain 数据模块
|
||||
|
||||
建议文件:
|
||||
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
职责:
|
||||
|
||||
- 选择 DEM zoom level
|
||||
- 请求 Terrarium tiles
|
||||
- 解码 tile 高程
|
||||
- 将高程重采样到当前地形球体网格
|
||||
|
||||
2. 替换 `createTerrain()`
|
||||
|
||||
当前:
|
||||
|
||||
- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形
|
||||
|
||||
调整后:
|
||||
|
||||
- `createTerrain()` 只负责创建 terrain mesh 骨架
|
||||
- 真正的顶点位移由 terrain 模块异步注入
|
||||
|
||||
3. 第一阶段采用“整球低分辨率位移”
|
||||
|
||||
不要一上来做动态 patch stitching。第一阶段更稳的办法是:
|
||||
|
||||
- 保留一张全球 terrain sphere
|
||||
- 使用较低分辨率几何
|
||||
- 例如 `SphereGeometry(radius, 192, 192)` 或 `256/256`
|
||||
- 运行时按一个固定地形 zoom(如 `z=4` 或 `z=5`)抓取覆盖全球的 Terrarium tiles
|
||||
- 将 tile 解码后重投影到经纬度采样网格
|
||||
- 将每个球面顶点按真实高度抬升
|
||||
|
||||
这样第一阶段就能做到:
|
||||
|
||||
- 有真实地形
|
||||
- 不需要复杂的局部 LOD
|
||||
- 不会让现有球体对象体系爆炸
|
||||
|
||||
### Phase 2 — View-Aware Refinement
|
||||
|
||||
目标:
|
||||
|
||||
- 正面可见区域更精细
|
||||
- 背面与远处维持低成本
|
||||
|
||||
做法:
|
||||
|
||||
- 引入“基础全球地形 + 当前视角高分局部补丁”
|
||||
- 正面区域额外抓更高 zoom 的高程 tile
|
||||
- 只替换局部顶点位移或局部 overlay mesh
|
||||
|
||||
这一阶段适合在第一阶段稳定后做。
|
||||
|
||||
### Phase 3 — Normals / Shading / Terrain UX
|
||||
|
||||
目标:
|
||||
|
||||
- 地形不仅有起伏,还更好看、更可读
|
||||
|
||||
包括:
|
||||
|
||||
- 根据高度生成更合理的 normals
|
||||
- 调整 terrain material,使山脉/高原更易读
|
||||
- 可选加入:
|
||||
- hillshade
|
||||
- contour lines
|
||||
- snowline / bathymetry tint
|
||||
|
||||
## Calibration Overlay Before More Terrain Tuning
|
||||
|
||||
在当前项目里,terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。
|
||||
|
||||
没有清晰的海岸线、国界线和地表分层时,人眼很难判断:
|
||||
|
||||
- 山脉是不是在应该高的地方高
|
||||
- terrain 是否真的贴在正确的大陆位置上
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否存在偏移
|
||||
|
||||
这里要明确区分两件事:
|
||||
|
||||
- 国界线不会修好错误的 terrain
|
||||
- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准
|
||||
|
||||
所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。
|
||||
|
||||
### Recommended order for the calibration layer
|
||||
|
||||
1. 海岸线
|
||||
2. 国界线
|
||||
3. 再继续调 terrain
|
||||
|
||||
原因:
|
||||
|
||||
- 海岸线比国界线更基础,也更接近真实地表边界
|
||||
- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系
|
||||
- 国界线更多是政治边界,只能作为辅助参照
|
||||
|
||||
如果只加国界线,不加海岸线,效果仍然可能会怪,因为:
|
||||
|
||||
- 很多国界线本来就是人为直线
|
||||
- 它们并不总是跟真实地形走
|
||||
|
||||
### Suggested layer order during debugging
|
||||
|
||||
建议调试期临时把地球层次明确成:
|
||||
|
||||
1. base earth texture
|
||||
2. coastline / borders overlay
|
||||
3. terrain relief
|
||||
4. cables / landing points / bgp / satellites
|
||||
|
||||
这样会比现在更容易判断:
|
||||
|
||||
- 山脉是否位于正确区域
|
||||
- terrain 是否和地表对齐
|
||||
- 国界/海岸是否漂移
|
||||
|
||||
### Suggested data source for the calibration overlay
|
||||
|
||||
优先用 `Natural Earth` 的轻量全球矢量数据:
|
||||
|
||||
- 海岸线(coastline)
|
||||
- Admin 0 国界线(country borders)
|
||||
|
||||
优点:
|
||||
|
||||
- 全球一致
|
||||
- 轻量
|
||||
- 很适合当前 Three.js globe 做 overlay
|
||||
|
||||
### Recommended execution path
|
||||
|
||||
#### Phase A — Add reference overlays
|
||||
|
||||
先加两层可开关的参考线:
|
||||
|
||||
- 海岸线
|
||||
- 国界线
|
||||
|
||||
这两层的目标不是最终美术表现,而是调试 / 校准。
|
||||
|
||||
#### Phase B — Recalibrate terrain against coastline
|
||||
|
||||
有了海岸线以后,再重新看 terrain:
|
||||
|
||||
- terrain 是否和大陆边缘错位
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移
|
||||
|
||||
#### Phase C — Decide whether to keep the current terrain path
|
||||
|
||||
这时再决定后面的路线:
|
||||
|
||||
- 如果发现真实高程整体是对的,只是缺少 shading / readability
|
||||
继续保留当前 DEM + terrain overlay 路线
|
||||
- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭
|
||||
再考虑重做 terrain pipeline
|
||||
|
||||
### Practical recommendation
|
||||
|
||||
当前阶段不建议“从头开始重做 terrain”。
|
||||
|
||||
更稳的策略是:
|
||||
|
||||
- 暂停继续盲调 terrain 参数
|
||||
- 先补海岸线 / 国界线作为校准参照层
|
||||
- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移”
|
||||
|
||||
## Recommended Geometry Model
|
||||
|
||||
### First usable model
|
||||
|
||||
保留一层独立 terrain sphere:
|
||||
|
||||
- base earth sphere:贴纹理、昼夜、海洋
|
||||
- terrain sphere:略高于地球半径,真实高程位移
|
||||
|
||||
建议:
|
||||
|
||||
- `terrainBaseRadius = CONFIG.earthRadius + 0.2`
|
||||
- 高度缩放使用真实米制换算,再乘一个可调 exaggeration
|
||||
|
||||
示例关系:
|
||||
|
||||
- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration`
|
||||
|
||||
建议第一阶段 `exaggeration = 1.3 ~ 1.8`
|
||||
|
||||
因为完全真实比例在全球球体上会太平,看不出来。
|
||||
|
||||
## Tile Decoding Plan
|
||||
|
||||
### Terrarium decode
|
||||
|
||||
对于每个高程 tile 像素:
|
||||
|
||||
```text
|
||||
heightMeters = (R * 256 + G + B / 256) - 32768
|
||||
```
|
||||
|
||||
### Sampling path
|
||||
|
||||
对于 terrain mesh 上每个顶点:
|
||||
|
||||
1. 将顶点方向转成经纬度
|
||||
2. 将经纬度映射到 Web Mercator tile 坐标
|
||||
3. 找到对应的 tile 和像素
|
||||
4. 解码高程
|
||||
5. 将顶点沿法线方向抬升
|
||||
|
||||
### Needed helpers
|
||||
|
||||
建议新增:
|
||||
|
||||
- `latLonToTileXY(lat, lon, z)`
|
||||
- `tilePixelFromLatLon(lat, lon, z, tileSize)`
|
||||
- `decodeTerrariumHeight(r, g, b)`
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
为了不让地形开关每次重开都重新抓全量 tile:
|
||||
|
||||
- terrain tile 按 `z/x/y` 存到内存缓存
|
||||
- terrain mesh 结果也缓存一份
|
||||
- 当用户关闭/开启 terrain:
|
||||
- 直接复用已有位移结果
|
||||
|
||||
建议:
|
||||
|
||||
- `Map<string, Float32Array | ImageBitmap>`
|
||||
|
||||
## Material Strategy
|
||||
|
||||
第一阶段不要复杂化。
|
||||
|
||||
建议 terrain material:
|
||||
|
||||
- 半透明低饱和地形色
|
||||
- 比 base earth 稍亮或稍偏冷
|
||||
- 保留当前 HUD 风格下的可读性
|
||||
|
||||
第一阶段不需要:
|
||||
|
||||
- 真实土地覆被纹理
|
||||
- 独立卫星影像贴 terrain
|
||||
|
||||
因为那会和现有地球纹理、云层、昼夜 shader 打架。
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files to change
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 重写 `createTerrain()`
|
||||
- 删除 simplex noise 占位逻辑
|
||||
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 初始化 terrain 数据加载
|
||||
- 控制 terrain readiness / loading message
|
||||
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- `toggleTerrain` 逻辑保持,但应能区分:
|
||||
- mesh 已就绪
|
||||
- 正在加载
|
||||
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 新增 `TERRAIN_CONFIG`
|
||||
- 新文件:
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
### Suggested new config
|
||||
|
||||
建议新增:
|
||||
|
||||
```js
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
baseRadiusOffset: 0.2,
|
||||
exaggeration: 1.5,
|
||||
opacity: 0.55,
|
||||
color: 0x6c876f,
|
||||
maxConcurrentRequests: 8,
|
||||
cacheEnabled: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Loading UX
|
||||
|
||||
地形第一次开启时,不能像现在一样瞬时切换。
|
||||
|
||||
建议:
|
||||
|
||||
- 如果地形数据尚未准备:
|
||||
- 顶部状态条显示:`正在加载真实地形数据...`
|
||||
- 完成后:
|
||||
- `真实地形已就绪`
|
||||
|
||||
如果加载失败:
|
||||
|
||||
- 保留 base earth
|
||||
- 显示轻量错误提示
|
||||
- 不要让 terrain 开关卡死在“开”状态
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Global tile count too high
|
||||
|
||||
即使 `z=5` 全球 tile 数也不少。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段限定低 zoom
|
||||
- 并发上限
|
||||
- 缓存
|
||||
|
||||
### 2. Mesh resolution too low
|
||||
|
||||
如果球面分段太低,山脉会被抹平。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段先选一个中等分辨率
|
||||
- 用 exaggeration 保证可见性
|
||||
|
||||
### 3. Existing overlays may z-fight with terrain
|
||||
|
||||
海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。
|
||||
|
||||
缓解:
|
||||
|
||||
- terrain sphere 单独作为 overlay
|
||||
- overlay 保持略低或略高的固定 offset
|
||||
- 必要时局部调整 landing point / cable altitude offset
|
||||
|
||||
### 4. Mercator sampling distortion near poles
|
||||
|
||||
Web Mercator 在高纬会有失真。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段接受
|
||||
- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
第一阶段完成后,应满足:
|
||||
|
||||
1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声
|
||||
2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认
|
||||
3. 关闭/重新开启 terrain 不重复全量请求
|
||||
4. 不破坏:
|
||||
- 海缆
|
||||
- 卫星
|
||||
- BGP
|
||||
- 地球昼夜
|
||||
- 天球层
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
1. 引入 `TERRAIN_CONFIG`
|
||||
2. 新建 `terrain.js`
|
||||
3. 实现 Terrarium tile 请求与 decode
|
||||
4. 用低 zoom 全球 tile 构建真实 terrain sphere
|
||||
5. 接管 `toggleTerrain()`
|
||||
6. 调整 terrain material 和高度 exaggeration
|
||||
7. 做缓存
|
||||
8. 再考虑第二阶段局部高分 refinement
|
||||
|
||||
## Source References
|
||||
|
||||
- Mapzen Terrarium / AWS terrain tiles
|
||||
[Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
- Terrarium tile experiments / format background
|
||||
[mapzen/terrarium](https://github.com/mapzen/terrarium)
|
||||
- Copernicus DEM overview
|
||||
[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
|
||||
## Recommendation Summary
|
||||
|
||||
如果现在就要开始做,我建议直接按这条路线开工:
|
||||
|
||||
- 第一阶段接入 Terrarium 全球高程 tile
|
||||
- 替换掉当前 simplex 假地形
|
||||
- 先做一层真实可见的全球 terrain overlay
|
||||
- 等第一阶段稳定,再做视角高分 refinement
|
||||
|
||||
这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。
|
||||
111
docs/plans/earth-renderer-architecture-separation-plan.md
Normal file
111
docs/plans/earth-renderer-architecture-separation-plan.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Earth Renderer / Logic Separation Plan
|
||||
|
||||
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/earth-architecture-refactor.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
将 Earth 前端继续往“逻辑层 / 状态层 / 渲染层”分离推进,降低后续这几类工作的耦合成本:
|
||||
|
||||
- Three.js 渲染重构
|
||||
- 部分图层替换实现
|
||||
- 未来 UE / Cesium 客户端迁移
|
||||
- Earth 行为逻辑复用
|
||||
|
||||
## Why This Matters
|
||||
|
||||
当前 Earth 已经有一些良好分层,例如:
|
||||
|
||||
- 图层显隐入口
|
||||
- Cable state 枚举与状态 map
|
||||
- 交互逻辑与实际视觉效果的部分分离
|
||||
|
||||
但还没有形成一套更明确的统一规则。现在的风险是:
|
||||
|
||||
- 同一类对象的 hover / locked / hidden / loading 语义不一致
|
||||
- 状态和渲染更新散落在多个模块
|
||||
- 后续再加新图层时容易复制旧逻辑
|
||||
|
||||
## Target Architecture
|
||||
|
||||
Earth 对每类对象都尽量拆成三层:
|
||||
|
||||
1. `state layer`
|
||||
- 保存对象状态
|
||||
- 例如:`normal / hovered / locked / hidden / loading`
|
||||
|
||||
2. `logic layer`
|
||||
- 处理点击、悬停、锁定、过滤、显隐切换
|
||||
- 不直接关心 Three.js 具体材质怎么改
|
||||
|
||||
3. `renderer layer`
|
||||
- 根据状态更新 Three.js / HUD 外观
|
||||
- 是最容易针对不同渲染引擎替换的一层
|
||||
|
||||
## Current Good Signals
|
||||
|
||||
当前已经接近这条方向的地方:
|
||||
|
||||
- cable 状态管理
|
||||
- 部分 landing point 状态同步
|
||||
- layer button 的统一状态入口
|
||||
- tooltip / legend / info-card 开始朝状态驱动靠拢
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Standardize object state enums
|
||||
|
||||
优先为这些对象建立更稳定的状态语义:
|
||||
|
||||
- cables
|
||||
- satellites
|
||||
- landing points
|
||||
- BGP markers
|
||||
- media / news 面板入口按钮
|
||||
|
||||
### 2. Unify state-to-visual adapters
|
||||
|
||||
为各模块建立更清晰的渲染适配函数,例如:
|
||||
|
||||
- `applyCableVisualState()`
|
||||
- `applySatelliteVisualState()`
|
||||
- `applyBGPVisualState()`
|
||||
|
||||
要求:
|
||||
|
||||
- 逻辑层只改状态
|
||||
- 视觉层负责把状态映射到材质、透明度、发光、尺寸、文字
|
||||
|
||||
### 3. Separate Earth UI state from render state
|
||||
|
||||
HUD / 面板 / 图层按钮状态也需要和渲染状态分离:
|
||||
|
||||
- `loading`
|
||||
- `active`
|
||||
- `locked`
|
||||
- `hidden`
|
||||
- `error`
|
||||
|
||||
不要再让 UI 通过“猜渲染结果”推导业务状态。
|
||||
|
||||
### 4. Prepare migration-safe boundaries
|
||||
|
||||
后续如果做 UE / Cesium 客户端,尽量保留:
|
||||
|
||||
- 状态枚举
|
||||
- 交互规则
|
||||
- 数据层接口
|
||||
|
||||
只替换:
|
||||
|
||||
- Three.js 具体渲染实现
|
||||
- HUD 展示实现
|
||||
|
||||
## Practical Rule
|
||||
|
||||
后续 Earth 新功能开发时,优先问三个问题:
|
||||
|
||||
1. 这个状态由谁持有?
|
||||
2. 这个交互逻辑在哪一层处理?
|
||||
3. 这个视觉变化是否能在不改逻辑的情况下单独替换?
|
||||
|
||||
如果答不上来,就说明还在把状态、逻辑、渲染揉在一起。
|
||||
82
docs/plans/earth-webgl-instancing-satellites-plan.md
Normal file
82
docs/plans/earth-webgl-instancing-satellites-plan.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Earth WebGL Instancing Satellites Plan
|
||||
|
||||
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/webgl-instancing-satellites.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
把 Earth 卫星渲染从当前方案继续推进到更适合高数量卫星的 instancing 方向,目标是:
|
||||
|
||||
- 支持更多卫星
|
||||
- 降低渲染压力
|
||||
- 仍然保留当前数据层和交互层
|
||||
|
||||
## Why It Matters
|
||||
|
||||
当前卫星系统已经具备:
|
||||
|
||||
- 数据加载
|
||||
- 轨迹
|
||||
- 选择/锁定
|
||||
- 图例
|
||||
- 相关区域联动
|
||||
|
||||
但当卫星数量持续增加时,渲染层会越来越接近瓶颈。
|
||||
|
||||
## Recommended Direction
|
||||
|
||||
优先调研并原型验证:
|
||||
|
||||
- `InstancedBufferGeometry + custom shader`
|
||||
|
||||
而不是一开始就推倒重写成 raw WebGL。
|
||||
|
||||
原因:
|
||||
|
||||
- 仍能保留 Three.js 主架构
|
||||
- 更容易渐进迁移
|
||||
- 比继续堆普通点渲染更有上限
|
||||
|
||||
## What Should Stay
|
||||
|
||||
尽量保留这些层:
|
||||
|
||||
- 卫星数据获取
|
||||
- 位置计算
|
||||
- 锁定/悬停逻辑
|
||||
- legend / info-card / 相关联动
|
||||
|
||||
主要替换的是:
|
||||
|
||||
- 卫星点渲染实现
|
||||
- 颜色/大小等实例属性更新方式
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Prototype
|
||||
|
||||
- 用 instancing 做最小原型
|
||||
- 先只渲染卫星点
|
||||
- 不碰轨迹系统
|
||||
|
||||
### Phase 2: Integrate
|
||||
|
||||
- 接入当前 `satellites.js` 数据层
|
||||
- 保留当前选择和高亮语义
|
||||
|
||||
### Phase 3: Tune
|
||||
|
||||
- 调整可视大小
|
||||
- 调整选中高亮方式
|
||||
- 评估是否需要分层 LOD
|
||||
|
||||
## Risks
|
||||
|
||||
1. 透明度排序更复杂
|
||||
2. Shader 调试成本更高
|
||||
3. 选中态和 hover 态不能简单复用旧材质逻辑
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. 在更高卫星数量下保持可接受帧率
|
||||
2. 不破坏现有锁定/高亮语义
|
||||
3. 图例、信息卡、相关卫星联动仍然成立
|
||||
@@ -30,7 +30,7 @@
|
||||
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py)
|
||||
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
|
||||
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
|
||||
### 2. 本地运行与配置打通
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
相关文件:
|
||||
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
|
||||
## 当前限制
|
||||
@@ -979,3 +979,37 @@ Content/
|
||||
如果你按这份方案推进,一期最现实的目标不是“立刻做出完整 UE 大屏”,而是:
|
||||
|
||||
**在 14 天左右,做出一个能显示真实地球、能显示超算点、能点击看详情、能接后端的可用 UE 客户端 MVP。**
|
||||
|
||||
---
|
||||
|
||||
# 附录:来自 sisyphus 草案的补充
|
||||
|
||||
> 这部分吸收自一个 sisyphus-created draft,原始草案已归档,不再单独维护为主计划。
|
||||
|
||||
## 1. 项目骨架建议
|
||||
|
||||
原草案给过一个更偏“工程初始化”的目录示意,适合拿来做一期的命名参考:
|
||||
|
||||
- `Levels/`
|
||||
- `Blueprints/`
|
||||
- `Materials/`
|
||||
- `Widgets/`
|
||||
- `Source/PlanetAPI/`
|
||||
- `Source/CesiumIntegration/`
|
||||
- `Source/Visualization/`
|
||||
|
||||
这不是强制结构,但对 UE 初期整理目录很有帮助。
|
||||
|
||||
## 2. API 契约意识
|
||||
|
||||
原草案有一个很对的提醒:
|
||||
|
||||
- 一期虽然可以先走 HTTP
|
||||
- 但数据模型命名不应只服务于一次性演示
|
||||
- 后续 WebSocket 接入时,字段设计最好能沿用
|
||||
|
||||
所以当前主计划继续建议:
|
||||
|
||||
- 先做 HTTP 拉取
|
||||
- 尽量把 UE 侧数据模型定义清楚
|
||||
- 不要在蓝图各处散写临时 JSON 字段解析
|
||||
26
docs/technical/README.md
Normal file
26
docs/technical/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Technical Docs
|
||||
|
||||
这里放“当前实现和当前结构”的文档,重点回答:
|
||||
|
||||
- 现在代码是怎么组织的
|
||||
- 当前入口在哪
|
||||
- 状态和组件如何工作
|
||||
- 后续改动应该沿着哪条实现边界继续走
|
||||
|
||||
适合放入这里的内容:
|
||||
|
||||
- 前端上下文
|
||||
- Earth 前端结构
|
||||
- 后端运行控制
|
||||
- collector 现状
|
||||
- 采集格式约定
|
||||
|
||||
不适合放入这里的内容:
|
||||
|
||||
- 尚未完成的 roadmap
|
||||
- 未来迭代方案
|
||||
- 大范围重构计划
|
||||
|
||||
这些应放入:
|
||||
|
||||
- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md)
|
||||
@@ -187,7 +187,7 @@ Current reality:
|
||||
- that is expected, because incidents are aggregated and de-noised
|
||||
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
||||
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-region-aggregation-plan.md).
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md).
|
||||
|
||||
So the immediate next milestone is:
|
||||
|
||||
298
docs/technical/earth-frontend-context.md
Normal file
298
docs/technical/earth-frontend-context.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Earth Frontend Context
|
||||
|
||||
本文件描述当前 Earth 大屏前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。
|
||||
|
||||
相关规则建议一起参考:
|
||||
|
||||
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
|
||||
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
|
||||
## 当前目标
|
||||
|
||||
Earth 前端不是普通管理页,它是独立的大屏展示前端。当前产品目标是:
|
||||
|
||||
- 维持地球视图的空间感和可读性
|
||||
- 让 HUD、图层、媒体面板、BGP、卫星、海缆等保持统一交互
|
||||
- 把加载中、已启用、已隐藏、锁定中这类状态做清楚
|
||||
|
||||
## 当前入口
|
||||
|
||||
React 路由入口:
|
||||
|
||||
- [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
|
||||
|
||||
当前做法很简单:
|
||||
|
||||
- React 页面只负责提供一个全屏 `iframe`
|
||||
- 真正的 Earth 应用运行在:
|
||||
- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||
|
||||
所以 Earth 前端本质上是 `public/earth` 下的一套独立静态应用。
|
||||
|
||||
## 当前文件分层
|
||||
|
||||
### 1. 页面入口与结构
|
||||
|
||||
- [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||
|
||||
职责:
|
||||
|
||||
- HUD 基础 DOM
|
||||
- 图层面板
|
||||
- 媒体面板
|
||||
- 工具栏
|
||||
- 设置弹窗
|
||||
- 兼容旧元素 id
|
||||
|
||||
### 2. 主运行时
|
||||
|
||||
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 地球初始化
|
||||
- Three.js 场景组装
|
||||
- 数据加载与刷新
|
||||
- 各图层集成
|
||||
- Earth 级别状态同步
|
||||
|
||||
### 3. 地球控制层
|
||||
|
||||
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 工具栏交互
|
||||
- 图层面板交互
|
||||
- 旋转/缩放/布局
|
||||
- HUD 面板拖拽
|
||||
- 图层开关状态机
|
||||
|
||||
这份文件是 Earth 前端当前最核心的 UI 控制入口。
|
||||
|
||||
### 4. UI 与状态消息
|
||||
|
||||
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
|
||||
|
||||
职责:
|
||||
|
||||
- loading 面板
|
||||
- status message
|
||||
- tooltip / error / 清理逻辑
|
||||
|
||||
### 5. 地球与地形
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 地球球体、云层、大气
|
||||
- 真实地形 mesh
|
||||
- terrain tile 拉取、解码、位移、着色
|
||||
|
||||
### 6. 图层模块
|
||||
|
||||
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
||||
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
||||
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
|
||||
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 各自的数据层
|
||||
- 开关行为
|
||||
- 面板内容
|
||||
- hover/lock/selection 语义
|
||||
|
||||
其中巡航模式现在已经拆成两层:
|
||||
|
||||
- `cruise-sequencer.js`
|
||||
- 负责目标队列顺序、停留时长、切换节奏、打断与恢复
|
||||
- `callout-connector.js`
|
||||
- 负责卡片连线 SVG、路径计算与绘制动画
|
||||
- `bgp-cruise-adapter.js`
|
||||
- 负责 BGP 巡航展示适配:目标排序、卡片落点、连线路径、focus/overlay/info-card 时序
|
||||
|
||||
当前 BGP 巡航只是这套能力的一个调用方,不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。
|
||||
|
||||
## 当前样式分层
|
||||
|
||||
Earth 的 CSS 不是一份大样式表,而是分层管理:
|
||||
|
||||
- [base.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/base.css)
|
||||
- [hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css)
|
||||
- [toolbar.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/toolbar.css)
|
||||
- [layer-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/layer-panel.css)
|
||||
- [info-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/info-panel.css)
|
||||
- [legend.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/legend.css)
|
||||
- [earth-stats.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/earth-stats.css)
|
||||
- [coordinates-display.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/coordinates-display.css)
|
||||
- [tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css)
|
||||
|
||||
当前建议:
|
||||
|
||||
- 通用 HUD 壳层写进 `hud.css`
|
||||
- 单一面板特性写进各自子文件
|
||||
- 不要把业务状态样式再散回 `index.html`
|
||||
|
||||
## 当前图层开关状态语义
|
||||
|
||||
Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
|
||||
|
||||
- `inactive`
|
||||
- `active`
|
||||
- `loading`
|
||||
|
||||
当前入口在:
|
||||
|
||||
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- [layer-button-state.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/layer-button-state.js)
|
||||
|
||||
关键函数:
|
||||
|
||||
- `updateLayerButtonState(button, isActive)`
|
||||
- `setLayerButtonState(button, options)`
|
||||
|
||||
`setLayerButtonState` 负责:
|
||||
|
||||
- `loading` 样式
|
||||
- `aria-busy`
|
||||
- 按钮禁用
|
||||
- tooltip 更新
|
||||
- 绑定状态文本更新
|
||||
- 可选同步 `active`
|
||||
|
||||
因此后续如果别的图层也需要异步启用,应该直接走这套状态机,而不是再手写一套临时 loading class。
|
||||
|
||||
### `data-status-target`
|
||||
|
||||
图层按钮可以通过:
|
||||
|
||||
- `data-status-target`
|
||||
|
||||
指向一个状态文本节点。当前 terrain 已接入:
|
||||
|
||||
- 按钮:`#toggle-terrain`
|
||||
- 状态节点:`#terrain-status`
|
||||
|
||||
以后别的异步图层也可以沿用这套约定。
|
||||
|
||||
## 当前地形链路
|
||||
|
||||
真实地形首次启用会慢,原因不只是一个:
|
||||
|
||||
1. 需要拉取 Terrarium 瓦片
|
||||
2. 需要解码图片
|
||||
3. 需要按顶点采样高程
|
||||
4. 需要重新写入 geometry 和 color
|
||||
5. 需要重新计算法线与包围体
|
||||
|
||||
当前入口在:
|
||||
|
||||
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
|
||||
|
||||
当前已经做了两层体验优化:
|
||||
|
||||
1. 图层开关 loading 状态持续可见
|
||||
2. 页面空闲时会预热 `ensureTerrainReady()`
|
||||
|
||||
也就是说,后续再继续优化 terrain 时,优先顺序应该是:
|
||||
|
||||
1. 先保证用户感知正确
|
||||
2. 再压缩首次等待
|
||||
3. 最后才做更激进的几何/瓦片优化
|
||||
|
||||
## 当前高频风险点
|
||||
|
||||
### 1. 视觉状态和业务状态不同步
|
||||
|
||||
Earth 里最常见的 bug 不是“没渲染”,而是:
|
||||
|
||||
- 图层关了,tooltip 还在
|
||||
- 锁定对象隐藏了,info card 还在
|
||||
- legend 没跟图层切换
|
||||
- loading 已结束,但按钮还像没开
|
||||
|
||||
后续改动必须优先检查状态同步。
|
||||
|
||||
### 2. HUD 布局问题先查结构,不要先打 CSS 补丁
|
||||
|
||||
Earth HUD 历史上反复出现:
|
||||
|
||||
- 面板只剩一条缝
|
||||
- markdown 被裁掉
|
||||
- tabs/iframe 被 `overflow: hidden` 吃掉
|
||||
|
||||
优先检查:
|
||||
|
||||
1. 谁负责高度
|
||||
2. 谁负责滚动
|
||||
3. 哪一层在裁剪
|
||||
|
||||
不要上来先加 `overflow: hidden` 或额外包装层。
|
||||
|
||||
### 3. Transitional path 必须收口
|
||||
|
||||
Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易积累:
|
||||
|
||||
- 旧 helper
|
||||
- 旧 class
|
||||
- 旧 fallback 逻辑
|
||||
- 已废弃变体
|
||||
|
||||
每次大功能完成后,都要做一次 cleanup pass。
|
||||
|
||||
### 4. 巡航与业务事件不要再深度耦合
|
||||
|
||||
当前正确边界应该是:
|
||||
|
||||
- 通用巡航层只知道:
|
||||
- 当前目标
|
||||
- 队列顺序
|
||||
- 相机 focus
|
||||
- 停留 / 隐藏 / 切换
|
||||
- 业务模块只负责:
|
||||
- 提供目标队列
|
||||
- 提供 focus 坐标
|
||||
- 提供卡片内容
|
||||
- 提供高亮/图层副作用
|
||||
|
||||
如果以后再给海缆、卫星或新闻做巡航,不应复制一套新的 `main.js` 状态变量,而应复用:
|
||||
|
||||
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 这种业务适配层模式
|
||||
|
||||
## 当前推荐改动方式
|
||||
|
||||
如果后续继续改 Earth,建议按这个顺序:
|
||||
|
||||
1. 先确认改的是:
|
||||
- Three.js 渲染层
|
||||
- HUD 结构层
|
||||
- 图层状态层
|
||||
- 面板内容层
|
||||
2. 如果涉及图层按钮,优先接入统一状态机
|
||||
3. 如果涉及可见性切换,检查 tooltip / legend / info-card / lock 是否一起收口
|
||||
4. 如果涉及面板布局,先查结构再动 CSS
|
||||
|
||||
## 当前与控制台前端的边界
|
||||
|
||||
Earth 前端和控制台前端不是同一套 UI 系统:
|
||||
|
||||
- 控制台前端:React + Ant Design 工作台
|
||||
- Earth 前端:`public/earth` 原生 HUD + Three.js 展示面
|
||||
|
||||
因此:
|
||||
|
||||
- Earth 不应该直接复用 Ant Table / AppLayout 语义
|
||||
- 控制台也不应该照搬 Earth HUD 动画和玻璃层语言
|
||||
|
||||
控制台相关结构见:
|
||||
|
||||
- [admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/frontend-admin-frontend-context.md)
|
||||
236
docs/technical/frontend-admin-frontend-context.md
Normal file
236
docs/technical/frontend-admin-frontend-context.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Admin Frontend Context
|
||||
|
||||
本文件描述当前控制台前端的真实结构,目标是帮助后续页面开发、表格改造、布局治理和状态收口时快速找到正确入口。
|
||||
|
||||
相关规则建议一起参考:
|
||||
|
||||
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
|
||||
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
|
||||
## 当前目标
|
||||
|
||||
控制台前端承担的是后台工作台,而不是展示型大屏。当前约束是:
|
||||
|
||||
- 页面默认遵循单屏工作区
|
||||
- 主交互在内部模块滚动,而不是依赖整页无限变长
|
||||
- 列表、表格、分析页优先保证主工作区可见
|
||||
- 通用布局、滚动条、表格滚动行为尽量复用,不要每页各写一套
|
||||
|
||||
## 当前路由入口
|
||||
|
||||
主入口在:
|
||||
|
||||
- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
|
||||
|
||||
当前后台相关路由包括:
|
||||
|
||||
- `/admin`
|
||||
- `/users`
|
||||
- `/datasources`
|
||||
- `/data`
|
||||
- `/alerts/system`
|
||||
- `/alerts/bgp`
|
||||
- `/alerts/situational`
|
||||
- `/bgp`
|
||||
- `/playground`
|
||||
- `/settings`
|
||||
|
||||
`/earth` 是独立展示页,不属于控制台骨架。
|
||||
|
||||
## 当前页面骨架
|
||||
|
||||
控制台公共壳层在:
|
||||
|
||||
- [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx)
|
||||
|
||||
职责:
|
||||
|
||||
- 左侧导航
|
||||
- 折叠与展开
|
||||
- 当前账号/版本信息
|
||||
- 内容区高度闭合
|
||||
- 全站统一侧边栏滚动条
|
||||
|
||||
当前结构是:
|
||||
|
||||
```tsx
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider className="dashboard-sider">...</Sider>
|
||||
<Layout>
|
||||
<Content className="dashboard-content">
|
||||
<div className="dashboard-content-inner">{children}</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
```
|
||||
|
||||
后续控制台页面应优先适配这套壳层,而不是重新定义全页高度语义。
|
||||
|
||||
## 当前共享组件
|
||||
|
||||
### 1. `Scrollbar`
|
||||
|
||||
文件:
|
||||
|
||||
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx)
|
||||
|
||||
用途:
|
||||
|
||||
- 控制台侧边栏这类普通内容容器
|
||||
- 组件内部管理可见性、thumb 尺寸、拖拽和双轴 overflow 判定
|
||||
|
||||
当前约束:
|
||||
|
||||
- 滚动条必须是浮层,不参与布局
|
||||
- 无 overflow 时不应留下可见痕迹
|
||||
- 真实滚动仍交给原生容器,只替换可见层和交互层
|
||||
|
||||
### 2. `ScrollbarOverlay`
|
||||
|
||||
文件:
|
||||
|
||||
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx)
|
||||
|
||||
用途:
|
||||
|
||||
- Ant Table 这类内部已有滚动容器的区域
|
||||
- 不接管滚动语义,只叠加新的滚动条可见层
|
||||
|
||||
当前使用场景:
|
||||
|
||||
- 数据源
|
||||
- 采集数据
|
||||
- 用户管理
|
||||
- 设置页
|
||||
- 告警页
|
||||
- BGP 页面
|
||||
|
||||
### 3. `TableScrollRegion`
|
||||
|
||||
文件:
|
||||
|
||||
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/TableScrollRegion.tsx)
|
||||
|
||||
用途:
|
||||
|
||||
- 为表格滚动区提供统一包裹层
|
||||
- 后续新表格页优先复用,不要重复写“表格区域 + overlay scrollbar”样板
|
||||
|
||||
### 4. 其他共享组件
|
||||
|
||||
- [MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx)
|
||||
- [TableActions.tsx](/home/ray/dev/linkong/planet/frontend/src/components/TableActions/TableActions.tsx)
|
||||
|
||||
## 当前状态来源
|
||||
|
||||
### 1. 认证状态
|
||||
|
||||
文件:
|
||||
|
||||
- [auth.ts](/home/ray/dev/linkong/planet/frontend/src/stores/auth.ts)
|
||||
|
||||
职责:
|
||||
|
||||
- token
|
||||
- 当前用户
|
||||
- 登录/退出
|
||||
|
||||
`App.tsx` 用它判断是否进入登录页。
|
||||
|
||||
### 2. 业务数据网关
|
||||
|
||||
目前 AI / 态势感知相关服务集中在:
|
||||
|
||||
- [http-gateway.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/http-gateway.ts)
|
||||
- [port.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/port.ts)
|
||||
- [types.ts](/home/ray/dev/linkong/planet/frontend/src/services/situational-awareness/types.ts)
|
||||
|
||||
约束:
|
||||
|
||||
- 页面不要直接散落拼 URL
|
||||
- 先通过 port/types 定义边界
|
||||
- 再由 http/mock gateway 实现
|
||||
|
||||
## 当前页面分层建议
|
||||
|
||||
### 1. 仪表盘和摘要型页面
|
||||
|
||||
例如:
|
||||
|
||||
- [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx)
|
||||
|
||||
优先目标:
|
||||
|
||||
- 页头稳定
|
||||
- 摘要卡片先紧凑化
|
||||
- 主工作区占据主要高度
|
||||
|
||||
### 2. 表格型页面
|
||||
|
||||
例如:
|
||||
|
||||
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx)
|
||||
- [DataList.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataList/DataList.tsx)
|
||||
- [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx)
|
||||
- [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx)
|
||||
|
||||
约束:
|
||||
|
||||
- 优先内部滚动
|
||||
- 不要让表格撑爆整页
|
||||
- 新表格区域优先复用 `TableScrollRegion` / `ScrollbarOverlay`
|
||||
|
||||
### 3. 复杂工作区页面
|
||||
|
||||
例如:
|
||||
|
||||
- [BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
- [Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx)
|
||||
|
||||
约束:
|
||||
|
||||
- Tabs 里的内容不能套同一套高度逻辑
|
||||
- 表格 tab、Markdown tab、配置 tab 要各自定义滚动责任
|
||||
- AI 结果区、长文本区优先保证最小可读高度
|
||||
|
||||
## 当前布局约束
|
||||
|
||||
这些原则已经在项目里反复验证过:
|
||||
|
||||
1. 父容器高度链要闭合
|
||||
2. `min-height: 0` 不能漏
|
||||
3. overflow 责任必须明确
|
||||
4. 不要用 `overflow: hidden` 掩盖结构问题
|
||||
5. 不要为了摘要卡完整显示去压缩主工作区
|
||||
6. 自定义滚动条必须是浮层,不得挤压内容宽度
|
||||
|
||||
详细经验见:
|
||||
|
||||
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
|
||||
## 当前推荐改动方式
|
||||
|
||||
如果后续继续改后台页面,建议按这个顺序:
|
||||
|
||||
1. 先确认页面属于摘要页、表格页还是复杂工作区
|
||||
2. 先接入现有壳层和滚动语义
|
||||
3. 优先复用共享滚动组件
|
||||
4. 最后再改视觉和细节交互
|
||||
|
||||
不要先写局部 CSS 补丁,再回头补结构。
|
||||
|
||||
## 当前明显边界
|
||||
|
||||
控制台前端和 Earth 前端不是一套系统:
|
||||
|
||||
- 控制台前端是 React + Ant Design 工作台
|
||||
- Earth 前端是 `public/earth` 下的独立原生 HUD 系统
|
||||
|
||||
因此:
|
||||
|
||||
- 不要把 Earth 的 HUD/动画/状态机直接挪进控制台
|
||||
- 不要把控制台表格/滚动策略硬套到 Earth HUD
|
||||
|
||||
Earth 相关结构见:
|
||||
|
||||
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
|
||||
@@ -16,12 +16,17 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.29.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.31.2`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
|
||||
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
|
||||
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
|
||||
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||
| `0.29.2` | bugfix | `dev` | `pending` | 修正 Earth 设置弹窗展开表现与系统入口,继续统一液态玻璃 HUD,并校正太阳受光方向 |
|
||||
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
||||
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
|
||||
| `0.28.2` | bugfix | `dev` | `pending` | 修正媒体情报 tab 尺寸记忆与切换锚点逻辑,并清理 docs 根目录遗留旧路径文档 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.29.1",
|
||||
"version": "0.31.2",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/* hud.css - HUD surfaces and shared overlays */
|
||||
|
||||
.hud-panel {
|
||||
--panel-glow-x: 18%;
|
||||
--panel-glow-y: 0%;
|
||||
--panel-glow-opacity: 0.1;
|
||||
--panel-tilt-x: 0deg;
|
||||
--panel-tilt-y: 0deg;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
radial-gradient(circle at var(--panel-glow-x) var(--panel-glow-y), rgba(255, 255, 255, calc(0.08 + var(--panel-glow-opacity))), transparent 30%),
|
||||
radial-gradient(circle at 86% 115%, rgba(145, 186, 255, 0.08), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 26%),
|
||||
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom));
|
||||
@@ -14,9 +19,14 @@
|
||||
inset 0 1px 0 var(--hud-highlight),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
|
||||
var(--hud-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 0 20px rgba(123, 176, 236, calc(0.04 + var(--panel-glow-opacity) * 0.32));
|
||||
backdrop-filter: blur(18px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(125%);
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
border-color 0.22s ease,
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel::before {
|
||||
@@ -28,6 +38,11 @@
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%);
|
||||
opacity: 0.52;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
perspective(240px)
|
||||
rotateX(calc(var(--panel-tilt-x) * 0.36))
|
||||
rotateY(calc(var(--panel-tilt-y) * 0.36));
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel::after {
|
||||
@@ -40,7 +55,11 @@
|
||||
linear-gradient(135deg, rgba(244, 249, 255, 0.22), rgba(164, 194, 226, 0.08) 36%, rgba(90, 123, 161, 0.04) 70%, rgba(255, 255, 255, 0.16));
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
filter: blur(0.2px);
|
||||
filter: url(#liquid-glass-distortion) blur(0.22px);
|
||||
transform:
|
||||
perspective(240px)
|
||||
rotateX(calc(var(--panel-tilt-x) * 0.24))
|
||||
rotateY(calc(var(--panel-tilt-y) * 0.24));
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
@@ -49,6 +68,36 @@
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging) {
|
||||
--panel-glow-opacity: 0.16;
|
||||
border-color: var(--hud-border-hover);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 20px 48px rgba(1, 7, 16, 0.34),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03),
|
||||
0 0 28px rgba(123, 176, 236, 0.1);
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging)::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging)::after {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.hud-panel.is-pressed:not(.is-dragging) {
|
||||
--panel-glow-opacity: 0.13;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 14px 34px rgba(1, 7, 16, 0.28),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 0 22px rgba(123, 176, 236, 0.08);
|
||||
}
|
||||
|
||||
.hud-panel > * {
|
||||
@@ -230,18 +279,8 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hud-error-message {
|
||||
color: #ff4444;
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
display: none;
|
||||
padding: 10px;
|
||||
background-color: rgba(255, 68, 68, 0.1);
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #ff4444;
|
||||
}
|
||||
|
||||
.earth-status-message {
|
||||
.earth-status-message,
|
||||
.earth-error-message {
|
||||
position: absolute;
|
||||
top: calc(20px * var(--hud-scale));
|
||||
left: 50%;
|
||||
@@ -262,6 +301,7 @@
|
||||
0 0 18px rgba(145, 186, 255, 0.06);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
@@ -275,18 +315,27 @@
|
||||
opacity 0.28s ease;
|
||||
}
|
||||
|
||||
.earth-status-message.visible {
|
||||
.earth-status-message.visible,
|
||||
.earth-error-message.visible {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earth-error-message {
|
||||
top: calc(62px * var(--hud-scale));
|
||||
z-index: 211;
|
||||
min-width: min(calc(220px * var(--hud-scale)), 58vw);
|
||||
}
|
||||
|
||||
/* ── Indicator: single dot (transient) or three dots (loading) ── */
|
||||
|
||||
.earth-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.earth-status-dot {
|
||||
@@ -301,6 +350,8 @@
|
||||
}
|
||||
|
||||
.earth-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -406,11 +457,21 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 260;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.24s ease, visibility 0.24s ease;
|
||||
}
|
||||
|
||||
.earth-settings-modal.is-opening,
|
||||
.earth-settings-modal.is-open,
|
||||
.earth-settings-modal.is-closing {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.earth-settings-modal.is-open {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-settings-backdrop {
|
||||
@@ -419,46 +480,47 @@
|
||||
background: rgba(2, 8, 20, 0.46);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
|
||||
.earth-settings-modal.is-open .earth-settings-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earth-settings-sheet {
|
||||
--settings-scale: clamp(0.72, calc(var(--hud-scale) * 0.96), 1);
|
||||
position: fixed;
|
||||
top: max(32px, 9vh);
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-width: 560px;
|
||||
max-height: calc(100vh - max(64px, 18vh));
|
||||
top: max(calc(32px * var(--settings-scale)), 9vh);
|
||||
right: calc(16px * var(--settings-scale));
|
||||
left: calc(16px * var(--settings-scale));
|
||||
width: min(calc(560px * var(--settings-scale)), calc(100vw - (32px * var(--settings-scale))));
|
||||
max-width: calc(560px * var(--settings-scale));
|
||||
max-height: calc(100vh - max(calc(64px * var(--settings-scale)), 18vh));
|
||||
margin-inline: auto;
|
||||
transform: none;
|
||||
border-radius: calc(24px * var(--hud-scale));
|
||||
padding: calc(20px * var(--hud-scale));
|
||||
border-radius: 0;
|
||||
padding: calc(var(--hud-panel-padding) * var(--settings-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-md);
|
||||
gap: calc(var(--hud-gap-md) * var(--settings-scale));
|
||||
overflow: hidden;
|
||||
transform: translateZ(0);
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
border-radius: 0;
|
||||
will-change: transform, opacity, filter, border-radius;
|
||||
}
|
||||
|
||||
.earth-settings-sheet.liquid-glass-surface {
|
||||
animation: none;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.09), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%),
|
||||
linear-gradient(180deg, rgba(19, 34, 56, 0.92), rgba(8, 18, 31, 0.9));
|
||||
border-color: rgba(207, 224, 243, 0.12);
|
||||
.earth-settings-sheet.hud-panel {
|
||||
--panel-glow-x: 18%;
|
||||
--panel-glow-y: 0%;
|
||||
--panel-glow-opacity: 0.1;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 24px 56px rgba(2, 7, 15, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.earth-settings-sheet.liquid-glass-surface:hover,
|
||||
.earth-settings-sheet.liquid-glass-surface:active,
|
||||
.earth-settings-sheet.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 1;
|
||||
--press-offset: 0px;
|
||||
--glow-opacity: 0.24;
|
||||
transform: none;
|
||||
inset 0 1px 0 var(--hud-highlight),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
|
||||
var(--hud-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.earth-settings-header,
|
||||
@@ -468,40 +530,22 @@
|
||||
}
|
||||
|
||||
.earth-settings-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
--hud-header-padding: 0 0 var(--hud-gap-sm);
|
||||
--hud-header-gap: var(--hud-gap-md);
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-md);
|
||||
padding-bottom: var(--hud-gap-sm);
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.earth-settings-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-kicker-size);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-settings-title {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hud-title);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-settings-close {
|
||||
margin-top: 4px;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
@@ -566,6 +610,119 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-item--stacked {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earth-settings-item--stacked:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.earth-settings-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.earth-settings-segmented {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn.is-active {
|
||||
color: var(--hud-title);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.earth-settings-slider {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
appearance: none;
|
||||
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-settings-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
|
||||
linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider-value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 46px;
|
||||
text-align: right;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-settings-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -584,6 +741,22 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.earth-settings-link-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--hud-text-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.earth-settings-link-meta .material-symbols-rounded:first-child {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.earth-settings-link-meta .material-symbols-rounded:last-child {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.earth-settings-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -161,6 +161,84 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.info-card-cruise-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease;
|
||||
z-index: 49;
|
||||
}
|
||||
|
||||
.info-card-cruise-link polyline {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.98);
|
||||
stroke-width: 2.15;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter:
|
||||
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
|
||||
drop-shadow(0 0 2px rgba(6, 14, 28, 0.56))
|
||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||
}
|
||||
|
||||
.info-card-cruise-link circle {
|
||||
fill: rgba(255, 255, 255, 0.98);
|
||||
stroke: rgba(7, 16, 32, 0.72);
|
||||
stroke-width: 1.0;
|
||||
filter:
|
||||
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
|
||||
drop-shadow(0 0 2px rgba(6, 14, 28, 0.54))
|
||||
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating polyline {
|
||||
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle:first-of-type {
|
||||
animation: cruiseConnectorNodeIn 0.14s ease forwards;
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
|
||||
.info-card-cruise-link.is-animating circle:last-of-type {
|
||||
animation: cruiseConnectorNodeIn 0.16s ease forwards;
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorDraw {
|
||||
from {
|
||||
stroke-dashoffset: var(--connector-length, 0px);
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cruiseConnectorNodeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Info Card ────────────────────────────────────────────────── */
|
||||
|
||||
.info-card {
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row-toggle-track {
|
||||
@@ -247,6 +248,11 @@
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row-toggle:disabled {
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.layer-row-toggle-track::after {
|
||||
content: "";
|
||||
@@ -261,6 +267,24 @@
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-loading .layer-row-toggle-track {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(104, 147, 221, 0.38),
|
||||
rgba(143, 185, 255, 0.72),
|
||||
rgba(104, 147, 221, 0.38)
|
||||
);
|
||||
background-size: 180% 100%;
|
||||
border-color: rgba(223, 236, 252, 0.28);
|
||||
animation: layer-toggle-loading-track 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.layer-row-toggle.is-loading .layer-row-toggle-track::after {
|
||||
background: #f0f6ff;
|
||||
transform: translateX(calc(7px * var(--hud-scale)));
|
||||
animation: layer-toggle-loading-thumb 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Active (ON) state */
|
||||
.layer-row-toggle.active .layer-row-toggle-track {
|
||||
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
||||
@@ -272,5 +296,24 @@
|
||||
transform: translateX(calc(14px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
@keyframes layer-toggle-loading-track {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 180% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes layer-toggle-loading-thumb {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 0 rgba(174, 205, 255, 0.18);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3), 0 0 calc(10px * var(--hud-scale)) rgba(174, 205, 255, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
||||
individual rule needed since the whole column translates together. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* toolbar.css - bottom dock and floating toolbar primitives */
|
||||
/* toolbar.css - orbital hub toolbar */
|
||||
|
||||
.earth-toolbar-group {
|
||||
position: absolute;
|
||||
@@ -6,7 +6,6 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
@@ -24,119 +23,153 @@
|
||||
}
|
||||
|
||||
.earth-toolbar {
|
||||
--toolbar-scale: 1;
|
||||
--toolbar-orb-size: calc(46px * var(--toolbar-scale));
|
||||
--toolbar-hub-size: calc(58px * var(--toolbar-scale));
|
||||
--toolbar-arc-width: calc(420px * var(--toolbar-scale));
|
||||
--toolbar-arc-height: calc(160px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale));
|
||||
position: relative;
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
height: calc(200px * var(--toolbar-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-items {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover {
|
||||
.earth-toolbar-cluster {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: 56px;
|
||||
height: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: calc(100% + 12px);
|
||||
transform: translate(-50%, 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn {
|
||||
.earth-toolbar-cluster::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(14px * var(--toolbar-scale));
|
||||
width: var(--toolbar-arc-width);
|
||||
height: var(--toolbar-arc-height);
|
||||
transform: translateX(-50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(145, 186, 255, 0.08);
|
||||
border-bottom-color: transparent;
|
||||
background:
|
||||
radial-gradient(circle at 50% 100%, rgba(145, 186, 255, 0.05), transparent 58%);
|
||||
opacity: 0.9;
|
||||
mask: linear-gradient(180deg, rgba(0, 0, 0, 0.82), transparent 86%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(30px * var(--toolbar-scale));
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-hub {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(8px * var(--toolbar-scale));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.36s cubic-bezier(0.34, 1.15, 0.64, 1),
|
||||
opacity 0.24s ease;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-orb {
|
||||
transform: translate(calc(-50% + var(--orb-x)), calc(-50% + var(--orb-y)));
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb {
|
||||
transform: translate(-50%, -50%) scale(0.42);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > *,
|
||||
.earth-toolbar-hub > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation: floatDock 4.6s ease-in-out infinite;
|
||||
animation-delay: var(--orb-delay, 0s);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-dock-engaged .earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn,
|
||||
.earth-toolbar-hub-btn {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #4db8ff;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn.floating-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
min-height: 42px;
|
||||
width: var(--toolbar-orb-size);
|
||||
height: var(--toolbar-orb-size);
|
||||
min-width: var(--toolbar-orb-size);
|
||||
min-height: var(--toolbar-orb-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:not(.liquid-glass-surface)::after {
|
||||
content: none;
|
||||
.earth-toolbar-hub-btn {
|
||||
width: var(--toolbar-hub-size);
|
||||
height: var(--toolbar-hub-size);
|
||||
min-width: var(--toolbar-hub-size);
|
||||
min-height: var(--toolbar-hub-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transform: translateZ(0);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .material-symbols-rounded {
|
||||
font-size: 21px;
|
||||
.earth-toolbar-btn .material-symbols-rounded,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
font-size: calc(21px * var(--toolbar-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
@@ -154,28 +187,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
shape-rendering: geometricPrecision;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.earth-toolbar-items > :nth-child(2n).floating-btn,
|
||||
.earth-toolbar-items > :nth-child(2n) .floating-btn {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.earth-toolbar-items > :nth-child(3n).floating-btn,
|
||||
.earth-toolbar-items > :nth-child(3n) .floating-btn {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.liquid-glass-surface {
|
||||
--elastic-x: 0px;
|
||||
--elastic-y: 0px;
|
||||
@@ -190,12 +201,14 @@
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center center;
|
||||
will-change: transform, box-shadow;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)),
|
||||
rgba(8, 20, 38, 0.22);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.05), transparent 30%),
|
||||
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)),
|
||||
rgba(8, 20, 38, 0.12);
|
||||
border: 1px solid var(--hud-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
@@ -205,7 +218,11 @@
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
transform:
|
||||
translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0)
|
||||
translate3d(
|
||||
var(--elastic-x),
|
||||
calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)),
|
||||
0
|
||||
)
|
||||
scale(var(--btn-scale));
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
@@ -213,17 +230,16 @@
|
||||
background 0.22s ease,
|
||||
opacity 0.18s ease,
|
||||
border-color 0.22s ease;
|
||||
animation: floatDock 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px 1px 18px 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%);
|
||||
opacity: 0.5;
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04) 28%, transparent 68%);
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
perspective(120px)
|
||||
@@ -234,14 +250,14 @@
|
||||
}
|
||||
|
||||
.liquid-glass-surface::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
padding: 1.35px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
|
||||
opacity: 0.82;
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
filter: url(#liquid-glass-distortion) blur(0.35px);
|
||||
transform:
|
||||
@@ -260,15 +276,28 @@
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 50% 24%, rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
linear-gradient(180deg, rgba(28, 54, 90, 0.26), rgba(11, 24, 43, 0.22)),
|
||||
rgba(10, 28, 52, 0.16);
|
||||
border-color: var(--hud-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 16px 30px rgba(0, 0, 0, 0.24),
|
||||
0 0 30px rgba(104, 181, 247, 0.18);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover {
|
||||
--btn-scale: 1.035;
|
||||
--btn-scale: 1.04;
|
||||
--press-offset: -1px;
|
||||
--glow-opacity: 0.32;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)),
|
||||
rgba(8, 20, 38, 0.2);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(128, 198, 255, 0.06)),
|
||||
rgba(8, 20, 38, 0.14);
|
||||
border-color: var(--hud-border-hover);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
@@ -289,52 +318,16 @@
|
||||
|
||||
.liquid-glass-surface:active,
|
||||
.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 0.942;
|
||||
--btn-scale: 0.95;
|
||||
--press-offset: 2px;
|
||||
--glow-opacity: 0.2;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)),
|
||||
rgba(10, 24, 44, 0.24);
|
||||
border-color: rgba(240, 249, 255, 0.58);
|
||||
box-shadow:
|
||||
inset 0 2px 10px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||
0 4px 10px rgba(0, 0, 0, 0.18),
|
||||
0 0 14px rgba(176, 226, 255, 0.18);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active::before,
|
||||
.liquid-glass-surface.is-pressed::before {
|
||||
opacity: 0.46;
|
||||
transform: translateY(2px) scale(0.985);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active::after,
|
||||
.liquid-glass-surface.is-pressed::after {
|
||||
opacity: 0.78;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active .icon,
|
||||
.liquid-glass-surface.is-pressed .icon {
|
||||
transform: translateY(1.5px);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active img,
|
||||
.liquid-glass-surface.is-pressed img,
|
||||
.liquid-glass-surface:active .material-symbols-rounded,
|
||||
.liquid-glass-surface.is-pressed .material-symbols-rounded {
|
||||
transform: translateY(1.5px);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active {
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)),
|
||||
rgba(11, 34, 58, 0.26);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)),
|
||||
rgba(11, 34, 58, 0.18);
|
||||
border-color: var(--hud-border-active);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
@@ -357,137 +350,50 @@
|
||||
|
||||
.earth-zoom-group:hover > .earth-zoom-toolbar,
|
||||
.earth-zoom-group:focus-within > .earth-zoom-toolbar,
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar,
|
||||
.earth-info-group:hover > .earth-info-toolbar,
|
||||
.earth-info-group:focus-within > .earth-info-toolbar,
|
||||
.earth-info-group.open > .earth-info-toolbar {
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar,
|
||||
.earth-info-group.force-closed > .earth-info-toolbar {
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
|
||||
.earth-zoom-group > .earth-zoom-toolbar,
|
||||
.earth-info-group > .earth-info-toolbar {
|
||||
.earth-toolbar-popover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(56px * var(--toolbar-scale));
|
||||
height: calc(16px * var(--toolbar-scale));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 12px);
|
||||
bottom: calc(100% + (12px * var(--toolbar-scale)));
|
||||
transform: translate(-50%, 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-info-toolbar {
|
||||
width: min(280px, calc(100vw - 36px));
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94));
|
||||
border: 1px solid rgba(211, 228, 246, 0.14);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(135%);
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-header {
|
||||
width: 100%;
|
||||
padding: 2px 4px 8px;
|
||||
border-bottom: 1px solid rgba(201, 225, 247, 0.08);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-title {
|
||||
display: block;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-subtitle {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-layer-btn__copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-layer-btn__label {
|
||||
color: var(--hud-text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.earth-layer-btn__meta {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-layer-btn__state {
|
||||
flex: 0 0 auto;
|
||||
min-width: 42px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-layer-btn.active .earth-layer-btn__state {
|
||||
color: #dff4ff;
|
||||
border-color: rgba(220, 240, 255, 0.24);
|
||||
background: rgba(131, 197, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-layer-btn .earth-toolbar-tooltip {
|
||||
display: none;
|
||||
gap: calc(8px * var(--toolbar-scale));
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn,
|
||||
@@ -495,8 +401,8 @@
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
border-radius: 50%;
|
||||
color: #4db8ff;
|
||||
animation: floatDock 3.8s ease-in-out infinite;
|
||||
color: var(--hud-text-soft);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn {
|
||||
@@ -514,37 +420,8 @@
|
||||
padding: 0;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: normal;
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:active,
|
||||
.earth-zoom-toolbar .earth-zoom-btn.is-pressed,
|
||||
.earth-zoom-toolbar .earth-zoom-value:active,
|
||||
.earth-zoom-toolbar .earth-zoom-value.is-pressed {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:nth-child(3) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-toolbar-tooltip {
|
||||
bottom: calc(100% + 10px);
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-toolbar-tooltip::after {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.earth-app.layout-expanded .earth-toolbar-group {
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
@@ -555,8 +432,9 @@
|
||||
bottom: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 30, 0.95);
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
||||
color: var(--hud-text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
@@ -564,9 +442,10 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(77, 184, 255, 0.4);
|
||||
border: 1px solid var(--hud-border);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: var(--hud-shadow-soft);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:hover .earth-toolbar-tooltip,
|
||||
@@ -578,11 +457,11 @@
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
border-top-color: rgba(18, 31, 52, 0.96);
|
||||
}
|
||||
|
||||
@@ -133,8 +133,8 @@
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: calc(-1 * var(--hud-gap-sm));
|
||||
margin-bottom: calc(-1 * var(--hud-gap-sm));
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tv-panel-meta {
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
.tv-panel-player {
|
||||
position: relative;
|
||||
flex: 1 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(220px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示">
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -150,45 +150,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div class="earth-toolbar-items">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
</button>
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
</span>
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">newspaper</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
<div id="zoom-control-group" class="earth-toolbar-popover earth-zoom-group">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
</span>
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
|
||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">zoom_in</span>
|
||||
@@ -201,27 +203,38 @@
|
||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
</span>
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
</span>
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-hub">
|
||||
<button id="toolbar-hub" class="earth-toolbar-hub-btn liquid-glass-surface" title="工具菜单" aria-label="展开工具菜单">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">tune</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,17 +414,45 @@
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet liquid-glass-surface" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="earth-settings-header">
|
||||
<div>
|
||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||
<div class="earth-settings-header hud-panel__header">
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-settings-kicker">设置</div>
|
||||
<h3 id="settings-title" class="earth-settings-title hud-panel-title">显示与视图</h3>
|
||||
</div>
|
||||
<button id="settings-close" class="earth-settings-close hud-panel-close" type="button" aria-label="关闭设置">
|
||||
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-content">
|
||||
<div class="earth-settings-content hud-panel__body">
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">旋转</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">旋转模式</span>
|
||||
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn is-active"
|
||||
data-rotation-mode="rotate"
|
||||
aria-pressed="true"
|
||||
>
|
||||
旋转模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="earth-settings-segmented-btn"
|
||||
data-rotation-mode="cruise"
|
||||
aria-pressed="false"
|
||||
>
|
||||
巡航模式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
@@ -447,8 +488,8 @@
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-tv">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">电视直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
<span class="earth-settings-item-title">新闻直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制电视直播 / 态势聚合显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
|
||||
@@ -457,6 +498,50 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
<a
|
||||
class="earth-settings-item earth-settings-link"
|
||||
href="/admin"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">Admin</span>
|
||||
<span class="earth-settings-item-subtitle">打开管理后台仪表盘</span>
|
||||
</div>
|
||||
<span class="earth-settings-link-meta">
|
||||
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||
<span class="material-symbols-rounded">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
300
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
300
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
@@ -0,0 +1,300 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||
import { createElbowConnectorPoints } 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_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
|
||||
function getMarkerTimestamp(marker) {
|
||||
const rawValue = marker?.userData?.created_at_raw;
|
||||
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
|
||||
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||||
}
|
||||
|
||||
export function createBGPCruiseAdapter({
|
||||
camera,
|
||||
getMarkers,
|
||||
connector,
|
||||
focusView,
|
||||
setMarkerLocked,
|
||||
clearMarkerState,
|
||||
showMarkerOverlay,
|
||||
applySatelliteHighlights,
|
||||
showMarkerInfo,
|
||||
hideInfo,
|
||||
isInfoVisible,
|
||||
getLockedObject,
|
||||
refreshMarkers,
|
||||
}) {
|
||||
let currentMarkerId = null;
|
||||
let cardPlacement = null;
|
||||
let knownEventIds = new Set();
|
||||
|
||||
function getCurrentMarker() {
|
||||
if (!currentMarkerId) return null;
|
||||
return getMarkers().find((marker) => marker?.userData?.id === currentMarkerId) || null;
|
||||
}
|
||||
|
||||
function getSortedMarkers() {
|
||||
return getMarkers()
|
||||
.slice()
|
||||
.sort((a, b) => getMarkerTimestamp(b) - getMarkerTimestamp(a));
|
||||
}
|
||||
|
||||
function getMarkerScreenCoords(marker) {
|
||||
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 null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getCardScreenCoords(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
if (!markerCoords) return null;
|
||||
|
||||
const hudScale =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||
) || 1;
|
||||
const estimatedCardHeight = Math.min(
|
||||
CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale,
|
||||
window.innerHeight * 0.7,
|
||||
);
|
||||
const estimatedCardWidth = Math.min(
|
||||
CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale,
|
||||
window.innerWidth - CRUISE_CARD_VIEWPORT_PADDING_PX,
|
||||
);
|
||||
|
||||
const x =
|
||||
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
|
||||
const y =
|
||||
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
|
||||
const margin = CRUISE_CARD_SCREEN_MARGIN_PX;
|
||||
const clampedX = Math.min(
|
||||
Math.max(margin, x),
|
||||
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
|
||||
);
|
||||
const clampedY = Math.min(
|
||||
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 getConnectorPath(marker) {
|
||||
const markerCoords = getMarkerScreenCoords(marker);
|
||||
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||
if (!markerCoords || !targetCardCoords) return null;
|
||||
|
||||
return createElbowConnectorPoints(
|
||||
markerCoords,
|
||||
{
|
||||
x: targetCardCoords.anchorX,
|
||||
y: targetCardCoords.anchorY,
|
||||
},
|
||||
{
|
||||
startFrom: "source",
|
||||
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
|
||||
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
|
||||
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
|
||||
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function renderConnector(marker, { animate = false } = {}) {
|
||||
const path = getConnectorPath(marker);
|
||||
if (!path) return false;
|
||||
return connector.render(path, { animate });
|
||||
}
|
||||
|
||||
function extractFeatureIds(features = []) {
|
||||
return features
|
||||
.map((feature) => {
|
||||
const properties = feature?.properties || {};
|
||||
const coords = feature?.geometry?.coordinates || [];
|
||||
return (
|
||||
properties.id ||
|
||||
properties.incident_key ||
|
||||
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return {
|
||||
getSortedMarkers,
|
||||
getCurrentMarker,
|
||||
isPresentationVisible() {
|
||||
return cardPlacement != null;
|
||||
},
|
||||
clearCurrentHighlight() {
|
||||
const marker = getCurrentMarker();
|
||||
if (marker && getLockedObject() !== marker) {
|
||||
clearMarkerState(marker);
|
||||
}
|
||||
currentMarkerId = null;
|
||||
},
|
||||
async focusMarker(marker, { interrupt = false } = {}) {
|
||||
if (!marker) return;
|
||||
currentMarkerId = marker.userData?.id || null;
|
||||
cardPlacement = getCardScreenCoords(marker);
|
||||
setMarkerLocked(marker);
|
||||
showMarkerOverlay(marker);
|
||||
applySatelliteHighlights(marker);
|
||||
|
||||
await focusView({
|
||||
lat: marker.userData?.latitude ?? 0,
|
||||
lon: marker.userData?.longitude ?? 0,
|
||||
rotLon: (marker.userData?.longitude ?? 0) - 270,
|
||||
zoom: 1.0,
|
||||
duration: interrupt
|
||||
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||
: CRUISE_CONFIG.focusDurationMs,
|
||||
suppressStatus: true,
|
||||
});
|
||||
},
|
||||
async presentMarker(marker, { context }) {
|
||||
if (!marker) return false;
|
||||
|
||||
const startedAt = performance.now();
|
||||
let connectorReady = false;
|
||||
while (context.isCurrent()) {
|
||||
connectorReady = renderConnector(marker, { animate: !connectorReady });
|
||||
if (connectorReady) break;
|
||||
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||
break;
|
||||
}
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!connectorReady || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
});
|
||||
await context.nextFrame();
|
||||
if (!isInfoVisible()) {
|
||||
showMarkerInfo(marker, {
|
||||
x: cardPlacement?.x,
|
||||
y: cardPlacement?.y,
|
||||
absolute: true,
|
||||
});
|
||||
await context.nextFrame();
|
||||
}
|
||||
|
||||
if (!isInfoVisible() || !context.isCurrent()) {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
hideInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
async hidePresentation({ context }) {
|
||||
if (!getLockedObject()) {
|
||||
hideInfo();
|
||||
}
|
||||
connector.hide();
|
||||
const hideDelayCompleted = await context.wait(CRUISE_PRESENTATION_HIDE_MS, {
|
||||
secondary: true,
|
||||
});
|
||||
if (!hideDelayCompleted) return;
|
||||
cardPlacement = null;
|
||||
},
|
||||
repositionConnector(marker) {
|
||||
if (!cardPlacement || !marker || !connector.isVisible() || connector.isAnimating()) {
|
||||
return;
|
||||
}
|
||||
renderConnector(marker, { animate: false });
|
||||
},
|
||||
resetPresentation() {
|
||||
cardPlacement = null;
|
||||
connector.hide();
|
||||
},
|
||||
syncKnownEventIds() {
|
||||
knownEventIds = new Set(
|
||||
getMarkers()
|
||||
.map((marker) => marker?.userData?.id)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return knownEventIds;
|
||||
},
|
||||
async pollForNewMarkerIds() {
|
||||
const [incidentResponse, anomalyResponse] = await Promise.all([
|
||||
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
||||
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
||||
]);
|
||||
|
||||
if (!incidentResponse.ok || !anomalyResponse.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [incidentPayload, anomalyPayload] = await Promise.all([
|
||||
incidentResponse.json(),
|
||||
anomalyResponse.json(),
|
||||
]);
|
||||
|
||||
const incidentFeatures = Array.isArray(incidentPayload?.features)
|
||||
? incidentPayload.features
|
||||
: [];
|
||||
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
|
||||
? anomalyPayload.features
|
||||
: [];
|
||||
const selectedFeatures =
|
||||
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
|
||||
|
||||
const nextIds = extractFeatureIds(selectedFeatures);
|
||||
const newIds = nextIds.filter((id) => !knownEventIds.has(id));
|
||||
if (newIds.length === 0) return [];
|
||||
|
||||
await refreshMarkers();
|
||||
this.syncKnownEventIds();
|
||||
return newIds;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
|
||||
}
|
||||
|
||||
function drawWaveSymbol(context) {
|
||||
context.lineWidth = 12;
|
||||
context.lineCap = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(18, 76);
|
||||
context.bezierCurveTo(34, 46, 46, 46, 64, 76);
|
||||
context.bezierCurveTo(80, 106, 94, 106, 110, 76);
|
||||
context.stroke();
|
||||
context.moveTo(14, 100);
|
||||
context.lineTo(38, 26);
|
||||
context.lineTo(64, 100);
|
||||
context.lineTo(90, 26);
|
||||
context.lineTo(114, 100);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function drawBurstSymbol(context) {
|
||||
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
|
||||
}
|
||||
|
||||
export async function loadBGPAnomalies(scene, earth) {
|
||||
clearBGPData(earth);
|
||||
|
||||
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
|
||||
if (!collectorsResponse.ok) {
|
||||
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
||||
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
? collectorsPayload.features
|
||||
: [];
|
||||
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
|
||||
|
||||
clearBGPData(earth);
|
||||
|
||||
totalAnomalyCount = selectedEventData.totalAnomalyCount;
|
||||
totalIncidentCount = selectedEventData.totalIncidentCount;
|
||||
activeEventCountByCollector.clear();
|
||||
@@ -1351,7 +1353,7 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
|
||||
const now = performance.now();
|
||||
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
||||
const hasLockedLayer = Boolean(
|
||||
@@ -1459,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
const isLinkedCollectorLocked =
|
||||
lockedObjectType === "bgp_collector" &&
|
||||
lockedObject?.userData?.collector === marker.userData.collector;
|
||||
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
|
||||
const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
|
||||
const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
|
||||
const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
|
||||
const isActive = isLocked || isLinkedCollectorLocked || isCruise;
|
||||
const isHovered = marker.userData.state === "hover";
|
||||
const pulse =
|
||||
0.5 +
|
||||
@@ -1477,18 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
|
||||
if (isLocked || isLinkedCollectorLocked) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity =
|
||||
0.9 +
|
||||
0.1 * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
markerColor = 0xfff1a8;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isCruise) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity = 0.9 + 0.1 * pulse;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isHovered) {
|
||||
scale *= BGP_CONFIG.marker.hoverScale;
|
||||
opacity = 0.9;
|
||||
ringBaseOpacity *= 1.05;
|
||||
} else if (isOtherLocked) {
|
||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||
opacity = 0.1;
|
||||
opacity = 0.22;
|
||||
markerColor = 0x7d8ca3;
|
||||
ringBaseOpacity = 0.02;
|
||||
} else {
|
||||
@@ -1500,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
marker.material.color.setHex(markerColor);
|
||||
marker.material.opacity = opacity;
|
||||
marker.visible = showBGP;
|
||||
marker.renderOrder = isActive ? 7 : 3;
|
||||
|
||||
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
|
||||
const applyRingState = (ring, phase, maxScale) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export let lockedCable = null;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
let landingPointGeometry = null;
|
||||
const landingPointWorldPosition = new THREE.Vector3();
|
||||
|
||||
function clamp(value, min, max) {
|
||||
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
|
||||
if (owner) {
|
||||
owner.remove(object);
|
||||
}
|
||||
if (object.geometry) {
|
||||
if (object.geometry && !object.userData?.sharedGeometry) {
|
||||
object.geometry.dispose();
|
||||
}
|
||||
if (object.material) {
|
||||
@@ -245,9 +246,12 @@ export function clearCableData(earthObj = null) {
|
||||
clearLandingPoints(earthObj);
|
||||
}
|
||||
|
||||
export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载电缆数据...");
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
if (!response.ok) {
|
||||
@@ -344,11 +348,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
textureQuality: "8K 卫星图",
|
||||
});
|
||||
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
}
|
||||
return cableLines.length;
|
||||
}
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj) {
|
||||
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载登陆点数据...");
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
@@ -363,70 +370,69 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
|
||||
const sphereGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
if (!landingPointGeometry) {
|
||||
landingPointGeometry = new THREE.SphereGeometry(
|
||||
CABLE_CONFIG.landingPoint.radius,
|
||||
CABLE_CONFIG.landingPoint.widthSegments,
|
||||
CABLE_CONFIG.landingPoint.heightSegments,
|
||||
);
|
||||
}
|
||||
let validCount = 0;
|
||||
|
||||
try {
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
for (const feature of data.features) {
|
||||
if (!feature.geometry || !feature.geometry.coordinates) continue;
|
||||
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const properties = feature.properties || {};
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
const properties = feature.properties || {};
|
||||
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = latLonToVector3(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
sphereGeometry.clone(),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
};
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
validCount++;
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
sphereGeometry.dispose();
|
||||
|
||||
const position = latLonToVector3(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
landingPointGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
emissive: CABLE_CONFIG.landingPoint.emissive,
|
||||
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
|
||||
transparent: true,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
}),
|
||||
);
|
||||
sphere.position.copy(position);
|
||||
sphere.userData = {
|
||||
type: "landingPoint",
|
||||
name: properties.name || "未知登陆站",
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || "未知国家",
|
||||
status: properties.status || "Unknown",
|
||||
baseScale: CABLE_CONFIG.landingPoint.baseScale,
|
||||
sharedGeometry: true,
|
||||
};
|
||||
|
||||
earthObj.add(sphere);
|
||||
landingPoints.push(sphere);
|
||||
validCount++;
|
||||
}
|
||||
|
||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
||||
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
}
|
||||
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
}
|
||||
return validCount;
|
||||
}
|
||||
|
||||
|
||||
185
frontend/public/earth/js/callout-connector.js
Normal file
185
frontend/public/earth/js/callout-connector.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
|
||||
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
|
||||
|
||||
function createSvgElement(tagName) {
|
||||
return document.createElementNS(SVG_NS, tagName);
|
||||
}
|
||||
|
||||
export function createElbowConnectorPoints(source, target, options = {}) {
|
||||
if (!source || !target) return null;
|
||||
|
||||
const {
|
||||
startFrom = "source",
|
||||
sourceGapPx = 12,
|
||||
targetGapPx = 8,
|
||||
elbowOffsetPx = 18,
|
||||
elbowDropPx = 14,
|
||||
} = options;
|
||||
|
||||
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
|
||||
const targetPoint = { x: Number(target.x), y: Number(target.y) };
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x) ||
|
||||
!Number.isFinite(sourcePoint.y) ||
|
||||
!Number.isFinite(targetPoint.x) ||
|
||||
!Number.isFinite(targetPoint.y)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const horizontalDirection = sourcePoint.x <= targetPoint.x ? 1 : -1;
|
||||
const startX = sourcePoint.x + horizontalDirection * sourceGapPx;
|
||||
const startY = sourcePoint.y;
|
||||
const endX = targetPoint.x - horizontalDirection * targetGapPx;
|
||||
const endY = targetPoint.y;
|
||||
const elbowX = endX - horizontalDirection * elbowOffsetPx;
|
||||
const elbowY = Math.min(startY, endY) + elbowDropPx;
|
||||
|
||||
if (Math.abs(endX - startX) < 8 && Math.abs(endY - startY) < 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderedPoints = [
|
||||
{ x: startX, y: startY },
|
||||
{ x: elbowX, y: elbowY },
|
||||
{ x: endX, y: endY },
|
||||
];
|
||||
|
||||
return {
|
||||
points: startFrom === "target" ? orderedPoints.slice().reverse() : orderedPoints,
|
||||
start: startFrom === "target" ? orderedPoints[2] : orderedPoints[0],
|
||||
end: startFrom === "target" ? orderedPoints[0] : orderedPoints[2],
|
||||
};
|
||||
}
|
||||
|
||||
export class CalloutConnector {
|
||||
constructor({
|
||||
container = null,
|
||||
containerId = "container",
|
||||
className = DEFAULT_CLASS_NAME,
|
||||
drawAnimationName = DEFAULT_DRAW_ANIMATION_NAME,
|
||||
} = {}) {
|
||||
this.container = container;
|
||||
this.containerId = containerId;
|
||||
this.className = className;
|
||||
this.drawAnimationName = drawAnimationName;
|
||||
this.connectorEl = null;
|
||||
this.polylineEl = null;
|
||||
this.startpointEl = null;
|
||||
this.endpointEl = null;
|
||||
}
|
||||
|
||||
resolveContainer() {
|
||||
if (this.container instanceof HTMLElement) return this.container;
|
||||
this.container = document.getElementById(this.containerId);
|
||||
return this.container instanceof HTMLElement ? this.container : null;
|
||||
}
|
||||
|
||||
ensure() {
|
||||
if (this.connectorEl instanceof SVGSVGElement) {
|
||||
return this.connectorEl;
|
||||
}
|
||||
|
||||
const container = this.resolveContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const connector = createSvgElement("svg");
|
||||
connector.setAttribute("class", this.className);
|
||||
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
||||
connector.setAttribute("preserveAspectRatio", "none");
|
||||
|
||||
const polyline = createSvgElement("polyline");
|
||||
const startpoint = createSvgElement("circle");
|
||||
const endpoint = createSvgElement("circle");
|
||||
startpoint.setAttribute("r", "4");
|
||||
endpoint.setAttribute("r", "4");
|
||||
|
||||
connector.append(startpoint, polyline, endpoint);
|
||||
container.appendChild(connector);
|
||||
|
||||
connector.addEventListener("animationend", (event) => {
|
||||
if (
|
||||
event.animationName === this.drawAnimationName &&
|
||||
this.connectorEl?.classList.contains("is-visible")
|
||||
) {
|
||||
if (this.polylineEl) {
|
||||
this.polylineEl.style.strokeDashoffset = "0";
|
||||
}
|
||||
this.connectorEl?.classList.remove("is-animating");
|
||||
}
|
||||
});
|
||||
|
||||
this.connectorEl = connector;
|
||||
this.polylineEl = polyline;
|
||||
this.startpointEl = startpoint;
|
||||
this.endpointEl = endpoint;
|
||||
return connector;
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return this.connectorEl?.classList.contains("is-visible") === true;
|
||||
}
|
||||
|
||||
isAnimating() {
|
||||
return this.connectorEl?.classList.contains("is-animating") === true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
const connector = this.ensure();
|
||||
if (!connector) return;
|
||||
connector.classList.remove("is-visible", "is-animating");
|
||||
}
|
||||
|
||||
render(path, { animate = false } = {}) {
|
||||
const connector = this.ensure();
|
||||
if (
|
||||
!connector ||
|
||||
!this.polylineEl ||
|
||||
!this.startpointEl ||
|
||||
!this.endpointEl ||
|
||||
!Array.isArray(path?.points) ||
|
||||
path.points.length < 2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewWidth = window.innerWidth;
|
||||
const viewHeight = window.innerHeight;
|
||||
connector.setAttribute("viewBox", `0 0 ${viewWidth} ${viewHeight}`);
|
||||
|
||||
const pointsText = path.points
|
||||
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
this.polylineEl.setAttribute("points", pointsText);
|
||||
this.startpointEl.setAttribute("cx", path.start.x.toFixed(2));
|
||||
this.startpointEl.setAttribute("cy", path.start.y.toFixed(2));
|
||||
this.endpointEl.setAttribute("cx", path.end.x.toFixed(2));
|
||||
this.endpointEl.setAttribute("cy", path.end.y.toFixed(2));
|
||||
|
||||
const totalLength =
|
||||
typeof this.polylineEl.getTotalLength === "function"
|
||||
? this.polylineEl.getTotalLength()
|
||||
: 0;
|
||||
|
||||
this.polylineEl.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
|
||||
this.polylineEl.style.strokeDashoffset =
|
||||
totalLength > 0 ? `${animate ? totalLength : 0}` : "";
|
||||
connector.style.setProperty(
|
||||
"--connector-length",
|
||||
totalLength > 0 ? `${totalLength}` : "0px",
|
||||
);
|
||||
connector.classList.add("is-visible");
|
||||
|
||||
if (animate && totalLength > 0) {
|
||||
connector.classList.remove("is-animating");
|
||||
void connector.getBoundingClientRect();
|
||||
this.polylineEl.style.strokeDashoffset = `${totalLength}`;
|
||||
connector.classList.add("is-animating");
|
||||
} else {
|
||||
connector.classList.remove("is-animating");
|
||||
}
|
||||
|
||||
return totalLength > 0;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
||||
import * as Astronomy from "astronomy-engine";
|
||||
|
||||
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const defaultSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
|
||||
@@ -32,6 +33,59 @@ let runtimeFollowConfig = {
|
||||
};
|
||||
const scratchEuler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||
|
||||
function normalizeDegrees180(value) {
|
||||
let normalized = value;
|
||||
while (normalized <= -180) normalized += 360;
|
||||
while (normalized > 180) normalized -= 360;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function computeGreenwichMeanSiderealDegrees(date) {
|
||||
const jd = date.getTime() / 86400000 + 2440587.5;
|
||||
const t = (jd - 2451545.0) / 36525.0;
|
||||
const gmst =
|
||||
280.46061837 +
|
||||
360.98564736629 * (jd - 2451545.0) +
|
||||
0.000387933 * t * t -
|
||||
(t * t * t) / 38710000;
|
||||
return THREE.MathUtils.euclideanModulo(gmst, 360);
|
||||
}
|
||||
|
||||
function computeSubsolarLocalDirection(date) {
|
||||
const vector = Astronomy.GeoVector(Astronomy.Body.Sun, date, false);
|
||||
const radius = Math.sqrt(
|
||||
vector.x * vector.x +
|
||||
vector.y * vector.y +
|
||||
vector.z * vector.z,
|
||||
);
|
||||
if (!Number.isFinite(radius) || radius === 0) {
|
||||
return defaultSunDirection.clone();
|
||||
}
|
||||
|
||||
const rightAscensionDeg = THREE.MathUtils.radToDeg(
|
||||
Math.atan2(vector.y, vector.x),
|
||||
);
|
||||
const declinationDeg = THREE.MathUtils.radToDeg(
|
||||
Math.asin(THREE.MathUtils.clamp(vector.z / radius, -1, 1)),
|
||||
);
|
||||
const gmstDeg = computeGreenwichMeanSiderealDegrees(date);
|
||||
const subsolarLonDeg = normalizeDegrees180(rightAscensionDeg - gmstDeg);
|
||||
|
||||
return latLonToVector3(declinationDeg, subsolarLonDeg, 1).normalize();
|
||||
}
|
||||
|
||||
function getPhysicalSunDirection(date = new Date()) {
|
||||
const localSunDirection = computeSubsolarLocalDirection(date);
|
||||
if (!linkedEarth) {
|
||||
return localSunDirection;
|
||||
}
|
||||
|
||||
return localSunDirection
|
||||
.clone()
|
||||
.applyQuaternion(linkedEarth.quaternion)
|
||||
.normalize();
|
||||
}
|
||||
|
||||
function getCelestialEuler() {
|
||||
const { x, y, z } = runtimeOrientationEuler;
|
||||
return new THREE.Euler(x, y, z, "YXZ");
|
||||
@@ -279,13 +333,15 @@ function updateSpritePositions() {
|
||||
}
|
||||
|
||||
function updateLighting() {
|
||||
const calibratedSunDirection = applyCelestialOrientation(sunDirection);
|
||||
const physicalSunDirection = getPhysicalSunDirection(
|
||||
new Date(lastUpdatedAt || Date.now()),
|
||||
);
|
||||
|
||||
if (linkedSunLight) {
|
||||
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
|
||||
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
|
||||
linkedSunLight.position
|
||||
.copy(calibratedSunDirection)
|
||||
.copy(physicalSunDirection)
|
||||
.multiplyScalar(CELESTIAL_CONFIG.sunLightDistance);
|
||||
}
|
||||
|
||||
@@ -293,7 +349,7 @@ function updateLighting() {
|
||||
linkedBackLight.color.setHex(CELESTIAL_CONFIG.backLightColor);
|
||||
linkedBackLight.intensity = CELESTIAL_CONFIG.backLightIntensity;
|
||||
linkedBackLight.position
|
||||
.copy(calibratedSunDirection)
|
||||
.copy(physicalSunDirection)
|
||||
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
|
||||
}
|
||||
}
|
||||
@@ -413,7 +469,7 @@ export function updateCelestialLayer(date = new Date(), camera = null) {
|
||||
}
|
||||
|
||||
export function getSunDirection() {
|
||||
return applyCelestialOrientation(sunDirection);
|
||||
return getPhysicalSunDirection(new Date(lastUpdatedAt || Date.now()));
|
||||
}
|
||||
|
||||
export function getMoonDirection() {
|
||||
|
||||
@@ -12,6 +12,26 @@ export const CONFIG = {
|
||||
dragRotationScaleMax: 2.0,
|
||||
};
|
||||
|
||||
export const ROTATION_MODE = {
|
||||
ROTATE: "rotate",
|
||||
CRUISE: "cruise",
|
||||
};
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
pollIntervalMs: 15_000,
|
||||
maxPolledEvents: 200,
|
||||
cardAnchorXRatio: 0.68,
|
||||
cardAnchorYRatio: 0.24,
|
||||
linkMarkerGapPx: 18,
|
||||
linkPanelGapPx: 12,
|
||||
linkElbowOffsetPx: 72,
|
||||
linkAnchorHeightRatio: 0.26,
|
||||
linkForcedBendPx: 34,
|
||||
linkElbowDropPx: 24,
|
||||
};
|
||||
|
||||
export const HUD_CONFIG = {
|
||||
scaleReferenceWidth: 1920,
|
||||
scaleReferenceHeight: 1080,
|
||||
@@ -74,6 +94,25 @@ export const CELESTIAL_CONFIG = {
|
||||
backLightColor: 0x2b4c78,
|
||||
};
|
||||
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
opacity: 0.62,
|
||||
color: 0x7f9d7f,
|
||||
emissive: 0x061008,
|
||||
specular: 0x233126,
|
||||
shininess: 10,
|
||||
urlTemplate:
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
@@ -150,6 +189,8 @@ export const CABLE_STATE = {
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
initialLoadCount: 2400,
|
||||
hydrateFullAfterInitialLoad: true,
|
||||
trailLength: 10,
|
||||
dotSize: 4,
|
||||
ringSize: 0.07,
|
||||
|
||||
910
frontend/public/earth/js/controls.js
vendored
910
frontend/public/earth/js/controls.js
vendored
File diff suppressed because it is too large
Load Diff
229
frontend/public/earth/js/cruise-sequencer.js
Normal file
229
frontend/public/earth/js/cruise-sequencer.js
Normal file
@@ -0,0 +1,229 @@
|
||||
function nextAnimationFrame() {
|
||||
return new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
export class CruiseSequencer {
|
||||
constructor({
|
||||
isActive,
|
||||
getItems,
|
||||
getItemId,
|
||||
focusItem,
|
||||
presentItem,
|
||||
hideItem,
|
||||
clearCurrent,
|
||||
onStop,
|
||||
dwellMs = 2400,
|
||||
transitionGapMs = 24,
|
||||
}) {
|
||||
this.isActive = isActive;
|
||||
this.getItems = getItems;
|
||||
this.getItemId = getItemId;
|
||||
this.focusItem = focusItem;
|
||||
this.presentItem = presentItem;
|
||||
this.hideItem = hideItem;
|
||||
this.clearCurrent = clearCurrent;
|
||||
this.onStop = onStop;
|
||||
this.dwellMs = dwellMs;
|
||||
this.transitionGapMs = transitionGapMs;
|
||||
|
||||
this.currentItemId = null;
|
||||
this.currentIndex = -1;
|
||||
this.queuedItemIds = [];
|
||||
this.sequenceToken = 0;
|
||||
this.advanceQueued = false;
|
||||
this.advanceInterrupt = false;
|
||||
this.advanceInFlight = false;
|
||||
this.advanceLoopToken = 0;
|
||||
this.primaryTimerId = null;
|
||||
this.secondaryTimerId = null;
|
||||
this.presentationVisible = false;
|
||||
}
|
||||
|
||||
getCurrentItem() {
|
||||
if (!this.currentItemId) return null;
|
||||
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
|
||||
}
|
||||
|
||||
getCurrentItemId() {
|
||||
return this.currentItemId;
|
||||
}
|
||||
|
||||
isPresentationPinned() {
|
||||
return this.presentationVisible;
|
||||
}
|
||||
|
||||
isBusy() {
|
||||
return this.advanceInFlight || this.presentationVisible;
|
||||
}
|
||||
|
||||
enqueue(itemIds = []) {
|
||||
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
|
||||
this.queuedItemIds = Array.from(
|
||||
new Set([...itemIds.filter(Boolean), ...this.queuedItemIds]),
|
||||
);
|
||||
}
|
||||
|
||||
setPresentationVisible(visible) {
|
||||
this.presentationVisible = Boolean(visible);
|
||||
}
|
||||
|
||||
clearTimers() {
|
||||
if (this.primaryTimerId) {
|
||||
clearTimeout(this.primaryTimerId);
|
||||
this.primaryTimerId = null;
|
||||
}
|
||||
if (this.secondaryTimerId) {
|
||||
clearTimeout(this.secondaryTimerId);
|
||||
this.secondaryTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
interruptPresentation({ preservePresentation = false, resetLoop = false } = {}) {
|
||||
this.sequenceToken += 1;
|
||||
this.clearTimers();
|
||||
this.advanceQueued = false;
|
||||
this.advanceInterrupt = false;
|
||||
if (resetLoop) {
|
||||
this.advanceLoopToken += 1;
|
||||
this.advanceInFlight = false;
|
||||
}
|
||||
if (!preservePresentation) {
|
||||
this.presentationVisible = false;
|
||||
this.clearCurrent?.();
|
||||
}
|
||||
}
|
||||
|
||||
stop({ preservePresentation = false } = {}) {
|
||||
this.interruptPresentation({ preservePresentation });
|
||||
this.currentItemId = preservePresentation ? this.currentItemId : null;
|
||||
this.currentIndex = preservePresentation ? this.currentIndex : -1;
|
||||
this.queuedItemIds = [];
|
||||
this.onStop?.({ preservePresentation });
|
||||
}
|
||||
|
||||
createContext(token) {
|
||||
return {
|
||||
token,
|
||||
isCurrent: () => token === this.sequenceToken && this.isActive(),
|
||||
wait: (durationMs, { secondary = false } = {}) =>
|
||||
new Promise((resolve) => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (secondary) {
|
||||
if (this.secondaryTimerId === timerId) this.secondaryTimerId = null;
|
||||
} else if (this.primaryTimerId === timerId) {
|
||||
this.primaryTimerId = null;
|
||||
}
|
||||
resolve(token === this.sequenceToken && this.isActive());
|
||||
}, durationMs);
|
||||
|
||||
if (secondary) {
|
||||
this.secondaryTimerId = timerId;
|
||||
} else {
|
||||
this.primaryTimerId = timerId;
|
||||
}
|
||||
}),
|
||||
nextFrame: nextAnimationFrame,
|
||||
setPresentationVisible: (visible) => {
|
||||
if (token !== this.sequenceToken) return;
|
||||
this.presentationVisible = Boolean(visible);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
resolveNextItem(items) {
|
||||
let targetItem = null;
|
||||
while (this.queuedItemIds.length > 0 && !targetItem) {
|
||||
const queuedId = this.queuedItemIds.shift();
|
||||
targetItem = items.find((item) => this.getItemId(item) === queuedId) || null;
|
||||
}
|
||||
|
||||
if (targetItem) return targetItem;
|
||||
|
||||
const nextIndex = this.currentIndex >= 0 ? (this.currentIndex + 1) % items.length : 0;
|
||||
return items[nextIndex] || items[0] || null;
|
||||
}
|
||||
|
||||
async performAdvance({ interrupt = false } = {}) {
|
||||
if (!this.isActive()) return;
|
||||
|
||||
const items = this.getItems();
|
||||
if (!Array.isArray(items) || items.length === 0) return;
|
||||
|
||||
const targetItem = this.resolveNextItem(items);
|
||||
if (!targetItem) return;
|
||||
|
||||
const token = ++this.sequenceToken;
|
||||
const context = this.createContext(token);
|
||||
|
||||
this.clearTimers();
|
||||
this.presentationVisible = false;
|
||||
this.clearCurrent?.();
|
||||
|
||||
this.currentItemId = this.getItemId(targetItem);
|
||||
this.currentIndex = items.findIndex(
|
||||
(item) => this.getItemId(item) === this.currentItemId,
|
||||
);
|
||||
|
||||
await this.focusItem?.(targetItem, { interrupt, context });
|
||||
if (!context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const presented = await this.presentItem?.(targetItem, { interrupt, context });
|
||||
if (!presented || !context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.presentationVisible = true;
|
||||
const dwellCompleted = await context.wait(this.dwellMs);
|
||||
if (!dwellCompleted || !context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.hideItem?.(targetItem, { context });
|
||||
if (!context.isCurrent()) {
|
||||
this.presentationVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.presentationVisible = false;
|
||||
const gapCompleted = await context.wait(this.transitionGapMs, { secondary: true });
|
||||
if (!gapCompleted || !context.isCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.advance();
|
||||
}
|
||||
|
||||
async advance({ interrupt = false } = {}) {
|
||||
if (!this.isActive()) return;
|
||||
|
||||
this.advanceQueued = true;
|
||||
this.advanceInterrupt = this.advanceInterrupt || interrupt;
|
||||
if (this.advanceInFlight) return;
|
||||
|
||||
const activeLoopToken = ++this.advanceLoopToken;
|
||||
this.advanceInFlight = true;
|
||||
try {
|
||||
while (
|
||||
this.advanceQueued &&
|
||||
this.isActive() &&
|
||||
this.advanceLoopToken === activeLoopToken
|
||||
) {
|
||||
const nextInterrupt = this.advanceInterrupt;
|
||||
this.advanceQueued = false;
|
||||
this.advanceInterrupt = false;
|
||||
await this.performAdvance({ interrupt: nextInterrupt });
|
||||
}
|
||||
} finally {
|
||||
if (this.advanceLoopToken === activeLoopToken) {
|
||||
this.advanceInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
@@ -212,34 +212,35 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function createTerrain(scene, earthObj, simplex) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
const positionAttribute = geometry.getAttribute('position');
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const x = positionAttribute.getX(i);
|
||||
const y = positionAttribute.getY(i);
|
||||
const z = positionAttribute.getZ(i);
|
||||
|
||||
const noise = simplex(x / 20, y / 20, z / 20);
|
||||
const height = 1 + noise * 0.02;
|
||||
|
||||
positionAttribute.setXYZ(i, x * height, y * height, z * height);
|
||||
}
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
TERRAIN_CONFIG.geometryWidthSegments,
|
||||
TERRAIN_CONFIG.geometryHeightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x00aa00,
|
||||
flatShading: true,
|
||||
color: TERRAIN_CONFIG.color,
|
||||
emissive: TERRAIN_CONFIG.emissive,
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
vertexAlphas: true,
|
||||
transparent: true,
|
||||
opacity: 0.7
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
});
|
||||
|
||||
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
earthObj.add(terrain);
|
||||
|
||||
|
||||
return terrain;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ function mountCard() {
|
||||
cardMounted = true;
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y) {
|
||||
function positionPanel(panel, x, y, options = {}) {
|
||||
if (!panel) return;
|
||||
const margin = 12;
|
||||
const offset = 14;
|
||||
@@ -251,6 +251,22 @@ function positionPanel(panel, x, y) {
|
||||
const estW = Math.min(300 * scale, vpW - 32);
|
||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||
|
||||
if (options.absolute === true) {
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(margin, x),
|
||||
Math.max(margin, vpW - estW - margin),
|
||||
);
|
||||
const clampedTop = Math.min(
|
||||
Math.max(margin, y),
|
||||
Math.max(margin, vpH - estH - margin),
|
||||
);
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
let left = x + offset;
|
||||
let top = y + offset;
|
||||
|
||||
@@ -263,10 +279,10 @@ function positionPanel(panel, x, y) {
|
||||
panel.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function showPanel(x, y) {
|
||||
function showPanel(x, y, options = {}) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
if (x != null && y != null) positionPanel(panel, x, y);
|
||||
if (x != null && y != null) positionPanel(panel, x, y, options);
|
||||
panel.classList.add('is-visible');
|
||||
}
|
||||
|
||||
@@ -327,7 +343,7 @@ export function showInfoCard(type, data, options = {}) {
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
showPanel(options.x, options.y);
|
||||
showPanel(options.x, options.y, options);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
|
||||
46
frontend/public/earth/js/layer-button-state.js
Normal file
46
frontend/public/earth/js/layer-button-state.js
Normal file
@@ -0,0 +1,46 @@
|
||||
export function setButtonTooltip(button, text) {
|
||||
if (button instanceof HTMLElement) {
|
||||
button.title = text;
|
||||
}
|
||||
const tooltip = button?.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLayerButtonState(button, isActive) {
|
||||
if (!button) return;
|
||||
button.classList.toggle("active", isActive);
|
||||
button.setAttribute("aria-checked", isActive ? "true" : "false");
|
||||
const state = button.querySelector(".earth-layer-btn__state");
|
||||
if (state) {
|
||||
state.textContent = isActive ? "ON" : "OFF";
|
||||
}
|
||||
}
|
||||
|
||||
export function setLayerButtonState(button, options = {}) {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const {
|
||||
active = null,
|
||||
loading = false,
|
||||
tooltip = null,
|
||||
statusText = null,
|
||||
} = options;
|
||||
button.classList.toggle("is-loading", loading);
|
||||
button.toggleAttribute("aria-busy", loading);
|
||||
button.disabled = loading;
|
||||
if (typeof active === "boolean") {
|
||||
updateLayerButtonState(button, active);
|
||||
}
|
||||
if (tooltip) {
|
||||
setButtonTooltip(button, tooltip);
|
||||
}
|
||||
if (statusText) {
|
||||
const statusTarget = button.dataset.statusTarget
|
||||
? document.getElementById(button.dataset.statusTarget)
|
||||
: null;
|
||||
if (statusTarget) {
|
||||
statusTarget.textContent = statusText;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import * as THREE from "three";
|
||||
import { createNoise3D } from "simplex-noise";
|
||||
|
||||
import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
|
||||
import {
|
||||
CONFIG,
|
||||
HUD_CONFIG,
|
||||
CABLE_CONFIG,
|
||||
CABLE_STATE,
|
||||
SATELLITE_CONFIG,
|
||||
PATHS,
|
||||
BGP_CONFIG,
|
||||
CRUISE_CONFIG,
|
||||
ROTATION_MODE,
|
||||
} from "./constants.js";
|
||||
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
|
||||
import {
|
||||
showStatusMessage,
|
||||
queueStatusMessage,
|
||||
updateCoordinatesDisplay,
|
||||
updateZoomDisplay,
|
||||
updateEarthStats,
|
||||
@@ -26,6 +36,7 @@ import {
|
||||
clearEarthTexture,
|
||||
setEarthSunDirection,
|
||||
} from "./earth.js";
|
||||
import { registerTerrainMesh, clearTerrainData } from "./terrain.js";
|
||||
import {
|
||||
initCelestialLayer,
|
||||
updateCelestialLayer,
|
||||
@@ -116,13 +127,20 @@ import {
|
||||
import {
|
||||
setupControls,
|
||||
getAutoRotate,
|
||||
getRotationMode,
|
||||
getShowTerrain,
|
||||
setAutoRotate,
|
||||
resetView,
|
||||
applyImmediateView,
|
||||
focusEarthView,
|
||||
getZoomLevel,
|
||||
teardownControls,
|
||||
updateLayerButtonState,
|
||||
} from "./controls.js";
|
||||
import {
|
||||
setLayerButtonState,
|
||||
} from "./layer-button-state.js";
|
||||
import { CalloutConnector } from "./callout-connector.js";
|
||||
import { CruiseSequencer } from "./cruise-sequencer.js";
|
||||
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
|
||||
import {
|
||||
initInfoCard,
|
||||
showInfoCard,
|
||||
@@ -142,7 +160,6 @@ export let scene;
|
||||
export let camera;
|
||||
export let renderer;
|
||||
|
||||
let simplex;
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let targetRotation = { x: 0, y: 0 };
|
||||
@@ -174,7 +191,12 @@ let cablesEnabled = true;
|
||||
let satellitesEnabled = true;
|
||||
let cableToggleToken = 0;
|
||||
let satelliteToggleToken = 0;
|
||||
let satelliteHydrationToken = 0;
|
||||
let sceneLights = null;
|
||||
let cruisePollTimerId = null;
|
||||
let cruiseConnector = null;
|
||||
let cruiseBGPAdapter = null;
|
||||
let cruiseSequencer = null;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const interactionRaycaster = new THREE.Raycaster();
|
||||
@@ -190,9 +212,11 @@ const cleanupFns = [];
|
||||
const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||
const INERTIA_DAMPING = 0.92;
|
||||
const INERTIA_MIN_VELOCITY = 0.00008;
|
||||
const CRUISE_TRANSITION_GAP_MS = 24;
|
||||
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
|
||||
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
|
||||
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
|
||||
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
|
||||
const HUD_INTERACTIVE_SELECTORS = [
|
||||
".earth-left-column",
|
||||
".earth-left-column *",
|
||||
@@ -614,8 +638,200 @@ function updateBGPHud(bgpResult) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCruiseConnector() {
|
||||
if (!cruiseConnector) {
|
||||
cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" });
|
||||
}
|
||||
return cruiseConnector;
|
||||
}
|
||||
|
||||
function ensureBGPCruiseAdapter() {
|
||||
if (cruiseBGPAdapter) return cruiseBGPAdapter;
|
||||
|
||||
cruiseBGPAdapter = createBGPCruiseAdapter({
|
||||
camera,
|
||||
getMarkers: () => getBGPAnomalyMarkers(),
|
||||
connector: ensureCruiseConnector(),
|
||||
focusView: (options) => focusEarthView(camera, options),
|
||||
setMarkerLocked: (marker) => {
|
||||
setLegendMode("bgp");
|
||||
setBGPMarkerState(marker, "locked");
|
||||
},
|
||||
clearMarkerState: (marker) => setBGPMarkerState(marker, "normal"),
|
||||
showMarkerOverlay: (marker) => {
|
||||
const earth = getEarth();
|
||||
if (!marker || !earth) return;
|
||||
showBGPEventOverlay(marker, earth);
|
||||
},
|
||||
applySatelliteHighlights: (marker) => {
|
||||
if (!marker) return;
|
||||
applyBGPEventSatelliteHighlights(marker);
|
||||
},
|
||||
showMarkerInfo: showBGPInfo,
|
||||
hideInfo: hideInfoCard,
|
||||
isInfoVisible: () =>
|
||||
document.getElementById("info-panel")?.classList.contains("is-visible") === true,
|
||||
getLockedObject: () => lockedObject,
|
||||
refreshMarkers: async () => {
|
||||
const bgpResult = await loadBGPAnomalies(scene, getEarth());
|
||||
updateBGPHud(bgpResult);
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
refreshLegend();
|
||||
},
|
||||
});
|
||||
|
||||
return cruiseBGPAdapter;
|
||||
}
|
||||
|
||||
function isCruisePresentationPinned() {
|
||||
return cruiseSequencer?.isPresentationPinned() === true;
|
||||
}
|
||||
|
||||
function setCruisePresentationVisible(visible) {
|
||||
if (cruiseSequencer) {
|
||||
cruiseSequencer.setPresentationVisible(visible);
|
||||
}
|
||||
if (!visible) {
|
||||
ensureBGPCruiseAdapter().resetPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
function clearCruiseMarkerHighlight() {
|
||||
ensureBGPCruiseAdapter().clearCurrentHighlight();
|
||||
}
|
||||
|
||||
function getCruiseMarkersSorted() {
|
||||
return ensureBGPCruiseAdapter().getSortedMarkers();
|
||||
}
|
||||
|
||||
function repositionCruiseConnector() {
|
||||
if (!isCruisePresentationPinned()) return;
|
||||
const marker = cruiseSequencer?.getCurrentItem() ?? null;
|
||||
ensureBGPCruiseAdapter().repositionConnector(marker);
|
||||
}
|
||||
|
||||
function isCruiseModeActive() {
|
||||
return getRotationMode() === ROTATION_MODE.CRUISE;
|
||||
}
|
||||
|
||||
function ensureCruiseSequencer() {
|
||||
if (cruiseSequencer) return cruiseSequencer;
|
||||
|
||||
cruiseSequencer = new CruiseSequencer({
|
||||
isActive: () => isCruiseModeActive() && getAutoRotate(),
|
||||
getItems: () => getCruiseMarkersSorted(),
|
||||
getItemId: (marker) => marker?.userData?.id || null,
|
||||
dwellMs: CRUISE_CONFIG.dwellMs,
|
||||
transitionGapMs: CRUISE_TRANSITION_GAP_MS,
|
||||
clearCurrent: () => {
|
||||
clearCruiseMarkerHighlight();
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
setCruisePresentationVisible(false);
|
||||
},
|
||||
onStop: ({ preservePresentation }) => {
|
||||
clearBGPSelection();
|
||||
if (!preservePresentation && !lockedObject) {
|
||||
hideInfoCard();
|
||||
}
|
||||
},
|
||||
focusItem: async (marker, { interrupt }) =>
|
||||
ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }),
|
||||
presentItem: async (marker, { context }) => {
|
||||
setCruisePresentationVisible(true);
|
||||
const presented = await ensureBGPCruiseAdapter().presentMarker(marker, {
|
||||
context,
|
||||
});
|
||||
if (!presented) {
|
||||
setCruisePresentationVisible(false);
|
||||
}
|
||||
return presented;
|
||||
},
|
||||
hideItem: async (_marker, { context }) => {
|
||||
await ensureBGPCruiseAdapter().hidePresentation({ context });
|
||||
setCruisePresentationVisible(false);
|
||||
},
|
||||
});
|
||||
|
||||
return cruiseSequencer;
|
||||
}
|
||||
|
||||
function interruptCruisePresentation({ resetLoop = false } = {}) {
|
||||
ensureCruiseSequencer().interruptPresentation({ resetLoop });
|
||||
setCruisePresentationVisible(false);
|
||||
}
|
||||
|
||||
function stopCruiseMode({ preserveCard = false } = {}) {
|
||||
ensureCruiseSequencer().stop({ preservePresentation: preserveCard });
|
||||
if (!preserveCard) {
|
||||
setCruisePresentationVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function advanceCruiseEvent({ interrupt = false } = {}) {
|
||||
if (!isCruiseModeActive() || !getAutoRotate()) return;
|
||||
await ensureCruiseSequencer().advance({ interrupt });
|
||||
}
|
||||
|
||||
async function pollCruiseEventsIfNeeded() {
|
||||
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
|
||||
|
||||
try {
|
||||
const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
|
||||
if (newIds.length === 0) return;
|
||||
|
||||
ensureCruiseSequencer().enqueue(newIds);
|
||||
if (!ensureCruiseSequencer().isBusy()) {
|
||||
await advanceCruiseEvent({ interrupt: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("巡航模式轮询 BGP 事件失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCruisePolling() {
|
||||
if (cruisePollTimerId) return;
|
||||
cruisePollTimerId = window.setInterval(() => {
|
||||
pollCruiseEventsIfNeeded().catch((error) => {
|
||||
console.warn("巡航轮询失败:", error);
|
||||
});
|
||||
}, CRUISE_CONFIG.pollIntervalMs);
|
||||
cleanupFns.push(() => {
|
||||
if (cruisePollTimerId) {
|
||||
clearInterval(cruisePollTimerId);
|
||||
cruisePollTimerId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRotationModeChange(event) {
|
||||
const detailMode = event?.detail?.mode || getRotationMode();
|
||||
const detailActive =
|
||||
typeof event?.detail?.active === "boolean"
|
||||
? event.detail.active
|
||||
: getAutoRotate();
|
||||
|
||||
if (detailMode !== ROTATION_MODE.CRUISE) {
|
||||
stopCruiseMode();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureCruisePolling();
|
||||
ensureBGPCruiseAdapter().syncKnownEventIds();
|
||||
|
||||
if (!detailActive) {
|
||||
stopCruiseMode({ preserveCard: true });
|
||||
return;
|
||||
}
|
||||
|
||||
advanceCruiseEvent({ interrupt: true }).catch((error) => {
|
||||
console.warn("启动巡航模式失败:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelectionAndInfo() {
|
||||
clearLockedObject();
|
||||
interruptCruisePresentation();
|
||||
hideInfoCard();
|
||||
}
|
||||
|
||||
@@ -689,6 +905,32 @@ function getBGPRelatedRegions(marker) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function applyBGPEventSatelliteHighlights(marker) {
|
||||
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
|
||||
getBGPRelatedRegions(marker),
|
||||
{ limit: 6, maxAngleDeg: 20 },
|
||||
);
|
||||
marker.userData.related_satellite_count = relatedSatelliteIndices.length;
|
||||
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
|
||||
}
|
||||
|
||||
function applyBGPRelatedCablesAndLandingPoints(marker, camera) {
|
||||
const relatedCableNames = getBGPRelatedCableNames(marker);
|
||||
clearAllCableStates();
|
||||
relatedCableNames.forEach((name) => {
|
||||
getCableLines().forEach((cable) => {
|
||||
if (cable.userData?.name === name) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
|
||||
}
|
||||
});
|
||||
});
|
||||
applyLandingPointVisualState(
|
||||
relatedCableNames.length > 0 ? relatedCableNames : null,
|
||||
relatedCableNames.length === 0,
|
||||
camera,
|
||||
);
|
||||
}
|
||||
|
||||
function applyCableVisualState() {
|
||||
const allCables = getCableLines();
|
||||
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
||||
@@ -697,28 +939,21 @@ function applyCableVisualState() {
|
||||
const cableId = cable.userData.cableId;
|
||||
const state = getCableState(cableId);
|
||||
|
||||
const hasFocus =
|
||||
(lockedObjectType === "cable" && lockedObject) ||
|
||||
(lockedObjectType === "satellite" && lockedSatellite) ||
|
||||
(lockedObjectType === "bgp" && lockedObject) ||
|
||||
(isCruiseModeActive() && isCruisePresentationPinned());
|
||||
|
||||
switch (state) {
|
||||
case CABLE_STATE.LOCKED:
|
||||
cable.material.opacity =
|
||||
Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.lockedOpacityMin +
|
||||
pulse *
|
||||
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin),
|
||||
);
|
||||
cable.material.color.setRGB(0.86, 0.96, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.HOVERED:
|
||||
cable.material.opacity = 1;
|
||||
cable.material.color.setRGB(0.92, 0.98, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.NORMAL:
|
||||
default:
|
||||
if (
|
||||
(lockedObjectType === "cable" && lockedObject) ||
|
||||
(lockedObjectType === "satellite" && lockedSatellite) ||
|
||||
(lockedObjectType === "bgp" && lockedObject)
|
||||
) {
|
||||
if (hasFocus) {
|
||||
cable.material.opacity = CABLE_CONFIG.otherOpacity;
|
||||
const origColor = cable.userData.originalColor;
|
||||
const brightness = CABLE_CONFIG.otherBrightness;
|
||||
@@ -756,9 +991,11 @@ function buildLoadErrorMessage(errors) {
|
||||
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
|
||||
const satBtn = document.getElementById("toggle-satellites");
|
||||
if (satBtn) {
|
||||
updateLayerButtonState(satBtn, enabled);
|
||||
const tooltip = satBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星";
|
||||
setLayerButtonState(satBtn, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏卫星" : "显示卫星",
|
||||
});
|
||||
}
|
||||
|
||||
const satelliteCountEl = document.getElementById("satellite-count");
|
||||
@@ -770,9 +1007,11 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
|
||||
function updateCableToggleUi(enabled) {
|
||||
const cableBtn = document.getElementById("toggle-cables");
|
||||
if (cableBtn) {
|
||||
updateLayerButtonState(cableBtn, enabled);
|
||||
const tooltip = cableBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) tooltip.textContent = enabled ? "隐藏线缆" : "显示线缆";
|
||||
setLayerButtonState(cableBtn, {
|
||||
active: enabled,
|
||||
loading: false,
|
||||
tooltip: enabled ? "隐藏线缆" : "显示线缆",
|
||||
});
|
||||
}
|
||||
|
||||
const cableCountEl = document.getElementById("cable-count");
|
||||
@@ -807,8 +1046,10 @@ async function ensureCablesEnabled() {
|
||||
|
||||
clearCableData(earth);
|
||||
// Load landing points first so they appear before cable lines
|
||||
await loadLandingPoints(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
await loadLandingPoints(scene, earth, { silent: true });
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||
silent: true,
|
||||
});
|
||||
|
||||
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
|
||||
clearCableData(earth);
|
||||
@@ -845,7 +1086,9 @@ async function ensureSatellitesEnabled() {
|
||||
}
|
||||
|
||||
clearSatelliteData();
|
||||
const satelliteCount = await loadSatellites();
|
||||
const loadResult = await loadSatellites({
|
||||
limit: getInitialSatelliteLoadLimit(),
|
||||
});
|
||||
|
||||
if (
|
||||
requestToken !== satelliteToggleToken ||
|
||||
@@ -856,17 +1099,37 @@ async function ensureSatellitesEnabled() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
|
||||
toggleSatellites(true);
|
||||
updateSatelliteToggleUi(true, satelliteCount);
|
||||
updateSatelliteToggleUi(true, loadResult.count);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
refreshLegend();
|
||||
return satelliteCount;
|
||||
scheduleSatellitePositionWarmup(() => {
|
||||
if (
|
||||
requestToken === satelliteToggleToken &&
|
||||
satellitesEnabled &&
|
||||
!destroyed
|
||||
) {
|
||||
toggleSatellites(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldHydrateFullSatelliteSet(loadResult)) {
|
||||
const hydrationToken = ++satelliteHydrationToken;
|
||||
hydrateAllSatellitesInBackground(
|
||||
() =>
|
||||
hydrationToken === satelliteHydrationToken &&
|
||||
requestToken === satelliteToggleToken &&
|
||||
satellitesEnabled &&
|
||||
!destroyed,
|
||||
);
|
||||
}
|
||||
|
||||
return loadResult.count;
|
||||
}
|
||||
|
||||
function disableSatellites() {
|
||||
satellitesEnabled = false;
|
||||
satelliteToggleToken += 1;
|
||||
satelliteHydrationToken += 1;
|
||||
resetSatelliteState();
|
||||
updateSatelliteToggleUi(false, 0);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
@@ -913,7 +1176,6 @@ export function init() {
|
||||
|
||||
destroyed = false;
|
||||
initialized = true;
|
||||
simplex = createNoise3D();
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
@@ -952,13 +1214,14 @@ export function init() {
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
const earthObj = createEarth(scene);
|
||||
applyImmediateView(earthObj, camera);
|
||||
targetRotation = {
|
||||
x: earthObj.rotation.x,
|
||||
y: earthObj.rotation.y,
|
||||
};
|
||||
inertialVelocity = { x: 0, y: 0 };
|
||||
createClouds(scene, earthObj);
|
||||
createTerrain(scene, earthObj, simplex);
|
||||
registerTerrainMesh(createTerrain(earthObj));
|
||||
initCelestialLayer(scene, {
|
||||
camera,
|
||||
sunLight: sceneLights?.sunLight ?? null,
|
||||
@@ -969,7 +1232,6 @@ export function init() {
|
||||
createSatellites(scene, earthObj);
|
||||
|
||||
setupControls(camera, renderer, scene, earthObj);
|
||||
resetView(camera);
|
||||
setupEventListeners();
|
||||
|
||||
clock.start();
|
||||
@@ -1022,7 +1284,49 @@ function addLights() {
|
||||
}
|
||||
|
||||
// Yield control to the browser so the renderer can paint a frame before the next step
|
||||
const yieldFrame = (ms = 60) => new Promise((r) => setTimeout(r, ms));
|
||||
const yieldFrame = (ms = 24) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function scheduleSatellitePositionWarmup(onReady) {
|
||||
window.requestAnimationFrame(() => {
|
||||
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
|
||||
if (typeof onReady === "function") {
|
||||
onReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getInitialSatelliteLoadLimit() {
|
||||
const configuredInitial = Number.isFinite(SATELLITE_CONFIG.initialLoadCount)
|
||||
? Math.max(1, Math.floor(SATELLITE_CONFIG.initialLoadCount))
|
||||
: null;
|
||||
|
||||
if (SATELLITE_CONFIG.maxCount > 0) {
|
||||
return configuredInitial === null
|
||||
? SATELLITE_CONFIG.maxCount
|
||||
: Math.min(configuredInitial, SATELLITE_CONFIG.maxCount);
|
||||
}
|
||||
|
||||
return configuredInitial;
|
||||
}
|
||||
|
||||
function shouldHydrateFullSatelliteSet(loadResult) {
|
||||
if (!SATELLITE_CONFIG.hydrateFullAfterInitialLoad) return false;
|
||||
if (!loadResult || loadResult.requestedLimit === null) return false;
|
||||
return loadResult.count >= loadResult.requestedLimit;
|
||||
}
|
||||
|
||||
async function hydrateAllSatellitesInBackground(guardFn) {
|
||||
try {
|
||||
const loadResult = await loadSatellites({ limit: null });
|
||||
if (!guardFn()) return;
|
||||
updateSatelliteToggleUi(true, loadResult.count);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
refreshLegend();
|
||||
scheduleSatellitePositionWarmup();
|
||||
} catch (error) {
|
||||
console.warn("后台补全卫星全量数据失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!scene || !camera || !renderer) return;
|
||||
@@ -1044,30 +1348,43 @@ async function loadData() {
|
||||
|
||||
setLoadingMessage("正在初始化...");
|
||||
setLoading(true);
|
||||
await yieldFrame();
|
||||
await yieldFrame(18);
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
|
||||
const errors = [];
|
||||
|
||||
// Step 1 — Landing points
|
||||
// Step 1 — Earth texture
|
||||
setLoadingMessage("正在加载地球纹理...");
|
||||
await yieldFrame(12);
|
||||
try {
|
||||
await loadEarthTexture();
|
||||
} catch (err) {
|
||||
// texture failure is non-fatal
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame(16);
|
||||
|
||||
// Step 2 — Landing points
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载登陆点...");
|
||||
await yieldFrame(30);
|
||||
await yieldFrame(12);
|
||||
try {
|
||||
await loadLandingPoints(scene, earth);
|
||||
await loadLandingPoints(scene, earth, { silent: true });
|
||||
} catch (err) {
|
||||
errors.push({ label: "登陆点", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
await yieldFrame(16);
|
||||
}
|
||||
|
||||
// Step 2 — Cables
|
||||
// Step 3 — Cables
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载海缆...");
|
||||
await yieldFrame(30);
|
||||
await yieldFrame(12);
|
||||
try {
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||
silent: true,
|
||||
});
|
||||
if (loadToken === currentLoadToken && cablesEnabled) {
|
||||
toggleCables(true);
|
||||
updateCableToggleUi(true);
|
||||
@@ -1078,60 +1395,70 @@ async function loadData() {
|
||||
errors.push({ label: "海缆", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
await yieldFrame(16);
|
||||
}
|
||||
|
||||
// Step 3 — Satellites
|
||||
// Step 4 — Satellites
|
||||
if (satellitesEnabled) {
|
||||
setLoadingMessage("正在加载卫星...");
|
||||
await yieldFrame(30);
|
||||
await yieldFrame(12);
|
||||
try {
|
||||
clearSatelliteData();
|
||||
const satelliteCount = await loadSatellites();
|
||||
const loadResult = await loadSatellites({
|
||||
limit: getInitialSatelliteLoadLimit(),
|
||||
});
|
||||
if (loadToken === currentLoadToken && satellitesEnabled) {
|
||||
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
|
||||
toggleSatellites(true);
|
||||
updateSatelliteToggleUi(true, satelliteCount);
|
||||
updateSatelliteToggleUi(true, loadResult.count);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
refreshLegend();
|
||||
scheduleSatellitePositionWarmup(() => {
|
||||
if (
|
||||
loadToken === currentLoadToken &&
|
||||
satellitesEnabled &&
|
||||
!destroyed
|
||||
) {
|
||||
toggleSatellites(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldHydrateFullSatelliteSet(loadResult)) {
|
||||
const hydrationToken = ++satelliteHydrationToken;
|
||||
hydrateAllSatellitesInBackground(
|
||||
() =>
|
||||
hydrationToken === satelliteHydrationToken &&
|
||||
loadToken === currentLoadToken &&
|
||||
satellitesEnabled &&
|
||||
!destroyed,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ label: "卫星", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
await yieldFrame(16);
|
||||
}
|
||||
|
||||
// Step 4 — BGP
|
||||
// Step 5 — BGP
|
||||
setLoadingMessage("正在加载BGP态势...");
|
||||
await yieldFrame(30);
|
||||
await yieldFrame(12);
|
||||
try {
|
||||
const bgpResult = await loadBGPAnomalies(scene, earth);
|
||||
if (loadToken === currentLoadToken) {
|
||||
toggleBGP(true);
|
||||
updateBGPHud(bgpResult);
|
||||
ensureBGPCruiseAdapter().syncKnownEventIds();
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ label: "BGP态势", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
|
||||
// Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
|
||||
setLoadingMessage("正在加载地球纹理...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadEarthTexture();
|
||||
} catch (err) {
|
||||
// texture failure is non-fatal
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
await yieldFrame(16);
|
||||
|
||||
// Step 6 — Terrain (if enabled)
|
||||
if (getShowTerrain()) {
|
||||
setLoadingMessage("正在渲染地形...");
|
||||
await yieldFrame(50);
|
||||
await yieldFrame(24);
|
||||
}
|
||||
|
||||
updateStatsSummary();
|
||||
@@ -1144,13 +1471,19 @@ async function loadData() {
|
||||
setLoading(false);
|
||||
isDataLoading = false;
|
||||
|
||||
if (getRotationMode() === ROTATION_MODE.CRUISE && getAutoRotate()) {
|
||||
advanceCruiseEvent({ interrupt: true }).catch((error) => {
|
||||
console.warn("初始化后启动巡航失败:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = buildLoadErrorMessage(errors);
|
||||
showError(errorMessage);
|
||||
showStatusMessage(errorMessage, "error");
|
||||
queueStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
showStatusMessage("数据已加载", "success");
|
||||
queueStatusMessage("数据已加载", "success");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1235,10 +1568,12 @@ function setupEventListeners() {
|
||||
const handleMouseLeave = () => onMouseLeave();
|
||||
const handleClick = (event) => onClick(event);
|
||||
const handlePageHide = () => destroy();
|
||||
const handleRotationMode = (event) => handleRotationModeChange(event);
|
||||
|
||||
bindListener(window, "resize", handleResize);
|
||||
bindListener(window, "pagehide", handlePageHide);
|
||||
bindListener(window, "beforeunload", handlePageHide);
|
||||
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
|
||||
bindListener(window, "mousemove", handleMouseMove);
|
||||
bindListener(renderer.domElement, "mousedown", handleMouseDown);
|
||||
bindListener(window, "mouseup", handleMouseUp);
|
||||
@@ -1264,6 +1599,7 @@ function updateHudScale() {
|
||||
function onWindowResize() {
|
||||
updateHudScale();
|
||||
syncRendererViewport();
|
||||
repositionCruiseConnector();
|
||||
}
|
||||
|
||||
function getFrontFacingCables(cableLines) {
|
||||
@@ -1313,7 +1649,7 @@ function onMouseMove(event) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (!lockedObject && !lockedSatellite) {
|
||||
} else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) {
|
||||
hideInfoCard();
|
||||
}
|
||||
hideTooltip();
|
||||
@@ -1439,7 +1775,7 @@ function onMouseMove(event) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
} else if (!lockedObjectType) {
|
||||
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
|
||||
resetTransientBGPStates();
|
||||
hideInfoCard();
|
||||
}
|
||||
@@ -1529,6 +1865,7 @@ function onClick(event) {
|
||||
: null;
|
||||
|
||||
if (clickedBGPMarker?.userData?.type === "bgp") {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
const clickedMarker = clickedBGPMarker;
|
||||
@@ -1542,14 +1879,7 @@ function onClick(event) {
|
||||
lastBGPClickPos = { x: event.clientX, y: event.clientY };
|
||||
setAutoRotate(false);
|
||||
showBGPEventOverlay(clickedMarker, earth);
|
||||
{
|
||||
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
|
||||
getBGPRelatedRegions(clickedMarker),
|
||||
{ limit: 6, maxAngleDeg: 20 },
|
||||
);
|
||||
clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
|
||||
highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
|
||||
}
|
||||
applyBGPEventSatelliteHighlights(clickedMarker);
|
||||
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
|
||||
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
@@ -1560,6 +1890,7 @@ function onClick(event) {
|
||||
}
|
||||
|
||||
if (clickedBGPMarker?.userData?.type === "bgp_collector") {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
const clickedMarker = clickedBGPMarker;
|
||||
@@ -1583,6 +1914,7 @@ function onClick(event) {
|
||||
}
|
||||
|
||||
if (cableIntersects.length > 0 && getShowCables()) {
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
const clickedCable = cableIntersects[0].object;
|
||||
@@ -1592,6 +1924,19 @@ function onClick(event) {
|
||||
lockedObject = clickedCable;
|
||||
lockedObjectType = "cable";
|
||||
setAutoRotate(false);
|
||||
{
|
||||
const cableLandingRegions = getLandingPoints()
|
||||
.filter((lp) => lp.userData.cableNames?.includes(clickedCable.userData.name))
|
||||
.map((lp) => {
|
||||
const { lat, lon } = vector3ToLatLon(lp.position);
|
||||
return { latitude: lat, longitude: lon };
|
||||
});
|
||||
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
|
||||
cableLandingRegions,
|
||||
{ limit: 6, maxAngleDeg: 20 },
|
||||
);
|
||||
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
|
||||
}
|
||||
handleCableClick(clickedCable);
|
||||
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
|
||||
return;
|
||||
@@ -1628,6 +1973,7 @@ function onClick(event) {
|
||||
const sat = selectSatellite(selectedIndex);
|
||||
if (!sat?.properties) return;
|
||||
|
||||
interruptCruisePresentation();
|
||||
clearLockedObject();
|
||||
|
||||
lockedObject = sat;
|
||||
@@ -1653,6 +1999,15 @@ function onClick(event) {
|
||||
}
|
||||
|
||||
if (!isLongDrag) {
|
||||
if (isCruiseModeActive()) {
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
setAutoRotate(true);
|
||||
return;
|
||||
}
|
||||
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
setAutoRotate(true);
|
||||
@@ -1670,7 +2025,7 @@ function animate() {
|
||||
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
|
||||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY;
|
||||
|
||||
if (getAutoRotate() && earth) {
|
||||
if (getAutoRotate() && getRotationMode() === ROTATION_MODE.ROTATE && earth) {
|
||||
earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16);
|
||||
|
||||
// Keep the drag target aligned with autorotation only when the user is not
|
||||
@@ -1710,7 +2065,11 @@ function animate() {
|
||||
}
|
||||
|
||||
applyCableVisualState();
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera);
|
||||
const activeCruiseMarker =
|
||||
isCruiseModeActive() && isCruisePresentationPinned()
|
||||
? cruiseSequencer?.getCurrentItem() ?? null
|
||||
: null;
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||
|
||||
if (lockedObjectType === "cable" && lockedObject) {
|
||||
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
|
||||
@@ -1719,23 +2078,12 @@ function animate() {
|
||||
) {
|
||||
applyLandingPointVisualState(null, true, camera);
|
||||
} else if (lockedObjectType === "bgp" && lockedObject) {
|
||||
const relatedCableNames = getBGPRelatedCableNames(lockedObject);
|
||||
clearAllCableStates();
|
||||
relatedCableNames.forEach((name) => {
|
||||
getCableLines().forEach((cable) => {
|
||||
if (cable.userData?.name === name) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
|
||||
}
|
||||
});
|
||||
});
|
||||
applyLandingPointVisualState(
|
||||
relatedCableNames.length > 0 ? relatedCableNames : null,
|
||||
relatedCableNames.length === 0,
|
||||
camera,
|
||||
);
|
||||
applyBGPRelatedCablesAndLandingPoints(lockedObject, camera);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
clearAllCableStates();
|
||||
resetLandingPointVisualState(camera);
|
||||
} else if (activeCruiseMarker) {
|
||||
applyBGPRelatedCablesAndLandingPoints(activeCruiseMarker, camera);
|
||||
} else {
|
||||
resetLandingPointVisualState(camera);
|
||||
}
|
||||
@@ -1746,7 +2094,6 @@ function animate() {
|
||||
updateCelestialLayer(new Date(), camera);
|
||||
setEarthSunDirection(getSunDirection());
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
lockedObjectType === "satellite" &&
|
||||
@@ -1761,6 +2108,7 @@ function animate() {
|
||||
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
|
||||
}
|
||||
|
||||
repositionCruiseConnector();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
@@ -1787,6 +2135,7 @@ export function destroy() {
|
||||
resetSatelliteState();
|
||||
clearUiState();
|
||||
disposeCelestialLayer();
|
||||
clearTerrainData();
|
||||
|
||||
if (scene) {
|
||||
disposeSceneObject(scene);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getActiveTVTab, openTVPanelTab, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
import { isTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
@@ -19,7 +19,6 @@ let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
return {
|
||||
toggleBtn: document.getElementById("toggle-news"),
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
status: document.getElementById("news-board-status"),
|
||||
@@ -53,14 +52,7 @@ function formatRelativeTime(raw) {
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭态势新闻" : "打开态势新闻";
|
||||
}
|
||||
void visible;
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
@@ -283,39 +275,11 @@ export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { toggleBtn, refreshBtn, openBtn } = getElements();
|
||||
const { refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
const openNewsTab = async () => {
|
||||
openTVPanelTab("news");
|
||||
updateNewsToggleUI(true);
|
||||
try {
|
||||
await ensureNewsPanelReady();
|
||||
} catch {
|
||||
// surface already handled
|
||||
}
|
||||
};
|
||||
|
||||
toggleBtn?.addEventListener("click", async () => {
|
||||
const visible = isTVPanelVisible();
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
|
||||
if (!visible) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setTVPanelVisible(false);
|
||||
updateNewsToggleUI(false);
|
||||
});
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
let satellitePoints = null;
|
||||
let satelliteBackdropPoints = null;
|
||||
let satelliteTrails = null;
|
||||
let satelliteData = [];
|
||||
let showSatellites = false;
|
||||
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
|
||||
let lockedDotSprite = null;
|
||||
let predictedOrbitLine = null;
|
||||
let relatedSatelliteSprites = [];
|
||||
let highlightedSatelliteIndices = null;
|
||||
let earthObjRef = null;
|
||||
let sceneRef = null;
|
||||
let cameraRef = null;
|
||||
@@ -24,6 +26,7 @@ let lockedSatelliteIndex = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let positionUpdateAccumulator = 0;
|
||||
let satelliteCapacity = 0;
|
||||
let satelliteSatrecCache = new Map();
|
||||
|
||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
@@ -117,6 +120,10 @@ export function updateBreathingPhase(deltaTime = 16) {
|
||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||
}
|
||||
|
||||
function getBreathingPulse(phase) {
|
||||
return 0.5 + 0.5 * Math.sin(phase);
|
||||
}
|
||||
|
||||
export function getSatelliteLegendItems() {
|
||||
const presentKeys = new Set();
|
||||
|
||||
@@ -204,6 +211,37 @@ function createDotTexture() {
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createBackdropDotTexture() {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = DOT_TEXTURE_SIZE;
|
||||
canvas.height = DOT_TEXTURE_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const center = DOT_TEXTURE_SIZE / 2;
|
||||
const radius = center - 1;
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
center,
|
||||
center,
|
||||
0,
|
||||
center,
|
||||
center,
|
||||
radius,
|
||||
);
|
||||
gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
|
||||
gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
|
||||
gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
|
||||
gradient.addColorStop(1, "rgba(7, 14, 27, 0)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
const size = DOT_TEXTURE_SIZE * 2;
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -226,8 +264,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
|
||||
export function createSatellites(scene, earthObj) {
|
||||
initSatelliteScene(scene, earthObj);
|
||||
const dotTexture = createDotTexture();
|
||||
const backdropTexture = createBackdropDotTexture();
|
||||
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
const backdropGeometry = new THREE.BufferGeometry();
|
||||
|
||||
const backdropMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize * 1.28,
|
||||
map: backdropTexture,
|
||||
color: 0x0b1626,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.04,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const pointsMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize,
|
||||
@@ -237,29 +288,45 @@ export function createSatellites(scene, earthObj) {
|
||||
opacity: 0.9,
|
||||
sizeAttenuation: false,
|
||||
alphaTest: 0.1,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
|
||||
satelliteBackdropPoints.visible = false;
|
||||
satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
|
||||
satelliteBackdropPoints.renderOrder = 5;
|
||||
|
||||
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
|
||||
satellitePoints.visible = false;
|
||||
satellitePoints.userData = { type: "satellitePoints" };
|
||||
satellitePoints.renderOrder = 6;
|
||||
|
||||
const originalScale = { x: 1, y: 1, z: 1 };
|
||||
satellitePoints.onBeforeRender = () => {
|
||||
const syncPointScale = () => {
|
||||
if (earthObj && earthObj.scale.x !== 1) {
|
||||
satellitePoints.scale.set(
|
||||
originalScale.x / earthObj.scale.x,
|
||||
originalScale.y / earthObj.scale.y,
|
||||
originalScale.z / earthObj.scale.z,
|
||||
);
|
||||
const scaleX = originalScale.x / earthObj.scale.x;
|
||||
const scaleY = originalScale.y / earthObj.scale.y;
|
||||
const scaleZ = originalScale.z / earthObj.scale.z;
|
||||
satellitePoints.scale.set(scaleX, scaleY, scaleZ);
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
|
||||
}
|
||||
} else {
|
||||
satellitePoints.scale.set(
|
||||
originalScale.x,
|
||||
originalScale.y,
|
||||
originalScale.z,
|
||||
);
|
||||
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.scale.set(
|
||||
originalScale.x,
|
||||
originalScale.y,
|
||||
originalScale.z,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
satelliteBackdropPoints.onBeforeRender = syncPointScale;
|
||||
satellitePoints.onBeforeRender = syncPointScale;
|
||||
|
||||
earthObj.add(satelliteBackdropPoints);
|
||||
earthObj.add(satellitePoints);
|
||||
|
||||
const trailGeometry = new THREE.BufferGeometry();
|
||||
@@ -281,7 +348,12 @@ export function createSatellites(scene, earthObj) {
|
||||
return satellitePoints;
|
||||
}
|
||||
|
||||
function getRequestedSatelliteLimit() {
|
||||
function getRequestedSatelliteLimit(limitOverride) {
|
||||
if (limitOverride === null) return null;
|
||||
if (Number.isFinite(limitOverride) && limitOverride > 0) {
|
||||
return Math.floor(limitOverride);
|
||||
}
|
||||
|
||||
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
||||
}
|
||||
|
||||
@@ -295,13 +367,50 @@ function createSatellitePositionState() {
|
||||
}
|
||||
|
||||
function ensureSatelliteCapacity(count) {
|
||||
if (!satellitePoints || !satelliteTrails) return;
|
||||
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
|
||||
|
||||
const nextCapacity = Math.max(count, 0);
|
||||
if (nextCapacity === satelliteCapacity) return;
|
||||
|
||||
const previousPointPositions =
|
||||
satellitePoints.geometry.attributes.position?.array || null;
|
||||
const previousBackdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position?.array || null;
|
||||
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
|
||||
const previousTrailPositions =
|
||||
satelliteTrails.geometry.attributes.position?.array || null;
|
||||
const previousTrailColors =
|
||||
satelliteTrails.geometry.attributes.color?.array || null;
|
||||
const previousSatellitePositions = satellitePositions;
|
||||
const previousCapacity = satelliteCapacity;
|
||||
|
||||
const positions = new Float32Array(nextCapacity * 3);
|
||||
const backdropPositions = new Float32Array(nextCapacity * 3);
|
||||
const colors = new Float32Array(nextCapacity * 3);
|
||||
if (previousPointPositions) {
|
||||
positions.set(
|
||||
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
|
||||
);
|
||||
}
|
||||
if (previousBackdropPositions) {
|
||||
backdropPositions.set(
|
||||
previousBackdropPositions.subarray(
|
||||
0,
|
||||
Math.min(previousBackdropPositions.length, backdropPositions.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (previousColors) {
|
||||
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(backdropPositions, 3),
|
||||
);
|
||||
satelliteBackdropPoints.geometry.setDrawRange(
|
||||
0,
|
||||
Math.min(previousCapacity, nextCapacity),
|
||||
);
|
||||
satellitePoints.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions, 3),
|
||||
@@ -310,10 +419,26 @@ function ensureSatelliteCapacity(count) {
|
||||
"color",
|
||||
new THREE.BufferAttribute(colors, 3),
|
||||
);
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
|
||||
|
||||
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
|
||||
if (previousTrailPositions) {
|
||||
trailPositions.set(
|
||||
previousTrailPositions.subarray(
|
||||
0,
|
||||
Math.min(previousTrailPositions.length, trailPositions.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (previousTrailColors) {
|
||||
trailColors.set(
|
||||
previousTrailColors.subarray(
|
||||
0,
|
||||
Math.min(previousTrailColors.length, trailColors.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
satelliteTrails.geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(trailPositions, 3),
|
||||
@@ -323,10 +448,19 @@ function ensureSatelliteCapacity(count) {
|
||||
new THREE.BufferAttribute(trailColors, 3),
|
||||
);
|
||||
|
||||
satellitePositions = Array.from(
|
||||
{ length: nextCapacity },
|
||||
createSatellitePositionState,
|
||||
);
|
||||
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
||||
const previousState = previousSatellitePositions[index];
|
||||
if (!previousState) {
|
||||
return createSatellitePositionState();
|
||||
}
|
||||
|
||||
return {
|
||||
current: previousState.current.clone(),
|
||||
trail: previousState.trail.slice(),
|
||||
trailIndex: previousState.trailIndex,
|
||||
trailCount: previousState.trailCount,
|
||||
};
|
||||
});
|
||||
satelliteCapacity = nextCapacity;
|
||||
}
|
||||
|
||||
@@ -337,7 +471,7 @@ function computeSatellitePosition(satellite, time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, time);
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
@@ -382,6 +516,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
|
||||
return twoline2satrec(tleLines.line1, tleLines.line2);
|
||||
}
|
||||
|
||||
function getSatelliteSatrecCacheKey(props) {
|
||||
if (!props?.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.tle_line1 && props.tle_line2) {
|
||||
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
|
||||
}
|
||||
|
||||
if (props.epoch) {
|
||||
return [
|
||||
"elements",
|
||||
props.norad_cat_id,
|
||||
props.epoch,
|
||||
props.inclination,
|
||||
props.raan,
|
||||
props.eccentricity,
|
||||
props.arg_of_perigee,
|
||||
props.mean_anomaly,
|
||||
props.mean_motion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrBuildSatrec(props, fallbackTime) {
|
||||
const cacheKey = getSatelliteSatrecCacheKey(props);
|
||||
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
|
||||
return satelliteSatrecCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, fallbackTime);
|
||||
if (cacheKey && satrec && !satrec.error) {
|
||||
satelliteSatrecCache.set(cacheKey, satrec);
|
||||
}
|
||||
return satrec;
|
||||
}
|
||||
|
||||
function computeTleChecksum(line) {
|
||||
let sum = 0;
|
||||
|
||||
@@ -491,8 +664,8 @@ function generateFallbackPosition(satellite, index, total) {
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
export async function loadSatellites() {
|
||||
const limit = getRequestedSatelliteLimit();
|
||||
export async function loadSatellites(options = {}) {
|
||||
const limit = getRequestedSatelliteLimit(options.limit);
|
||||
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
|
||||
if (limit !== null) {
|
||||
url.searchParams.set("limit", String(limit));
|
||||
@@ -505,13 +678,17 @@ export async function loadSatellites() {
|
||||
|
||||
const data = await response.json();
|
||||
satelliteData = data.features || [];
|
||||
satelliteSatrecCache = new Map();
|
||||
ensureSatelliteCapacity(satelliteData.length);
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
return satelliteData.length;
|
||||
return {
|
||||
count: satelliteData.length,
|
||||
requestedLimit: limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
if (!satellitePoints || satelliteData.length === 0) return;
|
||||
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
|
||||
|
||||
const shouldUpdateTrails =
|
||||
showSatellites || showTrails || lockedSatelliteIndex !== null;
|
||||
@@ -528,6 +705,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positionUpdateAccumulator = 0;
|
||||
|
||||
const positions = satellitePoints.geometry.attributes.position.array;
|
||||
const backdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position.array;
|
||||
const colors = satellitePoints.geometry.attributes.color.array;
|
||||
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
||||
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
||||
@@ -559,13 +738,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3] = pos.x;
|
||||
positions[i * 3 + 1] = pos.y;
|
||||
positions[i * 3 + 2] = pos.z;
|
||||
backdropPositions[i * 3] = pos.x;
|
||||
backdropPositions[i * 3 + 1] = pos.y;
|
||||
backdropPositions[i * 3 + 2] = pos.z;
|
||||
|
||||
const rule = getSatelliteLegendRule(props);
|
||||
const { r, g, b } = getSatelliteRuleColor(rule);
|
||||
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
colors[i * 3 + 2] = b;
|
||||
if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) {
|
||||
const lum = r * 0.299 + g * 0.587 + b * 0.114;
|
||||
colors[i * 3] = lum * 0.75 + r * 0.25;
|
||||
colors[i * 3 + 1] = lum * 0.75 + g * 0.25;
|
||||
colors[i * 3 + 2] = lum * 0.75 + b * 0.25;
|
||||
} else {
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
colors[i * 3 + 2] = b;
|
||||
}
|
||||
|
||||
const satPosition = satellitePositions[i];
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
@@ -601,6 +790,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3] = 0;
|
||||
positions[i * 3 + 1] = 0;
|
||||
positions[i * 3 + 2] = 0;
|
||||
backdropPositions[i * 3] = 0;
|
||||
backdropPositions[i * 3 + 1] = 0;
|
||||
backdropPositions[i * 3 + 2] = 0;
|
||||
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||
@@ -613,6 +805,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
||||
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
||||
satellitePoints.geometry.setDrawRange(0, count);
|
||||
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, count);
|
||||
|
||||
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
||||
@@ -631,6 +825,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
|
||||
export function toggleSatellites(visible) {
|
||||
showSatellites = visible;
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.visible = visible;
|
||||
}
|
||||
if (satellitePoints) {
|
||||
satellitePoints.visible = visible;
|
||||
}
|
||||
@@ -821,10 +1018,15 @@ export function hideLockedRing() {
|
||||
|
||||
export function updateLockedRingPosition(position) {
|
||||
if (!position) return;
|
||||
if (!lockedRingSprite || !lockedDotSprite) {
|
||||
showHoverRing(position, true);
|
||||
}
|
||||
if (lockedRingSprite) {
|
||||
lockedRingSprite.position.copy(position);
|
||||
const ringPulse = getBreathingPulse(breathingPhase);
|
||||
const breathScale =
|
||||
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
1 +
|
||||
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
|
||||
lockedRingSprite.scale.set(
|
||||
SATELLITE_CONFIG.ringSize * breathScale,
|
||||
SATELLITE_CONFIG.ringSize * breathScale,
|
||||
@@ -832,20 +1034,21 @@ export function updateLockedRingPosition(position) {
|
||||
);
|
||||
lockedRingSprite.material.opacity =
|
||||
SATELLITE_CONFIG.breathingOpacityMin +
|
||||
Math.sin(breathingPhase) *
|
||||
ringPulse *
|
||||
(SATELLITE_CONFIG.breathingOpacityMax -
|
||||
SATELLITE_CONFIG.breathingOpacityMin);
|
||||
}
|
||||
|
||||
if (lockedDotSprite) {
|
||||
lockedDotSprite.position.copy(position);
|
||||
const dotPulse = getBreathingPulse(breathingPhase);
|
||||
const dotBreathScale =
|
||||
1 +
|
||||
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||
(dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
|
||||
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
|
||||
lockedDotSprite.material.opacity =
|
||||
SATELLITE_CONFIG.dotOpacityMin +
|
||||
Math.sin(breathingPhase) *
|
||||
dotPulse *
|
||||
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
|
||||
}
|
||||
}
|
||||
@@ -881,6 +1084,15 @@ export function setSatelliteRingState(index, state, position) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyDimMaterialState(isDimmed) {
|
||||
if (satellitePoints) {
|
||||
satellitePoints.material.opacity = isDimmed ? 0.32 : 0.9;
|
||||
}
|
||||
if (satelliteBackdropPoints) {
|
||||
satelliteBackdropPoints.material.opacity = isDimmed ? 0.12 : 0.42;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRelatedSatelliteHighlights() {
|
||||
relatedSatelliteSprites.forEach((item) => {
|
||||
if (item.sprite) {
|
||||
@@ -888,12 +1100,16 @@ export function clearRelatedSatelliteHighlights() {
|
||||
}
|
||||
});
|
||||
relatedSatelliteSprites = [];
|
||||
highlightedSatelliteIndices = null;
|
||||
applyDimMaterialState(false);
|
||||
}
|
||||
|
||||
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
|
||||
clearRelatedSatelliteHighlights();
|
||||
if (!Array.isArray(indices) || indices.length === 0) return;
|
||||
|
||||
highlightedSatelliteIndices = new Set(indices);
|
||||
applyDimMaterialState(true);
|
||||
indices.forEach((index) => {
|
||||
const pos = satellitePositions?.[index]?.current;
|
||||
if (!pos) return;
|
||||
@@ -1049,10 +1265,12 @@ export function hidePredictedOrbit() {
|
||||
|
||||
export function clearSatelliteData() {
|
||||
satelliteData = [];
|
||||
satelliteSatrecCache = new Map();
|
||||
selectedSatellite = null;
|
||||
lockedSatelliteIndex = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
positionUpdateAccumulator = 0;
|
||||
breathingPhase = 0;
|
||||
|
||||
satellitePositions.forEach((position) => {
|
||||
position.current.set(0, 0, 0);
|
||||
@@ -1075,6 +1293,16 @@ export function clearSatelliteData() {
|
||||
satellitePoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
const backdropPositionAttr =
|
||||
satelliteBackdropPoints.geometry.attributes.position;
|
||||
if (backdropPositionAttr?.array) {
|
||||
backdropPositionAttr.array.fill(0);
|
||||
backdropPositionAttr.needsUpdate = true;
|
||||
}
|
||||
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
|
||||
}
|
||||
|
||||
if (satelliteTrails) {
|
||||
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
|
||||
const trailColorAttr = satelliteTrails.geometry.attributes.color;
|
||||
@@ -1097,6 +1325,11 @@ export function clearSatelliteData() {
|
||||
export function resetSatelliteState() {
|
||||
clearSatelliteData();
|
||||
|
||||
if (satelliteBackdropPoints) {
|
||||
disposeObject3D(satelliteBackdropPoints);
|
||||
satelliteBackdropPoints = null;
|
||||
}
|
||||
|
||||
if (satellitePoints) {
|
||||
disposeObject3D(satellitePoints);
|
||||
satellitePoints = null;
|
||||
@@ -1109,6 +1342,7 @@ export function resetSatelliteState() {
|
||||
|
||||
satellitePositions = [];
|
||||
satelliteCapacity = 0;
|
||||
satelliteSatrecCache = new Map();
|
||||
showSatellites = false;
|
||||
showTrails = true;
|
||||
}
|
||||
|
||||
304
frontend/public/earth/js/terrain.js
Normal file
304
frontend/public/earth/js/terrain.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, TERRAIN_CONFIG } from "./constants.js";
|
||||
import { vector3ToLatLon } from "./utils.js";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371000;
|
||||
const TERRAIN_COLOR_STOPS = [
|
||||
{ height: 0, color: new THREE.Color(0x5f7f5b) },
|
||||
{ height: 800, color: new THREE.Color(0x7f9564) },
|
||||
{ height: 1800, color: new THREE.Color(0x9e956c) },
|
||||
{ height: 3200, color: new THREE.Color(0x9f866a) },
|
||||
{ height: 5200, color: new THREE.Color(0xc6c0b1) },
|
||||
{ height: 7800, color: new THREE.Color(0xe8e5de) },
|
||||
];
|
||||
|
||||
let terrainMesh = null;
|
||||
let terrainLoadPromise = null;
|
||||
let terrainReady = false;
|
||||
let terrainFailed = false;
|
||||
let terrainTileCache = new Map();
|
||||
let terrainVertexSamples = null;
|
||||
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
|
||||
function clampLatitude(lat) {
|
||||
return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878);
|
||||
}
|
||||
|
||||
function buildTileUrl(z, x, y) {
|
||||
return TERRAIN_CONFIG.urlTemplate
|
||||
.replace("{z}", String(z))
|
||||
.replace("{x}", String(x))
|
||||
.replace("{y}", String(y));
|
||||
}
|
||||
|
||||
function getTerrainCanvas(size) {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(size, size);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function decodeTerrainTile(z, x, y) {
|
||||
const cacheKey = `${z}/${x}/${y}`;
|
||||
if (terrainTileCache.has(cacheKey)) {
|
||||
return terrainTileCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const tilePromise = (async () => {
|
||||
const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||
bitmap.close?.();
|
||||
const { data, width, height } = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
return { data, width, height };
|
||||
})();
|
||||
|
||||
terrainTileCache.set(cacheKey, tilePromise);
|
||||
return tilePromise;
|
||||
}
|
||||
|
||||
function decodeTerrariumHeight(tile, pixelX, pixelY) {
|
||||
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
|
||||
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
|
||||
const index = (safeY * tile.width + safeX) * 4;
|
||||
const r = tile.data[index];
|
||||
const g = tile.data[index + 1];
|
||||
const b = tile.data[index + 2];
|
||||
return (r * 256 + g + b / 256) - 32768;
|
||||
}
|
||||
|
||||
function latLonToTileSample(lat, lon, z, tileSize) {
|
||||
const n = 2 ** z;
|
||||
const clampedLat = clampLatitude(lat);
|
||||
const latRad = THREE.MathUtils.degToRad(clampedLat);
|
||||
const normalizedX = ((lon + 180) / 360) * n;
|
||||
const normalizedY =
|
||||
((1 -
|
||||
Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
|
||||
2) *
|
||||
n;
|
||||
|
||||
const tileX = THREE.MathUtils.euclideanModulo(
|
||||
Math.floor(normalizedX),
|
||||
n,
|
||||
);
|
||||
const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1);
|
||||
const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize);
|
||||
const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize);
|
||||
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
pixelX,
|
||||
pixelY,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerrainVertexSamples(positionAttribute) {
|
||||
const z = TERRAIN_CONFIG.baseZoom;
|
||||
const tileSize = TERRAIN_CONFIG.tileSize;
|
||||
const samples = [];
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const direction = new THREE.Vector3(
|
||||
positionAttribute.getX(i),
|
||||
positionAttribute.getY(i),
|
||||
positionAttribute.getZ(i),
|
||||
).normalize();
|
||||
const { lat, lon } = vector3ToLatLon(direction);
|
||||
const sample = latLonToTileSample(lat, lon, z, tileSize);
|
||||
samples.push({
|
||||
index: i,
|
||||
direction,
|
||||
...sample,
|
||||
});
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function sampleTerrainColor(heightMeters) {
|
||||
if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) {
|
||||
return TERRAIN_COLOR_STOPS[0].color;
|
||||
}
|
||||
|
||||
for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) {
|
||||
const lower = TERRAIN_COLOR_STOPS[i - 1];
|
||||
const upper = TERRAIN_COLOR_STOPS[i];
|
||||
if (heightMeters <= upper.height) {
|
||||
const t =
|
||||
(heightMeters - lower.height) / Math.max(upper.height - lower.height, 1);
|
||||
return lower.color.clone().lerp(upper.color, t);
|
||||
}
|
||||
}
|
||||
|
||||
return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color;
|
||||
}
|
||||
|
||||
async function runWithConcurrency(items, limit, worker) {
|
||||
const queue = [...items];
|
||||
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function fetchRequiredTiles(samples) {
|
||||
const uniqueKeys = Array.from(
|
||||
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
|
||||
);
|
||||
const resolvedTiles = new Map();
|
||||
|
||||
await runWithConcurrency(
|
||||
uniqueKeys,
|
||||
TERRAIN_CONFIG.maxConcurrentRequests,
|
||||
async (key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||
},
|
||||
);
|
||||
|
||||
return resolvedTiles;
|
||||
}
|
||||
|
||||
function applyTerrainDisplacement(samples, mesh, resolvedTiles) {
|
||||
const geometry = mesh.geometry;
|
||||
const positionAttribute = geometry.getAttribute("position");
|
||||
let colorAttribute = geometry.getAttribute("color");
|
||||
if (!colorAttribute || colorAttribute.itemSize !== 4) {
|
||||
colorAttribute = new THREE.BufferAttribute(
|
||||
new Float32Array(positionAttribute.count * 4),
|
||||
4,
|
||||
);
|
||||
geometry.setAttribute("color", colorAttribute);
|
||||
}
|
||||
const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset;
|
||||
|
||||
samples.forEach((sample) => {
|
||||
const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`;
|
||||
const tile = resolvedTiles.get(tileKey);
|
||||
if (!tile) return;
|
||||
const rawElevationMeters = decodeTerrariumHeight(
|
||||
tile,
|
||||
sample.pixelX,
|
||||
sample.pixelY,
|
||||
);
|
||||
const elevationMeters = Math.max(0, rawElevationMeters);
|
||||
const heightWorld =
|
||||
(elevationMeters / EARTH_RADIUS_METERS) *
|
||||
CONFIG.earthRadius *
|
||||
TERRAIN_CONFIG.exaggeration;
|
||||
const radius = baseRadius + heightWorld;
|
||||
const tint = sampleTerrainColor(elevationMeters);
|
||||
const landAlpha = THREE.MathUtils.clamp(
|
||||
elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
positionAttribute.setXYZ(
|
||||
sample.index,
|
||||
sample.direction.x * radius,
|
||||
sample.direction.y * radius,
|
||||
sample.direction.z * radius,
|
||||
);
|
||||
colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha);
|
||||
});
|
||||
|
||||
positionAttribute.needsUpdate = true;
|
||||
colorAttribute.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
export function registerTerrainMesh(mesh) {
|
||||
terrainMesh = mesh;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainLoadPromise = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
terrainVertexSamples = mesh
|
||||
? buildTerrainVertexSamples(mesh.geometry.getAttribute("position"))
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isTerrainReady() {
|
||||
return terrainReady;
|
||||
}
|
||||
|
||||
export async function ensureTerrainReady() {
|
||||
if (!terrainMesh || !TERRAIN_CONFIG.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (terrainReady) {
|
||||
return true;
|
||||
}
|
||||
if (terrainLoadPromise) {
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
terrainLoadPromise = (async () => {
|
||||
try {
|
||||
const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples);
|
||||
applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles);
|
||||
terrainReady = true;
|
||||
terrainFailed = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
terrainFailed = true;
|
||||
console.error("加载真实地形失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
terrainLoadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
export function clearTerrainData() {
|
||||
terrainMesh = null;
|
||||
terrainLoadPromise = null;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainVertexSamples = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
}
|
||||
|
||||
export function setTerrainOpacity(nextOpacity) {
|
||||
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
return terrainOpacity;
|
||||
}
|
||||
|
||||
export function getTerrainOpacity() {
|
||||
return terrainOpacity;
|
||||
}
|
||||
@@ -323,11 +323,20 @@ function setupResizeHandle() {
|
||||
function updateToggleButton(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const icon = toggleBtn.querySelector(".material-symbols-rounded");
|
||||
const isLiveTab = activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
if (icon) {
|
||||
icon.textContent = isLiveTab ? "live_tv" : "newspaper";
|
||||
}
|
||||
const title = visible
|
||||
? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播")
|
||||
: (isLiveTab ? "打开新闻直播" : "打开态势新闻");
|
||||
toggleBtn.title = title;
|
||||
toggleBtn.setAttribute("aria-label", title);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播";
|
||||
tooltip.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +507,7 @@ function setActiveTab(tab) {
|
||||
updateTabState(newsHeaderControls, nextTab === "news");
|
||||
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
|
||||
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
if (
|
||||
restoreLayoutState &&
|
||||
@@ -1037,20 +1047,23 @@ export function initTVPanel() {
|
||||
const currentlyVisible = mediaPanel?.isVisible() ?? false;
|
||||
if (!currentlyVisible) {
|
||||
setPanelVisible(true);
|
||||
setActiveTab("live");
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
if (activeTab === "live") {
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
} else {
|
||||
showStatusMessage("态势新闻窗口已打开", "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab !== "live") {
|
||||
setActiveTab("live");
|
||||
const nextTab = activeTab === "live" ? "news" : "live";
|
||||
setActiveTab(nextTab);
|
||||
if (nextTab === "live") {
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("已切换到新闻直播", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
showStatusMessage("已切换到态势新闻", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
|
||||
@@ -9,6 +9,11 @@ let statusQueue = [];
|
||||
let statusBusy = false;
|
||||
let loadingActive = false;
|
||||
let loadingLockedWidth = 0;
|
||||
let pendingLoadingMessage = "";
|
||||
|
||||
function createStatusEntry(message, type = "info") {
|
||||
return { message, type };
|
||||
}
|
||||
|
||||
function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
@@ -67,6 +72,10 @@ function buildStatusContent(statusEl, message, type) {
|
||||
statusEl.appendChild(text);
|
||||
}
|
||||
|
||||
function buildPersistentErrorContent(errorEl, message) {
|
||||
buildStatusContent(errorEl, message, "error");
|
||||
}
|
||||
|
||||
function hideStatusElement(statusEl, onHidden) {
|
||||
statusEl.classList.remove("visible");
|
||||
statusHideTimeoutId = setTimeout(() => {
|
||||
@@ -113,7 +122,15 @@ function startTransientStatus(message, type = "info") {
|
||||
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
statusQueue.push({ message, type });
|
||||
if (loadingActive) {
|
||||
statusQueue.unshift(createStatusEntry(message, type));
|
||||
return;
|
||||
}
|
||||
startTransientStatus(message, type);
|
||||
}
|
||||
|
||||
export function queueStatusMessage(message, type = "info") {
|
||||
statusQueue.push(createStatusEntry(message, type));
|
||||
processStatusQueue();
|
||||
}
|
||||
|
||||
@@ -179,7 +196,12 @@ export function setLoading(loading) {
|
||||
loadingActive = true;
|
||||
statusBusy = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
buildStatusContent(statusEl, "正在加载...", "loading");
|
||||
buildStatusContent(
|
||||
statusEl,
|
||||
pendingLoadingMessage || "正在加载...",
|
||||
"loading",
|
||||
);
|
||||
pendingLoadingMessage = "";
|
||||
statusEl.className = `${STATUS_BASE_CLASS} loading`;
|
||||
setElementDisplay(statusEl, true, "inline-flex");
|
||||
statusEl.offsetHeight;
|
||||
@@ -188,6 +210,7 @@ export function setLoading(loading) {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
});
|
||||
} else {
|
||||
pendingLoadingMessage = "";
|
||||
if (!statusEl.classList.contains("loading")) {
|
||||
loadingActive = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
@@ -205,7 +228,10 @@ export function setLoading(loading) {
|
||||
|
||||
export function setLoadingMessage(title) {
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) return;
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) {
|
||||
pendingLoadingMessage = title;
|
||||
return;
|
||||
}
|
||||
const textEl = statusEl.querySelector(".earth-status-text");
|
||||
if (textEl) {
|
||||
textEl.textContent = title;
|
||||
@@ -237,16 +263,21 @@ export function hideTooltip() {
|
||||
export function showError(message) {
|
||||
const errorEl = getElement("error-message");
|
||||
if (!errorEl) return;
|
||||
errorEl.textContent = message;
|
||||
setElementDisplay(errorEl, true);
|
||||
buildPersistentErrorContent(errorEl, message);
|
||||
errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
|
||||
setElementDisplay(errorEl, true, "inline-flex");
|
||||
errorEl.offsetHeight;
|
||||
errorEl.classList.add("visible");
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
export function hideError() {
|
||||
const errorEl = getElement("error-message");
|
||||
if (errorEl) {
|
||||
errorEl.classList.remove("visible");
|
||||
setElementDisplay(errorEl, false);
|
||||
errorEl.textContent = "";
|
||||
errorEl.className = "earth-error-message";
|
||||
errorEl.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +286,7 @@ export function clearUiState() {
|
||||
statusQueue = [];
|
||||
statusBusy = false;
|
||||
loadingActive = false;
|
||||
pendingLoadingMessage = "";
|
||||
|
||||
const statusEl = getElement("status-message");
|
||||
if (statusEl) {
|
||||
|
||||
18
planet.sh
18
planet.sh
@@ -1054,6 +1054,14 @@ start_ai_provider_service() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
ai_provider_service_healthy() {
|
||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
|
||||
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
|
||||
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
|
||||
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_database_services_healthy() {
|
||||
local retry=1
|
||||
|
||||
@@ -1130,7 +1138,15 @@ start_backend_service() {
|
||||
log_success "启动数据库已就绪"
|
||||
sleep 3
|
||||
|
||||
start_ai_provider_service "$ai_provider_port"
|
||||
# Backend depends on AI Provider reachability, but a backend-only restart
|
||||
# should reuse the existing healthy provider instead of rebuilding or
|
||||
# restarting it.
|
||||
if ai_provider_service_healthy "$ai_provider_port"; then
|
||||
log_note "AI Provider 已健康,复用现有服务,跳过启动/重建"
|
||||
else
|
||||
log_note "AI Provider 当前不健康,先执行托底启动"
|
||||
start_ai_provider_service "$ai_provider_port"
|
||||
fi
|
||||
|
||||
if [ "$backend_port_requested" -eq 1 ]; then
|
||||
kill_port_if_requested "$backend_port" "后端"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.29.1"
|
||||
version = "0.31.2"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user