Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f89372d71 | ||
|
|
2b0d4cfc49 | ||
|
|
e6d0332fba | ||
|
|
fe45a99cbd | ||
|
|
ae77b06c3c |
@@ -6,7 +6,8 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import math
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
@@ -23,6 +24,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
|
||||
router = APIRouter()
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
@@ -804,6 +808,50 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Terrain tile upstream error: {exc.response.status_code}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
|
||||
@@ -8,6 +8,72 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [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
|
||||
|
||||
@@ -112,6 +112,285 @@
|
||||
|
||||
- [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:真实天球背景
|
||||
|
||||
472
docs/earth/earth-real-terrain-plan.md
Normal file
472
docs/earth/earth-real-terrain-plan.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Earth Real Terrain Plan
|
||||
|
||||
## Goal
|
||||
|
||||
将 Earth 页当前的“程序噪声假地形”替换成基于真实 DEM 的可用地形层,使 `地形 terrain` 开关真正显示全球海拔起伏,而不是占位效果。
|
||||
|
||||
当前占位实现位于:
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
具体问题:
|
||||
|
||||
- `createTerrain()` 直接对球体顶点应用 `simplex noise`
|
||||
- 没有真实海拔数据来源
|
||||
- 没有分辨率分层
|
||||
- 没有和当前相机/视角配套的性能控制
|
||||
|
||||
## Constraints
|
||||
|
||||
本计划必须贴合当前 Earth 架构,而不是引入一套全新的地形引擎:
|
||||
|
||||
- 地球主体仍然是一个 Three.js sphere
|
||||
- 海缆、登陆点、卫星、BGP 都已经建立在当前球体坐标系之上
|
||||
- 不能为了地形把整页改成 Cesium/MapLibre Globe 之类的全栈替换
|
||||
- 第一阶段优先做“真实可用”,不是一步到位做摄影测量级地形
|
||||
|
||||
## Recommended Data Source
|
||||
|
||||
### Primary recommendation
|
||||
|
||||
使用公开的 Terrarium 编码高程瓦片作为浏览器端高度来源,第一阶段优先接入:
|
||||
|
||||
- Mapzen/AWS `Terrarium` elevation tiles
|
||||
参考:[Mapzen terrain tile format / Terrarium](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
|
||||
原因:
|
||||
|
||||
- 已经是全球瓦片化高程
|
||||
- 浏览器端按 tile 请求,最适合当前 Earth 这种在线 globe
|
||||
- 编码简单稳定:
|
||||
- `heightMeters = (R * 256 + G + B / 256) - 32768`
|
||||
- 不需要我们先离线拼整球 DEM
|
||||
|
||||
### Data quality upgrade path
|
||||
|
||||
如果后面第一阶段效果确认可用,再逐步升级到底层源:
|
||||
|
||||
- Copernicus DEM GLO-30
|
||||
参考:[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
- 或用 Copernicus / SRTM / ASTER 等离线切成我们自己的 terrain tiles
|
||||
|
||||
这条升级路径适合第二阶段,不建议一开始就直接自建全球瓦片服务。
|
||||
|
||||
## Why Not Replace the Engine
|
||||
|
||||
不建议为了地形直接切到 Cesium terrain / quantized mesh 引擎,原因:
|
||||
|
||||
- 现有 Earth 业务对象都依附当前球面坐标
|
||||
- 切引擎会同时波及:
|
||||
- 海缆绘制
|
||||
- 卫星/轨迹
|
||||
- BGP 标记
|
||||
- HUD 与交互
|
||||
- 这是“重做一页”,不是“给地形层接真实数据”
|
||||
|
||||
所以推荐路线是:
|
||||
|
||||
- 保持当前 sphere globe
|
||||
- 为 sphere 增加真实高度位移层
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
分三期推进。
|
||||
|
||||
### Phase 1 — Global Heightmap Terrain Overlay
|
||||
|
||||
目标:
|
||||
|
||||
- 地形层切换后显示真实海拔起伏
|
||||
- 全球范围可用
|
||||
- 性能可控
|
||||
|
||||
做法:
|
||||
|
||||
1. 新增 terrain 数据模块
|
||||
|
||||
建议文件:
|
||||
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
职责:
|
||||
|
||||
- 选择 DEM zoom level
|
||||
- 请求 Terrarium tiles
|
||||
- 解码 tile 高程
|
||||
- 将高程重采样到当前地形球体网格
|
||||
|
||||
2. 替换 `createTerrain()`
|
||||
|
||||
当前:
|
||||
|
||||
- 在 [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) 中同步生成噪声地形
|
||||
|
||||
调整后:
|
||||
|
||||
- `createTerrain()` 只负责创建 terrain mesh 骨架
|
||||
- 真正的顶点位移由 terrain 模块异步注入
|
||||
|
||||
3. 第一阶段采用“整球低分辨率位移”
|
||||
|
||||
不要一上来做动态 patch stitching。第一阶段更稳的办法是:
|
||||
|
||||
- 保留一张全球 terrain sphere
|
||||
- 使用较低分辨率几何
|
||||
- 例如 `SphereGeometry(radius, 192, 192)` 或 `256/256`
|
||||
- 运行时按一个固定地形 zoom(如 `z=4` 或 `z=5`)抓取覆盖全球的 Terrarium tiles
|
||||
- 将 tile 解码后重投影到经纬度采样网格
|
||||
- 将每个球面顶点按真实高度抬升
|
||||
|
||||
这样第一阶段就能做到:
|
||||
|
||||
- 有真实地形
|
||||
- 不需要复杂的局部 LOD
|
||||
- 不会让现有球体对象体系爆炸
|
||||
|
||||
### Phase 2 — View-Aware Refinement
|
||||
|
||||
目标:
|
||||
|
||||
- 正面可见区域更精细
|
||||
- 背面与远处维持低成本
|
||||
|
||||
做法:
|
||||
|
||||
- 引入“基础全球地形 + 当前视角高分局部补丁”
|
||||
- 正面区域额外抓更高 zoom 的高程 tile
|
||||
- 只替换局部顶点位移或局部 overlay mesh
|
||||
|
||||
这一阶段适合在第一阶段稳定后做。
|
||||
|
||||
### Phase 3 — Normals / Shading / Terrain UX
|
||||
|
||||
目标:
|
||||
|
||||
- 地形不仅有起伏,还更好看、更可读
|
||||
|
||||
包括:
|
||||
|
||||
- 根据高度生成更合理的 normals
|
||||
- 调整 terrain material,使山脉/高原更易读
|
||||
- 可选加入:
|
||||
- hillshade
|
||||
- contour lines
|
||||
- snowline / bathymetry tint
|
||||
|
||||
## Calibration Overlay Before More Terrain Tuning
|
||||
|
||||
在当前项目里,terrain 看起来“不像真地形”,不一定只是 DEM 或 exaggeration 不够,也可能是因为缺少稳定参照物。
|
||||
|
||||
没有清晰的海岸线、国界线和地表分层时,人眼很难判断:
|
||||
|
||||
- 山脉是不是在应该高的地方高
|
||||
- terrain 是否真的贴在正确的大陆位置上
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否存在偏移
|
||||
|
||||
这里要明确区分两件事:
|
||||
|
||||
- 国界线不会修好错误的 terrain
|
||||
- 但海岸线 / 国界线会让我们更容易判断 terrain 有没有贴准
|
||||
|
||||
所以在继续盲调 terrain 参数之前,建议先插入一个“校准参照层”阶段。
|
||||
|
||||
### Recommended order for the calibration layer
|
||||
|
||||
1. 海岸线
|
||||
2. 国界线
|
||||
3. 再继续调 terrain
|
||||
|
||||
原因:
|
||||
|
||||
- 海岸线比国界线更基础,也更接近真实地表边界
|
||||
- 判断 terrain 是否贴准,最重要的是大陆边缘和山脉/海岸关系
|
||||
- 国界线更多是政治边界,只能作为辅助参照
|
||||
|
||||
如果只加国界线,不加海岸线,效果仍然可能会怪,因为:
|
||||
|
||||
- 很多国界线本来就是人为直线
|
||||
- 它们并不总是跟真实地形走
|
||||
|
||||
### Suggested layer order during debugging
|
||||
|
||||
建议调试期临时把地球层次明确成:
|
||||
|
||||
1. base earth texture
|
||||
2. coastline / borders overlay
|
||||
3. terrain relief
|
||||
4. cables / landing points / bgp / satellites
|
||||
|
||||
这样会比现在更容易判断:
|
||||
|
||||
- 山脉是否位于正确区域
|
||||
- terrain 是否和地表对齐
|
||||
- 国界/海岸是否漂移
|
||||
|
||||
### Suggested data source for the calibration overlay
|
||||
|
||||
优先用 `Natural Earth` 的轻量全球矢量数据:
|
||||
|
||||
- 海岸线(coastline)
|
||||
- Admin 0 国界线(country borders)
|
||||
|
||||
优点:
|
||||
|
||||
- 全球一致
|
||||
- 轻量
|
||||
- 很适合当前 Three.js globe 做 overlay
|
||||
|
||||
### Recommended execution path
|
||||
|
||||
#### Phase A — Add reference overlays
|
||||
|
||||
先加两层可开关的参考线:
|
||||
|
||||
- 海岸线
|
||||
- 国界线
|
||||
|
||||
这两层的目标不是最终美术表现,而是调试 / 校准。
|
||||
|
||||
#### Phase B — Recalibrate terrain against coastline
|
||||
|
||||
有了海岸线以后,再重新看 terrain:
|
||||
|
||||
- terrain 是否和大陆边缘错位
|
||||
- 地球纹理、本初子午线、terrain 采样之间是否有固定偏移
|
||||
|
||||
#### Phase C — Decide whether to keep the current terrain path
|
||||
|
||||
这时再决定后面的路线:
|
||||
|
||||
- 如果发现真实高程整体是对的,只是缺少 shading / readability
|
||||
继续保留当前 DEM + terrain overlay 路线
|
||||
- 如果发现整球采样投影、本初子午线或 overlay 关系本身就很别扭
|
||||
再考虑重做 terrain pipeline
|
||||
|
||||
### Practical recommendation
|
||||
|
||||
当前阶段不建议“从头开始重做 terrain”。
|
||||
|
||||
更稳的策略是:
|
||||
|
||||
- 暂停继续盲调 terrain 参数
|
||||
- 先补海岸线 / 国界线作为校准参照层
|
||||
- 再基于参照层判断 terrain 是“参数没调好”,还是“整条实现路径有偏移”
|
||||
|
||||
## Recommended Geometry Model
|
||||
|
||||
### First usable model
|
||||
|
||||
保留一层独立 terrain sphere:
|
||||
|
||||
- base earth sphere:贴纹理、昼夜、海洋
|
||||
- terrain sphere:略高于地球半径,真实高程位移
|
||||
|
||||
建议:
|
||||
|
||||
- `terrainBaseRadius = CONFIG.earthRadius + 0.2`
|
||||
- 高度缩放使用真实米制换算,再乘一个可调 exaggeration
|
||||
|
||||
示例关系:
|
||||
|
||||
- `heightWorld = (elevationMeters / 6371000) * CONFIG.earthRadius * exaggeration`
|
||||
|
||||
建议第一阶段 `exaggeration = 1.3 ~ 1.8`
|
||||
|
||||
因为完全真实比例在全球球体上会太平,看不出来。
|
||||
|
||||
## Tile Decoding Plan
|
||||
|
||||
### Terrarium decode
|
||||
|
||||
对于每个高程 tile 像素:
|
||||
|
||||
```text
|
||||
heightMeters = (R * 256 + G + B / 256) - 32768
|
||||
```
|
||||
|
||||
### Sampling path
|
||||
|
||||
对于 terrain mesh 上每个顶点:
|
||||
|
||||
1. 将顶点方向转成经纬度
|
||||
2. 将经纬度映射到 Web Mercator tile 坐标
|
||||
3. 找到对应的 tile 和像素
|
||||
4. 解码高程
|
||||
5. 将顶点沿法线方向抬升
|
||||
|
||||
### Needed helpers
|
||||
|
||||
建议新增:
|
||||
|
||||
- `latLonToTileXY(lat, lon, z)`
|
||||
- `tilePixelFromLatLon(lat, lon, z, tileSize)`
|
||||
- `decodeTerrariumHeight(r, g, b)`
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
为了不让地形开关每次重开都重新抓全量 tile:
|
||||
|
||||
- terrain tile 按 `z/x/y` 存到内存缓存
|
||||
- terrain mesh 结果也缓存一份
|
||||
- 当用户关闭/开启 terrain:
|
||||
- 直接复用已有位移结果
|
||||
|
||||
建议:
|
||||
|
||||
- `Map<string, Float32Array | ImageBitmap>`
|
||||
|
||||
## Material Strategy
|
||||
|
||||
第一阶段不要复杂化。
|
||||
|
||||
建议 terrain material:
|
||||
|
||||
- 半透明低饱和地形色
|
||||
- 比 base earth 稍亮或稍偏冷
|
||||
- 保留当前 HUD 风格下的可读性
|
||||
|
||||
第一阶段不需要:
|
||||
|
||||
- 真实土地覆被纹理
|
||||
- 独立卫星影像贴 terrain
|
||||
|
||||
因为那会和现有地球纹理、云层、昼夜 shader 打架。
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files to change
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 重写 `createTerrain()`
|
||||
- 删除 simplex noise 占位逻辑
|
||||
- [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 初始化 terrain 数据加载
|
||||
- 控制 terrain readiness / loading message
|
||||
- [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- `toggleTerrain` 逻辑保持,但应能区分:
|
||||
- mesh 已就绪
|
||||
- 正在加载
|
||||
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 新增 `TERRAIN_CONFIG`
|
||||
- 新文件:
|
||||
- `frontend/public/earth/js/terrain.js`
|
||||
|
||||
### Suggested new config
|
||||
|
||||
建议新增:
|
||||
|
||||
```js
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
baseRadiusOffset: 0.2,
|
||||
exaggeration: 1.5,
|
||||
opacity: 0.55,
|
||||
color: 0x6c876f,
|
||||
maxConcurrentRequests: 8,
|
||||
cacheEnabled: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Loading UX
|
||||
|
||||
地形第一次开启时,不能像现在一样瞬时切换。
|
||||
|
||||
建议:
|
||||
|
||||
- 如果地形数据尚未准备:
|
||||
- 顶部状态条显示:`正在加载真实地形数据...`
|
||||
- 完成后:
|
||||
- `真实地形已就绪`
|
||||
|
||||
如果加载失败:
|
||||
|
||||
- 保留 base earth
|
||||
- 显示轻量错误提示
|
||||
- 不要让 terrain 开关卡死在“开”状态
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Global tile count too high
|
||||
|
||||
即使 `z=5` 全球 tile 数也不少。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段限定低 zoom
|
||||
- 并发上限
|
||||
- 缓存
|
||||
|
||||
### 2. Mesh resolution too low
|
||||
|
||||
如果球面分段太低,山脉会被抹平。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段先选一个中等分辨率
|
||||
- 用 exaggeration 保证可见性
|
||||
|
||||
### 3. Existing overlays may z-fight with terrain
|
||||
|
||||
海缆、登陆点、BGP、卫星相关对象都假设地球半径固定。
|
||||
|
||||
缓解:
|
||||
|
||||
- terrain sphere 单独作为 overlay
|
||||
- overlay 保持略低或略高的固定 offset
|
||||
- 必要时局部调整 landing point / cable altitude offset
|
||||
|
||||
### 4. Mercator sampling distortion near poles
|
||||
|
||||
Web Mercator 在高纬会有失真。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一阶段接受
|
||||
- 后续若需要更严格极区质量,再上 geodetic reprojection pipeline
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
第一阶段完成后,应满足:
|
||||
|
||||
1. `地形 terrain` 开关开启时,地表起伏明显不再是随机噪声
|
||||
2. 喜马拉雅、安第斯、落基山、东非高原等全球大尺度地形可辨认
|
||||
3. 关闭/重新开启 terrain 不重复全量请求
|
||||
4. 不破坏:
|
||||
- 海缆
|
||||
- 卫星
|
||||
- BGP
|
||||
- 地球昼夜
|
||||
- 天球层
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
1. 引入 `TERRAIN_CONFIG`
|
||||
2. 新建 `terrain.js`
|
||||
3. 实现 Terrarium tile 请求与 decode
|
||||
4. 用低 zoom 全球 tile 构建真实 terrain sphere
|
||||
5. 接管 `toggleTerrain()`
|
||||
6. 调整 terrain material 和高度 exaggeration
|
||||
7. 做缓存
|
||||
8. 再考虑第二阶段局部高分 refinement
|
||||
|
||||
## Source References
|
||||
|
||||
- Mapzen Terrarium / AWS terrain tiles
|
||||
[Mapzen Terrain Tile Service](https://www.mapzen.com/blog/terrain-tile-service/)
|
||||
- Terrarium tile experiments / format background
|
||||
[mapzen/terrarium](https://github.com/mapzen/terrarium)
|
||||
- Copernicus DEM overview
|
||||
[Copernicus DEM docs](https://documentation.dataspace.copernicus.eu/APIs/SentinelHub/Data/DEM.html)
|
||||
|
||||
## Recommendation Summary
|
||||
|
||||
如果现在就要开始做,我建议直接按这条路线开工:
|
||||
|
||||
- 第一阶段接入 Terrarium 全球高程 tile
|
||||
- 替换掉当前 simplex 假地形
|
||||
- 先做一层真实可见的全球 terrain overlay
|
||||
- 等第一阶段稳定,再做视角高分 refinement
|
||||
|
||||
这是对当前项目风险最低、最贴合现有 Earth 架构的一条路。
|
||||
@@ -16,12 +16,16 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.28.2`
|
||||
- `dev` 当前开发分支历史推导到:`0.30.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `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 行为 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.28.2",
|
||||
"version": "0.30.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
30
frontend/public/earth/assets/celestial/bright-stars.json
Normal file
30
frontend/public/earth/assets/celestial/bright-stars.json
Normal 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 }
|
||||
]
|
||||
BIN
frontend/public/earth/assets/celestial/starmap_deep_8k.jpg
Normal file
BIN
frontend/public/earth/assets/celestial/starmap_deep_8k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 MiB |
BIN
frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg
Normal file
BIN
frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -83,59 +83,17 @@ body.earth-page {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 240;
|
||||
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);
|
||||
@keyframes earthLoadingPulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
transform: scale(0.78);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/* hud.css - HUD surfaces and shared overlays */
|
||||
|
||||
.hud-panel {
|
||||
--panel-glow-x: 18%;
|
||||
--panel-glow-y: 0%;
|
||||
--panel-glow-opacity: 0.1;
|
||||
--panel-tilt-x: 0deg;
|
||||
--panel-tilt-y: 0deg;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
radial-gradient(circle at var(--panel-glow-x) var(--panel-glow-y), rgba(255, 255, 255, calc(0.08 + var(--panel-glow-opacity))), transparent 30%),
|
||||
radial-gradient(circle at 86% 115%, rgba(145, 186, 255, 0.08), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 26%),
|
||||
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom));
|
||||
@@ -14,9 +19,14 @@
|
||||
inset 0 1px 0 var(--hud-highlight),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
|
||||
var(--hud-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 0 20px rgba(123, 176, 236, calc(0.04 + var(--panel-glow-opacity) * 0.32));
|
||||
backdrop-filter: blur(18px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(125%);
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
border-color 0.22s ease,
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel::before {
|
||||
@@ -28,6 +38,11 @@
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%);
|
||||
opacity: 0.52;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
perspective(240px)
|
||||
rotateX(calc(var(--panel-tilt-x) * 0.36))
|
||||
rotateY(calc(var(--panel-tilt-y) * 0.36));
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel::after {
|
||||
@@ -40,7 +55,11 @@
|
||||
linear-gradient(135deg, rgba(244, 249, 255, 0.22), rgba(164, 194, 226, 0.08) 36%, rgba(90, 123, 161, 0.04) 70%, rgba(255, 255, 255, 0.16));
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
filter: blur(0.2px);
|
||||
filter: url(#liquid-glass-distortion) blur(0.22px);
|
||||
transform:
|
||||
perspective(240px)
|
||||
rotateX(calc(var(--panel-tilt-x) * 0.24))
|
||||
rotateY(calc(var(--panel-tilt-y) * 0.24));
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
@@ -49,6 +68,36 @@
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging) {
|
||||
--panel-glow-opacity: 0.16;
|
||||
border-color: var(--hud-border-hover);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 20px 48px rgba(1, 7, 16, 0.34),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03),
|
||||
0 0 28px rgba(123, 176, 236, 0.1);
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging)::before {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging)::after {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.hud-panel.is-pressed:not(.is-dragging) {
|
||||
--panel-glow-opacity: 0.13;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 14px 34px rgba(1, 7, 16, 0.28),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 0 22px rgba(123, 176, 236, 0.08);
|
||||
}
|
||||
|
||||
.hud-panel > * {
|
||||
@@ -243,21 +292,33 @@
|
||||
|
||||
.earth-status-message {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
top: calc(20px * var(--hud-scale));
|
||||
left: 50%;
|
||||
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;
|
||||
backdrop-filter: blur(10px);
|
||||
text-align: center;
|
||||
min-width: 180px;
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
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;
|
||||
transition:
|
||||
transform 0.28s ease,
|
||||
@@ -269,19 +330,114 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
color: #d8f7df;
|
||||
border-left: 3px solid #66d18f;
|
||||
color: #dff8e7;
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
@@ -304,11 +460,21 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 260;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.24s ease, visibility 0.24s ease;
|
||||
}
|
||||
|
||||
.earth-settings-modal.is-opening,
|
||||
.earth-settings-modal.is-open,
|
||||
.earth-settings-modal.is-closing {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.earth-settings-modal.is-open {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-settings-backdrop {
|
||||
@@ -317,46 +483,47 @@
|
||||
background: rgba(2, 8, 20, 0.46);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.26s ease;
|
||||
}
|
||||
|
||||
.earth-settings-modal.is-open .earth-settings-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earth-settings-sheet {
|
||||
--settings-scale: clamp(0.72, calc(var(--hud-scale) * 0.96), 1);
|
||||
position: fixed;
|
||||
top: max(32px, 9vh);
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-width: 560px;
|
||||
max-height: calc(100vh - max(64px, 18vh));
|
||||
top: max(calc(32px * var(--settings-scale)), 9vh);
|
||||
right: calc(16px * var(--settings-scale));
|
||||
left: calc(16px * var(--settings-scale));
|
||||
width: min(calc(560px * var(--settings-scale)), calc(100vw - (32px * var(--settings-scale))));
|
||||
max-width: calc(560px * var(--settings-scale));
|
||||
max-height: calc(100vh - max(calc(64px * var(--settings-scale)), 18vh));
|
||||
margin-inline: auto;
|
||||
transform: none;
|
||||
border-radius: calc(24px * var(--hud-scale));
|
||||
padding: calc(20px * var(--hud-scale));
|
||||
border-radius: 0;
|
||||
padding: calc(var(--hud-panel-padding) * var(--settings-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-md);
|
||||
gap: calc(var(--hud-gap-md) * var(--settings-scale));
|
||||
overflow: hidden;
|
||||
transform: translateZ(0);
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
border-radius: 0;
|
||||
will-change: transform, opacity, filter, border-radius;
|
||||
}
|
||||
|
||||
.earth-settings-sheet.liquid-glass-surface {
|
||||
animation: none;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.09), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%),
|
||||
linear-gradient(180deg, rgba(19, 34, 56, 0.92), rgba(8, 18, 31, 0.9));
|
||||
border-color: rgba(207, 224, 243, 0.12);
|
||||
.earth-settings-sheet.hud-panel {
|
||||
--panel-glow-x: 18%;
|
||||
--panel-glow-y: 0%;
|
||||
--panel-glow-opacity: 0.1;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 24px 56px rgba(2, 7, 15, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.earth-settings-sheet.liquid-glass-surface:hover,
|
||||
.earth-settings-sheet.liquid-glass-surface:active,
|
||||
.earth-settings-sheet.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 1;
|
||||
--press-offset: 0px;
|
||||
--glow-opacity: 0.24;
|
||||
transform: none;
|
||||
inset 0 1px 0 var(--hud-highlight),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.03),
|
||||
var(--hud-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.earth-settings-header,
|
||||
@@ -366,12 +533,10 @@
|
||||
}
|
||||
|
||||
.earth-settings-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
--hud-header-padding: 0 0 var(--hud-gap-sm);
|
||||
--hud-header-gap: var(--hud-gap-md);
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-md);
|
||||
padding-bottom: var(--hud-gap-sm);
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.earth-settings-kicker {
|
||||
@@ -381,25 +546,9 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-settings-title {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hud-title);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-settings-close {
|
||||
margin-top: 4px;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
@@ -464,6 +613,73 @@
|
||||
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-slider {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
appearance: none;
|
||||
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-settings-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
|
||||
linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider-value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 46px;
|
||||
text-align: right;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-settings-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -482,6 +698,22 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.earth-settings-link-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--hud-text-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.earth-settings-link-meta .material-symbols-rounded:first-child {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.earth-settings-link-meta .material-symbols-rounded:last-child {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.earth-settings-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -26,13 +26,23 @@
|
||||
.hud-panel-brand {
|
||||
--brand-scale: 0.88;
|
||||
--brand-copy-width: 160px;
|
||||
border-radius: 0;
|
||||
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
/* Reserve full panel height before brand images load */
|
||||
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 {
|
||||
@@ -40,6 +50,20 @@
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* toolbar.css - bottom dock and floating toolbar primitives */
|
||||
/* toolbar.css - orbital hub toolbar */
|
||||
|
||||
.earth-toolbar-group {
|
||||
position: absolute;
|
||||
@@ -6,7 +6,6 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
@@ -24,119 +23,153 @@
|
||||
}
|
||||
|
||||
.earth-toolbar {
|
||||
--toolbar-scale: 1;
|
||||
--toolbar-orb-size: calc(46px * var(--toolbar-scale));
|
||||
--toolbar-hub-size: calc(58px * var(--toolbar-scale));
|
||||
--toolbar-arc-width: calc(420px * var(--toolbar-scale));
|
||||
--toolbar-arc-height: calc(160px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale));
|
||||
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale));
|
||||
position: relative;
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
height: calc(200px * var(--toolbar-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-items {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover {
|
||||
.earth-toolbar-cluster {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: 56px;
|
||||
height: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: calc(100% + 12px);
|
||||
transform: translate(-50%, 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn {
|
||||
.earth-toolbar-cluster::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(14px * var(--toolbar-scale));
|
||||
width: var(--toolbar-arc-width);
|
||||
height: var(--toolbar-arc-height);
|
||||
transform: translateX(-50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(145, 186, 255, 0.08);
|
||||
border-bottom-color: transparent;
|
||||
background:
|
||||
radial-gradient(circle at 50% 100%, rgba(145, 186, 255, 0.05), transparent 58%);
|
||||
opacity: 0.9;
|
||||
mask: linear-gradient(180deg, rgba(0, 0, 0, 0.82), transparent 86%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(30px * var(--toolbar-scale));
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-hub {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(8px * var(--toolbar-scale));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.36s cubic-bezier(0.34, 1.15, 0.64, 1),
|
||||
opacity 0.24s ease;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-orb {
|
||||
transform: translate(calc(-50% + var(--orb-x)), calc(-50% + var(--orb-y)));
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb {
|
||||
transform: translate(-50%, -50%) scale(0.42);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > *,
|
||||
.earth-toolbar-hub > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation: floatDock 4.6s ease-in-out infinite;
|
||||
animation-delay: var(--orb-delay, 0s);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-dock-engaged .earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn,
|
||||
.earth-toolbar-hub-btn {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #4db8ff;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn.floating-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
min-height: 42px;
|
||||
width: var(--toolbar-orb-size);
|
||||
height: var(--toolbar-orb-size);
|
||||
min-width: var(--toolbar-orb-size);
|
||||
min-height: var(--toolbar-orb-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:not(.liquid-glass-surface)::after {
|
||||
content: none;
|
||||
.earth-toolbar-hub-btn {
|
||||
width: var(--toolbar-hub-size);
|
||||
height: var(--toolbar-hub-size);
|
||||
min-width: var(--toolbar-hub-size);
|
||||
min-height: var(--toolbar-hub-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transform: translateZ(0);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .material-symbols-rounded {
|
||||
font-size: 21px;
|
||||
.earth-toolbar-btn .material-symbols-rounded,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
font-size: calc(21px * var(--toolbar-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
@@ -154,28 +187,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
shape-rendering: geometricPrecision;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.earth-toolbar-items > :nth-child(2n).floating-btn,
|
||||
.earth-toolbar-items > :nth-child(2n) .floating-btn {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.earth-toolbar-items > :nth-child(3n).floating-btn,
|
||||
.earth-toolbar-items > :nth-child(3n) .floating-btn {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.liquid-glass-surface {
|
||||
--elastic-x: 0px;
|
||||
--elastic-y: 0px;
|
||||
@@ -190,12 +201,14 @@
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center center;
|
||||
will-change: transform, box-shadow;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)),
|
||||
rgba(8, 20, 38, 0.22);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.05), transparent 30%),
|
||||
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)),
|
||||
rgba(8, 20, 38, 0.12);
|
||||
border: 1px solid var(--hud-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
@@ -205,7 +218,11 @@
|
||||
backdrop-filter: blur(18px) saturate(145%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(145%);
|
||||
transform:
|
||||
translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0)
|
||||
translate3d(
|
||||
var(--elastic-x),
|
||||
calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)),
|
||||
0
|
||||
)
|
||||
scale(var(--btn-scale));
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
@@ -213,17 +230,16 @@
|
||||
background 0.22s ease,
|
||||
opacity 0.18s ease,
|
||||
border-color 0.22s ease;
|
||||
animation: floatDock 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 1px 1px 18px 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%);
|
||||
opacity: 0.5;
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04) 28%, transparent 68%);
|
||||
opacity: 0.42;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
perspective(120px)
|
||||
@@ -234,14 +250,14 @@
|
||||
}
|
||||
|
||||
.liquid-glass-surface::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
padding: 1.35px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
|
||||
opacity: 0.82;
|
||||
opacity: 0.72;
|
||||
pointer-events: none;
|
||||
filter: url(#liquid-glass-distortion) blur(0.35px);
|
||||
transform:
|
||||
@@ -260,15 +276,28 @@
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 50% 24%, rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
linear-gradient(180deg, rgba(28, 54, 90, 0.26), rgba(11, 24, 43, 0.22)),
|
||||
rgba(10, 28, 52, 0.16);
|
||||
border-color: var(--hud-border);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 16px 30px rgba(0, 0, 0, 0.24),
|
||||
0 0 30px rgba(104, 181, 247, 0.18);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover {
|
||||
--btn-scale: 1.035;
|
||||
--btn-scale: 1.04;
|
||||
--press-offset: -1px;
|
||||
--glow-opacity: 0.32;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)),
|
||||
rgba(8, 20, 38, 0.2);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(128, 198, 255, 0.06)),
|
||||
rgba(8, 20, 38, 0.14);
|
||||
border-color: var(--hud-border-hover);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
@@ -289,52 +318,16 @@
|
||||
|
||||
.liquid-glass-surface:active,
|
||||
.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 0.942;
|
||||
--btn-scale: 0.95;
|
||||
--press-offset: 2px;
|
||||
--glow-opacity: 0.2;
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%),
|
||||
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)),
|
||||
rgba(10, 24, 44, 0.24);
|
||||
border-color: rgba(240, 249, 255, 0.58);
|
||||
box-shadow:
|
||||
inset 0 2px 10px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||
0 4px 10px rgba(0, 0, 0, 0.18),
|
||||
0 0 14px rgba(176, 226, 255, 0.18);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active::before,
|
||||
.liquid-glass-surface.is-pressed::before {
|
||||
opacity: 0.46;
|
||||
transform: translateY(2px) scale(0.985);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active::after,
|
||||
.liquid-glass-surface.is-pressed::after {
|
||||
opacity: 0.78;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active .icon,
|
||||
.liquid-glass-surface.is-pressed .icon {
|
||||
transform: translateY(1.5px);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active img,
|
||||
.liquid-glass-surface.is-pressed img,
|
||||
.liquid-glass-surface:active .material-symbols-rounded,
|
||||
.liquid-glass-surface.is-pressed .material-symbols-rounded {
|
||||
transform: translateY(1.5px);
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active {
|
||||
background:
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)),
|
||||
rgba(11, 34, 58, 0.26);
|
||||
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)),
|
||||
rgba(11, 34, 58, 0.18);
|
||||
border-color: var(--hud-border-active);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
@@ -357,137 +350,50 @@
|
||||
|
||||
.earth-zoom-group:hover > .earth-zoom-toolbar,
|
||||
.earth-zoom-group:focus-within > .earth-zoom-toolbar,
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar,
|
||||
.earth-info-group:hover > .earth-info-toolbar,
|
||||
.earth-info-group:focus-within > .earth-info-toolbar,
|
||||
.earth-info-group.open > .earth-info-toolbar {
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar,
|
||||
.earth-info-group.force-closed > .earth-info-toolbar {
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, 8px);
|
||||
}
|
||||
|
||||
.earth-zoom-group > .earth-zoom-toolbar,
|
||||
.earth-info-group > .earth-info-toolbar {
|
||||
.earth-toolbar-popover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(56px * var(--toolbar-scale));
|
||||
height: calc(16px * var(--toolbar-scale));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 12px);
|
||||
bottom: calc(100% + (12px * var(--toolbar-scale)));
|
||||
transform: translate(-50%, 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-info-toolbar {
|
||||
width: min(280px, calc(100vw - 36px));
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94));
|
||||
border: 1px solid rgba(211, 228, 246, 0.14);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(135%);
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-header {
|
||||
width: 100%;
|
||||
padding: 2px 4px 8px;
|
||||
border-bottom: 1px solid rgba(201, 225, 247, 0.08);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-title {
|
||||
display: block;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-toolbar-subtitle {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-layer-btn__copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-layer-btn__label {
|
||||
color: var(--hud-text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.earth-layer-btn__meta {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-layer-btn__state {
|
||||
flex: 0 0 auto;
|
||||
min-width: 42px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-layer-btn.active .earth-layer-btn__state {
|
||||
color: #dff4ff;
|
||||
border-color: rgba(220, 240, 255, 0.24);
|
||||
background: rgba(131, 197, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-layer-btn .earth-toolbar-tooltip {
|
||||
display: none;
|
||||
gap: calc(8px * var(--toolbar-scale));
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn,
|
||||
@@ -495,8 +401,8 @@
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
border-radius: 50%;
|
||||
color: #4db8ff;
|
||||
animation: floatDock 3.8s ease-in-out infinite;
|
||||
color: var(--hud-text-soft);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn {
|
||||
@@ -514,37 +420,8 @@
|
||||
padding: 0;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: normal;
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:active,
|
||||
.earth-zoom-toolbar .earth-zoom-btn.is-pressed,
|
||||
.earth-zoom-toolbar .earth-zoom-value:active,
|
||||
.earth-zoom-toolbar .earth-zoom-value.is-pressed {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn:nth-child(3) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-toolbar-tooltip {
|
||||
bottom: calc(100% + 10px);
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-toolbar-tooltip::after {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.earth-app.layout-expanded .earth-toolbar-group {
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
@@ -555,8 +432,9 @@
|
||||
bottom: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 30, 0.95);
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
||||
color: var(--hud-text);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
@@ -564,9 +442,10 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(77, 184, 255, 0.4);
|
||||
border: 1px solid var(--hud-border);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: var(--hud-shadow-soft);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:hover .earth-toolbar-tooltip,
|
||||
@@ -578,11 +457,11 @@
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
border-top-color: rgba(18, 31, 52, 0.96);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"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>
|
||||
@@ -64,6 +65,9 @@
|
||||
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</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>
|
||||
|
||||
<!-- Collapsible body -->
|
||||
@@ -150,41 +154,43 @@
|
||||
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div class="earth-toolbar-items">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
</button>
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
</span>
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">newspaper</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
<div id="zoom-control-group" class="earth-toolbar-popover earth-zoom-group">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-expanded">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索功能(待开发)">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索功能(待开发)</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.18s;">
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
</span>
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.36s;">
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.54s;">
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="4" style="--orb-delay: 0.72s;">
|
||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">zoom_in</span>
|
||||
@@ -197,27 +203,38 @@
|
||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
</span>
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
<div class="earth-toolbar-orb" data-orb-index="5" style="--orb-delay: 0.9s;">
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 1.08s;">
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.26s;">
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
</span>
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-hub">
|
||||
<button id="toolbar-hub" class="earth-toolbar-hub-btn liquid-glass-surface" title="工具菜单" aria-label="展开工具菜单">
|
||||
<span class="material-symbols-rounded" aria-hidden="true">tune</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,26 +410,20 @@
|
||||
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="earth-loading">
|
||||
<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="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
<div class="earth-settings-sheet liquid-glass-surface" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="earth-settings-header">
|
||||
<div>
|
||||
<div class="earth-settings-sheet hud-panel" role="dialog" aria-modal="true" aria-label="设置">
|
||||
<div class="earth-settings-header hud-panel__header">
|
||||
<div class="hud-panel__title-group">
|
||||
<div class="earth-settings-kicker">设置</div>
|
||||
<h3 id="settings-title" class="earth-settings-title hud-panel-title">显示与视图</h3>
|
||||
</div>
|
||||
<button id="settings-close" class="earth-settings-close hud-panel-close" type="button" aria-label="关闭设置">
|
||||
<button id="settings-close" class="earth-settings-close hud-panel__action hud-panel__action--close" type="button" aria-label="关闭设置">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-content">
|
||||
<div class="earth-settings-content hud-panel__body">
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
@@ -448,8 +459,8 @@
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-tv">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">电视直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
<span class="earth-settings-item-title">新闻直播</span>
|
||||
<span class="earth-settings-item-subtitle">控制电视直播 / 态势聚合显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
|
||||
@@ -458,6 +469,50 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
<a
|
||||
class="earth-settings-item earth-settings-link"
|
||||
href="/admin"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">Admin</span>
|
||||
<span class="earth-settings-item-subtitle">打开管理后台仪表盘</span>
|
||||
</div>
|
||||
<span class="earth-settings-link-meta">
|
||||
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||
<span class="material-symbols-rounded">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1386,21 +1386,17 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
|
||||
if (isLocked) {
|
||||
scale *= 1.1 + 0.14 * pulse;
|
||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||
haloOpacity = 0.022;
|
||||
pulseOpacity = 0.012;
|
||||
coverageOpacity = 0.02;
|
||||
markerColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.lockedNeutralColor,
|
||||
marker.userData.baseColor || BGP_CONFIG.collectorColor,
|
||||
BGP_CONFIG.collectorIcon.lockedBlend,
|
||||
);
|
||||
opacity = 0.96;
|
||||
haloOpacity = 0.05;
|
||||
pulseOpacity = 0.024;
|
||||
coverageOpacity = 0.036;
|
||||
markerColor = 0xcff2ff;
|
||||
} else if (isHovered) {
|
||||
scale *= 1.08;
|
||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||
haloOpacity = 0.016;
|
||||
pulseOpacity = 0.008;
|
||||
coverageOpacity = 0.014;
|
||||
opacity = 0.88;
|
||||
haloOpacity = 0.03;
|
||||
pulseOpacity = 0.014;
|
||||
coverageOpacity = 0.02;
|
||||
markerColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.hoverNeutralColor,
|
||||
marker.userData.baseColor || BGP_CONFIG.collectorColor,
|
||||
@@ -1482,12 +1478,13 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
if (isLocked || isLinkedCollectorLocked) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity =
|
||||
BGP_CONFIG.opacity.lockedMin +
|
||||
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
|
||||
0.9 +
|
||||
0.1 * pulse;
|
||||
markerColor = 0xfff1a8;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isHovered) {
|
||||
scale *= BGP_CONFIG.marker.hoverScale;
|
||||
opacity = BGP_CONFIG.opacity.hover;
|
||||
opacity = 0.9;
|
||||
ringBaseOpacity *= 1.05;
|
||||
} else if (isOtherLocked) {
|
||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||
|
||||
@@ -245,9 +245,12 @@ export function clearCableData(earthObj = null) {
|
||||
clearLandingPoints(earthObj);
|
||||
}
|
||||
|
||||
export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载电缆数据...");
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
if (!response.ok) {
|
||||
@@ -344,11 +347,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
textureQuality: "8K 卫星图",
|
||||
});
|
||||
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
}
|
||||
return cableLines.length;
|
||||
}
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj) {
|
||||
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载登陆点数据...");
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
}
|
||||
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
}
|
||||
return validCount;
|
||||
}
|
||||
|
||||
@@ -558,14 +566,18 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
|
||||
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||
|
||||
if (isRelated) {
|
||||
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
|
||||
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
|
||||
lp.material.color.setHex(0xffd27a);
|
||||
lp.material.emissive.setHex(0x7a4a00);
|
||||
lp.material.emissiveIntensity =
|
||||
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse;
|
||||
0.2 +
|
||||
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
|
||||
lp.material.opacity =
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse;
|
||||
Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
|
||||
);
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
lp.scale.setScalar(
|
||||
|
||||
543
frontend/public/earth/js/celestial.js
Normal file
543
frontend/public/earth/js/celestial.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,65 @@ export const EARTH_CONFIG = {
|
||||
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 = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
@@ -237,18 +296,18 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
occluderSegments: 48,
|
||||
|
||||
// Fresnel atmosphere glow — inner rim
|
||||
atmosInnerRadiusFactor: 1.018,
|
||||
atmosInnerRadiusFactor: 1.01,
|
||||
atmosInnerSegments: 64,
|
||||
atmosInnerColor: [0.25, 0.62, 1.0],
|
||||
atmosInnerRimPower: 3.2,
|
||||
atmosInnerIntensity: 0.72,
|
||||
atmosInnerIntensity: 0.18,
|
||||
|
||||
// Fresnel atmosphere glow — outer corona
|
||||
atmosOuterRadiusFactor: 1.07,
|
||||
atmosOuterRadiusFactor: 1.016,
|
||||
atmosOuterSegments: 48,
|
||||
atmosOuterColor: [0.18, 0.45, 0.9],
|
||||
atmosOuterRimPower: 5.0,
|
||||
atmosOuterIntensity: 0.28,
|
||||
atmosOuterIntensity: 0.02,
|
||||
|
||||
// Texture candidates — tried in order, first success wins
|
||||
textureUrls: [
|
||||
@@ -256,4 +315,16 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
'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',
|
||||
],
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
441
frontend/public/earth/js/controls.js
vendored
441
frontend/public/earth/js/controls.js
vendored
@@ -1,8 +1,15 @@
|
||||
// controls.js - Zoom, rotate and toggle controls
|
||||
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||
import { toggleTerrain } from "./earth.js";
|
||||
import {
|
||||
ensureTerrainReady,
|
||||
isTerrainReady,
|
||||
getTerrainOpacity,
|
||||
setTerrainOpacity,
|
||||
} from "./terrain.js";
|
||||
import {
|
||||
reloadData,
|
||||
clearLockedObject,
|
||||
@@ -40,6 +47,170 @@ const HUD_PANEL_IDS = [
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
const PANEL_LAYOUT_ANIMATION_MS = 420;
|
||||
const TOOLBAR_BASE_WIDTH_PX = 620;
|
||||
const TOOLBAR_MIN_SCALE = 0.68;
|
||||
const TOOLBAR_ORB_SIZE_PX = 46;
|
||||
const TOOLBAR_HUB_SIZE_PX = 58;
|
||||
const TOOLBAR_ORB_GAP_PX = 12;
|
||||
const TOOLBAR_ARCH_SPAN_PX = 232;
|
||||
const TOOLBAR_ARCH_RISE_PX = 40;
|
||||
const TOOLBAR_SIDE_PADDING_PX = 12;
|
||||
const TOOLBAR_BOTTOM_CLEARANCE_PX = 34;
|
||||
const TOOLBAR_EXTRA_HEIGHT_PX = 34;
|
||||
const SETTINGS_MODAL_OPEN_ANIMATION_MS = 420;
|
||||
const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
|
||||
const SETTINGS_SHEET_MIN_SCALE = 0.06;
|
||||
const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
let settingsModalTimer = null;
|
||||
let settingsSheetAnimation = null;
|
||||
let terrainToggleToken = 0;
|
||||
|
||||
function getViewRotation(targetLat, targetRotLon) {
|
||||
const latRot = (targetLat * Math.PI) / 180;
|
||||
return {
|
||||
x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient,
|
||||
y: -((targetRotLon * Math.PI) / 180),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTerrainUiState(button, enabled) {
|
||||
showTerrain = enabled;
|
||||
toggleTerrain(enabled);
|
||||
updateLayerButtonState(button, enabled);
|
||||
setButtonTooltip(button, enabled ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus) terrainStatus.textContent = enabled ? "开启" : "关闭";
|
||||
}
|
||||
|
||||
export function applyImmediateView(targetEarthObj, camera, options = {}) {
|
||||
if (!targetEarthObj) return;
|
||||
|
||||
const {
|
||||
lat = EARTH_CONFIG.chinaLat,
|
||||
rotLon = EARTH_CONFIG.chinaRotLon,
|
||||
zoom = 1.0,
|
||||
} = options;
|
||||
const nextRotation = getViewRotation(lat, rotLon);
|
||||
|
||||
targetEarthObj.rotation.x = nextRotation.x;
|
||||
targetEarthObj.rotation.y = nextRotation.y;
|
||||
zoomLevel = zoom;
|
||||
|
||||
if (camera) {
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSettingsSheetAnimation() {
|
||||
if (settingsSheetAnimation) {
|
||||
settingsSheetAnimation.cancel();
|
||||
settingsSheetAnimation = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSettingsSheetAnimationState(trigger, sheet) {
|
||||
if (!(trigger instanceof HTMLElement) || !(sheet instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const sheetRect = sheet.getBoundingClientRect();
|
||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
|
||||
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
|
||||
const sheetCenterX = sheetRect.left + sheetRect.width / 2;
|
||||
const sheetCenterY = sheetRect.top + sheetRect.height / 2;
|
||||
|
||||
return {
|
||||
translateX: triggerCenterX - sheetCenterX,
|
||||
translateY: triggerCenterY - sheetCenterY,
|
||||
scaleX: Math.max(
|
||||
SETTINGS_SHEET_MIN_SCALE,
|
||||
Math.min(
|
||||
SETTINGS_SHEET_MAX_SCALE_X,
|
||||
triggerRect.width / Math.max(sheetRect.width, 1),
|
||||
),
|
||||
),
|
||||
scaleY: Math.max(
|
||||
SETTINGS_SHEET_MIN_SCALE,
|
||||
Math.min(
|
||||
SETTINGS_SHEET_MAX_SCALE_Y,
|
||||
triggerRect.height / Math.max(sheetRect.height, 1),
|
||||
),
|
||||
),
|
||||
radius: `${Math.max(triggerRect.width, triggerRect.height).toFixed(2)}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function animateSettingsSheet(sheet, trigger, opening) {
|
||||
const animationState = getSettingsSheetAnimationState(trigger, sheet);
|
||||
if (!animationState || typeof sheet.animate !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelSettingsSheetAnimation();
|
||||
|
||||
const fromTransform = `translate(${animationState.translateX.toFixed(2)}px, ${animationState.translateY.toFixed(2)}px) scale(${animationState.scaleX.toFixed(4)}, ${animationState.scaleY.toFixed(4)})`;
|
||||
const toTransform = "translate(0px, 0px) scale(1, 1)";
|
||||
const keyframes = opening
|
||||
? [
|
||||
{
|
||||
transform: fromTransform,
|
||||
opacity: 0.22,
|
||||
filter: "blur(10px)",
|
||||
borderRadius: animationState.radius,
|
||||
},
|
||||
{
|
||||
transform: "translate(0px, 0px) scale(1.015, 1.015)",
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
borderRadius: "0px",
|
||||
offset: 0.76,
|
||||
},
|
||||
{
|
||||
transform: toTransform,
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
borderRadius: "0px",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
transform: toTransform,
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
borderRadius: "0px",
|
||||
},
|
||||
{
|
||||
transform: fromTransform,
|
||||
opacity: 0.08,
|
||||
filter: "blur(10px)",
|
||||
borderRadius: animationState.radius,
|
||||
},
|
||||
];
|
||||
|
||||
settingsSheetAnimation = sheet.animate(keyframes, {
|
||||
duration: opening
|
||||
? SETTINGS_MODAL_OPEN_ANIMATION_MS
|
||||
: SETTINGS_MODAL_CLOSE_ANIMATION_MS,
|
||||
easing: opening
|
||||
? "cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
: "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
fill: "both",
|
||||
});
|
||||
|
||||
settingsSheetAnimation.onfinish = () => {
|
||||
sheet.style.transform = "";
|
||||
sheet.style.opacity = "";
|
||||
sheet.style.filter = "";
|
||||
sheet.style.borderRadius = "";
|
||||
settingsSheetAnimation = null;
|
||||
};
|
||||
settingsSheetAnimation.oncancel = () => {
|
||||
settingsSheetAnimation = null;
|
||||
};
|
||||
}
|
||||
|
||||
function getFloatingGroups() {
|
||||
return [
|
||||
@@ -76,17 +247,49 @@ function closeFloatingMenus() {
|
||||
|
||||
function openSettingsModal() {
|
||||
const modal = document.getElementById("settings-modal");
|
||||
const trigger = document.getElementById("settings-trigger");
|
||||
const sheet = modal?.querySelector(".earth-settings-sheet");
|
||||
if (!modal) return;
|
||||
if (settingsModalTimer) {
|
||||
clearTimeout(settingsModalTimer);
|
||||
settingsModalTimer = null;
|
||||
}
|
||||
closeFloatingMenus();
|
||||
cancelSettingsSheetAnimation();
|
||||
modal.classList.remove("is-closing");
|
||||
modal.classList.add("is-opening");
|
||||
modal.classList.add("is-open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (sheet instanceof HTMLElement) {
|
||||
animateSettingsSheet(sheet, trigger, true);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
modal.classList.remove("is-opening");
|
||||
}, SETTINGS_MODAL_OPEN_ANIMATION_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById("settings-modal");
|
||||
const trigger = document.getElementById("settings-trigger");
|
||||
const sheet = modal?.querySelector(".earth-settings-sheet");
|
||||
if (!modal) return;
|
||||
cancelSettingsSheetAnimation();
|
||||
modal.classList.remove("is-open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modal.classList.add("is-closing");
|
||||
if (sheet instanceof HTMLElement) {
|
||||
animateSettingsSheet(sheet, trigger, false);
|
||||
}
|
||||
if (settingsModalTimer) {
|
||||
clearTimeout(settingsModalTimer);
|
||||
}
|
||||
settingsModalTimer = window.setTimeout(() => {
|
||||
modal.classList.remove("is-closing", "is-opening");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
settingsModalTimer = null;
|
||||
}, SETTINGS_MODAL_CLOSE_ANIMATION_MS);
|
||||
}
|
||||
|
||||
function setHudPanelVisibility(panelId, visible) {
|
||||
@@ -157,6 +360,32 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
|
||||
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
|
||||
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||
const safeOpacity = Math.round(nextOpacity * 100);
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
terrainOpacitySlider.value = nextOpacity.toFixed(2);
|
||||
}
|
||||
if (terrainOpacityValue) {
|
||||
terrainOpacityValue.textContent = `${safeOpacity}%`;
|
||||
}
|
||||
};
|
||||
|
||||
syncTerrainOpacityUi(getTerrainOpacity());
|
||||
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
bindListener(terrainOpacitySlider, "input", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
const nextOpacity = Number.parseFloat(target.value);
|
||||
const appliedOpacity = setTerrainOpacity(
|
||||
Number.isFinite(nextOpacity) ? nextOpacity : getTerrainOpacity(),
|
||||
);
|
||||
syncTerrainOpacityUi(appliedOpacity);
|
||||
});
|
||||
}
|
||||
|
||||
syncAllHudPanelToggles();
|
||||
}
|
||||
|
||||
@@ -251,6 +480,7 @@ function setupDraggableHudPanels() {
|
||||
const capturedWidth = panelRect.width;
|
||||
panel.style.position = "absolute";
|
||||
panel.style.width = `${capturedWidth}px`;
|
||||
panel.style.margin = "0";
|
||||
app.appendChild(panel);
|
||||
}
|
||||
|
||||
@@ -360,6 +590,7 @@ export function setupControls(camera, renderer, scene, earth) {
|
||||
setupRotateControls(camera, earth);
|
||||
setupTerrainControls();
|
||||
setupLiquidGlassInteractions();
|
||||
setupToolbarHubCluster();
|
||||
setupKeyboardControls();
|
||||
}
|
||||
|
||||
@@ -529,10 +760,7 @@ export function resetView(camera) {
|
||||
if (!earthObj) return;
|
||||
|
||||
function animateToView(targetLat, targetLon, targetRotLon) {
|
||||
const latRot = (targetLat * Math.PI) / 180;
|
||||
const targetRotX =
|
||||
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
|
||||
const targetRotY = -((targetRotLon * Math.PI) / 180);
|
||||
const targetRotation = getViewRotation(targetLat, targetRotLon);
|
||||
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
@@ -545,8 +773,10 @@ export function resetView(camera) {
|
||||
800,
|
||||
(progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
|
||||
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
|
||||
earthObj.rotation.x =
|
||||
startRotX + (targetRotation.x - startRotX) * ease;
|
||||
earthObj.rotation.y =
|
||||
startRotY + (targetRotation.y - startRotY) * ease;
|
||||
|
||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
@@ -717,15 +947,30 @@ function setupTerrainControls() {
|
||||
showStatusMessage("搜索功能待开发", "info");
|
||||
});
|
||||
|
||||
bindListener(terrainBtn, "click", function () {
|
||||
showTerrain = !showTerrain;
|
||||
toggleTerrain(showTerrain);
|
||||
updateLayerButtonState(this, showTerrain);
|
||||
setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus)
|
||||
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
|
||||
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
|
||||
bindListener(terrainBtn, "click", async function () {
|
||||
const nextShowTerrain = !showTerrain;
|
||||
const toggleToken = ++terrainToggleToken;
|
||||
|
||||
if (!nextShowTerrain) {
|
||||
applyTerrainUiState(this, false);
|
||||
showStatusMessage("地形已隐藏", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isTerrainReady()) {
|
||||
showStatusMessage("正在加载真实地形数据...", "info");
|
||||
await ensureTerrainReady();
|
||||
}
|
||||
if (toggleToken !== terrainToggleToken) return;
|
||||
|
||||
applyTerrainUiState(this, true);
|
||||
showStatusMessage("真实地形已显示", "success");
|
||||
} catch (error) {
|
||||
console.error("加载真实地形失败:", error);
|
||||
applyTerrainUiState(this, false);
|
||||
showStatusMessage("真实地形暂时不可用", "error");
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(satellitesBtn, "click", async function () {
|
||||
@@ -840,42 +1085,68 @@ function setupKeyboardControls() {
|
||||
}
|
||||
|
||||
function setupLiquidGlassInteractions() {
|
||||
const surfaces = document.querySelectorAll(".liquid-glass-surface");
|
||||
const surfaces = document.querySelectorAll(".liquid-glass-surface, .hud-panel");
|
||||
|
||||
const resetSurface = (surface) => {
|
||||
surface.style.setProperty("--elastic-x", "0px");
|
||||
surface.style.setProperty("--elastic-y", "0px");
|
||||
surface.style.setProperty("--tilt-x", "0deg");
|
||||
surface.style.setProperty("--tilt-y", "0deg");
|
||||
surface.style.setProperty("--panel-tilt-x", "0deg");
|
||||
surface.style.setProperty("--panel-tilt-y", "0deg");
|
||||
surface.style.setProperty("--dock-scale", "1");
|
||||
surface.style.setProperty("--dock-lift", "0px");
|
||||
surface.style.setProperty("--dock-shift-x", "0px");
|
||||
surface.style.setProperty("--glow-x", "50%");
|
||||
surface.style.setProperty("--glow-y", "22%");
|
||||
surface.style.setProperty("--glow-opacity", "0.24");
|
||||
surface.style.setProperty("--panel-glow-x", "18%");
|
||||
surface.style.setProperty("--panel-glow-y", "0%");
|
||||
surface.style.setProperty("--panel-glow-opacity", "0.1");
|
||||
surface.classList.remove("dock-focused");
|
||||
surface.classList.remove("dock-active");
|
||||
surface.classList.remove("is-pressed");
|
||||
};
|
||||
|
||||
surfaces.forEach((surface) => {
|
||||
resetSurface(surface);
|
||||
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar-items"));
|
||||
const isPanelSurface = surface.classList.contains("hud-panel");
|
||||
|
||||
bindListener(surface, "pointermove", (event) => {
|
||||
const rect = surface.getBoundingClientRect();
|
||||
const px = (event.clientX - rect.left) / rect.width;
|
||||
const py = (event.clientY - rect.top) / rect.height;
|
||||
const offsetX = (px - 0.5) * 6;
|
||||
const offsetY = (py - 0.5) * 6;
|
||||
const tiltX = (0.5 - py) * 8;
|
||||
const tiltY = (px - 0.5) * 10;
|
||||
if (isPanelSurface) {
|
||||
const panelTiltX = (0.5 - py) * 5;
|
||||
const panelTiltY = (px - 0.5) * 6;
|
||||
surface.style.setProperty("--panel-glow-x", `${(px * 100).toFixed(1)}%`);
|
||||
surface.style.setProperty("--panel-glow-y", `${(py * 100).toFixed(1)}%`);
|
||||
surface.style.setProperty("--panel-glow-opacity", "0.16");
|
||||
surface.style.setProperty("--panel-tilt-x", `${panelTiltX.toFixed(2)}deg`);
|
||||
surface.style.setProperty("--panel-tilt-y", `${panelTiltY.toFixed(2)}deg`);
|
||||
} else if (!isToolbarSurface) {
|
||||
const offsetX = (px - 0.5) * 6;
|
||||
const offsetY = (py - 0.5) * 6;
|
||||
const tiltX = (0.5 - py) * 8;
|
||||
const tiltY = (px - 0.5) * 10;
|
||||
|
||||
surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`);
|
||||
surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`);
|
||||
surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`);
|
||||
surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`);
|
||||
surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`);
|
||||
surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`);
|
||||
surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`);
|
||||
surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`);
|
||||
}
|
||||
surface.style.setProperty("--glow-x", `${(px * 100).toFixed(1)}%`);
|
||||
surface.style.setProperty("--glow-y", `${(py * 100).toFixed(1)}%`);
|
||||
surface.style.setProperty("--glow-opacity", "0.34");
|
||||
});
|
||||
|
||||
bindListener(surface, "pointerenter", () => {
|
||||
surface.style.setProperty("--glow-opacity", "0.28");
|
||||
if (isPanelSurface) {
|
||||
surface.style.setProperty("--panel-glow-opacity", "0.14");
|
||||
} else {
|
||||
surface.style.setProperty("--glow-opacity", "0.28");
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(surface, "pointerleave", () => {
|
||||
@@ -896,6 +1167,123 @@ function setupLiquidGlassInteractions() {
|
||||
});
|
||||
}
|
||||
|
||||
function setupToolbarHubCluster() {
|
||||
const cluster = document.getElementById("toolbar-cluster");
|
||||
const hub = document.getElementById("toolbar-hub");
|
||||
const toolbar = document.getElementById("control-toolbar");
|
||||
if (!(cluster instanceof HTMLElement) || !(hub instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let collapseTimer = null;
|
||||
|
||||
const layoutToolbarOrbs = () => {
|
||||
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
|
||||
if (orbs.length === 0) return;
|
||||
|
||||
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
|
||||
const orbCount = orbs.length;
|
||||
|
||||
let toolbarScale = THREE.MathUtils.clamp(
|
||||
toolbarWidth / TOOLBAR_BASE_WIDTH_PX,
|
||||
TOOLBAR_MIN_SCALE,
|
||||
1,
|
||||
);
|
||||
|
||||
let orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale;
|
||||
let desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale;
|
||||
let span = TOOLBAR_ARCH_SPAN_PX * toolbarScale;
|
||||
let rise = TOOLBAR_ARCH_RISE_PX * toolbarScale;
|
||||
const minSpanForSpacing =
|
||||
orbCount > 1 ? (orbCount - 1) * (orbSize + desiredGap) : orbSize;
|
||||
const maxSpanByWidth =
|
||||
toolbarWidth - orbSize - TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale;
|
||||
|
||||
if (minSpanForSpacing > maxSpanByWidth) {
|
||||
toolbarScale = THREE.MathUtils.clamp(
|
||||
maxSpanByWidth / minSpanForSpacing,
|
||||
TOOLBAR_MIN_SCALE,
|
||||
toolbarScale,
|
||||
);
|
||||
orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale;
|
||||
desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale;
|
||||
span = TOOLBAR_ARCH_SPAN_PX * toolbarScale;
|
||||
rise = TOOLBAR_ARCH_RISE_PX * toolbarScale;
|
||||
}
|
||||
|
||||
span = THREE.MathUtils.clamp(
|
||||
span,
|
||||
minSpanForSpacing,
|
||||
maxSpanByWidth,
|
||||
);
|
||||
rise = Math.min(rise, span * 0.32);
|
||||
|
||||
const hubSize = TOOLBAR_HUB_SIZE_PX * toolbarScale;
|
||||
const maxVerticalReach = rise + orbSize * 0.5;
|
||||
const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale;
|
||||
|
||||
toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3));
|
||||
toolbar.style.height = `${Math.ceil(toolbarHeight)}px`;
|
||||
toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`);
|
||||
toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`);
|
||||
toolbar.style.setProperty("--toolbar-inner-arc-width", `${Math.ceil(span * 0.72)}px`);
|
||||
toolbar.style.setProperty("--toolbar-inner-arc-height", `${Math.ceil((hubSize * 0.8) + (desiredGap * 0.5))}px`);
|
||||
|
||||
orbs.forEach((orb, index) => {
|
||||
const t = orbCount === 1 ? 0.5 : index / (orbCount - 1);
|
||||
const x = (t - 0.5) * span;
|
||||
const normalized = (x / (span / 2 || 1));
|
||||
const y = -(1 - normalized * normalized) * rise;
|
||||
orb.style.setProperty("--orb-x", `${x.toFixed(2)}px`);
|
||||
orb.style.setProperty("--orb-y", `${y.toFixed(2)}px`);
|
||||
});
|
||||
};
|
||||
|
||||
const setExpanded = (expanded) => {
|
||||
cluster.classList.toggle("is-expanded", expanded);
|
||||
cluster.classList.toggle("is-collapsed", !expanded);
|
||||
};
|
||||
|
||||
const scheduleCollapse = () => {
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
collapseTimer = window.setTimeout(() => {
|
||||
setExpanded(false);
|
||||
collapseTimer = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const cancelCollapse = () => {
|
||||
if (collapseTimer) {
|
||||
clearTimeout(collapseTimer);
|
||||
collapseTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
cleanupFns.push(() => {
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
});
|
||||
|
||||
// Start collapsed — hub acts as the hover target to reveal the arc
|
||||
layoutToolbarOrbs();
|
||||
setExpanded(false);
|
||||
|
||||
bindListener(window, "resize", layoutToolbarOrbs);
|
||||
|
||||
bindListener(hub, "mouseenter", () => {
|
||||
cancelCollapse();
|
||||
setExpanded(true);
|
||||
});
|
||||
|
||||
// Keep expanded while cursor stays anywhere within the toolbar area
|
||||
bindListener(toolbar, "mouseenter", () => {
|
||||
cancelCollapse();
|
||||
});
|
||||
|
||||
bindListener(toolbar, "mouseleave", () => {
|
||||
scheduleCollapse();
|
||||
});
|
||||
}
|
||||
|
||||
export function teardownControls() {
|
||||
resetCleanup();
|
||||
}
|
||||
@@ -972,6 +1360,7 @@ function resetPanelInlineLayout(panel) {
|
||||
panel.style.transform = "";
|
||||
panel.style.position = "";
|
||||
panel.style.width = "";
|
||||
panel.style.margin = "";
|
||||
delete panel.dataset.dragged;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
@@ -10,6 +10,73 @@ export let terrain = null;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
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) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
@@ -27,6 +94,7 @@ export function createEarth(scene) {
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
});
|
||||
applyEarthDayNightShader(material);
|
||||
_earthMaterial = material;
|
||||
|
||||
earth = new THREE.Mesh(geometry, material);
|
||||
@@ -144,32 +212,32 @@ export function createClouds(scene, earthObj) {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function createTerrain(scene, earthObj, simplex) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
const positionAttribute = geometry.getAttribute('position');
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const x = positionAttribute.getX(i);
|
||||
const y = positionAttribute.getY(i);
|
||||
const z = positionAttribute.getZ(i);
|
||||
|
||||
const noise = simplex(x / 20, y / 20, z / 20);
|
||||
const height = 1 + noise * 0.02;
|
||||
|
||||
positionAttribute.setXYZ(i, x * height, y * height, z * height);
|
||||
}
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
TERRAIN_CONFIG.geometryWidthSegments,
|
||||
TERRAIN_CONFIG.geometryHeightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x00aa00,
|
||||
flatShading: true,
|
||||
color: TERRAIN_CONFIG.color,
|
||||
emissive: TERRAIN_CONFIG.emissive,
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.7
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
});
|
||||
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
earthObj.add(terrain);
|
||||
|
||||
return terrain;
|
||||
@@ -271,6 +339,14 @@ export function clearEarthTexture() {
|
||||
_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() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as THREE from "three";
|
||||
import { createNoise3D } from "simplex-noise";
|
||||
|
||||
import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
|
||||
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
|
||||
import {
|
||||
showStatusMessage,
|
||||
queueStatusMessage,
|
||||
updateCoordinatesDisplay,
|
||||
updateZoomDisplay,
|
||||
updateEarthStats,
|
||||
@@ -20,12 +20,22 @@ import {
|
||||
createEarth,
|
||||
createClouds,
|
||||
createTerrain,
|
||||
createStars,
|
||||
createGridLines,
|
||||
getEarth,
|
||||
loadEarthTexture,
|
||||
clearEarthTexture,
|
||||
setEarthSunDirection,
|
||||
} from "./earth.js";
|
||||
import { registerTerrainMesh, clearTerrainData } from "./terrain.js";
|
||||
import {
|
||||
initCelestialLayer,
|
||||
updateCelestialLayer,
|
||||
disposeCelestialLayer,
|
||||
getCelestialDebugState,
|
||||
getSunDirection,
|
||||
setCelestialOrientation,
|
||||
setCelestialFollow,
|
||||
} from "./celestial.js";
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
loadLandingPoints,
|
||||
@@ -109,7 +119,7 @@ import {
|
||||
getAutoRotate,
|
||||
getShowTerrain,
|
||||
setAutoRotate,
|
||||
resetView,
|
||||
applyImmediateView,
|
||||
getZoomLevel,
|
||||
teardownControls,
|
||||
updateLayerButtonState,
|
||||
@@ -133,7 +143,6 @@ export let scene;
|
||||
export let camera;
|
||||
export let renderer;
|
||||
|
||||
let simplex;
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let targetRotation = { x: 0, y: 0 };
|
||||
@@ -165,6 +174,7 @@ let cablesEnabled = true;
|
||||
let satellitesEnabled = true;
|
||||
let cableToggleToken = 0;
|
||||
let satelliteToggleToken = 0;
|
||||
let sceneLights = null;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const interactionRaycaster = new THREE.Raycaster();
|
||||
@@ -690,14 +700,17 @@ function applyCableVisualState() {
|
||||
switch (state) {
|
||||
case CABLE_STATE.LOCKED:
|
||||
cable.material.opacity =
|
||||
CABLE_CONFIG.lockedOpacityMin +
|
||||
pulse *
|
||||
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
|
||||
cable.material.color.setRGB(1, 1, 1);
|
||||
Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.lockedOpacityMin +
|
||||
pulse *
|
||||
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin),
|
||||
);
|
||||
cable.material.color.setRGB(0.86, 0.96, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.HOVERED:
|
||||
cable.material.opacity = 1;
|
||||
cable.material.color.setRGB(1, 1, 1);
|
||||
cable.material.color.setRGB(0.92, 0.98, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.NORMAL:
|
||||
default:
|
||||
@@ -794,8 +807,10 @@ async function ensureCablesEnabled() {
|
||||
|
||||
clearCableData(earth);
|
||||
// Load landing points first so they appear before cable lines
|
||||
await loadLandingPoints(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
await loadLandingPoints(scene, earth, { silent: true });
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||
silent: true,
|
||||
});
|
||||
|
||||
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
|
||||
clearCableData(earth);
|
||||
@@ -900,7 +915,6 @@ export function init() {
|
||||
|
||||
destroyed = false;
|
||||
initialized = true;
|
||||
simplex = createNoise3D();
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
@@ -912,7 +926,7 @@ export function init() {
|
||||
75,
|
||||
getViewportAspect(),
|
||||
0.1,
|
||||
1000,
|
||||
5000,
|
||||
);
|
||||
camera.position.z = CONFIG.defaultCameraZ;
|
||||
setSatelliteCamera(camera);
|
||||
@@ -923,7 +937,7 @@ export function init() {
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
syncRendererViewport();
|
||||
renderer.setClearColor(0x0a0a1a, 1);
|
||||
renderer.setClearColor(0x02040a, 1);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
const container = document.getElementById("container");
|
||||
@@ -932,26 +946,31 @@ export function init() {
|
||||
container.appendChild(renderer.domElement);
|
||||
}
|
||||
|
||||
addLights();
|
||||
sceneLights = addLights();
|
||||
initInfoCard();
|
||||
initLegend();
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
const earthObj = createEarth(scene);
|
||||
applyImmediateView(earthObj, camera);
|
||||
targetRotation = {
|
||||
x: earthObj.rotation.x,
|
||||
y: earthObj.rotation.y,
|
||||
};
|
||||
inertialVelocity = { x: 0, y: 0 };
|
||||
createClouds(scene, earthObj);
|
||||
createTerrain(scene, earthObj, simplex);
|
||||
createStars(scene);
|
||||
registerTerrainMesh(createTerrain(earthObj));
|
||||
initCelestialLayer(scene, {
|
||||
camera,
|
||||
sunLight: sceneLights?.sunLight ?? null,
|
||||
backLight: sceneLights?.backLight ?? null,
|
||||
earth: earthObj,
|
||||
});
|
||||
createGridLines(scene, earthObj);
|
||||
createSatellites(scene, earthObj);
|
||||
|
||||
setupControls(camera, renderer, scene, earthObj);
|
||||
resetView(camera);
|
||||
setupEventListeners();
|
||||
|
||||
clock.start();
|
||||
@@ -967,17 +986,25 @@ function registerGlobalApi() {
|
||||
hideInfoCard();
|
||||
clearLockedObject();
|
||||
},
|
||||
celestial: {
|
||||
getState: () => getCelestialDebugState(),
|
||||
setOrientation: (nextEuler) => setCelestialOrientation(nextEuler),
|
||||
setFollow: (nextFollow) => setCelestialFollow(nextFollow),
|
||||
},
|
||||
destroy,
|
||||
init,
|
||||
};
|
||||
}
|
||||
|
||||
function addLights() {
|
||||
scene.add(new THREE.AmbientLight(0x404060));
|
||||
const ambientLight = new THREE.AmbientLight(0x404060);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
directionalLight.position.set(5, 3, 5);
|
||||
scene.add(directionalLight);
|
||||
const sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
sunLight.position.set(5, 3, 5);
|
||||
sunLight.target.position.set(0, 0, 0);
|
||||
scene.add(sunLight);
|
||||
scene.add(sunLight.target);
|
||||
|
||||
const backLight = new THREE.DirectionalLight(0x446688, 0.3);
|
||||
backLight.position.set(-5, 0, -5);
|
||||
@@ -986,6 +1013,13 @@ function addLights() {
|
||||
const pointLight = new THREE.PointLight(0xffffff, 0.4);
|
||||
pointLight.position.set(10, 10, 10);
|
||||
scene.add(pointLight);
|
||||
|
||||
return {
|
||||
ambientLight,
|
||||
sunLight,
|
||||
backLight,
|
||||
pointLight,
|
||||
};
|
||||
}
|
||||
|
||||
// Yield control to the browser so the renderer can paint a frame before the next step
|
||||
@@ -1009,7 +1043,7 @@ async function loadData() {
|
||||
clearCableData(earth);
|
||||
clearSatelliteData();
|
||||
|
||||
setLoadingMessage("正在初始化...", "清除旧数据");
|
||||
setLoadingMessage("正在初始化...");
|
||||
setLoading(true);
|
||||
await yieldFrame();
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
@@ -1018,10 +1052,10 @@ async function loadData() {
|
||||
|
||||
// Step 1 — Landing points
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载登陆点...", "同步海底光缆登陆站数据");
|
||||
setLoadingMessage("正在加载登陆点...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadLandingPoints(scene, earth);
|
||||
await loadLandingPoints(scene, earth, { silent: true });
|
||||
} catch (err) {
|
||||
errors.push({ label: "登陆点", reason: err });
|
||||
}
|
||||
@@ -1031,10 +1065,12 @@ async function loadData() {
|
||||
|
||||
// Step 2 — Cables
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载海缆...", "同步海底光缆网络数据");
|
||||
setLoadingMessage("正在加载海缆...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||
silent: true,
|
||||
});
|
||||
if (loadToken === currentLoadToken && cablesEnabled) {
|
||||
toggleCables(true);
|
||||
updateCableToggleUi(true);
|
||||
@@ -1050,7 +1086,7 @@ async function loadData() {
|
||||
|
||||
// Step 3 — Satellites
|
||||
if (satellitesEnabled) {
|
||||
setLoadingMessage("正在加载卫星...", "同步在轨卫星轨道数据");
|
||||
setLoadingMessage("正在加载卫星...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
clearSatelliteData();
|
||||
@@ -1070,7 +1106,7 @@ async function loadData() {
|
||||
}
|
||||
|
||||
// Step 4 — BGP
|
||||
setLoadingMessage("正在加载BGP态势...", "同步全球路由观测数据");
|
||||
setLoadingMessage("正在加载BGP态势...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const bgpResult = await loadBGPAnomalies(scene, earth);
|
||||
@@ -1085,7 +1121,7 @@ async function loadData() {
|
||||
await yieldFrame();
|
||||
|
||||
// Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
|
||||
setLoadingMessage("正在加载地球纹理...", "加载8K卫星地图");
|
||||
setLoadingMessage("正在加载地球纹理...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadEarthTexture();
|
||||
@@ -1097,19 +1133,10 @@ async function loadData() {
|
||||
|
||||
// Step 6 — Terrain (if enabled)
|
||||
if (getShowTerrain()) {
|
||||
setLoadingMessage("正在渲染地形...", "生成地表高程数据");
|
||||
setLoadingMessage("正在渲染地形...");
|
||||
await yieldFrame(50);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = buildLoadErrorMessage(errors);
|
||||
showError(errorMessage);
|
||||
showStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
showStatusMessage("数据已加载", "success");
|
||||
}
|
||||
|
||||
updateStatsSummary();
|
||||
updateCableToggleUi(cablesEnabled);
|
||||
updateSatelliteToggleUi(satellitesEnabled);
|
||||
@@ -1119,6 +1146,15 @@ async function loadData() {
|
||||
refreshLegend();
|
||||
setLoading(false);
|
||||
isDataLoading = false;
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = buildLoadErrorMessage(errors);
|
||||
showError(errorMessage);
|
||||
queueStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
queueStatusMessage("数据已加载", "success");
|
||||
}
|
||||
}
|
||||
|
||||
const POSITION_UPDATE_FORCE_DELTA = 250;
|
||||
@@ -1140,7 +1176,7 @@ export async function setCablesEnabled(enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
setLoadingMessage("正在加载线缆数据...", "重建海缆与登陆点对象");
|
||||
setLoadingMessage("正在加载线缆数据...");
|
||||
setLoading(true);
|
||||
hideError();
|
||||
|
||||
@@ -1173,7 +1209,7 @@ export async function setSatellitesEnabled(enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
setLoadingMessage("正在加载卫星数据...", "重建卫星点位与轨迹缓存");
|
||||
setLoadingMessage("正在加载卫星数据...");
|
||||
setLoading(true);
|
||||
hideError();
|
||||
|
||||
@@ -1710,6 +1746,8 @@ function animate() {
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateCelestialLayer(new Date(), camera);
|
||||
setEarthSunDirection(getSunDirection());
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
@@ -1751,6 +1789,8 @@ export function destroy() {
|
||||
clearBGPData(getEarth());
|
||||
resetSatelliteState();
|
||||
clearUiState();
|
||||
disposeCelestialLayer();
|
||||
clearTerrainData();
|
||||
|
||||
if (scene) {
|
||||
disposeSceneObject(scene);
|
||||
@@ -1767,6 +1807,7 @@ export function destroy() {
|
||||
scene = null;
|
||||
camera = null;
|
||||
renderer = null;
|
||||
sceneLights = null;
|
||||
initialized = false;
|
||||
|
||||
delete window.__planetEarth;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getActiveTVTab, openTVPanelTab, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
import { isTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
@@ -19,7 +19,6 @@ let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
return {
|
||||
toggleBtn: document.getElementById("toggle-news"),
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
status: document.getElementById("news-board-status"),
|
||||
@@ -53,14 +52,7 @@ function formatRelativeTime(raw) {
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭态势新闻" : "打开态势新闻";
|
||||
}
|
||||
void visible;
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
@@ -283,39 +275,11 @@ export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { toggleBtn, refreshBtn, openBtn } = getElements();
|
||||
const { refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
const openNewsTab = async () => {
|
||||
openTVPanelTab("news");
|
||||
updateNewsToggleUI(true);
|
||||
try {
|
||||
await ensureNewsPanelReady();
|
||||
} catch {
|
||||
// surface already handled
|
||||
}
|
||||
};
|
||||
|
||||
toggleBtn?.addEventListener("click", async () => {
|
||||
const visible = isTVPanelVisible();
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
|
||||
if (!visible) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setTVPanelVisible(false);
|
||||
updateNewsToggleUI(false);
|
||||
});
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ let lockedSatelliteIndex = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let positionUpdateAccumulator = 0;
|
||||
let satelliteCapacity = 0;
|
||||
let selectedSatelliteLegendKey = null;
|
||||
|
||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
@@ -38,43 +37,82 @@ export let breathingPhase = 0;
|
||||
|
||||
const SATELLITE_LEGEND_RULES = [
|
||||
{
|
||||
key: "starlink",
|
||||
label: "Starlink",
|
||||
color: "#00e6ff",
|
||||
match: (props) => (props?.name || "").includes("STARLINK"),
|
||||
},
|
||||
{
|
||||
key: "geo",
|
||||
label: "GEO / 倾角 20-30",
|
||||
color: "#ffcc00",
|
||||
key: "equatorial",
|
||||
label: "赤道轨道(0-30°)",
|
||||
color: "#ff3333",
|
||||
match: (props) => {
|
||||
const inclination = props?.inclination || 53;
|
||||
return inclination > 20 && inclination < 30;
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 0 && inclination < 30;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "iridium",
|
||||
label: "Iridium",
|
||||
color: "#ff8000",
|
||||
match: (props) => (props?.name || "").includes("IRIDIUM"),
|
||||
key: "low",
|
||||
label: "低倾角轨道(30-60°)",
|
||||
color: "#ff9933",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 30 && inclination < 60;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "mid-inclination",
|
||||
label: "倾角 50-70",
|
||||
color: "#00ff4d",
|
||||
key: "medium",
|
||||
label: "中倾角轨道(60-90°)",
|
||||
color: "#ffff33",
|
||||
match: (props) => {
|
||||
const inclination = props?.inclination || 53;
|
||||
return inclination > 50 && inclination < 70;
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
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",
|
||||
label: "其他卫星",
|
||||
color: "#ffffff",
|
||||
label: "其他",
|
||||
color: "#d7e2f4",
|
||||
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) {
|
||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||
}
|
||||
@@ -98,31 +136,15 @@ export function getSatelliteLegendItems() {
|
||||
.filter((item) => presentKeys.has(item.key))
|
||||
.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 }));
|
||||
}
|
||||
|
||||
export function setSelectedSatelliteLegend(props) {
|
||||
const rule = SATELLITE_LEGEND_RULES.find((item) =>
|
||||
item.match(props || {}),
|
||||
);
|
||||
selectedSatelliteLegendKey = rule?.key || null;
|
||||
return getSatelliteLegendRule(props || {});
|
||||
}
|
||||
|
||||
export function clearSelectedSatelliteLegend() {
|
||||
selectedSatelliteLegendKey = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
@@ -538,36 +560,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3 + 1] = pos.y;
|
||||
positions[i * 3 + 2] = pos.z;
|
||||
|
||||
const inclination = props?.inclination || 53;
|
||||
const name = props?.name || "";
|
||||
const isStarlink = name.includes("STARLINK");
|
||||
const isGeo = inclination > 20 && inclination < 30;
|
||||
const isIridium = name.includes("IRIDIUM");
|
||||
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
if (isStarlink) {
|
||||
r = 0.0;
|
||||
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 {
|
||||
r = 1.0;
|
||||
g = 1.0;
|
||||
b = 1.0;
|
||||
}
|
||||
const rule = getSatelliteLegendRule(props);
|
||||
const { r, g, b } = getSatelliteRuleColor(rule);
|
||||
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
|
||||
304
frontend/public/earth/js/terrain.js
Normal file
304
frontend/public/earth/js/terrain.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, TERRAIN_CONFIG } from "./constants.js";
|
||||
import { vector3ToLatLon } from "./utils.js";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371000;
|
||||
const TERRAIN_COLOR_STOPS = [
|
||||
{ height: 0, color: new THREE.Color(0x5f7f5b) },
|
||||
{ height: 800, color: new THREE.Color(0x7f9564) },
|
||||
{ height: 1800, color: new THREE.Color(0x9e956c) },
|
||||
{ height: 3200, color: new THREE.Color(0x9f866a) },
|
||||
{ height: 5200, color: new THREE.Color(0xc6c0b1) },
|
||||
{ height: 7800, color: new THREE.Color(0xe8e5de) },
|
||||
];
|
||||
|
||||
let terrainMesh = null;
|
||||
let terrainLoadPromise = null;
|
||||
let terrainReady = false;
|
||||
let terrainFailed = false;
|
||||
let terrainTileCache = new Map();
|
||||
let terrainVertexSamples = null;
|
||||
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
|
||||
function clampLatitude(lat) {
|
||||
return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878);
|
||||
}
|
||||
|
||||
function buildTileUrl(z, x, y) {
|
||||
return TERRAIN_CONFIG.urlTemplate
|
||||
.replace("{z}", String(z))
|
||||
.replace("{x}", String(x))
|
||||
.replace("{y}", String(y));
|
||||
}
|
||||
|
||||
function getTerrainCanvas(size) {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(size, size);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function decodeTerrainTile(z, x, y) {
|
||||
const cacheKey = `${z}/${x}/${y}`;
|
||||
if (terrainTileCache.has(cacheKey)) {
|
||||
return terrainTileCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const tilePromise = (async () => {
|
||||
const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||
bitmap.close?.();
|
||||
const { data, width, height } = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
return { data, width, height };
|
||||
})();
|
||||
|
||||
terrainTileCache.set(cacheKey, tilePromise);
|
||||
return tilePromise;
|
||||
}
|
||||
|
||||
function decodeTerrariumHeight(tile, pixelX, pixelY) {
|
||||
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
|
||||
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
|
||||
const index = (safeY * tile.width + safeX) * 4;
|
||||
const r = tile.data[index];
|
||||
const g = tile.data[index + 1];
|
||||
const b = tile.data[index + 2];
|
||||
return (r * 256 + g + b / 256) - 32768;
|
||||
}
|
||||
|
||||
function latLonToTileSample(lat, lon, z, tileSize) {
|
||||
const n = 2 ** z;
|
||||
const clampedLat = clampLatitude(lat);
|
||||
const latRad = THREE.MathUtils.degToRad(clampedLat);
|
||||
const normalizedX = ((lon + 180) / 360) * n;
|
||||
const normalizedY =
|
||||
((1 -
|
||||
Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
|
||||
2) *
|
||||
n;
|
||||
|
||||
const tileX = THREE.MathUtils.euclideanModulo(
|
||||
Math.floor(normalizedX),
|
||||
n,
|
||||
);
|
||||
const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1);
|
||||
const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize);
|
||||
const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize);
|
||||
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
pixelX,
|
||||
pixelY,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerrainVertexSamples(positionAttribute) {
|
||||
const z = TERRAIN_CONFIG.baseZoom;
|
||||
const tileSize = TERRAIN_CONFIG.tileSize;
|
||||
const samples = [];
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const direction = new THREE.Vector3(
|
||||
positionAttribute.getX(i),
|
||||
positionAttribute.getY(i),
|
||||
positionAttribute.getZ(i),
|
||||
).normalize();
|
||||
const { lat, lon } = vector3ToLatLon(direction);
|
||||
const sample = latLonToTileSample(lat, lon, z, tileSize);
|
||||
samples.push({
|
||||
index: i,
|
||||
direction,
|
||||
...sample,
|
||||
});
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function sampleTerrainColor(heightMeters) {
|
||||
if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) {
|
||||
return TERRAIN_COLOR_STOPS[0].color;
|
||||
}
|
||||
|
||||
for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) {
|
||||
const lower = TERRAIN_COLOR_STOPS[i - 1];
|
||||
const upper = TERRAIN_COLOR_STOPS[i];
|
||||
if (heightMeters <= upper.height) {
|
||||
const t =
|
||||
(heightMeters - lower.height) / Math.max(upper.height - lower.height, 1);
|
||||
return lower.color.clone().lerp(upper.color, t);
|
||||
}
|
||||
}
|
||||
|
||||
return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color;
|
||||
}
|
||||
|
||||
async function runWithConcurrency(items, limit, worker) {
|
||||
const queue = [...items];
|
||||
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function fetchRequiredTiles(samples) {
|
||||
const uniqueKeys = Array.from(
|
||||
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
|
||||
);
|
||||
const resolvedTiles = new Map();
|
||||
|
||||
await runWithConcurrency(
|
||||
uniqueKeys,
|
||||
TERRAIN_CONFIG.maxConcurrentRequests,
|
||||
async (key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||
},
|
||||
);
|
||||
|
||||
return resolvedTiles;
|
||||
}
|
||||
|
||||
function applyTerrainDisplacement(samples, mesh, resolvedTiles) {
|
||||
const geometry = mesh.geometry;
|
||||
const positionAttribute = geometry.getAttribute("position");
|
||||
let colorAttribute = geometry.getAttribute("color");
|
||||
if (!colorAttribute || colorAttribute.itemSize !== 4) {
|
||||
colorAttribute = new THREE.BufferAttribute(
|
||||
new Float32Array(positionAttribute.count * 4),
|
||||
4,
|
||||
);
|
||||
geometry.setAttribute("color", colorAttribute);
|
||||
}
|
||||
const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset;
|
||||
|
||||
samples.forEach((sample) => {
|
||||
const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`;
|
||||
const tile = resolvedTiles.get(tileKey);
|
||||
if (!tile) return;
|
||||
const rawElevationMeters = decodeTerrariumHeight(
|
||||
tile,
|
||||
sample.pixelX,
|
||||
sample.pixelY,
|
||||
);
|
||||
const elevationMeters = Math.max(0, rawElevationMeters);
|
||||
const heightWorld =
|
||||
(elevationMeters / EARTH_RADIUS_METERS) *
|
||||
CONFIG.earthRadius *
|
||||
TERRAIN_CONFIG.exaggeration;
|
||||
const radius = baseRadius + heightWorld;
|
||||
const tint = sampleTerrainColor(elevationMeters);
|
||||
const landAlpha = THREE.MathUtils.clamp(
|
||||
elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
positionAttribute.setXYZ(
|
||||
sample.index,
|
||||
sample.direction.x * radius,
|
||||
sample.direction.y * radius,
|
||||
sample.direction.z * radius,
|
||||
);
|
||||
colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha);
|
||||
});
|
||||
|
||||
positionAttribute.needsUpdate = true;
|
||||
colorAttribute.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
export function registerTerrainMesh(mesh) {
|
||||
terrainMesh = mesh;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainLoadPromise = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
terrainVertexSamples = mesh
|
||||
? buildTerrainVertexSamples(mesh.geometry.getAttribute("position"))
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isTerrainReady() {
|
||||
return terrainReady;
|
||||
}
|
||||
|
||||
export async function ensureTerrainReady() {
|
||||
if (!terrainMesh || !TERRAIN_CONFIG.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (terrainReady) {
|
||||
return true;
|
||||
}
|
||||
if (terrainLoadPromise) {
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
terrainLoadPromise = (async () => {
|
||||
try {
|
||||
const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples);
|
||||
applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles);
|
||||
terrainReady = true;
|
||||
terrainFailed = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
terrainFailed = true;
|
||||
console.error("加载真实地形失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
terrainLoadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
export function clearTerrainData() {
|
||||
terrainMesh = null;
|
||||
terrainLoadPromise = null;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainVertexSamples = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
}
|
||||
|
||||
export function setTerrainOpacity(nextOpacity) {
|
||||
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
return terrainOpacity;
|
||||
}
|
||||
|
||||
export function getTerrainOpacity() {
|
||||
return terrainOpacity;
|
||||
}
|
||||
@@ -323,11 +323,20 @@ function setupResizeHandle() {
|
||||
function updateToggleButton(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const icon = toggleBtn.querySelector(".material-symbols-rounded");
|
||||
const isLiveTab = activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
if (icon) {
|
||||
icon.textContent = isLiveTab ? "live_tv" : "newspaper";
|
||||
}
|
||||
const title = visible
|
||||
? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播")
|
||||
: (isLiveTab ? "打开新闻直播" : "打开态势新闻");
|
||||
toggleBtn.title = title;
|
||||
toggleBtn.setAttribute("aria-label", title);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播";
|
||||
tooltip.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +507,7 @@ function setActiveTab(tab) {
|
||||
updateTabState(newsHeaderControls, nextTab === "news");
|
||||
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
|
||||
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
if (
|
||||
restoreLayoutState &&
|
||||
@@ -1037,20 +1047,23 @@ export function initTVPanel() {
|
||||
const currentlyVisible = mediaPanel?.isVisible() ?? false;
|
||||
if (!currentlyVisible) {
|
||||
setPanelVisible(true);
|
||||
setActiveTab("live");
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
if (activeTab === "live") {
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
} else {
|
||||
showStatusMessage("态势新闻窗口已打开", "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab !== "live") {
|
||||
setActiveTab("live");
|
||||
const nextTab = activeTab === "live" ? "news" : "live";
|
||||
setActiveTab(nextTab);
|
||||
if (nextTab === "live") {
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("已切换到新闻直播", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
showStatusMessage("已切换到态势新闻", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
|
||||
let statusTimeoutId = null;
|
||||
let statusHideTimeoutId = null;
|
||||
let statusReplayTimeoutId = null;
|
||||
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) {
|
||||
return document.getElementById(id);
|
||||
@@ -19,53 +29,105 @@ function clearStatusTimers() {
|
||||
clearTimeout(statusTimeoutId);
|
||||
statusTimeoutId = null;
|
||||
}
|
||||
|
||||
if (statusHideTimeoutId) {
|
||||
clearTimeout(statusHideTimeoutId);
|
||||
statusHideTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusReplayTimeoutId) {
|
||||
clearTimeout(statusReplayTimeoutId);
|
||||
statusReplayTimeoutId = null;
|
||||
function clearLoadingWidthLock(statusEl) {
|
||||
loadingLockedWidth = 0;
|
||||
if (statusEl) {
|
||||
statusEl.style.minWidth = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
function updateLoadingWidthLock(statusEl) {
|
||||
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 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");
|
||||
if (!statusEl) return;
|
||||
|
||||
clearStatusTimers();
|
||||
statusBusy = true;
|
||||
|
||||
const startShow = () => {
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
|
||||
setElementDisplay(statusEl, true);
|
||||
statusEl.offsetHeight;
|
||||
statusEl.classList.add("visible");
|
||||
buildStatusContent(statusEl, message, type);
|
||||
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
|
||||
setElementDisplay(statusEl, true, "inline-flex");
|
||||
statusEl.offsetHeight;
|
||||
statusEl.classList.add("visible");
|
||||
|
||||
statusTimeoutId = setTimeout(() => {
|
||||
statusEl.classList.remove("visible");
|
||||
statusHideTimeoutId = setTimeout(() => {
|
||||
setElementDisplay(statusEl, false);
|
||||
statusEl.textContent = "";
|
||||
statusHideTimeoutId = null;
|
||||
}, 280);
|
||||
statusTimeoutId = null;
|
||||
}, 3000);
|
||||
};
|
||||
statusTimeoutId = setTimeout(() => {
|
||||
hideStatusElement(statusEl, () => {
|
||||
statusBusy = false;
|
||||
processStatusQueue();
|
||||
});
|
||||
statusTimeoutId = null;
|
||||
}, STATUS_DISPLAY_MS);
|
||||
}
|
||||
|
||||
if (statusEl.classList.contains("visible")) {
|
||||
statusEl.classList.remove("visible");
|
||||
statusReplayTimeoutId = setTimeout(() => {
|
||||
startShow();
|
||||
statusReplayTimeoutId = null;
|
||||
}, 180);
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
if (loadingActive) {
|
||||
statusQueue.unshift(createStatusEntry(message, type));
|
||||
return;
|
||||
}
|
||||
startTransientStatus(message, type);
|
||||
}
|
||||
|
||||
startShow();
|
||||
export function queueStatusMessage(message, type = "info") {
|
||||
statusQueue.push(createStatusEntry(message, type));
|
||||
processStatusQueue();
|
||||
}
|
||||
|
||||
// Update coordinates display
|
||||
@@ -120,23 +182,58 @@ export function updateEarthStats(stats) {
|
||||
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
|
||||
}
|
||||
|
||||
// Show/hide loading
|
||||
// Show/hide loading via status message
|
||||
export function setLoading(loading) {
|
||||
const loadingEl = getElement("loading");
|
||||
if (!loadingEl) return;
|
||||
setElementDisplay(loadingEl, loading);
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl) return;
|
||||
|
||||
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 = "") {
|
||||
const titleEl = getElement("loading-title");
|
||||
const subtitleEl = getElement("loading-subtitle");
|
||||
|
||||
if (titleEl) {
|
||||
titleEl.textContent = title;
|
||||
export function setLoadingMessage(title) {
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) {
|
||||
pendingLoadingMessage = title;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtitleEl) {
|
||||
subtitleEl.textContent = subtitle;
|
||||
const textEl = statusEl.querySelector(".earth-status-text");
|
||||
if (textEl) {
|
||||
textEl.textContent = title;
|
||||
requestAnimationFrame(() => {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,12 +274,17 @@ export function hideError() {
|
||||
|
||||
export function clearUiState() {
|
||||
clearStatusTimers();
|
||||
statusQueue = [];
|
||||
statusBusy = false;
|
||||
loadingActive = false;
|
||||
pendingLoadingMessage = "";
|
||||
|
||||
const statusEl = getElement("status-message");
|
||||
if (statusEl) {
|
||||
statusEl.className = STATUS_BASE_CLASS;
|
||||
setElementDisplay(statusEl, false);
|
||||
statusEl.textContent = "";
|
||||
statusEl.innerHTML = "";
|
||||
clearLoadingWidthLock(statusEl);
|
||||
}
|
||||
|
||||
hideTooltip();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.28.2"
|
||||
version = "0.30.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user