Compare commits

...

8 Commits

Author SHA1 Message Date
linkong
b7647379de release: bump version to 0.31.0 2026-04-21 18:35:40 +08:00
linkong
0f89372d71 release: bump version to 0.30.0 2026-04-21 12:28:04 +08:00
linkong
2b0d4cfc49 release: bump version to 0.29.2 2026-04-21 10:43:48 +08:00
rayd1o
e6d0332fba release: bump version to 0.29.1 2026-04-20 22:12:19 +08:00
linkong
fe45a99cbd release: bump version to 0.29.0 2026-04-20 17:43:59 +08:00
linkong
ae77b06c3c docs: expand earth celestial background implementation plan 2026-04-20 16:29:03 +08:00
linkong
b5dd4f12f8 release: bump version to 0.28.2 2026-04-20 16:00:54 +08:00
linkong
75cb214f23 release: bump version to 0.28.1 2026-04-20 15:14:53 +08:00
60 changed files with 5892 additions and 1152 deletions

View File

@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
详细文档: 详细文档:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) - [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) - [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) - [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) - [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) - [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/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) - [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) - [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
## License ## License

View File

@@ -1 +1 @@
0.28.0 0.31.0

View File

@@ -4,7 +4,7 @@
完整使用说明见: 完整使用说明见:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) - [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
当前支持: 当前支持:

View File

@@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
from datetime import UTC, datetime from datetime import UTC, datetime
import math 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.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from typing import List, Dict, Any, Optional 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 from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
router = APIRouter() router = APIRouter()
TERRAIN_TILE_URL_TEMPLATE = (
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
)
# ============== Converter Functions ============== # ============== Converter Functions ==============
@@ -782,9 +786,20 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/landing-points") @router.get("/geo/landing-points")
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
try: try:
records = await _load_current_collected_data(db, "arcgis_landing_points") records_by_source = await _load_current_collected_data_by_sources(
relation_records = await _load_current_collected_data(db, "arcgis_cable_landing_relation") db,
cable_records = await _load_current_collected_data(db, "arcgis_cables") [
"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( city_to_cable_ids_map, cable_id_to_name_map = _build_landing_point_cable_maps(
relation_records, 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)}") 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") @router.get("/geo/all")
async def get_all_geojson(db: AsyncSession = Depends(get_db)): async def get_all_geojson(db: AsyncSession = Depends(get_db)):
records_by_source = await _load_current_collected_data_by_sources( records_by_source = await _load_current_collected_data_by_sources(

View File

@@ -8,12 +8,129 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.31.0] — 2026-04-21
### ✨ 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
- Earth 加载状态条改成单一队列式通知面板,加载阶段不再因为步骤文案变化而回缩,也不会被其他通知打断
- 调整 brand panel 的呈现方式与昼夜/选中态可读性,让品牌区更自然、交互高亮在白天和黑夜里都更稳定
### 🔧 Improvements
- 移除旧的地球加载浮层结构,统一由 HUD 状态消息承载三点脉冲加载过程
- brand panel 改为无边框品牌层,仅保留轻微氛围光,不再因为非常规尺寸显得像第五块功能面板
- 温和收敛地球昼夜材质与主背光强度,保留昼夜辨识度的同时提升白天地表纹理和夜面交互可见性
### 🐛 Fixes
- 修复加载地球时通知条在步骤切换中反复缩短、其他状态消息抢占加载流程的问题
- 修复海缆、登陆点和 BGP 选中高亮在黑夜中过暗、在高光中过亮导致难以辨识的问题
---
## [0.29.0] — 2026-04-20
### ✨ Highlights
- Earth 新增天球层第一版:引入真实全天星图、亮星层与太阳/月亮位置计算,地球场景首次具备可校准的天文背景
- 地球昼夜分隔升级为更明显的日夜增强效果,夜面、晨昏带和太阳方向联动更容易直接读出来
### 🔧 Improvements
- 新增 `celestial.js` 模块和 `assets/celestial/` 资源目录,统一管理星图、亮星数据以及太阳/月亮与光照同步
- 卫星图例改为按倾角分组,严格固定为“赤道轨道 → 低倾角轨道 → 中倾角轨道 → 高倾角轨道 → 逆行轨道”顺序,并全部中文化
- 图层面板补齐关闭按钮,拖拽脱离左列后不再被流布局 margin 影响,能够真正贴到品牌面板下沿
### 🐛 Fixes
- 修复天球球壳放大后被相机 far plane 裁剪导致的外层黑环问题
- 修复图层面板在左侧上移时始终与 brand panel 保持额外间距的问题
---
## [0.28.2] — 2026-04-20
### ✨ Highlights
- 修正媒体情报面板在 `电视直播 / 态势聚合` tab 间切换时的尺寸记忆逻辑,切回原 tab 后可恢复各自大小状态
- 清理 `docs/` 根目录遗留的旧路径文档,只保留新的分组目录和归档目录,结束同一文档双路径并存状态
### 🔧 Improvements
- `media-panel` 切换逻辑改成按 tab 分别记忆尺寸状态,避免 `A -> B -> A` 时继续共用同一套外层尺寸
- 目录整理真正完成收尾:旧的 `docs/*.md` 平铺计划文档删除,继续以 `docs/agents / earth / backend / frontend / ops / ue5 / deprecated` 为唯一入口
### 🐛 Fixes
- 修复拉伸 `media-panel` 后切换 tab 时,`news-panel` 高度回退到旧默认值的问题
- 修复拉伸后切换 tab 导致面板视觉锚点异常的问题,切换时改为围绕当前卡片自身右下角进行尺寸恢复
---
## [0.28.1] — 2026-04-20
### ✨ Highlights
- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界
- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated`
### 🔧 Improvements
- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合”
- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义
- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构
### 🐛 Fixes
- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位
- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签
---
## [0.28.0] — 2026-04-20 ## [0.28.0] — 2026-04-20
### ✨ Features ### ✨ Features
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区 - 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现 - 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
说明:
- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel``news-panel`
### 🔧 Improvements ### 🔧 Improvements
- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口 - TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口
- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀 - 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀
@@ -193,7 +310,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 [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 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/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/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/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.
### Improved ### Improved
@@ -279,7 +396,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/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 [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/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/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/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
### Fixed ### Fixed
@@ -317,7 +434,7 @@ Released: 2026-04-10
### Improved ### 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 [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-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/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.”
## 0.24.6 ## 0.24.6
@@ -334,7 +451,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/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 [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 [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/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/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/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.
### Fixed ### Fixed
@@ -440,8 +557,8 @@ Released: 2026-04-09
### Added ### 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 [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-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling. - 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/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/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/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.
### Improved ### Improved
@@ -546,7 +663,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 [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/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 [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/aiprovider.md](/home/ray/dev/linkong/planet/docs/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/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). - 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 ### Improved
@@ -672,7 +789,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 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 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/prefix-geography-plan.md). - Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/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 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 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. - 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.
@@ -687,7 +804,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 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 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 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/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/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 ### Fixed
@@ -845,7 +962,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 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 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 `-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/system-service-control.md). - Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md).
### Improved ### Improved
@@ -1048,7 +1165,7 @@ Released: 2026-03-26
### Added ### Added
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md). - Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-module-plan.md).
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py). - Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API. - Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.

View File

@@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r
Related documents: Related documents:
- [aiprovider](/home/ray/dev/linkong/planet/docs/aiprovider.md) - [aiprovider](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/datasource-health-plan.md) - [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md)
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agent-architecture-plan.md) - [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/agent-architecture-plan.md)
## Big Picture ## Big Picture

17
docs/deprecated/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Deprecated Docs
这个目录用于存放两类文档:
1. 已经完成、主要保留为历史记录的实施计划
2. 已被现有实现或新方案替代的旧计划
放到这里并不代表这些文档“错误”,而是表示:
- 它们不再适合作为当前开发的主指导文档
- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考
当前归档原则:
- 明确写明“已完成”的计划,优先归档
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`

View File

@@ -187,7 +187,7 @@ Current reality:
- that is expected, because incidents are aggregated and de-noised - 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 - 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/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/earth/bgp-region-aggregation-plan.md).
So the immediate next milestone is: So the immediate next milestone is:

View File

@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
## Why This Layer Exists ## Why This Layer Exists
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md): Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md):
- incident density is naturally low - incident density is naturally low
- anomaly density is higher, but still not enough to keep the globe expressive all the time - 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 ## Earth Rendering Plan
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/bgp-earth-rendering-plan.md). Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md).
### Layer Relationship ### Layer Relationship

View File

@@ -0,0 +1,715 @@
# Earth 天球背景与日月位置实施方案
## 目标
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
1. 用真实天球背景替换当前随机星点
2. 在当前时间下显示太阳与月亮的相对位置
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
本方案优先追求:
- 与当前 Three.js Earth 架构兼容
- 风险可控
- 先落地一版真实感明显提升的 V1
- 为后续更严格的天文参考系升级预留余地
## 当前现状
当前 Earth 的基础条件已经具备:
- 地球、云层、地形、网格都基于 Three.js主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
这意味着:
- 不需要重写 Earth
- 可以在现有 scene/world 层新增一个 celestial layer
- 第一阶段不必拆 Earth / satellite / cable 的参考系
## 总体策略
采用“两层现实”设计:
### 1. 世界层world-space celestial layer
用于放置:
- 天球背景
- 太阳
- 月亮
- 太阳光方向
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
### 2. 地球层earth-fixed layer
继续保持当前结构:
- 海缆
- 登陆点
- 卫星点与轨迹
- BGP 覆盖
- 地球纹理、云层、地形
这些对象继续挂在 `earthObj` 下,不打断现有交互。
## 为什么先这样做
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
如果现在直接做严格惯性参考系改造,会同时影响:
- `earthObj.rotation`
- 卫星轨迹与锁定逻辑
- 海缆与登陆点附着关系
- resetView / autoRotate / hover / click 等交互链路
所以第一阶段只做:
- 真正的天空
- 真正的日月方向
- 不碰现有 Earth 附着对象的语义
## 推荐技术选型
### 天文计算库
推荐:
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
原因:
- 有 JavaScript 版本
- 支持 Sun / Moon 的矢量与坐标变换
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
不作为主选的库:
- [SunCalc](https://github.com/mourner/suncalc)
原因:
- 更偏本地观察者视角的太阳/月亮高度角
- 用于“地面日出日落”很好
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
### Three.js 表现层
推荐组合:
- 天球:内翻球壳 + 星图纹理
- 太阳:`THREE.Sprite`
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
- 太阳光:`THREE.DirectionalLight`
参考:
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
## 天球背景资源与星体数据来源
为避免把“视觉背景”和“可计算天体位置”混为一谈,本方案明确分成两类资源:
### 1. 背景资源:全天星图贴图
用于 Phase 1 的“真实天空背景”。
推荐优先来源:
- NASA SVS 的 Tycho 全天星图
- [The Tycho Catalog Skymap - Version 2.0](https://svs.gsfc.nasa.gov/3572/)
- NASA Deep Star Maps 2020
- SatelliteMap.space 在 credits 中明确提到其使用了 `NASA Deep Star Maps 2020 - High-resolution star field (1.7 billion stars from Gaia DR2)` 作为星空视觉资源
- 这说明行业内成熟实现并不一定直接渲染全部星表点,而很可能先使用一张高质量官方深空星图作为背景层
- 如需后续替换,也可评估 ESA / Gaia 的全天 sky map 资源
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
建议要求:
- 使用官方来源或官方衍生可复用资源
- 等距矩形投影equirectangular
- 坐标定义尽量明确为赤道坐标展开
- 分辨率建议至少 `4k`
- 颜色不要过亮,避免压过 Earth HUD 前景
- 尽量优先选择官方天文机构已经生产好的深空图,而不是自行拼接低质量星空纹理
建议本地资源目录:
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
### 2. 位置数据:星表与天体计算
用于 Phase 2+ 的“位置正确的星体”。
推荐来源分两层:
- 太阳、月亮位置
- 使用 [Astronomy Engine](https://github.com/cosinekitty/astronomy)
- 恒星位置
- 第一优先Hipparcos / Tycho
- [Hipparcos overview](https://www.cosmos.esa.int/web/Hipparcos)
- [Hipparcos catalogues](https://www.cosmos.esa.int/web/hipparcos/catalogues)
- 第二优先Gaia
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
建议策略:
- V1背景球壳只用全天星图不立即生成全量恒星点
- V2只挑选亮星例如星等 `< 5.5`)生成恒星点层
- V3如果确实需要更丰富的星场再逐步扩展到更深星等
这样做的原因:
- 背景球壳负责“天球真实感”
- 亮星点负责“位置正确、可后续标注和高亮”
- 不需要一开始就处理数十万甚至数百万颗星
### 3. 对外部成熟实现的参考结论
`SatelliteMap.space` 的公开 credits 提供了一个很有价值的参考样板:
- 图形渲染使用 `TWGL.js`
- 天文计算使用 `Skyfield``Astronomia`
- 星空/天球视觉资源使用 `NASA Deep Star Maps 2020`
这给本项目的启发是:
- “真实感强的天球背景”完全可以先依赖官方高质量深空图
- “位置正确的动态天体”则应依赖单独的天文计算链路
- 没有必要在第一版就直接渲染完整星表
因此本项目推荐继续坚持两层拆分:
- 背景层:官方深空图 / 全天星图
- 计算层:太阳、月亮与后续亮星点
## 如何保证星体位置正确
位置正确不是只看“图看起来像”,而是要统一参考系和转换链路。
### 1. 统一坐标基准
本方案推荐统一使用:
- `J2000` 赤道坐标系作为恒星位置基准
原因:
- Hipparcos / Tycho 资料和大量天文可视化都容易映射到该基准
- 太阳、月亮也可以通过 Astronomy Engine 转到同一坐标系
- 这样背景、恒星点、太阳、月亮就能共用一套 sky orientation
### 2. 背景贴图与点位必须使用同一展开逻辑
如果背景球壳使用赤道坐标全天图,那么:
- 亮星点也必须按赤道坐标贴到同一球面方向
- 太阳/月亮 sprite 也必须按赤道坐标转换后落到同一 world-space
否则会出现:
- 背景银河带是对的
- 但太阳/月亮或亮星点飘到不匹配的位置
### 3. RA / Dec 到 Three.js 坐标的落点方式
亮星点和日月方向最终都要转成单位球面向量。
概念步骤:
1. 读取赤经 `RA`
2. 读取赤纬 `Dec`
3. 转成弧度
4. 映射到单位球面向量
5. 再根据 Three.js 当前世界坐标定义做轴向映射
参考公式:
```text
x = cos(dec) * cos(ra)
y = sin(dec)
z = cos(dec) * sin(ra)
```
实际接入 Three.js 时,需要做一次项目内坐标轴校准:
- 验证 `RA = 0h`
- 验证 `RA = 6h`
- 验证北天极
- 验证银河带主方向
然后确定最终的:
- `x/y/z` 对应 Three.js 哪个轴
- 是否需要 `z` 取反
- 是否需要整体再做一个固定 `rotation`
建议把这层显式封装在:
```js
function equatorialToWorldVector(raRad, decRad)
```
不要把轴映射散落在不同模块里。
### 4. 背景球壳与恒星点的关系
推荐最终组合:
- 背景层:全天星图球壳
- 点位层:亮星点
- 动态层:太阳 / 月亮
这样有三个好处:
- 背景层提供密集真实的天空纹理
- 亮星点提供位置正确、可扩展的标注基础
- 太阳/月亮提供与时间相关的真实动态对象
## 数据与资源建议清单
### 推荐首批引入资源
1. 全天星图
- 来源NASA Tycho all-sky map
- 用途:背景球壳纹理
2. 月亮纹理
- 用途Phase 4 月相表现
- 路径建议:
- `frontend/public/earth/assets/celestial/moon_albedo_2k.jpg`
3. 太阳 glow 贴图
- 用途:太阳 sprite halo
- 路径建议:
- `frontend/public/earth/assets/celestial/sun_glow.png`
### 推荐首批数据文件
如果要上亮星层,建议新增一个预处理后的轻量数据文件:
- `frontend/public/earth/assets/celestial/bright-stars.json`
建议字段:
```json
[
{
"id": 32349,
"name": "Sirius",
"raDeg": 101.2875,
"decDeg": -16.7161,
"mag": -1.46,
"colorIndex": 0.00
}
]
```
建议不要在浏览器里直接吞原始 Gaia 大表,而是先离线裁剪成:
- 只保留亮星
- 只保留渲染必需字段
- JSON 或二进制轻量格式
## 资源与数据实施路线
### 路线 A先做可用版本推荐
1. 引入 NASA Tycho 全天图
- 或评估替换为更接近 SatelliteMap.space 路线的 `NASA Deep Star Maps 2020`
2. 实现背景球壳
3. 用 Astronomy Engine 计算太阳/月亮方向
4. 暂不做亮星点
优点:
- 最快见效
- 风险最低
- 就能明显提升天球真实感
### 路线 B在 A 基础上增强
1. 离线生成 `bright-stars.json`
2. 浏览器端渲染亮星点
3. 后续可加:
- 星座线
- 亮星名称
- 特定星体高亮
优点:
- 背景真实感和“位置正确的可交互星体”同时兼顾
## 代码模块建议细化
### 新增模块
- `frontend/public/earth/js/celestial.js`
- 管理天球背景
- 管理太阳/月亮
- 管理亮星层(后续)
- `frontend/public/earth/js/celestial-data.js`
- 资源路径
- 星图方向配置
- 亮星数据加载(后续)
### 建议函数设计
```js
export function initCelestialLayer(scene)
export function updateCelestialLayer(date)
export function setCelestialVisibility(visible)
export function disposeCelestialLayer()
function loadStarMapTexture()
function createSkySphere(texture)
function createSunSprite()
function createMoonSprite()
function getSunEquatorialPosition(date)
function getMoonEquatorialPosition(date)
function equatorialToWorldVector(raRad, decRad)
```
### 推荐后续预处理脚本
如要引入亮星层,建议单独做离线脚本:
- `scripts/build_bright_stars.py`
职责:
- 从 Hipparcos / Tycho 源数据读取
- 过滤亮星
- 生成 `bright-stars.json`
这样浏览器端只消费轻量结果,不承担大表解析成本。
## 分阶段实施
## Phase 1真实天球背景
### 目标
用真实全天星图替换当前随机星点背景。
### 做法
1. 新增一张全天星图纹理
建议路径:
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
纹理要求:
- 等距矩形投影
- 赤经/赤纬坐标展开
- 无地平线、无地景遮挡
- 尽量深色、弱干扰,适合大屏 HUD 叠加
2. 新增天球球壳
新增模块:
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
建议接口:
```js
export function initCelestialLayer(scene)
export function updateCelestialLayer(date, camera, earth)
export function disposeCelestialLayer()
```
3. 实现一个大半径内翻球体
建议参数:
- 半径:`600 ~ 900`
- 材质:`MeshBasicMaterial`
- `side: THREE.BackSide`
- 不受场景光照影响
- 始终围绕场景中心
### 验收标准
- 初始加载后背景不再是随机星点
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
- 不明显干扰海缆/卫星/BGP 的前景识别
## Phase 2太阳与月亮真实位置
### 目标
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
### 做法
1.`celestial.js` 内封装天体位置计算
建议函数:
```js
function getSunDirection(date)
function getMoonDirection(date)
```
输出统一为 world-space `THREE.Vector3`
2. 太阳显示
- 一个暖色发光 sprite
- 比月亮更大、更亮
- 可选添加柔和 halo
3. 月亮显示
- 一个较小 sprite 或 sphere
- 灰白偏冷色
- 后续 Phase 3 再做月相
4. 更新频率
不要每帧重新做完整天文计算,建议:
- 每 30 秒或 60 秒重算一次真实位置
- 渲染帧内做平滑过渡
### 验收标准
- 页面可见太阳与月亮两个对象
- 时间变化时位置会更新
- 日月不会跟随地球局部旋转而错误附着
## Phase 3太阳驱动地球受光
### 目标
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
### 做法
1. 替换或接管当前主定向光
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`
建议改为:
- 保留环境补光
- 主太阳光方向由 `sunDirection` 决定
2. 太阳光参数建议
- `DirectionalLight` 颜色偏暖白
- 强度略高于当前主光
- 保留一个弱背光作为氛围补偿,避免背面过死黑
3. 先不做物理级大气散射
第一版只要求:
- 亮面与暗面方向真实
- 云层和大气仍保持当前风格
### 验收标准
- 地球明暗面会随太阳方向改变
- 太阳 sprite 和地球亮面方向一致
- 不破坏现有海缆、卫星、BGP 的可见性
## Phase 4月相与天文细节增强
### 目标
在日月真实位置基础上增加更强的“天文可信度”。
### 可选项
1. 月相
- 根据日月夹角计算 illuminated fraction
- 用月相纹理或 shader 表达盈亏
2. 赤道/黄道辅助线
- 可作为开发调试层,不默认显示
3. 太阳 terminator 增强
- 给地球夜面加入更自然的 night tint
- 未来可叠加城市夜光纹理
4. 天文时间入口
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
### 验收标准
- 月亮不再只是一个静态圆点
- 后续扩展行星或观测模式时无需推倒重来
## Phase 5严格参考系升级可选不作为 V1 必做)
### 目标
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
### 需要处理的问题
- 地球自转角与 UTC 的一致性
- 赤道坐标系、地固坐标系、相机交互层分离
- 卫星轨道显示与 Earth 旋转同步关系
- resetView 和 autoRotate 的语义重定
### 风险
这一步会影响:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
因此不建议与 V1 同时推进。
## 代码改造清单
## 1. 新增文件
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
职责:
- 管理天球背景、太阳、月亮
- 对外暴露 init/update/dispose
## 2. 修改 `constants.js`
文件:
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
新增:
```js
export const CELESTIAL_CONFIG = {
sphereRadius: 800,
updateIntervalMs: 60000,
sunSpriteScale: 28,
moonSpriteScale: 16,
sunLightIntensity: 1.25,
ambientIntensity: 0.28,
backLightIntensity: 0.18,
};
```
## 3. 修改 `main.js`
文件:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
主要改动:
1. `init()` 中:
- 初始化 celestial layer
2. `addLights()` 中:
- 把固定太阳光改成可更新的 celestial sun light
3. `animate()` 中:
- 每帧调 `updateCelestialLayer()`
4. `destroy()` 中:
- 清理 celestial 资源
## 4. 修改 `earth.js`
文件:
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
主要改动:
- `createStars()` 逐步退役
- 第一阶段可先保留作为 fallback
- 当真实星图加载成功后,不再显示随机星点
## 5. 新增资源
目录建议:
- `frontend/public/earth/assets/celestial/`
建议至少包含:
- `starmap_equatorial_4k.jpg`
- `sun_glow.png`
- `moon_albedo_2k.jpg`
## 数据流设计
```mermaid
flowchart TD
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
B --> C["创建天球球壳"]
B --> D["创建太阳 sprite + 主定向光"]
B --> E["创建月亮 sprite"]
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
H --> I["更新 sun sprite / moon sprite 位置"]
H --> J["更新太阳 DirectionalLight 方向"]
J --> K["地球昼夜方向变化"]
```
## 风险与注意事项
### 1. 星图投影方向容易反
这会表现为:
- 星图左右镜像
- 赤经方向颠倒
- 日月位置和背景对不上
建议:
- 先做一个开发调试模式
- 显示赤经/赤纬参考点,快速校正纹理朝向
### 2. 不要让天球跟随 Earth 旋转
天球背景和日月必须属于 scene/world而不是 `earthObj`
### 3. 不要每帧做重型天文计算
真实位置更新应节流,否则会浪费 CPU。
### 4. 月亮先求“方向正确”,再求“月相精致”
月相属于第二步优化,不应阻塞 V1 上线。
## 推荐实施顺序
1. 新建 `celestial.js`
2. 用星图球壳替换随机星点
3. 接入 Astronomy Engine
4. 加太阳/月亮 sprite
5. 用太阳方向驱动主光
6. 再决定要不要做月相和更严格参考系
## 最终建议
对于当前 Planet Earth最稳妥的方案是
- 先做真实天球背景
- 再做真实太阳/月亮方向
- 再让太阳驱动地球受光
- 暂时不做 Earth 参考系重构
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。

View 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 架构的一条路。

View File

@@ -30,7 +30,7 @@
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py) - [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/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py) - [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) - [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
### 2. 本地运行与配置打通 ### 2. 本地运行与配置打通
@@ -77,7 +77,7 @@
相关文件: 相关文件:
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) - [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx) - [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
## 当前限制 ## 当前限制

View File

@@ -16,12 +16,19 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.28.0` - `dev` 当前开发分支历史推导到:`0.31.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `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 根目录遗留旧路径文档 |
| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 |
| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab并稳定 TV/news 的 reform、resize 与共享 HUD 行为 | | `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab并稳定 TV/news 的 reform、resize 与共享 HUD 行为 |
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 | | `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 | | `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |

View File

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

View File

@@ -0,0 +1,30 @@
[
{ "id": 32349, "name": "Sirius", "raDeg": 101.2875, "decDeg": -16.7161, "mag": -1.46, "colorIndex": 0.00 },
{ "id": 30438, "name": "Canopus", "raDeg": 95.9879, "decDeg": -52.6957, "mag": -0.74, "colorIndex": 0.15 },
{ "id": 69673, "name": "Arcturus", "raDeg": 213.9153, "decDeg": 19.1824, "mag": -0.05, "colorIndex": 1.23 },
{ "id": 71683, "name": "Alpha Centauri", "raDeg": 219.9021, "decDeg": -60.8339, "mag": -0.01, "colorIndex": 0.71 },
{ "id": 91262, "name": "Vega", "raDeg": 279.2347, "decDeg": 38.7837, "mag": 0.03, "colorIndex": 0.00 },
{ "id": 24608, "name": "Capella", "raDeg": 79.1723, "decDeg": 45.9979, "mag": 0.08, "colorIndex": 0.80 },
{ "id": 24436, "name": "Rigel", "raDeg": 78.6345, "decDeg": -8.2016, "mag": 0.12, "colorIndex": -0.03 },
{ "id": 37279, "name": "Procyon", "raDeg": 114.8255, "decDeg": 5.2250, "mag": 0.34, "colorIndex": 0.42 },
{ "id": 7588, "name": "Achernar", "raDeg": 24.4286, "decDeg": -57.2368, "mag": 0.46, "colorIndex": -0.16 },
{ "id": 27989, "name": "Betelgeuse", "raDeg": 88.7929, "decDeg": 7.4071, "mag": 0.50, "colorIndex": 1.85 },
{ "id": 68702, "name": "Hadar", "raDeg": 210.9559, "decDeg": -60.3731, "mag": 0.61, "colorIndex": -0.23 },
{ "id": 97649, "name": "Altair", "raDeg": 297.6958, "decDeg": 8.8683, "mag": 0.76, "colorIndex": 0.22 },
{ "id": 60718, "name": "Acrux", "raDeg": 186.6490, "decDeg": -63.0991, "mag": 0.77, "colorIndex": -0.24 },
{ "id": 21421, "name": "Aldebaran", "raDeg": 68.9800, "decDeg": 16.5093, "mag": 0.85, "colorIndex": 1.54 },
{ "id": 65474, "name": "Spica", "raDeg": 201.2983, "decDeg": -11.1614, "mag": 0.98, "colorIndex": -0.23 },
{ "id": 80763, "name": "Antares", "raDeg": 247.3519, "decDeg": -26.4320, "mag": 1.06, "colorIndex": 1.83 },
{ "id": 37826, "name": "Pollux", "raDeg": 116.3289, "decDeg": 28.0262, "mag": 1.14, "colorIndex": 1.00 },
{ "id": 113368, "name": "Fomalhaut", "raDeg": 344.4128, "decDeg": -29.6222, "mag": 1.16, "colorIndex": 0.09 },
{ "id": 102098, "name": "Deneb", "raDeg": 310.3579, "decDeg": 45.2803, "mag": 1.25, "colorIndex": 0.09 },
{ "id": 49669, "name": "Regulus", "raDeg": 152.0929, "decDeg": 11.9672, "mag": 1.35, "colorIndex": -0.11 },
{ "id": 65477, "name": "Mimosa", "raDeg": 191.9303, "decDeg": -59.6888, "mag": 1.25, "colorIndex": -0.23 },
{ "id": 33579, "name": "Alphard", "raDeg": 141.8969, "decDeg": -8.6586, "mag": 1.98, "colorIndex": 1.44 },
{ "id": 21444, "name": "Bellatrix", "raDeg": 81.2828, "decDeg": 6.3497, "mag": 1.64, "colorIndex": -0.22 },
{ "id": 25336, "name": "Elnath", "raDeg": 81.5729, "decDeg": 28.6075, "mag": 1.65, "colorIndex": -0.13 },
{ "id": 26311, "name": "Alnilam", "raDeg": 84.0534, "decDeg": -1.2019, "mag": 1.69, "colorIndex": -0.19 },
{ "id": 26727, "name": "Alnitak", "raDeg": 85.1897, "decDeg": -1.9426, "mag": 1.77, "colorIndex": -0.19 },
{ "id": 25930, "name": "Saiph", "raDeg": 86.9391, "decDeg": -9.6696, "mag": 2.06, "colorIndex": -0.20 },
{ "id": 58001, "name": "Alioth", "raDeg": 193.5073, "decDeg": 55.9598, "mag": 1.76, "colorIndex": -0.02 }
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

View File

@@ -83,59 +83,17 @@ body.earth-page {
pointer-events: none; pointer-events: none;
} }
.earth-loading { @keyframes earthLoadingPulse {
position: absolute; 0%,
top: 50%; 80%,
left: 50%; 100% {
transform: translate(-50%, -50%); opacity: 0.28;
z-index: 240; transform: scale(0.78);
min-width: min(calc(320px * var(--hud-scale)), 78vw);
padding: calc(26px * var(--hud-scale));
border-radius: calc(18px * var(--hud-scale));
border: 1px solid rgba(77, 184, 255, 0.34);
background:
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.12), transparent 35%),
linear-gradient(180deg, rgba(13, 24, 46, 0.95), rgba(7, 14, 28, 0.94));
box-shadow:
0 0 30px rgba(77, 184, 255, 0.22),
0 16px 40px rgba(0, 0, 0, 0.28);
text-align: center;
color: #4db8ff;
}
.earth-loading-text {
color: #4db8ff;
}
.earth-loading-title {
font-size: calc(1.15rem * var(--hud-scale));
font-weight: 600;
}
.earth-loading-subtitle {
margin-top: calc(10px * var(--hud-scale));
color: #9ab7d4;
font-size: calc(0.84rem * var(--hud-scale));
line-height: 1.45;
}
.earth-loading-spinner {
width: calc(40px * var(--hud-scale));
height: calc(40px * var(--hud-scale));
margin: 0 auto calc(15px * var(--hud-scale));
border: 4px solid rgba(77, 184, 255, 0.28);
border-top: 4px solid #4db8ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
} }
100% { 40% {
transform: rotate(360deg); opacity: 1;
transform: scale(1);
} }
} }

View File

@@ -1,11 +1,16 @@
/* hud.css - HUD surfaces and shared overlays */ /* hud.css - HUD surfaces and shared overlays */
.hud-panel { .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; position: absolute;
overflow: hidden; overflow: hidden;
isolation: isolate; isolation: isolate;
background: 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%), 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, rgba(255, 255, 255, 0.05), transparent 26%),
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)); 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 var(--hud-highlight),
inset 0 -1px 0 rgba(255, 255, 255, 0.03), inset 0 -1px 0 rgba(255, 255, 255, 0.03),
var(--hud-shadow), 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%); backdrop-filter: blur(18px) saturate(125%);
-webkit-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 { .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%); linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%);
opacity: 0.52; opacity: 0.52;
pointer-events: none; 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 { .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)); 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; opacity: 0.72;
pointer-events: none; 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: -webkit-mask:
linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0); linear-gradient(#000 0 0);
@@ -49,6 +68,36 @@
linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0); linear-gradient(#000 0 0);
mask-composite: exclude; 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 > * { .hud-panel > * {
@@ -230,58 +279,162 @@
font-weight: 600; font-weight: 600;
} }
.hud-error-message { .earth-status-message,
color: #ff4444; .earth-error-message {
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 {
position: absolute; position: absolute;
top: 20px; top: calc(20px * var(--hud-scale));
left: 50%; left: 50%;
transform: translate(-50%, -18px); transform: translate(-50%, -18px);
background:
linear-gradient(180deg, rgba(18, 31, 52, 0.92), rgba(8, 18, 32, 0.9));
border-radius: 14px;
padding: 11px 15px;
z-index: 210;
box-shadow: var(--hud-shadow-soft);
border: 1px solid var(--hud-border);
font-size: 0.9rem;
display: none; display: none;
backdrop-filter: blur(10px); align-items: center;
text-align: center; gap: calc(10px * var(--hud-scale));
min-width: 180px; background:
linear-gradient(90deg, rgba(145, 186, 255, 0.07) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-radius: 999px;
border: 1px solid var(--hud-border);
border-left-color: rgba(145, 186, 255, 0.32);
padding: calc(8px * var(--hud-scale)) calc(20px * var(--hud-scale)) calc(8px * var(--hud-scale)) calc(16px * var(--hud-scale));
z-index: 210;
box-shadow:
var(--hud-shadow-soft),
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);
text-align: left;
min-width: min(calc(160px * var(--hud-scale)), 58vw);
max-width: min(calc(440px * var(--hud-scale)), 74vw);
color: var(--hud-text);
opacity: 0; opacity: 0;
transition: transition:
transform 0.28s ease, transform 0.28s ease,
opacity 0.28s ease; opacity 0.28s ease;
} }
.earth-status-message.visible { .earth-status-message.visible,
.earth-error-message.visible {
transform: translate(-50%, 0); transform: translate(-50%, 0);
opacity: 1; 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 {
width: calc(7px * var(--hud-scale));
height: calc(7px * var(--hud-scale));
border-radius: 50%;
background: rgba(145, 186, 255, 0.95);
box-shadow:
0 0 8px rgba(145, 186, 255, 0.7),
0 0 20px rgba(145, 186, 255, 0.28);
animation: statusDotPulse 2.4s ease-in-out infinite;
}
.earth-status-text {
display: inline-flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
}
/* Loading: three-dot sequential pulse */
.earth-status-message.loading .earth-status-dot {
animation: earthLoadingPulse 1.2s ease-in-out infinite;
}
.earth-status-message.loading .earth-status-dot:nth-child(2) {
animation-delay: 0.16s;
}
.earth-status-message.loading .earth-status-dot:nth-child(3) {
animation-delay: 0.32s;
}
/* ── Status color variants ───────────────────────────────────── */
.earth-status-message.success { .earth-status-message.success {
color: #d8f7df; color: #dff8e7;
border-left: 3px solid #66d18f; background:
linear-gradient(90deg, rgba(102, 209, 143, 0.08) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-left-color: rgba(102, 209, 143, 0.38);
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(102, 209, 143, 0.06);
}
.earth-status-message.success .earth-status-dot {
background: #66d18f;
box-shadow:
0 0 8px rgba(102, 209, 143, 0.8),
0 0 20px rgba(102, 209, 143, 0.3);
} }
.earth-status-message.warning { .earth-status-message.warning {
color: #fff2c3; color: #fff2c3;
border-left: 3px solid #e4c464; background:
linear-gradient(90deg, rgba(228, 196, 100, 0.08) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-left-color: rgba(228, 196, 100, 0.38);
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(228, 196, 100, 0.06);
}
.earth-status-message.warning .earth-status-dot {
background: #e4c464;
box-shadow:
0 0 8px rgba(228, 196, 100, 0.8),
0 0 20px rgba(228, 196, 100, 0.3);
} }
.earth-status-message.error { .earth-status-message.error {
color: #ffd4d7; color: #ffd4d7;
border-left: 3px solid #ff7b86; background:
linear-gradient(90deg, rgba(255, 123, 134, 0.08) 0%, transparent 44%),
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
border-left-color: rgba(255, 123, 134, 0.38);
box-shadow:
var(--hud-shadow-soft),
0 0 18px rgba(255, 123, 134, 0.06);
}
.earth-status-message.error .earth-status-dot {
background: #ff7b86;
box-shadow:
0 0 8px rgba(255, 123, 134, 0.8),
0 0 20px rgba(255, 123, 134, 0.34);
}
@keyframes statusDotPulse {
0%, 100% {
opacity: 0.82;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1);
}
} }
.earth-tooltip { .earth-tooltip {
@@ -304,11 +457,21 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 260; 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 { .earth-settings-modal.is-open {
display: block; opacity: 1;
pointer-events: auto;
} }
.earth-settings-backdrop { .earth-settings-backdrop {
@@ -317,46 +480,47 @@
background: rgba(2, 8, 20, 0.46); background: rgba(2, 8, 20, 0.46);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-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 { .earth-settings-sheet {
--settings-scale: clamp(0.72, calc(var(--hud-scale) * 0.96), 1);
position: fixed; position: fixed;
top: max(32px, 9vh); top: max(calc(32px * var(--settings-scale)), 9vh);
right: 16px; right: calc(16px * var(--settings-scale));
left: 16px; left: calc(16px * var(--settings-scale));
width: min(560px, calc(100vw - 32px)); width: min(calc(560px * var(--settings-scale)), calc(100vw - (32px * var(--settings-scale))));
max-width: 560px; max-width: calc(560px * var(--settings-scale));
max-height: calc(100vh - max(64px, 18vh)); max-height: calc(100vh - max(calc(64px * var(--settings-scale)), 18vh));
margin-inline: auto; margin-inline: auto;
transform: none; transform: none;
border-radius: calc(24px * var(--hud-scale)); border-radius: 0;
padding: calc(20px * var(--hud-scale)); padding: calc(var(--hud-panel-padding) * var(--settings-scale));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hud-gap-md); gap: calc(var(--hud-gap-md) * var(--settings-scale));
overflow: hidden; 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 { .earth-settings-sheet.hud-panel {
animation: none; --panel-glow-x: 18%;
background: --panel-glow-y: 0%;
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.09), transparent 30%), --panel-glow-opacity: 0.1;
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);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08), inset 0 1px 0 var(--hud-highlight),
0 24px 56px rgba(2, 7, 15, 0.4), inset 0 -1px 0 rgba(255, 255, 255, 0.03),
0 0 0 1px rgba(255, 255, 255, 0.025); var(--hud-shadow),
} 0 0 0 1px rgba(255, 255, 255, 0.02);
.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;
} }
.earth-settings-header, .earth-settings-header,
@@ -366,40 +530,22 @@
} }
.earth-settings-header { .earth-settings-header {
display: flex; --hud-header-padding: 0 0 var(--hud-gap-sm);
align-items: flex-start; --hud-header-gap: var(--hud-gap-md);
justify-content: space-between; align-items: center;
gap: var(--hud-gap-md); gap: var(--hud-gap-md);
padding-bottom: var(--hud-gap-sm);
border-bottom: 1px solid var(--hud-line);
} }
.earth-settings-kicker { .earth-settings-kicker {
color: var(--hud-text-soft); color: var(--hud-text-soft);
font-size: var(--hud-kicker-size); font-size: var(--hud-panel-header-title-size);
letter-spacing: 0.16em; letter-spacing: 0.16em;
text-transform: uppercase; 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 { .earth-settings-close {
margin-top: 4px; margin-top: 0;
align-self: auto; flex-shrink: 0;
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));
} }
.earth-settings-content { .earth-settings-content {
@@ -464,6 +610,119 @@
transform: translateY(-1px); 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 { .earth-settings-copy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -482,6 +741,22 @@
line-height: 1.4; 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 { .earth-settings-switch {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -541,4 +816,4 @@
/* .hud-panel-legend layout-expanded rule lives in legend.css */ /* .hud-panel-legend layout-expanded rule lives in legend.css */
/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */ /* .hud-panel-stats layout-expanded rule lives in earth-stats.css */
/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */ /* .hud-panel-layers layout-expanded rule lives in layer-panel.css */
/* .hud-panel-tv layout-expanded rule lives in tv-panel.css */ /* .hud-panel-media layout-expanded rule lives in tv-panel.css */

View File

@@ -26,13 +26,23 @@
.hud-panel-brand { .hud-panel-brand {
--brand-scale: 0.88; --brand-scale: 0.88;
--brand-copy-width: 160px; --brand-copy-width: 160px;
border-radius: 0; padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
/* Reserve full panel height before brand images load */ /* Reserve full panel height before brand images load */
min-height: calc(66px * var(--hud-scale)); min-height: calc(66px * var(--hud-scale));
background: transparent;
border: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
isolation: isolate;
}
.hud-panel-brand::before,
.hud-panel-brand::after {
display: none;
} }
.hud-panel-brand .earth-brand { .hud-panel-brand .earth-brand {
@@ -40,6 +50,20 @@
align-items: center; align-items: center;
gap: calc(10px * var(--hud-scale) * var(--brand-scale)); gap: calc(10px * var(--hud-scale) * var(--brand-scale));
min-width: 0; min-width: 0;
position: relative;
}
.hud-panel-brand .earth-brand::after {
content: "";
position: absolute;
inset: calc(4px * var(--hud-scale)) calc(-10px * var(--hud-scale)) calc(6px * var(--hud-scale)) calc(-10px * var(--hud-scale));
background:
radial-gradient(circle at 18% 50%, rgba(145, 186, 255, 0.14), transparent 32%),
linear-gradient(90deg, rgba(145, 186, 255, 0.05), transparent 58%);
opacity: 0.75;
pointer-events: none;
filter: blur(14px);
z-index: -1;
} }
.hud-panel-brand .earth-brand__logo { .hud-panel-brand .earth-brand__logo {
@@ -137,6 +161,76 @@
pointer-events: auto; 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 {
animation: cruiseConnectorNodeIn 0.22s ease forwards;
animation-delay: 0.22s;
opacity: 0;
}
@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 ────────────────────────────────────────────────── */
.info-card { .info-card {

View File

@@ -3,21 +3,10 @@
.news-panel-title-row { .news-panel-title-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: calc(8px * var(--hud-scale));
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
} }
.news-panel-title {
margin: 0;
color: var(--hud-text);
font-size: calc(0.92rem * var(--hud-scale));
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.news-region-chip { .news-region-chip {
--news-accent: #d6e6ff; --news-accent: #d6e6ff;
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent); border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);

View File

@@ -1,4 +1,4 @@
/* toolbar.css - bottom dock and floating toolbar primitives */ /* toolbar.css - orbital hub toolbar */
.earth-toolbar-group { .earth-toolbar-group {
position: absolute; position: absolute;
@@ -6,7 +6,6 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 200;
@@ -24,119 +23,153 @@
} }
.earth-toolbar { .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; position: relative;
width: min(620px, calc(100vw - 40px));
height: calc(200px * var(--toolbar-scale));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0;
background: transparent; background: transparent;
border: none; border: none;
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
} }
.earth-toolbar-items { .earth-toolbar-cluster {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
.earth-toolbar-popover {
position: relative; position: relative;
} width: 100%;
height: 100%;
.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;
pointer-events: none; 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; position: relative;
width: 28px; width: 28px;
height: 28px; height: 28px;
border: none; border: none;
border-radius: 0;
background: transparent; background: transparent;
color: #4db8ff; color: var(--hud-text-soft);
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow: visible;
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
} }
.earth-toolbar-btn.floating-btn { .earth-toolbar-btn.floating-btn {
width: 42px; width: var(--toolbar-orb-size);
height: 42px; height: var(--toolbar-orb-size);
min-width: 42px; min-width: var(--toolbar-orb-size);
min-height: 42px; min-height: var(--toolbar-orb-size);
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
} }
.earth-toolbar-btn:not(.liquid-glass-surface)::after { .earth-toolbar-hub-btn {
content: none; 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 { .earth-toolbar-btn .icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative;
z-index: 1;
transform: translateZ(0);
transition: transform 0.16s ease, opacity 0.16s ease; transition: transform 0.16s ease, opacity 0.16s ease;
backface-visibility: hidden; backface-visibility: hidden;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
line-height: 1;
} }
.earth-toolbar-btn svg { .earth-toolbar-btn .material-symbols-rounded,
width: 20px; .earth-toolbar-hub-btn .material-symbols-rounded {
height: 20px; font-size: calc(21px * var(--toolbar-scale));
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.earth-toolbar-btn .material-symbols-rounded {
font-size: 21px;
line-height: 1; line-height: 1;
font-variation-settings: font-variation-settings:
'FILL' 0, 'FILL' 0,
@@ -154,28 +187,6 @@
-moz-osx-font-smoothing: grayscale; -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 { .liquid-glass-surface {
--elastic-x: 0px; --elastic-x: 0px;
--elastic-y: 0px; --elastic-y: 0px;
@@ -190,12 +201,14 @@
position: relative; position: relative;
isolation: isolate; isolation: isolate;
transform-style: preserve-3d; transform-style: preserve-3d;
transform-origin: center center;
will-change: transform, box-shadow;
overflow: hidden; overflow: hidden;
background: background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%), 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.08), transparent 30%), radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.05), transparent 30%),
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)), linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)),
rgba(8, 20, 38, 0.22); rgba(8, 20, 38, 0.12);
border: 1px solid var(--hud-border); border: 1px solid var(--hud-border);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.14),
@@ -205,7 +218,11 @@
backdrop-filter: blur(18px) saturate(145%); backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%); -webkit-backdrop-filter: blur(18px) saturate(145%);
transform: 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)); scale(var(--btn-scale));
transition: transition:
transform 0.22s ease, transform 0.22s ease,
@@ -213,17 +230,16 @@
background 0.22s ease, background 0.22s ease,
opacity 0.18s ease, opacity 0.18s ease,
border-color 0.22s ease; border-color 0.22s ease;
animation: floatDock 3.8s ease-in-out infinite;
} }
.liquid-glass-surface::before { .liquid-glass-surface::before {
content: ''; content: "";
position: absolute; position: absolute;
inset: 1px 1px 18px 1px; inset: 1px 1px 18px 1px;
border-radius: inherit; border-radius: inherit;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%); linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04) 28%, transparent 68%);
opacity: 0.5; opacity: 0.42;
pointer-events: none; pointer-events: none;
transform: transform:
perspective(120px) perspective(120px)
@@ -234,14 +250,14 @@
} }
.liquid-glass-surface::after { .liquid-glass-surface::after {
content: ''; content: "";
position: absolute; position: absolute;
inset: -1px; inset: -1px;
padding: 1.35px; padding: 1.35px;
border-radius: inherit; border-radius: inherit;
background: 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)); 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; pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px); filter: url(#liquid-glass-distortion) blur(0.35px);
transform: transform:
@@ -260,15 +276,28 @@
transition: opacity 0.18s ease, transform 0.18s ease; 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 { .liquid-glass-surface:hover {
--btn-scale: 1.035; --btn-scale: 1.04;
--press-offset: -1px; --press-offset: -1px;
--glow-opacity: 0.32; --glow-opacity: 0.32;
background: background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%), 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.1), transparent 30%), radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)), linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(128, 198, 255, 0.06)),
rgba(8, 20, 38, 0.2); rgba(8, 20, 38, 0.14);
border-color: var(--hud-border-hover); border-color: var(--hud-border-hover);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2),
@@ -289,52 +318,16 @@
.liquid-glass-surface:active, .liquid-glass-surface:active,
.liquid-glass-surface.is-pressed { .liquid-glass-surface.is-pressed {
--btn-scale: 0.942; --btn-scale: 0.95;
--press-offset: 2px; --press-offset: 2px;
--glow-opacity: 0.2; --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 { .liquid-glass-surface.active {
background: background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%), 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.2), rgba(118, 200, 255, 0.14)), linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)),
rgba(11, 34, 58, 0.26); rgba(11, 34, 58, 0.18);
border-color: var(--hud-border-active); border-color: var(--hud-border-active);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.22),
@@ -357,137 +350,50 @@
.earth-zoom-group:hover > .earth-zoom-toolbar, .earth-zoom-group:hover > .earth-zoom-toolbar,
.earth-zoom-group:focus-within > .earth-zoom-toolbar, .earth-zoom-group:focus-within > .earth-zoom-toolbar,
.earth-zoom-group.open > .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 {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
transform: translate(-50%, 0); transform: translate(-50%, 0);
} }
.earth-zoom-group.force-closed > .earth-zoom-toolbar, .earth-zoom-group.force-closed > .earth-zoom-toolbar {
.earth-info-group.force-closed > .earth-info-toolbar {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
transform: translate(-50%, 8px); transform: translate(-50%, 8px);
} }
.earth-zoom-group > .earth-zoom-toolbar, .earth-toolbar-popover::before {
.earth-info-group > .earth-info-toolbar { 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; top: auto;
right: auto; right: auto;
left: 50%; bottom: calc(100% + (12px * var(--toolbar-scale)));
bottom: calc(100% + 12px); transform: translate(-50%, 10px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-start; gap: calc(8px * var(--toolbar-scale));
gap: 8px; opacity: 0;
} visibility: hidden;
pointer-events: none;
.earth-info-toolbar { transition:
width: min(280px, calc(100vw - 36px)); opacity 0.22s ease,
padding: 12px; transform 0.22s ease,
border-radius: 22px; visibility 0.22s ease;
background: z-index: 220;
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;
} }
.earth-zoom-toolbar .earth-zoom-btn, .earth-zoom-toolbar .earth-zoom-btn,
@@ -495,8 +401,8 @@
width: 42px; width: 42px;
min-width: 42px; min-width: 42px;
border-radius: 50%; border-radius: 50%;
color: #4db8ff; color: var(--hud-text-soft);
animation: floatDock 3.8s ease-in-out infinite; animation: none;
} }
.earth-zoom-toolbar .earth-zoom-btn { .earth-zoom-toolbar .earth-zoom-btn {
@@ -514,37 +420,8 @@
padding: 0; padding: 0;
font-size: 0.68rem; font-size: 0.68rem;
letter-spacing: normal; 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 { .earth-app.layout-expanded .earth-toolbar-group {
bottom: 18px; bottom: 18px;
transform: translateX(-50%); transform: translateX(-50%);
@@ -555,8 +432,9 @@
bottom: 56px; bottom: 56px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95); background:
color: #fff; linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
color: var(--hud-text);
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
@@ -564,9 +442,10 @@
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4); border: 1px solid var(--hud-border);
pointer-events: none; pointer-events: none;
z-index: 100; z-index: 100;
box-shadow: var(--hud-shadow-soft);
} }
.earth-toolbar-btn:hover .earth-toolbar-tooltip, .earth-toolbar-btn:hover .earth-toolbar-tooltip,
@@ -578,11 +457,11 @@
} }
.earth-toolbar-btn .earth-toolbar-tooltip::after { .earth-toolbar-btn .earth-toolbar-tooltip::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
border: 6px solid transparent; border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4); border-top-color: rgba(18, 31, 52, 0.96);
} }

View File

@@ -1,6 +1,10 @@
/* tv-panel */ /* media-panel
* Outer HUD shell: #media-panel
* Inner live pane: #tv-panel
* Inner news pane: #news-panel
*/
.hud-panel-tv { .hud-panel-media {
bottom: var(--hud-offset); bottom: var(--hud-offset);
right: var(--hud-offset); right: var(--hud-offset);
width: calc(420px * var(--hud-scale)); width: calc(420px * var(--hud-scale));
@@ -14,39 +18,44 @@
z-index: 18; z-index: 18;
} }
.hud-panel-tv[data-active-tab="news"]:not([data-resized="true"]) { .hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) {
max-height: min( max-height: min(
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))), var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
calc(100vh - (2 * var(--hud-offset))) calc(100vh - (2 * var(--hud-offset)))
); );
} }
.hud-panel-tv.is-reforming { .hud-panel-media.is-reforming {
transition: transition:
height 0.24s cubic-bezier(0.22, 1, 0.36, 1), height 0.24s cubic-bezier(0.22, 1, 0.36, 1),
top 0.24s cubic-bezier(0.22, 1, 0.36, 1); top 0.24s cubic-bezier(0.22, 1, 0.36, 1);
will-change: height, top; will-change: height, top;
} }
.hud-panel-tv .hud-panel__header { .hud-panel-media .hud-panel__header {
align-items: center; align-items: center;
gap: var(--hud-gap-xs); gap: var(--hud-gap-xs);
} }
.hud-panel-media .hud-panel__title-group {
flex: 0 0 auto;
min-width: auto;
}
.tv-panel-header-title { .tv-panel-header-title {
flex: 0 0 auto; flex: 0 0 auto;
white-space: nowrap; white-space: nowrap;
margin: 0; margin: 0;
} }
.hud-panel-tv .hud-panel__header .hud-panel__action, .hud-panel-media .hud-panel__header .hud-panel__action,
.hud-panel-tv .hud-panel__header .hud-panel-close { .hud-panel-media .hud-panel__header .hud-panel-close {
cursor: pointer; cursor: pointer;
user-select: auto; user-select: auto;
} }
.hud-panel-tv .hud-panel__header .tv-panel-select, .hud-panel-media .hud-panel__header .tv-panel-select,
.hud-panel-tv .hud-panel__header .tv-panel-tab { .hud-panel-media .hud-panel__header .media-panel-tab {
cursor: pointer; cursor: pointer;
user-select: auto; user-select: auto;
} }
@@ -124,8 +133,8 @@
max-height: 0; max-height: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
margin-top: calc(-1 * var(--hud-gap-sm)); margin-top: 0;
margin-bottom: calc(-1 * var(--hud-gap-sm)); margin-bottom: 0;
} }
.tv-panel-meta { .tv-panel-meta {
@@ -160,7 +169,7 @@
.tv-panel-player { .tv-panel-player {
position: relative; position: relative;
flex: 1 0 auto; flex: 1 1 auto;
min-height: calc(220px * var(--hud-scale)); min-height: calc(220px * var(--hud-scale));
border-radius: calc(16px * var(--hud-scale)); border-radius: calc(16px * var(--hud-scale));
overflow: hidden; overflow: hidden;
@@ -194,13 +203,13 @@
background: #050a14; background: #050a14;
} }
.tv-panel-tabs { .media-panel-tabs {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: calc(8px * var(--hud-scale)); gap: calc(8px * var(--hud-scale));
} }
.tv-panel-tab { .media-panel-tab {
border: 1px solid rgba(201, 225, 247, 0.12); border: 1px solid rgba(201, 225, 247, 0.12);
border-radius: calc(12px * var(--hud-scale)); border-radius: calc(12px * var(--hud-scale));
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
@@ -213,13 +222,13 @@
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
} }
.tv-panel-tab:hover { .media-panel-tab:hover {
color: var(--hud-text); color: var(--hud-text);
border-color: rgba(214, 235, 255, 0.18); border-color: rgba(214, 235, 255, 0.18);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.tv-panel-tab--active { .media-panel-tab--active {
color: var(--hud-accent-strong); color: var(--hud-accent-strong);
border-color: rgba(120, 180, 255, 0.24); border-color: rgba(120, 180, 255, 0.24);
background: rgba(120, 180, 255, 0.12); background: rgba(120, 180, 255, 0.12);
@@ -290,35 +299,35 @@
border-color: rgba(244, 249, 255, 0.75); border-color: rgba(244, 249, 255, 0.75);
} }
.hud-panel-tv.is-resizing { .hud-panel-media.is-resizing {
transition: none !important; transition: none !important;
user-select: none; user-select: none;
} }
/* Whole panel is draggable; player overrides back to default */ /* Whole panel is draggable; player overrides back to default */
.hud-panel-tv:not(.is-resizing) { .hud-panel-media:not(.is-resizing) {
cursor: grab; cursor: grab;
} }
.hud-panel-tv.is-dragging { .hud-panel-media.is-dragging {
cursor: grabbing; cursor: grabbing;
} }
.hud-panel-tv .tv-panel-player { .hud-panel-media .tv-panel-player {
cursor: default; cursor: default;
} }
/* Disable iframe/video pointer capture while dragging so mouse events pass through */ /* Disable iframe/video pointer capture while dragging so mouse events pass through */
.hud-panel-tv.is-dragging .tv-panel-iframe, .hud-panel-media.is-dragging .tv-panel-iframe,
.hud-panel-tv.is-dragging .tv-panel-video { .hud-panel-media.is-dragging .tv-panel-video {
pointer-events: none; pointer-events: none;
} }
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) { .earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
bottom: var(--hud-offset); bottom: var(--hud-offset);
right: var(--hud-offset); right: var(--hud-offset);
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset))); transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
} }
/* TV panel keeps its fixed width on all screen sizes. /* Media panel keeps its fixed width on all screen sizes.
Responsive stretching removed — width only changes if user manually resizes. */ Responsive stretching removed — width only changes if user manually resizes. */

View File

@@ -10,7 +10,8 @@
"three": "https://esm.sh/three@0.128.0", "three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1", "simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0", "satellite.js": "https://esm.sh/satellite.js@5.0.0",
"hls.js": "https://esm.sh/hls.js@1.6.15" "hls.js": "https://esm.sh/hls.js@1.6.15",
"astronomy-engine": "https://esm.sh/astronomy-engine@2.1.19"
} }
} }
</script> </script>
@@ -64,6 +65,9 @@
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠"> <button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
<span class="material-symbols-rounded">expand_less</span> <span class="material-symbols-rounded">expand_less</span>
</button> </button>
<button class="layer-panel-btn hud-panel-close" type="button" data-close-panel="layer-toggles" aria-label="关闭图层面板" title="关闭">
<span class="material-symbols-rounded">close</span>
</button>
</div> </div>
<!-- Collapsible body --> <!-- Collapsible body -->
@@ -146,45 +150,47 @@
</div> </div>
</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="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar"> <div id="control-toolbar" class="earth-toolbar">
<div class="earth-toolbar-items"> <div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded">
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)"> <div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
<span class="icon" aria-hidden="true"> <button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
<span class="material-symbols-rounded">search</span> <span class="icon" aria-hidden="true">
</span> <span class="material-symbols-rounded">search</span>
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span> </span>
</button> <span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转"> </button>
<span class="icon rotate-icon icon-pause" aria-hidden="true"> </div>
<span class="material-symbols-rounded">pause</span> <div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
</span> <button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
<span class="icon rotate-icon icon-play" aria-hidden="true"> <span class="icon rotate-icon icon-pause" aria-hidden="true">
<span class="material-symbols-rounded">play_arrow</span> <span class="material-symbols-rounded">pause</span>
</span> </span>
<span class="tooltip earth-toolbar-tooltip">自动旋转</span> <span class="icon rotate-icon icon-play" aria-hidden="true">
</button> <span class="material-symbols-rounded">play_arrow</span>
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播"> </span>
<span class="icon" aria-hidden="true"> <span class="tooltip earth-toolbar-tooltip">自动旋转</span>
<span class="material-symbols-rounded">live_tv</span> </button>
</span> </div>
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span> <div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
</button> <button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻"> <span class="icon" aria-hidden="true">
<span class="icon" aria-hidden="true"> <span class="material-symbols-rounded">live_tv</span>
<span class="material-symbols-rounded">newspaper</span> </span>
</span> <span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span> </button>
</button> </div>
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据"> <div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
<span class="icon" aria-hidden="true"> <button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
<span class="material-symbols-rounded">refresh</span> <span class="icon" aria-hidden="true">
</span> <span class="material-symbols-rounded">refresh</span>
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span> </span>
</button> <span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
<div id="zoom-control-group" class="earth-toolbar-popover earth-zoom-group"> </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="缩放控制"> <button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
<span class="icon" aria-hidden="true"> <span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">zoom_in</span> <span class="material-symbols-rounded">zoom_in</span>
@@ -197,27 +203,38 @@
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button> <button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button>
</div> </div>
</div> </div>
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置"> <div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
<span class="icon" aria-hidden="true"> <button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
<span class="material-symbols-rounded">settings</span> <span class="icon" aria-hidden="true">
</span> <span class="material-symbols-rounded">settings</span>
<span class="tooltip earth-toolbar-tooltip">设置</span> </span>
</button> <span class="tooltip earth-toolbar-tooltip">设置</span>
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角"> </button>
<span class="icon" aria-hidden="true"> </div>
<span class="material-symbols-rounded">my_location</span> <div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
</span> <button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
<span class="tooltip earth-toolbar-tooltip">重置视角</span> <span class="icon" aria-hidden="true">
</button> <span class="material-symbols-rounded">my_location</span>
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局"> </span>
<span class="icon layout-icon layout-expand" aria-hidden="true"> <span class="tooltip earth-toolbar-tooltip">重置视角</span>
<span class="material-symbols-rounded">open_in_full</span> </button>
</span> </div>
<span class="icon layout-icon layout-collapse" aria-hidden="true"> <div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
<span class="material-symbols-rounded">close_fullscreen</span> <button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
</span> <span class="icon layout-icon layout-expand" aria-hidden="true">
<span class="tooltip earth-toolbar-tooltip">最大化布局</span> <span class="material-symbols-rounded">open_in_full</span>
</button> </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> </div>
</div> </div>
@@ -294,7 +311,7 @@
<span id="camera-distance" hidden></span> <span id="camera-distance" hidden></span>
</div> </div>
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true"> <div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-panel" data-drag-self="true">
<div class="hud-panel__header hud-panel-drag-handle"> <div class="hud-panel__header hud-panel-drag-handle">
<div class="hud-panel__title-group"> <div class="hud-panel__title-group">
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span> <span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
@@ -315,7 +332,6 @@
</div> </div>
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden> <div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
<div class="news-panel-title-row"> <div class="news-panel-title-row">
<span class="news-panel-title">全球态势聚合</span>
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span> <span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
</div> </div>
<div class="tv-panel-toolbar-actions"> <div class="tv-panel-toolbar-actions">
@@ -328,14 +344,14 @@
</div> </div>
</div> </div>
<div class="hud-panel__actions"> <div class="hud-panel__actions">
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="tv-panel" aria-label="关闭媒体情报面板"> <button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭媒体情报面板">
<span class="material-symbols-rounded">close</span> <span class="material-symbols-rounded">close</span>
</button> </button>
</div> </div>
</div> </div>
<div class="tv-panel-content"> <div class="tv-panel-content">
<section id="tv-tab-live-pane" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live"> <section id="tv-panel" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
<div class="tv-panel-meta-wrap" id="tv-meta-wrap"> <div class="tv-panel-meta-wrap" id="tv-meta-wrap">
<div class="tv-panel-meta" id="tv-panel-meta"> <div class="tv-panel-meta" id="tv-panel-meta">
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span> <span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
@@ -360,7 +376,7 @@
</div> </div>
</section> </section>
<section id="tv-tab-news-pane" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden> <section id="news-panel" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
<div id="news-panel-body" class="news-panel-body"> <div id="news-panel-body" class="news-panel-body">
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div> <div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
@@ -383,9 +399,9 @@
</section> </section>
</div> </div>
<div class="tv-panel-tabs" role="tablist" aria-label="媒体情报切换"> <div class="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
<button id="tv-tab-live" class="tv-panel-tab tv-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-tab-live-pane">直播</button> <button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
<button id="tv-tab-news" class="tv-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="tv-tab-news-pane">聚合</button> <button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
</div> </div>
<div class="tv-panel-edge" data-edge="r"></div> <div class="tv-panel-edge" data-edge="r"></div>
<div class="tv-panel-edge" data-edge="b"></div> <div class="tv-panel-edge" data-edge="b"></div>
@@ -394,26 +410,49 @@
<div class="tv-panel-edge" data-edge="bl"></div> <div class="tv-panel-edge" data-edge="bl"></div>
</div> </div>
<div id="loading" class="earth-loading"> <div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
<div id="loading-spinner" class="earth-loading-spinner"></div>
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
<div id="loading-subtitle" class="earth-loading-subtitle">同步卫星、海底光缆、登陆点与BGP态势数据</div>
</div>
<div id="status-message" class="earth-status-message"></div>
<div id="tooltip" class="earth-tooltip"></div> <div id="tooltip" class="earth-tooltip"></div>
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true"> <div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
<div id="settings-backdrop" class="earth-settings-backdrop"></div> <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-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
<div class="earth-settings-header"> <div class="earth-settings-header hud-panel__header">
<div> <div class="hud-panel__title-group">
<div class="earth-settings-kicker">设置</div> <div class="earth-settings-kicker">设置</div>
<h3 id="settings-title" class="earth-settings-title hud-panel-title">显示与视图</h3>
</div> </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> <span class="material-symbols-rounded">close</span>
</button> </button>
</div> </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"> <section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div> <div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list"> <div class="earth-settings-list">
@@ -449,16 +488,60 @@
</label> </label>
<label class="earth-settings-item" for="toggle-view-tv"> <label class="earth-settings-item" for="toggle-view-tv">
<div class="earth-settings-copy"> <div class="earth-settings-copy">
<span class="earth-settings-item-title">电视直播</span> <span class="earth-settings-item-title">新闻直播</span>
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span> <span class="earth-settings-item-subtitle">控制电视直播 / 态势聚合显示</span>
</div> </div>
<span class="earth-settings-switch"> <span class="earth-settings-switch">
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel"> <input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
<span class="earth-settings-switch-track"></span> <span class="earth-settings-switch-track"></span>
</span> </span>
</label> </label>
</div> </div>
</section> </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> </div>
</div> </div>

View File

@@ -156,13 +156,14 @@ function drawExclamationSymbol(context) {
} }
function drawWaveSymbol(context) { function drawWaveSymbol(context) {
context.lineWidth = 12;
context.lineCap = "round";
context.beginPath(); context.beginPath();
context.moveTo(18, 76); context.moveTo(14, 100);
context.bezierCurveTo(34, 46, 46, 46, 64, 76); context.lineTo(38, 26);
context.bezierCurveTo(80, 106, 94, 106, 110, 76); context.lineTo(64, 100);
context.stroke(); context.lineTo(90, 26);
context.lineTo(114, 100);
context.closePath();
context.fill();
} }
function drawBurstSymbol(context) { function drawBurstSymbol(context) {
@@ -1286,8 +1287,6 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
} }
export async function loadBGPAnomalies(scene, earth) { export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi); const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
if (!collectorsResponse.ok) { if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`); throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
@@ -1312,6 +1311,9 @@ export async function loadBGPAnomalies(scene, earth) {
? collectorsPayload.features ? collectorsPayload.features
: []; : [];
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload); const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
clearBGPData(earth);
totalAnomalyCount = selectedEventData.totalAnomalyCount; totalAnomalyCount = selectedEventData.totalAnomalyCount;
totalIncidentCount = selectedEventData.totalIncidentCount; totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear(); 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(); const now = performance.now();
updateCollectorOverlayScan(lockedObjectType, lockedObject); updateCollectorOverlayScan(lockedObjectType, lockedObject);
const hasLockedLayer = Boolean( const hasLockedLayer = Boolean(
@@ -1386,21 +1388,17 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
if (isLocked) { if (isLocked) {
scale *= 1.1 + 0.14 * pulse; scale *= 1.1 + 0.14 * pulse;
opacity = BGP_CONFIG.opacity.collectorHover; opacity = 0.96;
haloOpacity = 0.022; haloOpacity = 0.05;
pulseOpacity = 0.012; pulseOpacity = 0.024;
coverageOpacity = 0.02; coverageOpacity = 0.036;
markerColor = blendHexColors( markerColor = 0xcff2ff;
BGP_CONFIG.collectorIcon.lockedNeutralColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.collectorIcon.lockedBlend,
);
} else if (isHovered) { } else if (isHovered) {
scale *= 1.08; scale *= 1.08;
opacity = BGP_CONFIG.opacity.collectorHover; opacity = 0.88;
haloOpacity = 0.016; haloOpacity = 0.03;
pulseOpacity = 0.008; pulseOpacity = 0.014;
coverageOpacity = 0.014; coverageOpacity = 0.02;
markerColor = blendHexColors( markerColor = blendHexColors(
BGP_CONFIG.collectorIcon.hoverNeutralColor, BGP_CONFIG.collectorIcon.hoverNeutralColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor, marker.userData.baseColor || BGP_CONFIG.collectorColor,
@@ -1463,7 +1461,10 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
const isLinkedCollectorLocked = const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" && lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.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 isHovered = marker.userData.state === "hover";
const pulse = const pulse =
0.5 + 0.5 +
@@ -1481,17 +1482,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
if (isLocked || isLinkedCollectorLocked) { if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = opacity = 0.9 + 0.1 * pulse;
BGP_CONFIG.opacity.lockedMin + markerColor = 0xfff1a8;
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse; ringBaseOpacity *= 1.2;
} else if (isCruise) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
ringBaseOpacity *= 1.2; ringBaseOpacity *= 1.2;
} else if (isHovered) { } else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale; scale *= BGP_CONFIG.marker.hoverScale;
opacity = BGP_CONFIG.opacity.hover; opacity = 0.9;
ringBaseOpacity *= 1.05; ringBaseOpacity *= 1.05;
} else if (isOtherLocked) { } else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale; scale *= BGP_CONFIG.marker.dimmedScale;
opacity = 0.1; opacity = 0.22;
markerColor = 0x7d8ca3; markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02; ringBaseOpacity = 0.02;
} else { } else {
@@ -1503,6 +1507,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
marker.material.color.setHex(markerColor); marker.material.color.setHex(markerColor);
marker.material.opacity = opacity; marker.material.opacity = opacity;
marker.visible = showBGP; marker.visible = showBGP;
marker.renderOrder = isActive ? 7 : 3;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1; const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => { const applyRingState = (ring, phase, maxScale) => {

View File

@@ -20,6 +20,7 @@ export let lockedCable = null;
let cableIdMap = new Map(); let cableIdMap = new Map();
let cableStates = new Map(); let cableStates = new Map();
let cablesVisible = true; let cablesVisible = true;
let landingPointGeometry = null;
const landingPointWorldPosition = new THREE.Vector3(); const landingPointWorldPosition = new THREE.Vector3();
function clamp(value, min, max) { function clamp(value, min, max) {
@@ -72,7 +73,7 @@ function disposeObject(object, parent) {
if (owner) { if (owner) {
owner.remove(object); owner.remove(object);
} }
if (object.geometry) { if (object.geometry && !object.userData?.sharedGeometry) {
object.geometry.dispose(); object.geometry.dispose();
} }
if (object.material) { if (object.material) {
@@ -245,9 +246,12 @@ export function clearCableData(earthObj = null) {
clearLandingPoints(earthObj); clearLandingPoints(earthObj);
} }
export async function loadGeoJSONFromPath(scene, earthObj) { export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载电缆数据..."); console.log("正在加载电缆数据...");
showStatusMessage("正在加载电缆数据...", "warning"); if (!silent) {
showStatusMessage("正在加载电缆数据...", "warning");
}
const response = await fetch(PATHS.cablesApi); const response = await fetch(PATHS.cablesApi);
if (!response.ok) { if (!response.ok) {
@@ -344,11 +348,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
textureQuality: "8K 卫星图", textureQuality: "8K 卫星图",
}); });
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success"); if (!silent) {
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
}
return cableLines.length; return cableLines.length;
} }
export async function loadLandingPoints(scene, earthObj) { export async function loadLandingPoints(scene, earthObj, options = {}) {
const { silent = false } = options;
console.log("正在加载登陆点数据..."); console.log("正在加载登陆点数据...");
const response = await fetch(PATHS.landingPointsApi); const response = await fetch(PATHS.landingPointsApi);
@@ -363,70 +370,69 @@ export async function loadLandingPoints(scene, earthObj) {
clearLandingPoints(earthObj); clearLandingPoints(earthObj);
const sphereGeometry = new THREE.SphereGeometry( if (!landingPointGeometry) {
CABLE_CONFIG.landingPoint.radius, landingPointGeometry = new THREE.SphereGeometry(
CABLE_CONFIG.landingPoint.widthSegments, CABLE_CONFIG.landingPoint.radius,
CABLE_CONFIG.landingPoint.heightSegments, CABLE_CONFIG.landingPoint.widthSegments,
); CABLE_CONFIG.landingPoint.heightSegments,
);
}
let validCount = 0; let validCount = 0;
try { for (const feature of data.features) {
for (const feature of data.features) { if (!feature.geometry || !feature.geometry.coordinates) continue;
if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates; const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {}; const properties = feature.properties || {};
if ( if (
typeof lon !== "number" || typeof lon !== "number" ||
typeof lat !== "number" || typeof lat !== "number" ||
Number.isNaN(lon) || Number.isNaN(lon) ||
Number.isNaN(lat) || Number.isNaN(lat) ||
Math.abs(lat) > 90 || Math.abs(lat) > 90 ||
Math.abs(lon) > 180 Math.abs(lon) > 180
) { ) {
continue; 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++;
} }
} 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"); const landingPointCountEl = document.getElementById("landing-point-count");
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
landingPointCountEl.textContent = validCount + "个"; landingPointCountEl.textContent = validCount + "个";
} }
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success"); if (!silent) {
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
}
return validCount; return validCount;
} }
@@ -558,14 +566,18 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
lp.userData.cableNames.some((name) => relatedNames.includes(name)); lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) { if (isRelated) {
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color); lp.material.color.setHex(0xffd27a);
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive); lp.material.emissive.setHex(0x7a4a00);
lp.material.emissiveIntensity = lp.material.emissiveIntensity =
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase + CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse; 0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
lp.material.opacity = lp.material.opacity =
CABLE_CONFIG.landingPointVisual.related.opacityBase + Math.max(
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse; 0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
);
const distanceScale = getLandingPointDistanceScale(lp, camera); const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
lp.scale.setScalar( lp.scale.setScalar(

View File

@@ -0,0 +1,543 @@
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();
const defaultMoonDirection = new THREE.Vector3(-0.6, 0.45, -0.2).normalize();
let celestialRoot = null;
let skySphere = null;
let brightStarsGroup = null;
let sunSprite = null;
let moonSprite = null;
let sunHaloSprite = null;
let moonHaloSprite = null;
let brightStarTexture = null;
let sunDirection = defaultSunDirection.clone();
let moonDirection = defaultMoonDirection.clone();
let lastUpdatedAt = 0;
let linkedSunLight = null;
let linkedBackLight = null;
let linkedEarth = null;
let brightStarSprites = [];
let celestialRotationQuaternion = new THREE.Quaternion();
let celestialViewQuaternion = new THREE.Quaternion();
let runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
let runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
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");
}
function refreshCelestialOrientation() {
celestialRotationQuaternion.setFromEuler(getCelestialEuler());
refreshCelestialView();
}
function refreshCelestialView() {
if (linkedEarth && runtimeFollowConfig.enabled) {
const followX = runtimeFollowConfig.x
? (linkedEarth.rotation.x - EARTH_CONFIG.tiltRad) * (runtimeFollowConfig.invertX ? -1 : 1)
: 0;
const followY = runtimeFollowConfig.y
? linkedEarth.rotation.y * (runtimeFollowConfig.invertY ? -1 : 1)
: 0;
const followZ = runtimeFollowConfig.z
? linkedEarth.rotation.z * (runtimeFollowConfig.invertZ ? -1 : 1)
: 0;
scratchEuler.set(
followX,
followY,
followZ,
"YXZ",
);
celestialViewQuaternion.setFromEuler(scratchEuler);
} else {
celestialViewQuaternion.identity();
}
if (celestialRoot) {
celestialRoot.quaternion
.copy(celestialRotationQuaternion)
.multiply(celestialViewQuaternion);
}
}
function applyCelestialOrientation(direction) {
return direction
.clone()
.applyQuaternion(celestialRotationQuaternion)
.applyQuaternion(celestialViewQuaternion)
.normalize();
}
function configureTextureEncoding(texture) {
if (!texture) return;
if ("colorSpace" in texture && THREE.SRGBColorSpace) {
texture.colorSpace = THREE.SRGBColorSpace;
} else if ("encoding" in texture && THREE.sRGBEncoding) {
texture.encoding = THREE.sRGBEncoding;
}
}
function createDiscTexture(stops, size = 256) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const gradient = ctx.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2,
);
stops.forEach(([offset, color]) => gradient.addColorStop(offset, color));
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
configureTextureEncoding(texture);
texture.needsUpdate = true;
return texture;
}
function createSprite({ texture, scale, opacity = 1, name, color = 0xffffff }) {
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity,
depthWrite: false,
depthTest: true,
toneMapped: false,
color,
});
const sprite = new THREE.Sprite(material);
sprite.name = name;
sprite.scale.setScalar(scale);
sprite.renderOrder = 100;
return sprite;
}
function createSkySphere() {
const geometry = new THREE.SphereGeometry(
CELESTIAL_CONFIG.skyRadius,
64,
64,
);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.BackSide,
transparent: CELESTIAL_CONFIG.skyOpacity < 1,
opacity: CELESTIAL_CONFIG.skyOpacity,
depthWrite: false,
depthTest: false,
fog: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = "celestial-sky-sphere";
mesh.renderOrder = -1000;
mesh.raycast = () => {};
textureLoader.load(
CELESTIAL_CONFIG.starMapUrl,
(texture) => {
configureTextureEncoding(texture);
material.map = texture;
material.needsUpdate = true;
},
undefined,
() => {
console.warn("Failed to load celestial star map texture");
},
);
return mesh;
}
function geoVectorToWorldDirection(vector) {
return new THREE.Vector3(vector.x, vector.z, vector.y).normalize();
}
function raDecToWorldDirection(raDeg, decDeg) {
const raRad = THREE.MathUtils.degToRad(raDeg);
const decRad = THREE.MathUtils.degToRad(decDeg);
const x = Math.cos(decRad) * Math.cos(raRad);
const y = Math.sin(decRad);
const z = Math.cos(decRad) * Math.sin(raRad);
return new THREE.Vector3(x, z, y).normalize();
}
function colorFromBvIndex(colorIndex) {
if (colorIndex <= -0.2) return 0xa8c9ff;
if (colorIndex <= 0.0) return 0xc8dcff;
if (colorIndex <= 0.3) return 0xf4f7ff;
if (colorIndex <= 0.7) return 0xfff4dc;
if (colorIndex <= 1.1) return 0xffdfb0;
if (colorIndex <= 1.5) return 0xffc47f;
return 0xffa35e;
}
function scaleFromMagnitude(mag) {
const brightness = THREE.MathUtils.clamp(
1 - (mag - (-1.5)) / (CELESTIAL_CONFIG.brightStarMinMag - (-1.5)),
0,
1,
);
return THREE.MathUtils.lerp(
CELESTIAL_CONFIG.brightStarMinScale,
CELESTIAL_CONFIG.brightStarMaxScale,
Math.pow(brightness, 0.72),
);
}
function createBrightStarTexture() {
return createDiscTexture([
[0, "rgba(255,255,255,1)"],
[0.18, "rgba(255,255,255,0.98)"],
[0.46, "rgba(215,230,255,0.42)"],
[1, "rgba(128,168,255,0)"],
], 128);
}
function createBrightStarsGroup() {
const group = new THREE.Group();
group.name = "bright-stars-group";
group.visible = true;
return group;
}
async function loadBrightStars() {
try {
const response = await fetch(CELESTIAL_CONFIG.brightStarsUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const stars = await response.json();
const filteredStars = stars
.filter((star) => Number.isFinite(star?.raDeg) && Number.isFinite(star?.decDeg))
.filter((star) => star.mag <= CELESTIAL_CONFIG.brightStarMinMag)
.sort((a, b) => a.mag - b.mag)
.slice(0, CELESTIAL_CONFIG.brightStarMaxCount);
brightStarTexture = createBrightStarTexture();
brightStarSprites = filteredStars.map((star) => {
const sprite = createSprite({
texture: brightStarTexture,
scale: scaleFromMagnitude(star.mag),
opacity: CELESTIAL_CONFIG.brightStarOpacity,
name: `bright-star-${star.name}`,
color: colorFromBvIndex(star.colorIndex ?? 0.4),
});
sprite.position
.copy(raDecToWorldDirection(star.raDeg, star.decDeg))
.multiplyScalar(CELESTIAL_CONFIG.brightStarDistance);
sprite.userData.star = star;
return sprite;
});
brightStarSprites.forEach((sprite) => brightStarsGroup?.add(sprite));
} catch (error) {
console.warn("Failed to load bright star layer", error);
}
}
function computeBodyDirection(body, date) {
const vector = Astronomy.GeoVector(body, date, false);
return geoVectorToWorldDirection(vector);
}
function updateSpritePositions() {
if (sunSprite) {
sunSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
}
if (sunHaloSprite) {
sunHaloSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
}
if (moonSprite) {
moonSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
}
if (moonHaloSprite) {
moonHaloSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
}
}
function updateLighting() {
const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()),
);
if (linkedSunLight) {
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
linkedSunLight.position
.copy(physicalSunDirection)
.multiplyScalar(CELESTIAL_CONFIG.sunLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(CELESTIAL_CONFIG.backLightColor);
linkedBackLight.intensity = CELESTIAL_CONFIG.backLightIntensity;
linkedBackLight.position
.copy(physicalSunDirection)
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
}
}
function computeCelestialState(date = new Date()) {
sunDirection = computeBodyDirection(Astronomy.Body.Sun, date);
moonDirection = computeBodyDirection(Astronomy.Body.Moon, date);
updateSpritePositions();
updateLighting();
lastUpdatedAt = date.getTime();
}
export function initCelestialLayer(
scene,
{ camera = null, sunLight = null, backLight = null, earth = null } = {},
) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer();
linkedSunLight = sunLight;
linkedBackLight = backLight;
linkedEarth = earth;
celestialRoot = new THREE.Group();
celestialRoot.name = "celestial-root";
celestialRoot.position.set(0, 0, 0);
refreshCelestialOrientation();
skySphere = createSkySphere();
brightStarsGroup = createBrightStarsGroup();
const sunTexture = createDiscTexture([
[0, "rgba(255,255,255,1)"],
[0.08, "rgba(255,251,242,1)"],
[0.22, "rgba(255,242,205,0.98)"],
[0.52, "rgba(255,211,117,0.9)"],
[0.82, "rgba(255,162,54,0.18)"],
[1, "rgba(255,120,32,0)"],
]);
const sunHaloTexture = createDiscTexture([
[0, "rgba(255,245,214,0.9)"],
[0.2, "rgba(255,220,154,0.54)"],
[0.52, "rgba(255,160,72,0.16)"],
[1, "rgba(255,120,32,0)"],
], 512);
const moonTexture = createDiscTexture([
[0, "rgba(255,255,255,0.98)"],
[0.42, "rgba(229,236,248,0.92)"],
[0.76, "rgba(164,178,202,0.34)"],
[1, "rgba(80,92,118,0)"],
]);
const moonHaloTexture = createDiscTexture([
[0, "rgba(226,235,250,0.42)"],
[0.38, "rgba(188,203,230,0.16)"],
[1, "rgba(120,136,170,0)"],
], 384);
sunSprite = createSprite({
texture: sunTexture,
scale: CELESTIAL_CONFIG.sunScale,
name: "sun-sprite",
});
sunHaloSprite = createSprite({
texture: sunHaloTexture,
scale: CELESTIAL_CONFIG.sunHaloScale,
opacity: 0.78,
name: "sun-halo-sprite",
});
moonSprite = createSprite({
texture: moonTexture,
scale: CELESTIAL_CONFIG.moonScale,
opacity: 0.98,
name: "moon-sprite",
});
moonHaloSprite = createSprite({
texture: moonHaloTexture,
scale: CELESTIAL_CONFIG.moonHaloScale,
opacity: 0.52,
name: "moon-halo-sprite",
});
sunHaloSprite.renderOrder = 98;
moonHaloSprite.renderOrder = 98;
celestialRoot.add(skySphere);
celestialRoot.add(brightStarsGroup);
celestialRoot.add(sunHaloSprite);
celestialRoot.add(moonHaloSprite);
celestialRoot.add(sunSprite);
celestialRoot.add(moonSprite);
scene.add(celestialRoot);
computeCelestialState(new Date());
loadBrightStars();
return {
root: celestialRoot,
getSunDirection: () => sunDirection.clone(),
getMoonDirection: () => moonDirection.clone(),
};
}
export function updateCelestialLayer(date = new Date(), camera = null) {
if (!celestialRoot) return;
refreshCelestialView();
const now = date.getTime();
if (!lastUpdatedAt || now - lastUpdatedAt >= CELESTIAL_CONFIG.updateIntervalMs) {
computeCelestialState(date);
} else {
updateLighting();
updateSpritePositions();
}
}
export function getSunDirection() {
return getPhysicalSunDirection(new Date(lastUpdatedAt || Date.now()));
}
export function getMoonDirection() {
return applyCelestialOrientation(moonDirection);
}
export function getCelestialDebugState() {
return {
orientationEulerRad: { ...runtimeOrientationEuler },
followEarthRotation: { ...runtimeFollowConfig },
};
}
export function setCelestialOrientation(nextEuler = {}) {
runtimeOrientationEuler = {
...runtimeOrientationEuler,
...nextEuler,
};
refreshCelestialOrientation();
updateLighting();
return getCelestialDebugState();
}
export function setCelestialFollow(nextFollow = {}) {
runtimeFollowConfig = {
...runtimeFollowConfig,
...nextFollow,
};
refreshCelestialView();
updateLighting();
return getCelestialDebugState();
}
export function disposeCelestialLayer() {
if (celestialRoot?.parent) {
celestialRoot.parent.remove(celestialRoot);
}
[skySphere, sunSprite, moonSprite, sunHaloSprite, moonHaloSprite, ...brightStarSprites].forEach((object) => {
if (!object) return;
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (object.material.map) object.material.map.dispose?.();
object.material.dispose();
}
});
skySphere = null;
brightStarsGroup = null;
sunSprite = null;
moonSprite = null;
sunHaloSprite = null;
moonHaloSprite = null;
celestialRoot = null;
brightStarSprites = [];
brightStarTexture?.dispose?.();
brightStarTexture = null;
lastUpdatedAt = 0;
linkedSunLight = null;
linkedBackLight = null;
linkedEarth = null;
sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
}

View File

@@ -12,6 +12,26 @@ export const CONFIG = {
dragRotationScaleMax: 2.0, 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 = { export const HUD_CONFIG = {
scaleReferenceWidth: 1920, scaleReferenceWidth: 1920,
scaleReferenceHeight: 1080, scaleReferenceHeight: 1080,
@@ -34,6 +54,65 @@ export const EARTH_CONFIG = {
latCoefficient: 0.5 latCoefficient: 0.5
}; };
export const CELESTIAL_CONFIG = {
enabled: true,
updateIntervalMs: 10_000,
skyRadius: 2600,
skyOpacity: 1,
starMapUrl: "./assets/celestial/starmap_equatorial_4k.jpg",
brightStarsUrl: "./assets/celestial/bright-stars.json",
orientationEulerRad: {
x: 0.46,
y: 1.18,
z: -0.08,
},
followEarthRotation: {
enabled: true,
x: true,
y: true,
z: false,
invertX: true,
invertY: false,
invertZ: false,
},
sunDistance: 2150,
moonDistance: 2050,
sunScale: 78,
moonScale: 38,
sunHaloScale: 136,
moonHaloScale: 62,
brightStarDistance: 2350,
brightStarMinMag: 2.1,
brightStarMaxCount: 36,
brightStarMinScale: 2.4,
brightStarMaxScale: 6.8,
brightStarOpacity: 0.88,
sunLightDistance: 460,
sunLightIntensity: 1.02,
sunLightColor: 0xfff4df,
backLightIntensity: 0.3,
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 = { export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables', cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points', landingPointsApi: '/api/v1/visualization/geo/landing-points',
@@ -110,6 +189,8 @@ export const CABLE_STATE = {
export const SATELLITE_CONFIG = { export const SATELLITE_CONFIG = {
maxCount: -1, maxCount: -1,
initialLoadCount: 2400,
hydrateFullAfterInitialLoad: true,
trailLength: 10, trailLength: 10,
dotSize: 4, dotSize: 4,
ringSize: 0.07, ringSize: 0.07,
@@ -237,18 +318,18 @@ export const EARTH_MATERIAL_CONFIG = {
occluderSegments: 48, occluderSegments: 48,
// Fresnel atmosphere glow — inner rim // Fresnel atmosphere glow — inner rim
atmosInnerRadiusFactor: 1.018, atmosInnerRadiusFactor: 1.01,
atmosInnerSegments: 64, atmosInnerSegments: 64,
atmosInnerColor: [0.25, 0.62, 1.0], atmosInnerColor: [0.25, 0.62, 1.0],
atmosInnerRimPower: 3.2, atmosInnerRimPower: 3.2,
atmosInnerIntensity: 0.72, atmosInnerIntensity: 0.18,
// Fresnel atmosphere glow — outer corona // Fresnel atmosphere glow — outer corona
atmosOuterRadiusFactor: 1.07, atmosOuterRadiusFactor: 1.016,
atmosOuterSegments: 48, atmosOuterSegments: 48,
atmosOuterColor: [0.18, 0.45, 0.9], atmosOuterColor: [0.18, 0.45, 0.9],
atmosOuterRimPower: 5.0, atmosOuterRimPower: 5.0,
atmosOuterIntensity: 0.28, atmosOuterIntensity: 0.02,
// Texture candidates — tried in order, first success wins // Texture candidates — tried in order, first success wins
textureUrls: [ textureUrls: [
@@ -256,4 +337,16 @@ export const EARTH_MATERIAL_CONFIG = {
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg', 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg', 'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
], ],
dayNight: {
enabled: true,
sunDirection: { x: 1, y: 0.2, z: 0.4 },
nightFloor: 0.32,
dayBoost: 0.94,
twilightWidth: 0.24,
twilightIntensity: 0.14,
twilightColor: 0x4ea0ff,
nightTintColor: 0x0b1830,
nightTintIntensity: 0.05,
},
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
// earth.js - 3D Earth creation module // earth.js - 3D Earth creation module
import * as THREE from 'three'; 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'; import { latLonToVector3 } from './utils.js';
export let earth = null; export let earth = null;
@@ -10,6 +10,73 @@ export let terrain = null;
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null; let _earthMaterial = null;
let _earthShader = null;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
).normalize();
function applyEarthDayNightShader(material) {
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
material.onBeforeCompile = (shader) => {
_earthShader = shader;
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
shader.uniforms.uTwilightColor = { value: twilightColor };
shader.uniforms.uNightTintColor = { value: nightTintColor };
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
shader.vertexShader = shader.vertexShader.replace(
"#include <common>",
`#include <common>
varying vec3 vWorldNormal;`,
).replace(
"#include <begin_vertex>",
`#include <begin_vertex>
vWorldNormal = normalize(mat3(modelMatrix) * normal);`,
);
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
varying vec3 vWorldNormal;
uniform vec3 uSunDirectionWorld;
uniform float uNightFloor;
uniform float uDayBoost;
uniform float uTwilightWidth;
uniform float uTwilightIntensity;
uniform vec3 uTwilightColor;
uniform vec3 uNightTintColor;
uniform float uNightTintIntensity;`,
).replace(
"#include <output_fragment>",
`
vec3 worldNormal = normalize(vWorldNormal);
vec3 sunDir = normalize(uSunDirectionWorld);
float sunFacing = dot(worldNormal, sunDir);
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
outgoingLight *= mix(uNightFloor, uDayBoost, daylight);
outgoingLight += uTwilightColor * twilight * uTwilightIntensity;
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
#include <output_fragment>
`,
);
};
material.customProgramCacheKey = () => "earth-day-night-v1";
material.needsUpdate = true;
}
export function createEarth(scene) { export function createEarth(scene) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128); const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
@@ -27,6 +94,7 @@ export function createEarth(scene) {
depthWrite: true, depthWrite: true,
depthTest: true, depthTest: true,
}); });
applyEarthDayNightShader(material);
_earthMaterial = material; _earthMaterial = material;
earth = new THREE.Mesh(geometry, material); earth = new THREE.Mesh(geometry, material);
@@ -144,34 +212,35 @@ export function createClouds(scene, earthObj) {
return clouds; return clouds;
} }
export function createTerrain(scene, earthObj, simplex) { export function createTerrain(earthObj) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128); const geometry = new THREE.SphereGeometry(
const positionAttribute = geometry.getAttribute('position'); CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
TERRAIN_CONFIG.geometryWidthSegments,
for (let i = 0; i < positionAttribute.count; i++) { TERRAIN_CONFIG.geometryHeightSegments,
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();
const material = new THREE.MeshPhongMaterial({ const material = new THREE.MeshPhongMaterial({
color: 0x00aa00, color: TERRAIN_CONFIG.color,
flatShading: true, emissive: TERRAIN_CONFIG.emissive,
specular: TERRAIN_CONFIG.specular,
shininess: TERRAIN_CONFIG.shininess,
vertexColors: true,
vertexAlphas: true,
transparent: 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 = new THREE.Mesh(geometry, material);
terrain.name = "earth-real-terrain";
terrain.visible = false; terrain.visible = false;
terrain.renderOrder = 0.5;
earthObj.add(terrain); earthObj.add(terrain);
return terrain; return terrain;
} }
@@ -271,6 +340,14 @@ export function clearEarthTexture() {
_earthMaterial.needsUpdate = true; _earthMaterial.needsUpdate = true;
} }
export function setEarthSunDirection(direction) {
if (!direction) return;
_earthSunDirection.copy(direction).normalize();
if (_earthShader?.uniforms?.uSunDirectionWorld) {
_earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection);
}
}
export function loadEarthTexture() { export function loadEarthTexture() {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; } if (!_earthMaterial) { resolve(); return; }

View File

@@ -238,7 +238,7 @@ function mountCard() {
cardMounted = true; cardMounted = true;
} }
function positionPanel(panel, x, y) { function positionPanel(panel, x, y, options = {}) {
if (!panel) return; if (!panel) return;
const margin = 12; const margin = 12;
const offset = 14; const offset = 14;
@@ -251,6 +251,22 @@ function positionPanel(panel, x, y) {
const estW = Math.min(300 * scale, vpW - 32); const estW = Math.min(300 * scale, vpW - 32);
const estH = Math.min(420 * scale, vpH * 0.7); 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 left = x + offset;
let top = y + offset; let top = y + offset;
@@ -263,10 +279,10 @@ function positionPanel(panel, x, y) {
panel.style.bottom = 'auto'; panel.style.bottom = 'auto';
} }
function showPanel(x, y) { function showPanel(x, y, options = {}) {
const panel = getPanel(); const panel = getPanel();
if (!panel) return; 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'); panel.classList.add('is-visible');
} }
@@ -327,7 +343,7 @@ export function showInfoCard(type, data, options = {}) {
} }
content.innerHTML = html; content.innerHTML = html;
showPanel(options.x, options.y); showPanel(options.x, options.y, options);
} }
export function hideInfoCard() { export function hideInfoCard() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,9 @@
import { showStatusMessage } from "./ui.js"; 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
// - this module renders into inner pane: #news-panel
const EARTH_NEWS_API = "/api/v1/news/earth-feed"; const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const FOCUS_UPDATE_INTERVAL_MS = 4000; const FOCUS_UPDATE_INTERVAL_MS = 4000;
@@ -15,7 +19,6 @@ let lastFetchAt = 0;
let lastRegionSwitchAt = 0; let lastRegionSwitchAt = 0;
function getElements() { function getElements() {
return { return {
toggleBtn: document.getElementById("toggle-news"),
refreshBtn: document.getElementById("news-refresh"), refreshBtn: document.getElementById("news-refresh"),
openBtn: document.getElementById("news-open-external"), openBtn: document.getElementById("news-open-external"),
status: document.getElementById("news-board-status"), status: document.getElementById("news-board-status"),
@@ -49,14 +52,7 @@ function formatRelativeTime(raw) {
} }
export function updateNewsToggleUI(visible) { export function updateNewsToggleUI(visible) {
const { toggleBtn } = getElements(); void visible;
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 ? "关闭态势新闻" : "打开态势新闻";
}
} }
function renderEmptyState(message) { function renderEmptyState(message) {
@@ -279,39 +275,11 @@ export function initNewsPanel() {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
const { toggleBtn, refreshBtn, openBtn } = getElements(); const { refreshBtn, openBtn } = getElements();
updateNewsToggleUI(isTVPanelVisible()); updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源..."); 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", () => { window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible()); updateNewsToggleUI(isTVPanelVisible());
}); });

View File

@@ -6,6 +6,7 @@ import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { latLonToVector3 } from "./utils.js";
let satellitePoints = null; let satellitePoints = null;
let satelliteBackdropPoints = null;
let satelliteTrails = null; let satelliteTrails = null;
let satelliteData = []; let satelliteData = [];
let showSatellites = false; let showSatellites = false;
@@ -17,6 +18,7 @@ let lockedRingSprite = null;
let lockedDotSprite = null; let lockedDotSprite = null;
let predictedOrbitLine = null; let predictedOrbitLine = null;
let relatedSatelliteSprites = []; let relatedSatelliteSprites = [];
let highlightedSatelliteIndices = null;
let earthObjRef = null; let earthObjRef = null;
let sceneRef = null; let sceneRef = null;
let cameraRef = null; let cameraRef = null;
@@ -24,7 +26,7 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null; let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0; let positionUpdateAccumulator = 0;
let satelliteCapacity = 0; let satelliteCapacity = 0;
let selectedSatelliteLegendKey = null; let satelliteSatrecCache = new Map();
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32; const DOT_TEXTURE_SIZE = 32;
@@ -38,47 +40,90 @@ export let breathingPhase = 0;
const SATELLITE_LEGEND_RULES = [ const SATELLITE_LEGEND_RULES = [
{ {
key: "starlink", key: "equatorial",
label: "Starlink", label: "赤道轨道0-30°",
color: "#00e6ff", color: "#ff3333",
match: (props) => (props?.name || "").includes("STARLINK"),
},
{
key: "geo",
label: "GEO / 倾角 20-30",
color: "#ffcc00",
match: (props) => { match: (props) => {
const inclination = props?.inclination || 53; const inclination = Number(props?.inclination ?? 0);
return inclination > 20 && inclination < 30; return inclination >= 0 && inclination < 30;
}, },
}, },
{ {
key: "iridium", key: "low",
label: "Iridium", label: "低倾角轨道30-60°",
color: "#ff8000", color: "#ff9933",
match: (props) => (props?.name || "").includes("IRIDIUM"), match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 30 && inclination < 60;
},
}, },
{ {
key: "mid-inclination", key: "medium",
label: "倾角 50-70", label: "倾角轨道60-90°",
color: "#00ff4d", color: "#ffff33",
match: (props) => { match: (props) => {
const inclination = props?.inclination || 53; const inclination = Number(props?.inclination ?? 0);
return inclination > 50 && inclination < 70; return inclination >= 60 && inclination < 90;
},
},
{
key: "high",
label: "高倾角轨道90-120°",
color: "#33ff33",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 90 && inclination < 120;
},
},
{
key: "retrograde",
label: "逆行轨道120-180°",
color: "#3333ff",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 120 && inclination <= 180;
}, },
}, },
{ {
key: "other", key: "other",
label: "其他卫星", label: "其他",
color: "#ffffff", color: "#d7e2f4",
match: () => true, match: () => true,
}, },
]; ];
const SATELLITE_RULE_COLOR_CACHE = new Map();
function getSatelliteLegendRule(props = {}) {
return (
SATELLITE_LEGEND_RULES.find((rule) => rule.match(props)) ||
SATELLITE_LEGEND_RULES[SATELLITE_LEGEND_RULES.length - 1]
);
}
function getSatelliteRuleColor(rule) {
if (!rule) {
return { r: 1, g: 1, b: 1 };
}
if (SATELLITE_RULE_COLOR_CACHE.has(rule.key)) {
return SATELLITE_RULE_COLOR_CACHE.get(rule.key);
}
const color = new THREE.Color(rule.color);
const rgb = { r: color.r, g: color.g, b: color.b };
SATELLITE_RULE_COLOR_CACHE.set(rule.key, rgb);
return rgb;
}
export function updateBreathingPhase(deltaTime = 16) { export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16); breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
} }
function getBreathingPulse(phase) {
return 0.5 + 0.5 * Math.sin(phase);
}
export function getSatelliteLegendItems() { export function getSatelliteLegendItems() {
const presentKeys = new Set(); const presentKeys = new Set();
@@ -98,31 +143,15 @@ export function getSatelliteLegendItems() {
.filter((item) => presentKeys.has(item.key)) .filter((item) => presentKeys.has(item.key))
.map(({ key, label, color }) => ({ key, label, color })); .map(({ key, label, color }) => ({ key, label, color }));
if (!selectedSatelliteLegendKey) {
return items.map(({ label, color }) => ({ label, color }));
}
const selectedIndex = items.findIndex(
(item) => item.key === selectedSatelliteLegendKey,
);
if (selectedIndex > 0) {
const [selectedItem] = items.splice(selectedIndex, 1);
items.unshift(selectedItem);
}
return items.map(({ label, color }) => ({ label, color })); return items.map(({ label, color }) => ({ label, color }));
} }
export function setSelectedSatelliteLegend(props) { export function setSelectedSatelliteLegend(props) {
const rule = SATELLITE_LEGEND_RULES.find((item) => return getSatelliteLegendRule(props || {});
item.match(props || {}),
);
selectedSatelliteLegendKey = rule?.key || null;
} }
export function clearSelectedSatelliteLegend() { export function clearSelectedSatelliteLegend() {
selectedSatelliteLegendKey = null; return null;
} }
function disposeMaterial(material) { function disposeMaterial(material) {
@@ -182,6 +211,37 @@ function createDotTexture() {
return texture; 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") { function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
const size = DOT_TEXTURE_SIZE * 2; const size = DOT_TEXTURE_SIZE * 2;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@@ -204,8 +264,21 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") {
export function createSatellites(scene, earthObj) { export function createSatellites(scene, earthObj) {
initSatelliteScene(scene, earthObj); initSatelliteScene(scene, earthObj);
const dotTexture = createDotTexture(); const dotTexture = createDotTexture();
const backdropTexture = createBackdropDotTexture();
const pointsGeometry = new THREE.BufferGeometry(); 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({ const pointsMaterial = new THREE.PointsMaterial({
size: SATELLITE_CONFIG.dotSize, size: SATELLITE_CONFIG.dotSize,
@@ -215,29 +288,45 @@ export function createSatellites(scene, earthObj) {
opacity: 0.9, opacity: 0.9,
sizeAttenuation: false, sizeAttenuation: false,
alphaTest: 0.1, 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 = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false; satellitePoints.visible = false;
satellitePoints.userData = { type: "satellitePoints" }; satellitePoints.userData = { type: "satellitePoints" };
satellitePoints.renderOrder = 6;
const originalScale = { x: 1, y: 1, z: 1 }; const originalScale = { x: 1, y: 1, z: 1 };
satellitePoints.onBeforeRender = () => { const syncPointScale = () => {
if (earthObj && earthObj.scale.x !== 1) { if (earthObj && earthObj.scale.x !== 1) {
satellitePoints.scale.set( const scaleX = originalScale.x / earthObj.scale.x;
originalScale.x / earthObj.scale.x, const scaleY = originalScale.y / earthObj.scale.y;
originalScale.y / earthObj.scale.y, const scaleZ = originalScale.z / earthObj.scale.z;
originalScale.z / earthObj.scale.z, satellitePoints.scale.set(scaleX, scaleY, scaleZ);
); if (satelliteBackdropPoints) {
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
}
} else { } else {
satellitePoints.scale.set( satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
originalScale.x, if (satelliteBackdropPoints) {
originalScale.y, satelliteBackdropPoints.scale.set(
originalScale.z, originalScale.x,
); originalScale.y,
originalScale.z,
);
}
} }
}; };
satelliteBackdropPoints.onBeforeRender = syncPointScale;
satellitePoints.onBeforeRender = syncPointScale;
earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints); earthObj.add(satellitePoints);
const trailGeometry = new THREE.BufferGeometry(); const trailGeometry = new THREE.BufferGeometry();
@@ -259,7 +348,12 @@ export function createSatellites(scene, earthObj) {
return satellitePoints; 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; return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
} }
@@ -273,13 +367,50 @@ function createSatellitePositionState() {
} }
function ensureSatelliteCapacity(count) { function ensureSatelliteCapacity(count) {
if (!satellitePoints || !satelliteTrails) return; if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
const nextCapacity = Math.max(count, 0); const nextCapacity = Math.max(count, 0);
if (nextCapacity === satelliteCapacity) return; 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 positions = new Float32Array(nextCapacity * 3);
const backdropPositions = new Float32Array(nextCapacity * 3);
const colors = 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( satellitePoints.geometry.setAttribute(
"position", "position",
new THREE.BufferAttribute(positions, 3), new THREE.BufferAttribute(positions, 3),
@@ -288,10 +419,26 @@ function ensureSatelliteCapacity(count) {
"color", "color",
new THREE.BufferAttribute(colors, 3), 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 trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = 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( satelliteTrails.geometry.setAttribute(
"position", "position",
new THREE.BufferAttribute(trailPositions, 3), new THREE.BufferAttribute(trailPositions, 3),
@@ -301,10 +448,19 @@ function ensureSatelliteCapacity(count) {
new THREE.BufferAttribute(trailColors, 3), new THREE.BufferAttribute(trailColors, 3),
); );
satellitePositions = Array.from( satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
{ length: nextCapacity }, const previousState = previousSatellitePositions[index];
createSatellitePositionState, if (!previousState) {
); return createSatellitePositionState();
}
return {
current: previousState.current.clone(),
trail: previousState.trail.slice(),
trailIndex: previousState.trailIndex,
trailCount: previousState.trailCount,
};
});
satelliteCapacity = nextCapacity; satelliteCapacity = nextCapacity;
} }
@@ -315,7 +471,7 @@ function computeSatellitePosition(satellite, time) {
return null; return null;
} }
const satrec = buildSatrecFromProperties(props, time); const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) { if (!satrec || satrec.error) {
return null; return null;
} }
@@ -360,6 +516,45 @@ function buildSatrecFromProperties(props, fallbackTime) {
return twoline2satrec(tleLines.line1, tleLines.line2); 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) { function computeTleChecksum(line) {
let sum = 0; let sum = 0;
@@ -469,8 +664,8 @@ function generateFallbackPosition(satellite, index, total) {
return new THREE.Vector3(x, y, z); return new THREE.Vector3(x, y, z);
} }
export async function loadSatellites() { export async function loadSatellites(options = {}) {
const limit = getRequestedSatelliteLimit(); const limit = getRequestedSatelliteLimit(options.limit);
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin); const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
if (limit !== null) { if (limit !== null) {
url.searchParams.set("limit", String(limit)); url.searchParams.set("limit", String(limit));
@@ -483,13 +678,17 @@ export async function loadSatellites() {
const data = await response.json(); const data = await response.json();
satelliteData = data.features || []; satelliteData = data.features || [];
satelliteSatrecCache = new Map();
ensureSatelliteCapacity(satelliteData.length); ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satelliteData.length; return {
count: satelliteData.length,
requestedLimit: limit,
};
} }
export function updateSatellitePositions(deltaTime = 0, force = false) { export function updateSatellitePositions(deltaTime = 0, force = false) {
if (!satellitePoints || satelliteData.length === 0) return; if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails = const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null; showSatellites || showTrails || lockedSatelliteIndex !== null;
@@ -506,6 +705,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positionUpdateAccumulator = 0; positionUpdateAccumulator = 0;
const positions = satellitePoints.geometry.attributes.position.array; const positions = satellitePoints.geometry.attributes.position.array;
const backdropPositions =
satelliteBackdropPoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array; const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array; const trailColors = satelliteTrails.geometry.attributes.color.array;
@@ -537,42 +738,24 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = pos.x; positions[i * 3] = pos.x;
positions[i * 3 + 1] = pos.y; positions[i * 3 + 1] = pos.y;
positions[i * 3 + 2] = pos.z; positions[i * 3 + 2] = pos.z;
backdropPositions[i * 3] = pos.x;
backdropPositions[i * 3 + 1] = pos.y;
backdropPositions[i * 3 + 2] = pos.z;
const inclination = props?.inclination || 53; const rule = getSatelliteLegendRule(props);
const name = props?.name || ""; const { r, g, b } = getSatelliteRuleColor(rule);
const isStarlink = name.includes("STARLINK");
const isGeo = inclination > 20 && inclination < 30;
const isIridium = name.includes("IRIDIUM");
let r; if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) {
let g; const lum = r * 0.299 + g * 0.587 + b * 0.114;
let b; colors[i * 3] = lum * 0.75 + r * 0.25;
if (isStarlink) { colors[i * 3 + 1] = lum * 0.75 + g * 0.25;
r = 0.0; colors[i * 3 + 2] = lum * 0.75 + b * 0.25;
g = 0.9;
b = 1.0;
} else if (isGeo) {
r = 1.0;
g = 0.8;
b = 0.0;
} else if (isIridium) {
r = 1.0;
g = 0.5;
b = 0.0;
} else if (inclination > 50 && inclination < 70) {
r = 0.0;
g = 1.0;
b = 0.3;
} else { } else {
r = 1.0; colors[i * 3] = r;
g = 1.0; colors[i * 3 + 1] = g;
b = 1.0; colors[i * 3 + 2] = b;
} }
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
const satPosition = satellitePositions[i]; const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) { for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3; const trailIdx = (i * TRAIL_LENGTH + j) * 3;
@@ -607,6 +790,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
positions[i * 3] = 0; positions[i * 3] = 0;
positions[i * 3 + 1] = 0; positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 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++) { for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3; const trailIdx = (i * TRAIL_LENGTH + j) * 3;
@@ -619,6 +805,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
satellitePoints.geometry.attributes.position.needsUpdate = true; satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true; satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count); 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.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true; satelliteTrails.geometry.attributes.color.needsUpdate = true;
@@ -637,6 +825,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
export function toggleSatellites(visible) { export function toggleSatellites(visible) {
showSatellites = visible; showSatellites = visible;
if (satelliteBackdropPoints) {
satelliteBackdropPoints.visible = visible;
}
if (satellitePoints) { if (satellitePoints) {
satellitePoints.visible = visible; satellitePoints.visible = visible;
} }
@@ -827,10 +1018,15 @@ export function hideLockedRing() {
export function updateLockedRingPosition(position) { export function updateLockedRingPosition(position) {
if (!position) return; if (!position) return;
if (!lockedRingSprite || !lockedDotSprite) {
showHoverRing(position, true);
}
if (lockedRingSprite) { if (lockedRingSprite) {
lockedRingSprite.position.copy(position); lockedRingSprite.position.copy(position);
const ringPulse = getBreathingPulse(breathingPhase);
const breathScale = const breathScale =
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; 1 +
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
lockedRingSprite.scale.set( lockedRingSprite.scale.set(
SATELLITE_CONFIG.ringSize * breathScale, SATELLITE_CONFIG.ringSize * breathScale,
SATELLITE_CONFIG.ringSize * breathScale, SATELLITE_CONFIG.ringSize * breathScale,
@@ -838,20 +1034,21 @@ export function updateLockedRingPosition(position) {
); );
lockedRingSprite.material.opacity = lockedRingSprite.material.opacity =
SATELLITE_CONFIG.breathingOpacityMin + SATELLITE_CONFIG.breathingOpacityMin +
Math.sin(breathingPhase) * ringPulse *
(SATELLITE_CONFIG.breathingOpacityMax - (SATELLITE_CONFIG.breathingOpacityMax -
SATELLITE_CONFIG.breathingOpacityMin); SATELLITE_CONFIG.breathingOpacityMin);
} }
if (lockedDotSprite) { if (lockedDotSprite) {
lockedDotSprite.position.copy(position); lockedDotSprite.position.copy(position);
const dotPulse = getBreathingPulse(breathingPhase);
const dotBreathScale = const dotBreathScale =
1 + 1 +
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1); lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
lockedDotSprite.material.opacity = lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin + SATELLITE_CONFIG.dotOpacityMin +
Math.sin(breathingPhase) * dotPulse *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
} }
} }
@@ -887,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() { export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => { relatedSatelliteSprites.forEach((item) => {
if (item.sprite) { if (item.sprite) {
@@ -894,12 +1100,16 @@ export function clearRelatedSatelliteHighlights() {
} }
}); });
relatedSatelliteSprites = []; relatedSatelliteSprites = [];
highlightedSatelliteIndices = null;
applyDimMaterialState(false);
} }
export function highlightRelatedSatellites(indices, color = "#7dd3fc") { export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights(); clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return; if (!Array.isArray(indices) || indices.length === 0) return;
highlightedSatelliteIndices = new Set(indices);
applyDimMaterialState(true);
indices.forEach((index) => { indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current; const pos = satellitePositions?.[index]?.current;
if (!pos) return; if (!pos) return;
@@ -1055,10 +1265,12 @@ export function hidePredictedOrbit() {
export function clearSatelliteData() { export function clearSatelliteData() {
satelliteData = []; satelliteData = [];
satelliteSatrecCache = new Map();
selectedSatellite = null; selectedSatellite = null;
lockedSatelliteIndex = null; lockedSatelliteIndex = null;
hoveredSatelliteIndex = null; hoveredSatelliteIndex = null;
positionUpdateAccumulator = 0; positionUpdateAccumulator = 0;
breathingPhase = 0;
satellitePositions.forEach((position) => { satellitePositions.forEach((position) => {
position.current.set(0, 0, 0); position.current.set(0, 0, 0);
@@ -1081,6 +1293,16 @@ export function clearSatelliteData() {
satellitePoints.geometry.setDrawRange(0, 0); 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) { if (satelliteTrails) {
const trailPositionAttr = satelliteTrails.geometry.attributes.position; const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color; const trailColorAttr = satelliteTrails.geometry.attributes.color;
@@ -1103,6 +1325,11 @@ export function clearSatelliteData() {
export function resetSatelliteState() { export function resetSatelliteState() {
clearSatelliteData(); clearSatelliteData();
if (satelliteBackdropPoints) {
disposeObject3D(satelliteBackdropPoints);
satelliteBackdropPoints = null;
}
if (satellitePoints) { if (satellitePoints) {
disposeObject3D(satellitePoints); disposeObject3D(satellitePoints);
satellitePoints = null; satellitePoints = null;
@@ -1115,6 +1342,7 @@ export function resetSatelliteState() {
satellitePositions = []; satellitePositions = [];
satelliteCapacity = 0; satelliteCapacity = 0;
satelliteSatrecCache = new Map();
showSatellites = false; showSatellites = false;
showTrails = true; showTrails = true;
} }

View 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;
}

View File

@@ -2,6 +2,11 @@ import Hls from "hls.js";
import { showStatusMessage } from "./ui.js"; import { showStatusMessage } from "./ui.js";
import { createHUDPanel } from "./hud-panels.js"; import { createHUDPanel } from "./hud-panels.js";
// Naming convention:
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
// - #tv-panel is the inner live tab pane
// - #news-panel is the inner aggregation-news tab pane
const TV_STREAMS_API = "/api/v1/tv/streams"; const TV_STREAMS_API = "/api/v1/tv/streams";
const TV_PROXY_API = "/api/v1/tv/proxy"; const TV_PROXY_API = "/api/v1/tv/proxy";
const TV_STATUS_MESSAGE = { const TV_STATUS_MESSAGE = {
@@ -22,11 +27,15 @@ let refreshPromise = null;
let hlsPlayer = null; let hlsPlayer = null;
let hlsRecoveryAttempts = 0; let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null; let metaAutoCollapseTimer = null;
let tvPanel = null; let mediaPanel = null;
let activeTab = "live"; let activeTab = "live";
const failedSourceIds = new Set(); const failedSourceIds = new Set();
let probeTimer = null; let probeTimer = null;
let reformCleanupTimer = null; let reformCleanupTimer = null;
const tabPanelState = {
live: null,
news: null,
};
const META_AUTO_COLLAPSE_DELAY = 2500; const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000; const PROBE_INTERVAL_MS = 2 * 60 * 1000;
@@ -48,7 +57,8 @@ const HLS_RETRY_CONFIG = {
function getElements() { function getElements() {
return { return {
panel: document.getElementById("tv-panel"), // Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"), toggleBtn: document.getElementById("toggle-tv"),
select: document.getElementById("tv-source-select"), select: document.getElementById("tv-source-select"),
title: document.getElementById("tv-source-title"), title: document.getElementById("tv-source-title"),
@@ -67,13 +77,14 @@ function getElements() {
newsHeaderControls: document.getElementById("tv-header-controls-news"), newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"), liveTabBtn: document.getElementById("tv-tab-live"),
newsTabBtn: document.getElementById("tv-tab-news"), newsTabBtn: document.getElementById("tv-tab-news"),
livePane: document.getElementById("tv-tab-live-pane"), // Inner tab panes.
newsPane: document.getElementById("tv-tab-news-pane"), livePane: document.getElementById("tv-panel"),
newsPane: document.getElementById("news-panel"),
}; };
} }
function setMetaCollapsed(collapsed) { function setMetaCollapsed(collapsed) {
tvPanel?.setCollapsed(collapsed); mediaPanel?.setCollapsed(collapsed);
} }
function syncPanelActiveTab(tab = activeTab) { function syncPanelActiveTab(tab = activeTab) {
@@ -122,6 +133,82 @@ function clearPanelPositioningForResize(panel) {
panel.dataset.dragged = "true"; panel.dataset.dragged = "true";
} }
function readPanelLayoutState(panel) {
return {
width: panel.style.width || "",
height: panel.style.height || "",
resized: panel.dataset.resized === "true",
};
}
function resetPanelLayoutState(panel) {
panel.style.width = "";
panel.style.height = "";
delete panel.dataset.resized;
}
function captureTabState(tab = activeTab) {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
};
}
function restoreTabState(tab, panel, container, anchor = null) {
if (!(panel instanceof HTMLElement)) return;
const snapshot = tabPanelState[tab];
if (!snapshot?.layout) {
resetPanelLayoutState(panel);
return;
}
const { layout } = snapshot;
panel.style.width = layout.width;
panel.style.height = layout.height;
if (layout.resized) {
panel.dataset.resized = "true";
} else {
delete panel.dataset.resized;
}
if (tab === "live" && snapshot.metaCollapsed !== null) {
setMetaCollapsed(snapshot.metaCollapsed);
}
requestAnimationFrame(() => {
if (anchor) {
const panelRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const targetLeft = anchor.right - containerRect.left - panelRect.width;
const targetTop = anchor.bottom - containerRect.top - panelRect.height;
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
} else {
panel.style.right = "";
panel.style.bottom = "";
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
delete panel.dataset.dragged;
}
});
}
function getHudScale() { function getHudScale() {
const scale = Number.parseFloat( const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -129,6 +216,27 @@ function getHudScale() {
return Number.isFinite(scale) && scale > 0 ? scale : 1; return Number.isFinite(scale) && scale > 0 ? scale : 1;
} }
function clampPanelToContainer(panel, container) {
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
if (panel.dataset.dragged !== "true") return;
const containerRect = container.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const currentLeft = panelRect.left - containerRect.left;
const currentTop = panelRect.top - containerRect.top;
const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft));
const clampedTop = Math.min(maxTop, Math.max(0, currentTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
}
function setupResizeHandle() { function setupResizeHandle() {
const { panel } = getElements(); const { panel } = getElements();
const container = document.getElementById("container"); const container = document.getElementById("container");
@@ -215,16 +323,25 @@ function setupResizeHandle() {
function updateToggleButton(visible) { function updateToggleButton(visible) {
const { toggleBtn } = getElements(); const { toggleBtn } = getElements();
if (!toggleBtn) return; if (!toggleBtn) return;
const active = visible && activeTab === "live"; const icon = toggleBtn.querySelector(".material-symbols-rounded");
toggleBtn.classList.toggle("active", active); 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"); const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) { if (tooltip) {
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播"; tooltip.textContent = title;
} }
} }
function syncSettingsToggle(visible) { function syncSettingsToggle(visible) {
const input = document.querySelector('[data-settings-panel="tv-panel"]'); const input = document.querySelector('[data-settings-panel="media-panel"]');
if (input instanceof HTMLInputElement) { if (input instanceof HTMLInputElement) {
input.checked = visible; input.checked = visible;
} }
@@ -233,7 +350,7 @@ function syncSettingsToggle(visible) {
function setPanelVisible(visible) { function setPanelVisible(visible) {
const { panel } = getElements(); const { panel } = getElements();
if (!panel) return; if (!panel) return;
tvPanel?.setVisible(visible); mediaPanel?.setVisible(visible);
updateToggleButton(visible); updateToggleButton(visible);
syncSettingsToggle(visible); syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", { window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
@@ -273,6 +390,14 @@ function animateTabReform(applyChange) {
return; return;
} }
if (panel.dataset.resized === "true") {
applyChange();
requestAnimationFrame(() => {
clampPanelToContainer(panel, container);
});
return;
}
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) { if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
applyChange(); applyChange();
return; return;
@@ -336,7 +461,7 @@ function updateTabState(target, isActive, activeClassName = "") {
function updateTabButtonState(button, isActive) { function updateTabButtonState(button, isActive) {
if (!(button instanceof HTMLButtonElement)) return; if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle("tv-panel-tab--active", isActive); button.classList.toggle("media-panel-tab--active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false"); button.setAttribute("aria-selected", isActive ? "true" : "false");
} }
@@ -344,7 +469,26 @@ function setActiveTab(tab) {
const nextTab = tab === "news" ? "news" : "live"; const nextTab = tab === "news" ? "news" : "live";
if (activeTab === nextTab) return; if (activeTab === nextTab) return;
animateTabReform(() => { captureTabState(activeTab);
const { panel } = getElements();
const targetSnapshot = tabPanelState[nextTab];
const currentIsCustom =
panel instanceof HTMLElement && panel.dataset.resized === "true";
const targetIsCustom = Boolean(targetSnapshot?.layout?.resized);
const container = document.getElementById("container");
const currentAnchor =
panel instanceof HTMLElement && container instanceof HTMLElement
? (() => {
const panelRect = panel.getBoundingClientRect();
return {
right: panelRect.right,
bottom: panelRect.bottom,
};
})()
: null;
const applyTabSwitch = (restoreLayoutState = false) => {
activeTab = nextTab; activeTab = nextTab;
syncPanelActiveTab(nextTab); syncPanelActiveTab(nextTab);
syncNewsDefaultMaxHeight(); syncNewsDefaultMaxHeight();
@@ -363,10 +507,31 @@ function setActiveTab(tab) {
updateTabState(newsHeaderControls, nextTab === "news"); updateTabState(newsHeaderControls, nextTab === "news");
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active"); updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active"); updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
if (
restoreLayoutState &&
panel instanceof HTMLElement &&
container instanceof HTMLElement
) {
restoreTabState(nextTab, panel, container, currentAnchor);
}
requestAnimationFrame(() => {
captureTabState(nextTab);
});
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", { window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: nextTab }, detail: { tab: nextTab },
})); }));
}); };
if (currentIsCustom || targetIsCustom) {
applyTabSwitch(true);
return;
}
animateTabReform(() => applyTabSwitch(false));
} }
export function openTVPanelTab(tab = "live") { export function openTVPanelTab(tab = "live") {
@@ -376,7 +541,7 @@ export function openTVPanelTab(tab = "live") {
} }
export function isTVPanelVisible() { export function isTVPanelVisible() {
return tvPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden"); return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
} }
export function getActiveTVTab() { export function getActiveTVTab() {
@@ -861,7 +1026,7 @@ export function initTVPanel() {
} = getElements(); } = getElements();
if (panel && metaToggle) { if (panel && metaToggle) {
tvPanel = createHUDPanel({ mediaPanel = createHUDPanel({
panel, panel,
header: ".hud-panel__header", header: ".hud-panel__header",
body: "#tv-meta-wrap", body: "#tv-meta-wrap",
@@ -873,29 +1038,32 @@ export function initTVPanel() {
}); });
} }
updateToggleButton(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden")); updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden")); syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
toggleBtn?.addEventListener("click", async (event) => { toggleBtn?.addEventListener("click", async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const currentlyVisible = tvPanel?.isVisible() ?? false; const currentlyVisible = mediaPanel?.isVisible() ?? false;
if (!currentlyVisible) { if (!currentlyVisible) {
setPanelVisible(true); setPanelVisible(true);
setActiveTab("live"); if (activeTab === "live") {
await ensureTVPanelReady(); await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info"); showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("态势新闻窗口已打开", "info");
}
return; return;
} }
if (activeTab !== "live") { const nextTab = activeTab === "live" ? "news" : "live";
setActiveTab("live"); setActiveTab(nextTab);
if (nextTab === "live") {
await ensureTVPanelReady();
showStatusMessage("已切换到新闻直播", "info"); showStatusMessage("已切换到新闻直播", "info");
return; return;
} }
showStatusMessage("已切换到态势新闻", "info");
setPanelVisible(false);
showStatusMessage("新闻直播窗口已关闭", "info");
}); });
select?.addEventListener("change", (event) => { select?.addEventListener("change", (event) => {
@@ -907,7 +1075,7 @@ export function initTVPanel() {
metaToggle?.addEventListener("click", () => { metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer); clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !(tvPanel?.isCollapsed() ?? false); const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
setMetaCollapsed(isNowCollapsed); setMetaCollapsed(isNowCollapsed);
}); });
@@ -944,7 +1112,9 @@ export function initTVPanel() {
setupResizeHandle(); setupResizeHandle();
syncPanelActiveTab("live"); syncPanelActiveTab("live");
syncNewsDefaultMaxHeight(); syncNewsDefaultMaxHeight();
setActiveTab("live"); updateTabButtonState(liveTabBtn, true);
updateTabButtonState(newsTabBtn, false);
captureTabState("live");
window.addEventListener("resize", syncNewsDefaultMaxHeight); window.addEventListener("resize", syncNewsDefaultMaxHeight);
} }

View File

@@ -2,8 +2,18 @@
let statusTimeoutId = null; let statusTimeoutId = null;
let statusHideTimeoutId = null; let statusHideTimeoutId = null;
let statusReplayTimeoutId = null;
const STATUS_BASE_CLASS = "earth-status-message"; const STATUS_BASE_CLASS = "earth-status-message";
const STATUS_DISPLAY_MS = 3000;
const STATUS_FADE_MS = 280;
let statusQueue = [];
let statusBusy = false;
let loadingActive = false;
let loadingLockedWidth = 0;
let pendingLoadingMessage = "";
function createStatusEntry(message, type = "info") {
return { message, type };
}
function getElement(id) { function getElement(id) {
return document.getElementById(id); return document.getElementById(id);
@@ -19,53 +29,109 @@ function clearStatusTimers() {
clearTimeout(statusTimeoutId); clearTimeout(statusTimeoutId);
statusTimeoutId = null; statusTimeoutId = null;
} }
if (statusHideTimeoutId) { if (statusHideTimeoutId) {
clearTimeout(statusHideTimeoutId); clearTimeout(statusHideTimeoutId);
statusHideTimeoutId = null; statusHideTimeoutId = null;
} }
}
if (statusReplayTimeoutId) { function clearLoadingWidthLock(statusEl) {
clearTimeout(statusReplayTimeoutId); loadingLockedWidth = 0;
statusReplayTimeoutId = null; if (statusEl) {
statusEl.style.minWidth = "";
} }
} }
// Show status message function updateLoadingWidthLock(statusEl) {
export function showStatusMessage(message, type = "info") { if (!statusEl || !loadingActive) return;
const nextWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
if (nextWidth <= 0) return;
loadingLockedWidth = Math.max(loadingLockedWidth, nextWidth);
statusEl.style.minWidth = `${loadingLockedWidth}px`;
}
function buildStatusContent(statusEl, message, type) {
statusEl.innerHTML = "";
const indicator = document.createElement("span");
indicator.className = "earth-status-indicator";
indicator.setAttribute("aria-hidden", "true");
const dotCount = type === "loading" ? 3 : 1;
for (let i = 0; i < dotCount; i++) {
const dot = document.createElement("span");
dot.className = "earth-status-dot";
indicator.appendChild(dot);
}
const text = document.createElement("span");
text.className = "earth-status-text";
text.textContent = message;
statusEl.appendChild(indicator);
statusEl.appendChild(text);
}
function buildPersistentErrorContent(errorEl, message) {
buildStatusContent(errorEl, message, "error");
}
function hideStatusElement(statusEl, onHidden) {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
if (!statusEl.classList.contains("visible")) {
setElementDisplay(statusEl, false);
statusEl.className = STATUS_BASE_CLASS;
statusEl.innerHTML = "";
}
statusHideTimeoutId = null;
if (typeof onHidden === "function") {
onHidden();
}
}, STATUS_FADE_MS);
}
function processStatusQueue() {
if (loadingActive || statusBusy || statusQueue.length === 0) return;
const next = statusQueue.shift();
if (!next) return;
startTransientStatus(next.message, next.type);
}
function startTransientStatus(message, type = "info") {
const statusEl = getElement("status-message"); const statusEl = getElement("status-message");
if (!statusEl) return; if (!statusEl) return;
clearStatusTimers(); clearStatusTimers();
statusBusy = true;
const startShow = () => { buildStatusContent(statusEl, message, type);
statusEl.textContent = message; statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
statusEl.className = `${STATUS_BASE_CLASS} ${type}`; setElementDisplay(statusEl, true, "inline-flex");
setElementDisplay(statusEl, true); statusEl.offsetHeight;
statusEl.offsetHeight; statusEl.classList.add("visible");
statusEl.classList.add("visible");
statusTimeoutId = setTimeout(() => { statusTimeoutId = setTimeout(() => {
statusEl.classList.remove("visible"); hideStatusElement(statusEl, () => {
statusHideTimeoutId = setTimeout(() => { statusBusy = false;
setElementDisplay(statusEl, false); processStatusQueue();
statusEl.textContent = ""; });
statusHideTimeoutId = null; statusTimeoutId = null;
}, 280); }, STATUS_DISPLAY_MS);
statusTimeoutId = null; }
}, 3000);
};
if (statusEl.classList.contains("visible")) { // Show status message
statusEl.classList.remove("visible"); export function showStatusMessage(message, type = "info") {
statusReplayTimeoutId = setTimeout(() => { if (loadingActive) {
startShow(); statusQueue.unshift(createStatusEntry(message, type));
statusReplayTimeoutId = null;
}, 180);
return; return;
} }
startTransientStatus(message, type);
}
startShow(); export function queueStatusMessage(message, type = "info") {
statusQueue.push(createStatusEntry(message, type));
processStatusQueue();
} }
// Update coordinates display // Update coordinates display
@@ -120,23 +186,58 @@ export function updateEarthStats(stats) {
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图"; textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
} }
// Show/hide loading // Show/hide loading via status message
export function setLoading(loading) { export function setLoading(loading) {
const loadingEl = getElement("loading"); const statusEl = getElement("status-message");
if (!loadingEl) return; if (!statusEl) return;
setElementDisplay(loadingEl, loading);
if (loading) {
clearStatusTimers();
loadingActive = true;
statusBusy = false;
clearLoadingWidthLock(statusEl);
buildStatusContent(
statusEl,
pendingLoadingMessage || "正在加载...",
"loading",
);
pendingLoadingMessage = "";
statusEl.className = `${STATUS_BASE_CLASS} loading`;
setElementDisplay(statusEl, true, "inline-flex");
statusEl.offsetHeight;
statusEl.classList.add("visible");
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
});
} else {
pendingLoadingMessage = "";
if (!statusEl.classList.contains("loading")) {
loadingActive = false;
clearLoadingWidthLock(statusEl);
processStatusQueue();
return;
}
loadingActive = false;
hideStatusElement(statusEl, () => {
clearLoadingWidthLock(statusEl);
statusBusy = false;
processStatusQueue();
});
}
} }
export function setLoadingMessage(title, subtitle = "") { export function setLoadingMessage(title) {
const titleEl = getElement("loading-title"); const statusEl = getElement("status-message");
const subtitleEl = getElement("loading-subtitle"); if (!statusEl || !statusEl.classList.contains("loading")) {
pendingLoadingMessage = title;
if (titleEl) { return;
titleEl.textContent = title;
} }
const textEl = statusEl.querySelector(".earth-status-text");
if (subtitleEl) { if (textEl) {
subtitleEl.textContent = subtitle; textEl.textContent = title;
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
});
} }
} }
@@ -162,27 +263,37 @@ export function hideTooltip() {
export function showError(message) { export function showError(message) {
const errorEl = getElement("error-message"); const errorEl = getElement("error-message");
if (!errorEl) return; if (!errorEl) return;
errorEl.textContent = message; buildPersistentErrorContent(errorEl, message);
setElementDisplay(errorEl, true); errorEl.className = `${STATUS_BASE_CLASS} earth-error-message error`;
setElementDisplay(errorEl, true, "inline-flex");
errorEl.offsetHeight;
errorEl.classList.add("visible");
} }
// Hide error message // Hide error message
export function hideError() { export function hideError() {
const errorEl = getElement("error-message"); const errorEl = getElement("error-message");
if (errorEl) { if (errorEl) {
errorEl.classList.remove("visible");
setElementDisplay(errorEl, false); setElementDisplay(errorEl, false);
errorEl.textContent = ""; errorEl.className = "earth-error-message";
errorEl.innerHTML = "";
} }
} }
export function clearUiState() { export function clearUiState() {
clearStatusTimers(); clearStatusTimers();
statusQueue = [];
statusBusy = false;
loadingActive = false;
pendingLoadingMessage = "";
const statusEl = getElement("status-message"); const statusEl = getElement("status-message");
if (statusEl) { if (statusEl) {
statusEl.className = STATUS_BASE_CLASS; statusEl.className = STATUS_BASE_CLASS;
setElementDisplay(statusEl, false); setElementDisplay(statusEl, false);
statusEl.textContent = ""; statusEl.innerHTML = "";
clearLoadingWidthLock(statusEl);
} }
hideTooltip(); hideTooltip();

View File

@@ -1054,6 +1054,14 @@ start_ai_provider_service() {
exit 1 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() { ensure_database_services_healthy() {
local retry=1 local retry=1
@@ -1130,7 +1138,15 @@ start_backend_service() {
log_success "启动数据库已就绪" log_success "启动数据库已就绪"
sleep 3 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 if [ "$backend_port_requested" -eq 1 ]; then
kill_port_if_requested "$backend_port" "后端" kill_port_if_requested "$backend_port" "后端"

View File

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

2
uv.lock generated
View File

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