diff --git a/VERSION b/VERSION index 20f06870..c25c8e5b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.29.2 +0.30.0 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index be360ad1..e390402a 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -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( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f73cd04a..6dffd0f9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,19 @@ This project follows the repository versioning rule: ## [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 diff --git a/docs/earth/earth-real-terrain-plan.md b/docs/earth/earth-real-terrain-plan.md new file mode 100644 index 00000000..a21a0aaf --- /dev/null +++ b/docs/earth/earth-real-terrain-plan.md @@ -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` + +## 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 架构的一条路。 diff --git a/docs/version-history.md b/docs/version-history.md index 2102adf7..efe76523 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.29.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 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 | diff --git a/frontend/package.json b/frontend/package.json index ec69aa82..133d72a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.29.2", + "version": "0.30.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 553ec78c..1c3aecd0 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -311,6 +311,7 @@ 0 0 18px rgba(145, 186, 255, 0.06); font-size: calc(0.84rem * var(--hud-scale)); font-weight: 500; + line-height: 1.2; letter-spacing: 0.01em; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); @@ -334,8 +335,10 @@ .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 { @@ -350,6 +353,8 @@ } .earth-status-text { + display: inline-flex; + align-items: center; flex: 1 1 auto; min-width: 0; } @@ -608,10 +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; diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 17dff164..d373e6ac 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -469,6 +469,30 @@ +
+
地形
+
+
+
+ 地形透明度 + 调高后会呈现更明显的绿色地形覆盖效果 +
+
+ + 62% +
+
+
+
系统
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index d795b95b..a0bc7d39 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -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; } diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index fb7f7f8e..a5525c48 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -74,6 +74,25 @@ export const CELESTIAL_CONFIG = { backLightColor: 0x2b4c78, }; +export const TERRAIN_CONFIG = { + enabled: true, + tileSize: 256, + baseZoom: 4, + geometryWidthSegments: 320, + geometryHeightSegments: 320, + baseRadiusOffset: 0.04, + exaggeration: 34, + landRevealFadeMeters: 220, + maxConcurrentRequests: 10, + opacity: 0.62, + color: 0x7f9d7f, + emissive: 0x061008, + specular: 0x233126, + shininess: 10, + urlTemplate: + "/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png", +}; + export const PATHS = { cablesApi: '/api/v1/visualization/geo/cables', landingPointsApi: '/api/v1/visualization/geo/landing-points', diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index db206394..4e70d061 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -4,6 +4,12 @@ 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, @@ -58,6 +64,44 @@ 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) { @@ -316,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(); } @@ -690,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; @@ -706,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; @@ -878,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 () { diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js index ad95bddb..d9a313e3 100644 --- a/frontend/public/earth/js/earth.js +++ b/frontend/public/earth/js/earth.js @@ -1,7 +1,7 @@ // earth.js - 3D Earth creation module import * as THREE from 'three'; -import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js'; +import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js'; import { latLonToVector3 } from './utils.js'; export let earth = null; @@ -212,34 +212,34 @@ 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; } diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 2f9dcd11..a06875a2 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -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, @@ -26,6 +26,7 @@ import { clearEarthTexture, setEarthSunDirection, } from "./earth.js"; +import { registerTerrainMesh, clearTerrainData } from "./terrain.js"; import { initCelestialLayer, updateCelestialLayer, @@ -118,7 +119,7 @@ import { getAutoRotate, getShowTerrain, setAutoRotate, - resetView, + applyImmediateView, getZoomLevel, teardownControls, updateLayerButtonState, @@ -142,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 }; @@ -807,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); @@ -913,7 +915,6 @@ export function init() { destroyed = false; initialized = true; - simplex = createNoise3D(); updateHudScale(); const brandRoot = document.getElementById("brand-root"); mountBrand(brandRoot, HUD_CONFIG.brandLanguage); @@ -952,13 +953,14 @@ export function init() { setLegendItems("satellites", getSatelliteLegendItems()); setLegendItems("bgp", getBGPLegendItems()); const earthObj = createEarth(scene); + applyImmediateView(earthObj, camera); targetRotation = { x: earthObj.rotation.x, y: earthObj.rotation.y, }; inertialVelocity = { x: 0, y: 0 }; createClouds(scene, earthObj); - createTerrain(scene, earthObj, simplex); + registerTerrainMesh(createTerrain(earthObj)); initCelestialLayer(scene, { camera, sunLight: sceneLights?.sunLight ?? null, @@ -969,7 +971,6 @@ export function init() { createSatellites(scene, earthObj); setupControls(camera, renderer, scene, earthObj); - resetView(camera); setupEventListeners(); clock.start(); @@ -1054,7 +1055,7 @@ async function loadData() { setLoadingMessage("正在加载登陆点..."); await yieldFrame(30); try { - await loadLandingPoints(scene, earth); + await loadLandingPoints(scene, earth, { silent: true }); } catch (err) { errors.push({ label: "登陆点", reason: err }); } @@ -1067,7 +1068,9 @@ async function loadData() { 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); @@ -1147,10 +1150,10 @@ async function loadData() { if (errors.length > 0) { const errorMessage = buildLoadErrorMessage(errors); showError(errorMessage); - showStatusMessage(errorMessage, "error"); + queueStatusMessage(errorMessage, "error"); } else { hideError(); - showStatusMessage("数据已加载", "success"); + queueStatusMessage("数据已加载", "success"); } } @@ -1787,6 +1790,7 @@ export function destroy() { resetSatelliteState(); clearUiState(); disposeCelestialLayer(); + clearTerrainData(); if (scene) { disposeSceneObject(scene); diff --git a/frontend/public/earth/js/terrain.js b/frontend/public/earth/js/terrain.js new file mode 100644 index 00000000..04f44d8d --- /dev/null +++ b/frontend/public/earth/js/terrain.js @@ -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; +} diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js index d0f2b428..6dae6f35 100644 --- a/frontend/public/earth/js/ui.js +++ b/frontend/public/earth/js/ui.js @@ -9,6 +9,11 @@ let statusQueue = []; let statusBusy = false; let loadingActive = false; let loadingLockedWidth = 0; +let pendingLoadingMessage = ""; + +function createStatusEntry(message, type = "info") { + return { message, type }; +} function getElement(id) { return document.getElementById(id); @@ -113,7 +118,15 @@ function startTransientStatus(message, type = "info") { // Show status message export function showStatusMessage(message, type = "info") { - statusQueue.push({ message, type }); + if (loadingActive) { + statusQueue.unshift(createStatusEntry(message, type)); + return; + } + startTransientStatus(message, type); +} + +export function queueStatusMessage(message, type = "info") { + statusQueue.push(createStatusEntry(message, type)); processStatusQueue(); } @@ -179,7 +192,12 @@ export function setLoading(loading) { loadingActive = true; statusBusy = false; clearLoadingWidthLock(statusEl); - buildStatusContent(statusEl, "正在加载...", "loading"); + buildStatusContent( + statusEl, + pendingLoadingMessage || "正在加载...", + "loading", + ); + pendingLoadingMessage = ""; statusEl.className = `${STATUS_BASE_CLASS} loading`; setElementDisplay(statusEl, true, "inline-flex"); statusEl.offsetHeight; @@ -188,6 +206,7 @@ export function setLoading(loading) { updateLoadingWidthLock(statusEl); }); } else { + pendingLoadingMessage = ""; if (!statusEl.classList.contains("loading")) { loadingActive = false; clearLoadingWidthLock(statusEl); @@ -205,7 +224,10 @@ export function setLoading(loading) { export function setLoadingMessage(title) { const statusEl = getElement("status-message"); - if (!statusEl || !statusEl.classList.contains("loading")) return; + if (!statusEl || !statusEl.classList.contains("loading")) { + pendingLoadingMessage = title; + return; + } const textEl = statusEl.querySelector(".earth-status-text"); if (textEl) { textEl.textContent = title; @@ -255,6 +277,7 @@ export function clearUiState() { statusQueue = []; statusBusy = false; loadingActive = false; + pendingLoadingMessage = ""; const statusEl = getElement("status-message"); if (statusEl) { diff --git a/pyproject.toml b/pyproject.toml index 18f4b2a6..6ad4c4d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.29.2" +version = "0.30.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 3010354f..91f56f4b 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.29.2" +version = "0.30.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },