Compare commits

..

3 Commits

Author SHA1 Message Date
linkong
195a8bf71c release: bump version to 0.37.2 2026-04-23 11:12:49 +08:00
linkong
987c378f99 release: bump version to 0.37.1 2026-04-23 11:05:30 +08:00
rayd1o
67f82dc41c release: bump version to 0.37.0 2026-04-23 07:56:10 +08:00
24 changed files with 1552 additions and 231 deletions

View File

@@ -24,3 +24,11 @@
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略 - [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题 - [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector - [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis``OpenAI / Stargate``CoreWeave``Lambda``Crusoe` 这类同一对象多种写法导致的地点匹配失败
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例

View File

@@ -1 +1 @@
0.36.0 0.37.2

View File

@@ -8,6 +8,51 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.37.2] — 2026-04-23
### ✨ Highlights
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
### 🔧 Improvements
- 经纬线正式接入 Earth layer registry复用现有图层切换、移动端图层卡片与设置持久化流
### 🐛 Fixes
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
---
## [0.37.1] — 2026-04-23
### ✨ Highlights
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
### 🔧 Improvements
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
### 🐛 Fixes
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
---
## [0.35.1] — 2026-04-22
## [0.37.0] — 2026-04-23
### ✨ Highlights
- Earth 连线系统正式从巡航里解耦成通用 callout connector桌面端和移动端统一支持对象级锚点、四边切换与临界区边缘滑动
- BGP 巡航展示继续收口为稳定的“先定位卡片、再连真实锚点、再展示卡片”链路,移动端 popup 与桌面 info panel 的路线规则统一
### 🔧 Improvements
- connector 配置从 `CRUISE_CONFIG` 拆到独立 `CONNECTOR_CONFIG`,默认类名、动画名和实例命名也全部去 cruise 语义
- 移动端 popup 增加更稳定的 dock/obstacle 处理,拖动卡片时连线起终点会持续按几何关系自适应刷新
- Earth 多个图层与控制逻辑继续收口,补充算力中心/BGP 风格对齐、layer panel 与相关交互细节调整
### 🐛 Fixes
- 修复巡航模式下终点只像“视觉锚点”而不是真实绑定对象的问题,卡片拖动后终点现在会跟随
- 修复移动端与桌面端多类连线路线异常:压线、反向、临界区折返、起点遮挡事件点等问题
- 修复对象矩形临界区内连线仍强制中点到中点导致路线像“先钻进 source 内部”再出去的问题
---
## [0.35.1] — 2026-04-22 ## [0.35.1] — 2026-04-22
## [0.36.0] — 2026-04-22 ## [0.36.0] — 2026-04-22

View File

@@ -275,6 +275,66 @@
这样可以避免一开始把范围摊得过大。 这样可以避免一开始把范围摊得过大。
## Unknown Location Strategy
由于部分算力数据源不会直接提供经纬度,未知位置补全不能只依赖“继续找 API 字段”。
更稳妥的方式是做成一条分层富化链路,而不是单一猜测规则。
推荐按下面优先级推进:
1. 直接源信息
- 源记录显式给出 `latitude / longitude`
- 源记录给出 `city / region / facility / campus / operator`
- 源页面详情、内嵌 JSON、结构化元数据、新闻稿链接里能抽出地点线索
2. 名称与机构归一化
- 建立 `canonical_name / aliases / operator / facility` 归一化表
-`cluster name``operator``campus name` 归一到同一个实体
- 优先解决同一对象多写法导致的命中失败,而不是先扩大猜测范围
3. 本地位置注册表
- 用仓库内可维护的 registry 保存高价值对象的位置知识
- 每条记录至少包含:`canonical_name``aliases``operator``country``region``city``lat``lon``confidence``source_note`
- 转换层优先读取 registry避免地点知识长期散落在转换代码里
4. 分层回退定位
- `precise`
- `estimated_site`
- `estimated_city`
- `estimated_region`
- `estimated_national_hub`
- `estimated_country`
这里建议把“国家内主要算力城市”作为国家质心之前的一层。
例如没有美国精确位置时,优先考虑已知的主要算力/数据中心城市候选,而不是直接落在几何质心。
5. 候选证据富化
- 如果源 API 无地点信息,可以允许采集链路读取公开辅助证据
- 例如机构官网、数据中心介绍页、新闻稿、百科型页面、公开 PDF
- 但只提取“地点线索”,不把外部页面上的经纬度当真值直接写回
6. 人工校验闭环
- 对高价值且仍然未知的对象输出待核验清单
- 把人工确认结果回写到位置注册表
- 后续采集继续优先复用这层人工确认结果
### Additional Solution Paths
除了静态映射表,还可以考虑下面这些办法:
- 基于国家和运营方建立“主要园区候选集”,用稳定散列把同国未知节点分散到若干可信城市,而不是全部压到一个点
- 基于数据中心/云厂商公开 region 列表建立 `operator -> city set` 候选映射,用于云 GPU 集群类对象
- 把“估算依据”结构化,例如 `matched_alias``matched_operator``matched_city_text``fallback_country_hub`
- 给位置补全增加 `last_verified_at`,便于后续按时间重新校验老旧映射
- 单独维护“不可可靠定位”状态;这类对象仍可在国家级聚合统计中出现,但可以允许用户在地图上过滤掉
- 后续如果你们愿意投入更多,可把这条链路做成小型 enrichment pipeline而不是仅在 API 转换时临时判断
## Non-Goals ## Non-Goals
第一阶段不建议做这些内容: 第一阶段不建议做这些内容:

View File

@@ -16,12 +16,15 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.36.0` - `dev` 当前开发分支历史推导到:`0.37.2`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 |
| `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh``uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 |
| `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 | | `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 | | `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 | | `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |

View File

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

View File

@@ -271,6 +271,7 @@
display: flex; display: flex;
position: fixed; position: fixed;
z-index: 260; z-index: 260;
overflow: visible;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px 10px 12px; padding: 10px 12px 10px 12px;
@@ -304,6 +305,15 @@
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
} }
.layout-mode-mobile .earth-mobile-popup.earth-mobile-popup--anchor-stable {
transform: none;
transition: opacity 0.17s ease;
}
.layout-mode-mobile .earth-mobile-popup.earth-mobile-popup--anchor-stable.is-visible {
transform: none;
}
.earth-mobile-popup-icon { .earth-mobile-popup-icon {
font-size: 1.25rem; font-size: 1.25rem;
flex-shrink: 0; flex-shrink: 0;
@@ -342,6 +352,42 @@
flex-shrink: 0; flex-shrink: 0;
} }
.earth-mobile-popup-dock {
--earth-mobile-popup-dock-size: 14px;
--earth-mobile-popup-dock-offset: calc(var(--earth-mobile-popup-dock-size) * -0.5);
position: absolute;
top: 32%;
width: var(--earth-mobile-popup-dock-size);
height: var(--earth-mobile-popup-dock-size);
border-radius: 999px;
background: transparent;
border: 0;
box-shadow: none;
transform: translateY(-50%);
pointer-events: none;
}
.earth-mobile-popup[data-dock-side="left"] .earth-mobile-popup-dock {
left: var(--earth-mobile-popup-dock-offset);
}
.earth-mobile-popup[data-dock-side="right"] .earth-mobile-popup-dock {
right: var(--earth-mobile-popup-dock-offset);
}
.earth-mobile-popup[data-dock-side="top"] .earth-mobile-popup-dock {
top: var(--earth-mobile-popup-dock-offset);
left: 50%;
transform: translateX(-50%);
}
.earth-mobile-popup[data-dock-side="bottom"] .earth-mobile-popup-dock {
top: auto;
bottom: var(--earth-mobile-popup-dock-offset);
left: 50%;
transform: translateX(-50%);
}
/* ── Mobile drawer ───────────────────────────────────────────── */ /* ── Mobile drawer ───────────────────────────────────────────── */
.earth-mobile-drawer-overlay, .earth-mobile-drawer-overlay,

View File

@@ -153,15 +153,28 @@
transition: transition:
opacity 0.22s ease, opacity 0.22s ease,
transform 0.22s ease; transform 0.22s ease;
visibility: hidden;
user-select: none;
-webkit-user-select: none;
} }
.hud-panel-info.is-visible { .hud-panel-info.is-visible {
opacity: 1; opacity: 1;
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
pointer-events: auto; pointer-events: auto;
visibility: visible;
} }
.info-card-cruise-link { .hud-panel-info.hud-panel-info--anchor-stable {
transform: none;
transition: opacity 0.22s ease;
}
.hud-panel-info.hud-panel-info--anchor-stable.is-visible {
transform: none;
}
.callout-connector {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
@@ -173,7 +186,7 @@
z-index: 49; z-index: 49;
} }
.info-card-cruise-link polyline { .callout-connector polyline {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.98); stroke: rgba(255, 255, 255, 0.98);
stroke-width: 2.15; stroke-width: 2.15;
@@ -185,7 +198,7 @@
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1)); drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
} }
.info-card-cruise-link circle { .callout-connector circle {
fill: rgba(255, 255, 255, 0.98); fill: rgba(255, 255, 255, 0.98);
stroke: rgba(7, 16, 32, 0.72); stroke: rgba(7, 16, 32, 0.72);
stroke-width: 1.0; stroke-width: 1.0;
@@ -197,29 +210,29 @@
transform-origin: center; transform-origin: center;
} }
.info-card-cruise-link.is-visible { .callout-connector.is-visible {
opacity: 1; opacity: 1;
} }
.info-card-cruise-link.is-animating polyline { .callout-connector.is-animating polyline {
animation: cruiseConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards; animation: calloutConnectorDraw 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
.info-card-cruise-link.is-animating circle { .callout-connector.is-animating circle {
opacity: 0; opacity: 0;
} }
.info-card-cruise-link.is-animating circle:first-of-type { .callout-connector.is-animating circle:first-of-type {
animation: cruiseConnectorNodeIn 0.14s ease forwards; animation: calloutConnectorNodeIn 0.14s ease forwards;
animation-delay: 0.02s; animation-delay: 0.02s;
} }
.info-card-cruise-link.is-animating circle:last-of-type { .callout-connector.is-animating circle:last-of-type {
animation: cruiseConnectorNodeIn 0.16s ease forwards; animation: calloutConnectorNodeIn 0.16s ease forwards;
animation-delay: 0.34s; animation-delay: 0.34s;
} }
@keyframes cruiseConnectorDraw { @keyframes calloutConnectorDraw {
from { from {
stroke-dashoffset: var(--connector-length, 0px); stroke-dashoffset: var(--connector-length, 0px);
} }
@@ -228,7 +241,7 @@
} }
} }
@keyframes cruiseConnectorNodeIn { @keyframes calloutConnectorNodeIn {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.72); transform: scale(0.72);
@@ -290,6 +303,8 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent; scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
pointer-events: auto; pointer-events: auto;
user-select: none;
-webkit-user-select: none;
} }
.info-card-content::-webkit-scrollbar { .info-card-content::-webkit-scrollbar {
@@ -327,6 +342,8 @@
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: color 0.18s ease; transition: color 0.18s ease;
user-select: none;
-webkit-user-select: none;
} }
.info-card-label:hover { .info-card-label:hover {
@@ -341,6 +358,8 @@
text-align: right; text-align: right;
max-width: calc(180px * var(--hud-scale)); max-width: calc(180px * var(--hud-scale));
word-break: break-word; word-break: break-word;
user-select: none;
-webkit-user-select: none;
} }
/* Type-specific header accent colors */ /* Type-specific header accent colors */

View File

@@ -160,6 +160,23 @@
.layer-panel-list { .layer-panel-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(5 * (56px * var(--hud-scale)));
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
}
.layer-panel-list::-webkit-scrollbar {
width: 4px;
}
.layer-panel-list::-webkit-scrollbar-track {
background: transparent;
}
.layer-panel-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
border-radius: 999px;
} }
.layer-row { .layer-row {
@@ -169,6 +186,7 @@
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale)); padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
border-bottom: 1px solid var(--hud-line); border-bottom: 1px solid var(--hud-line);
transition: background 0.14s ease; transition: background 0.14s ease;
min-height: calc(56px * var(--hud-scale));
} }
.layer-row:last-child { .layer-row:last-child {
@@ -343,3 +361,8 @@
max-height: min(52vh, 460px); max-height: min(52vh, 460px);
overflow: auto; overflow: auto;
} }
.layout-mode-mobile .layer-panel-list {
max-height: none;
overflow-y: visible;
}

View File

@@ -16,6 +16,22 @@
} }
</script> </script>
<script> <script>
(function applyInitialEarthViewportMode() {
var width = window.innerWidth;
var height = window.innerHeight;
var mode = "desktop";
if (width <= 820) {
mode = "mobile";
} else if (width <= 1080 || height <= 760) {
mode = "compact";
}
document.documentElement.classList.toggle("layout-mode-mobile", mode === "mobile");
document.documentElement.classList.toggle("layout-mode-compact", mode === "compact");
document.documentElement.dataset.earthLayoutMode = mode;
})();
(function applyInitialHudScale() { (function applyInitialHudScale() {
var referenceWidth = 1920; var referenceWidth = 1920;
var referenceHeight = 1080; var referenceHeight = 1080;
@@ -102,6 +118,16 @@
<span class="layer-row-toggle-track"></span> <span class="layer-row-toggle-track"></span>
</button> </button>
</div> </div>
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
<div class="layer-row-copy">
<span class="layer-row-label">经纬线</span>
<span class="layer-row-meta">Graticule</span>
</div>
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="卫星 satellites"> <div class="layer-row" data-layer-name="卫星 satellites">
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span> <span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
<div class="layer-row-copy"> <div class="layer-row-copy">
@@ -435,6 +461,7 @@
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></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="tooltip" class="earth-tooltip"></div>
<div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite"> <div id="earth-mobile-popup" class="earth-mobile-popup" hidden aria-live="polite">
<span id="earth-mobile-popup-dock" class="earth-mobile-popup-dock" aria-hidden="true"></span>
<span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span> <span class="earth-mobile-popup-icon" id="earth-mobile-popup-icon"></span>
<div class="earth-mobile-popup-body"> <div class="earth-mobile-popup-body">
<div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div> <div class="earth-mobile-popup-title" id="earth-mobile-popup-title"></div>

View File

@@ -1,17 +1,73 @@
import * as THREE from "three"; import * as THREE from "three";
import { CRUISE_CONFIG, PATHS } from "./constants.js"; import { CONNECTOR_CONFIG, CRUISE_CONFIG, PATHS } from "./constants.js";
import { createElbowConnectorPoints } from "./callout-connector.js"; import {
computeNearestPerimeterAnchor,
createConnectorPath,
resolveConnectorAnchor,
} from "./callout-connector.js";
const scratchBGPWorldPosition = new THREE.Vector3(); const scratchBGPWorldPosition = new THREE.Vector3();
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420; const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300; const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32; const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
const CRUISE_CARD_SCREEN_MARGIN_PX = 12; const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18; const CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
const CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
const CRUISE_MOBILE_POPUP_TOP_RATIO = 0.17;
const CRUISE_MOBILE_POPUP_MARGIN_PX = 14;
const CRUISE_MOBILE_DRAWER_CLEARANCE_PX = 52;
const CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT = 3;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200; const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const CRUISE_CONNECTOR_DRAW_MS = 420; const CRUISE_CONNECTOR_DRAW_MS = 420;
const CRUISE_PRESENTATION_HIDE_MS = 220; const CRUISE_PRESENTATION_HIDE_MS = 220;
const MOBILE_POPUP_OBSTACLE_PADDING_PX = 16;
const DESKTOP_PANEL_OBSTACLE_PADDING_PX = 12;
const CRUISE_MARKER_SCREEN_PADDING_PX = 4;
function getDockAxisOffsets(dockSide, gapPx) {
return {
offsetX:
dockSide === "right" ? gapPx : dockSide === "left" ? -gapPx : 0,
offsetY:
dockSide === "bottom" ? gapPx : dockSide === "top" ? -gapPx : 0,
};
}
function getObstaclePaddingBySide(side, paddingPx) {
if (side === "left") {
return { left: 0, top: paddingPx, right: paddingPx, bottom: paddingPx };
}
if (side === "right") {
return { left: paddingPx, top: paddingPx, right: 0, bottom: paddingPx };
}
if (side === "top") {
return { left: paddingPx, top: 0, right: paddingPx, bottom: paddingPx };
}
return { left: paddingPx, top: paddingPx, right: paddingPx, bottom: 0 };
}
const scratchMarkerWorldScale = new THREE.Vector3();
const scratchCameraQuaternion = new THREE.Quaternion();
const scratchCameraRight = new THREE.Vector3();
const scratchCameraUp = new THREE.Vector3();
const scratchMarkerRightPoint = new THREE.Vector3();
const scratchMarkerLeftPoint = new THREE.Vector3();
const scratchMarkerTopPoint = new THREE.Vector3();
const scratchMarkerBottomPoint = new THREE.Vector3();
function projectWorldToScreen(point, camera) {
if (!point || !camera) return null;
const projected = point.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getMarkerTimestamp(marker) { function getMarkerTimestamp(marker) {
const rawValue = marker?.userData?.created_at_raw; const rawValue = marker?.userData?.created_at_raw;
@@ -53,14 +109,78 @@ export function createBGPCruiseAdapter({
if (!marker || !camera) return null; if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position); scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition); marker.parent?.localToWorld(scratchBGPWorldPosition);
const projected = scratchBGPWorldPosition.clone().project(camera); return projectWorldToScreen(scratchBGPWorldPosition, camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) { }
function getVisibleMobilePopup() {
const mobilePopup = document.getElementById("earth-mobile-popup");
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
? mobilePopup
: null;
}
function getVisibleInfoPanel() {
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
function getMarkerScreenRect(marker) {
const center = getMarkerScreenCoords(marker);
if (!center || !camera || !marker) return null;
marker.getWorldScale(scratchMarkerWorldScale);
const worldWidth = Math.max(
0.0001,
Number(marker.userData?.baseScale ?? scratchMarkerWorldScale.x ?? 0) || scratchMarkerWorldScale.x,
);
const worldHeight = Math.max(
0.0001,
Number(scratchMarkerWorldScale.y || worldWidth),
);
camera.getWorldQuaternion(scratchCameraQuaternion);
scratchCameraRight.set(1, 0, 0).applyQuaternion(scratchCameraQuaternion).normalize();
scratchCameraUp.set(0, 1, 0).applyQuaternion(scratchCameraQuaternion).normalize();
scratchMarkerRightPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraRight, worldWidth * 0.5);
scratchMarkerLeftPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraRight, -worldWidth * 0.5);
scratchMarkerTopPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraUp, worldHeight * 0.5);
scratchMarkerBottomPoint
.copy(scratchBGPWorldPosition)
.addScaledVector(scratchCameraUp, -worldHeight * 0.5);
const rightPoint = projectWorldToScreen(scratchMarkerRightPoint, camera);
const leftPoint = projectWorldToScreen(scratchMarkerLeftPoint, camera);
const topPoint = projectWorldToScreen(scratchMarkerTopPoint, camera);
const bottomPoint = projectWorldToScreen(scratchMarkerBottomPoint, camera);
if (!rightPoint || !leftPoint || !topPoint || !bottomPoint) {
return null; return null;
} }
const halfWidth = Math.max(
Math.abs(rightPoint.x - center.x),
Math.abs(leftPoint.x - center.x),
1,
);
const halfHeight = Math.max(
Math.abs(topPoint.y - center.y),
Math.abs(bottomPoint.y - center.y),
1,
);
return { return {
x: ((projected.x + 1) * 0.5) * window.innerWidth, x: center.x - halfWidth - CRUISE_MARKER_SCREEN_PADDING_PX,
y: ((1 - projected.y) * 0.5) * window.innerHeight, y: center.y - halfHeight - CRUISE_MARKER_SCREEN_PADDING_PX,
width: halfWidth * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
height: halfHeight * 2 + CRUISE_MARKER_SCREEN_PADDING_PX * 2,
}; };
} }
@@ -68,6 +188,53 @@ export function createBGPCruiseAdapter({
const markerCoords = getMarkerScreenCoords(marker); const markerCoords = getMarkerScreenCoords(marker);
if (!markerCoords) return null; if (!markerCoords) return null;
if (document.body.classList.contains("layout-mode-mobile")) {
const safeBottom =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
const estimatedCardWidth = Math.min(
CRUISE_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
window.innerWidth - CRUISE_MOBILE_POPUP_MARGIN_PX * 2,
);
const estimatedCardHeight = CRUISE_MOBILE_POPUP_ESTIMATED_HEIGHT_PX;
const topBound = Math.max(
CRUISE_MOBILE_POPUP_MARGIN_PX,
Math.min(
window.innerHeight * CRUISE_MOBILE_POPUP_TOP_RATIO,
window.innerHeight -
CRUISE_MOBILE_DRAWER_CLEARANCE_PX -
safeBottom -
estimatedCardHeight -
CRUISE_MOBILE_POPUP_MARGIN_PX,
),
);
const rightSlotLeft = Math.max(
CRUISE_MOBILE_POPUP_MARGIN_PX,
window.innerWidth - estimatedCardWidth - CRUISE_MOBILE_POPUP_MARGIN_PX,
);
const leftSlotLeft = CRUISE_MOBILE_POPUP_MARGIN_PX;
const rightSlotCenterX = rightSlotLeft + estimatedCardWidth * 0.5;
const leftSlotCenterX = leftSlotLeft + estimatedCardWidth * 0.5;
const rightClearance = rightSlotLeft - markerCoords.x;
const leftClearance = markerCoords.x - (leftSlotLeft + estimatedCardWidth);
const rightCost =
Math.max(0, -rightClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
Math.abs(rightSlotCenterX - markerCoords.x);
const leftCost =
Math.max(0, -leftClearance) * CRUISE_MOBILE_SLOT_OVERFLOW_WEIGHT +
Math.abs(markerCoords.x - leftSlotCenterX);
const placeOnRight = rightCost <= leftCost;
const left = placeOnRight ? rightSlotLeft : leftSlotLeft;
return {
x: left,
y: topBound,
width: estimatedCardWidth,
height: estimatedCardHeight,
dockSide: placeOnRight ? "left" : "right",
};
}
const hudScale = const hudScale =
Number.parseFloat( Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -94,38 +261,148 @@ export function createBGPCruiseAdapter({
Math.max(margin, y), Math.max(margin, y),
Math.max(margin, window.innerHeight - estimatedCardHeight - margin), Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
); );
const anchorY = clampedY + Math.max(
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
estimatedCardHeight * 0.18,
);
return { return {
x: clampedX, x: clampedX,
y: clampedY, y: clampedY,
width: estimatedCardWidth, width: estimatedCardWidth,
height: estimatedCardHeight, height: estimatedCardHeight,
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
anchorY,
}; };
} }
function getCardAnchorTarget() {
const mobilePopup = getVisibleMobilePopup();
if (
document.body.classList.contains("layout-mode-mobile") &&
mobilePopup
) {
const dockSide = mobilePopup.dataset.dockSide || "left";
const { offsetX, offsetY } = getDockAxisOffsets(
dockSide,
CONNECTOR_CONFIG.panelGapPx,
);
return {
element: mobilePopup,
side: dockSide,
alignRatio: 0.5,
offsetX,
offsetY,
};
}
return null;
}
function getCardObstacleTarget(fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
if (
document.body.classList.contains("layout-mode-mobile") &&
mobilePopup
) {
const side = mobilePopup.dataset.dockSide || "left";
return {
element: mobilePopup,
padding: getObstaclePaddingBySide(side, MOBILE_POPUP_OBSTACLE_PADDING_PX),
};
}
const infoPanel = getVisibleInfoPanel();
if (infoPanel) {
return {
element: infoPanel,
padding: getObstaclePaddingBySide("left", DESKTOP_PANEL_OBSTACLE_PADDING_PX),
};
}
if (fallbackPlacement) {
return {
x: fallbackPlacement.x,
y: fallbackPlacement.y,
width: fallbackPlacement.width ?? 0,
height: fallbackPlacement.height ?? 0,
padding: DESKTOP_PANEL_OBSTACLE_PADDING_PX,
};
}
return null;
}
function resolveAdaptiveDockSide(markerCoords, fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
const popupRect =
mobilePopup
? mobilePopup.getBoundingClientRect()
: fallbackPlacement
? {
left: fallbackPlacement.x,
top: fallbackPlacement.y,
right: fallbackPlacement.x + (fallbackPlacement.width ?? 0),
bottom: fallbackPlacement.y + (fallbackPlacement.height ?? 0),
}
: null;
if (!markerCoords || !popupRect) return fallbackPlacement?.dockSide === "right" ? "right" : "left";
return computeNearestPerimeterAnchor(markerCoords, popupRect, 0)?.side || "left";
}
function syncMobileDockSide(markerCoords, fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
const dockSide = resolveAdaptiveDockSide(markerCoords, fallbackPlacement);
if (mobilePopup) {
mobilePopup.dataset.dockSide = dockSide;
}
}
function getConnectorPath(marker) { function getConnectorPath(marker) {
const markerCoords = getMarkerScreenCoords(marker); const markerCoords = getMarkerScreenCoords(marker);
const targetCardCoords = cardPlacement || getCardScreenCoords(marker); const markerRect = getMarkerScreenRect(marker);
if (!markerCoords || !targetCardCoords) return null; if (!markerCoords) return null;
return createElbowConnectorPoints( if (document.body.classList.contains("layout-mode-mobile")) {
markerCoords, const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
{ syncMobileDockSide(markerCoords, targetCardCoords);
x: targetCardCoords.anchorX, const cardAnchorTarget = getCardAnchorTarget();
y: targetCardCoords.anchorY, const cardAnchorCoords = resolveConnectorAnchor(cardAnchorTarget);
}, const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
{ if (!cardAnchorCoords) return null;
return createConnectorPath(markerCoords, cardAnchorTarget ?? cardAnchorCoords, {
routingMode: "adaptive",
sourceRect: markerRect,
targetAnchor: cardAnchorTarget ?? cardAnchorCoords,
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
startFrom: "source", startFrom: "source",
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx, sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CRUISE_CONFIG.linkPanelGapPx, targetGapPx: CONNECTOR_CONFIG.panelGapPx,
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx, obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx, });
}
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
const cardObstacleTarget = getCardObstacleTarget(targetCardCoords);
if (!targetCardCoords) return null;
const infoPanel = getVisibleInfoPanel();
const desktopTarget =
infoPanel
? infoPanel
: {
x: targetCardCoords.x,
y: targetCardCoords.y,
width: targetCardCoords.width,
height: targetCardCoords.height,
};
return createConnectorPath(
markerCoords,
desktopTarget,
{
routingMode: "adaptive",
sourceRect: markerRect,
obstacles: cardObstacleTarget ? [cardObstacleTarget] : [],
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
startFrom: "source",
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
}, },
); );
} }
@@ -166,7 +443,6 @@ export function createBGPCruiseAdapter({
async focusMarker(marker, { interrupt = false } = {}) { async focusMarker(marker, { interrupt = false } = {}) {
if (!marker) return; if (!marker) return;
currentMarkerId = marker.userData?.id || null; currentMarkerId = marker.userData?.id || null;
cardPlacement = getCardScreenCoords(marker);
setMarkerLocked(marker); setMarkerLocked(marker);
showMarkerOverlay(marker); showMarkerOverlay(marker);
@@ -179,10 +455,31 @@ export function createBGPCruiseAdapter({
: CRUISE_CONFIG.focusDurationMs, : CRUISE_CONFIG.focusDurationMs,
suppressStatus: true, suppressStatus: true,
}); });
cardPlacement = getCardScreenCoords(marker);
}, },
async presentMarker(marker, { context }) { async presentMarker(marker, { context }) {
if (!marker) return false; if (!marker) return false;
const showCruiseMarkerInfo = ({ reveal = true } = {}) =>
showMarkerInfo(marker, {
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
reveal,
anchorStable: true,
dockSide: cardPlacement?.dockSide,
});
showCruiseMarkerInfo({ reveal: false });
await context.nextFrame();
if (!context.isCurrent()) {
cardPlacement = null;
connector.hide();
hideInfo();
return false;
}
const startedAt = performance.now(); const startedAt = performance.now();
let connectorReady = false; let connectorReady = false;
while (context.isCurrent()) { while (context.isCurrent()) {
@@ -211,18 +508,10 @@ export function createBGPCruiseAdapter({
return false; return false;
} }
showMarkerInfo(marker, { showCruiseMarkerInfo();
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
await context.nextFrame(); await context.nextFrame();
if (!isInfoVisible()) { if (!isInfoVisible()) {
showMarkerInfo(marker, { showCruiseMarkerInfo();
x: cardPlacement?.x,
y: cardPlacement?.y,
absolute: true,
});
await context.nextFrame(); await context.nextFrame();
} }

View File

@@ -1,7 +1,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group(); const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group(); const bgpOverlayGroup = new THREE.Group();
@@ -280,37 +280,23 @@ function blendHexColors(fromHex, toHex, ratio) {
function getCollectorDistanceScale(marker, camera) { function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
marker.getWorldPosition(collectorWorldPosition); return getSurfaceMarkerCameraScale(camera, {
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition); altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset; referenceFov: 75,
const referenceFovRad = (75 * Math.PI) / 180; min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180; max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6); });
const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9);
const worldPerPixel =
distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel =
referenceDistance * Math.tan(referenceFovRad / 2);
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
} }
function getEventDistanceScale(marker, camera) { function getEventDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
marker.getWorldPosition(collectorWorldPosition); return getSurfaceMarkerCameraScale(camera, {
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition); altitudeOffset: BGP_CONFIG.altitudeOffset,
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset; referenceFov: 75,
const referenceFovRad = (75 * Math.PI) / 180; min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180; max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7); });
const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9);
const worldPerPixel =
distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel =
referenceDistance * Math.tan(referenceFovRad / 2);
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
} }
function orientCollectorMarkerToSurface(marker, position) { function orientCollectorMarkerToSurface(marker, position) {

View File

@@ -9,7 +9,7 @@ import {
CABLE_STATE, CABLE_STATE,
CABLE_CONFIG, CABLE_CONFIG,
} from "./constants.js"; } from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js"; import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js"; import { showInfoCard } from "./info-card.js";
import { setLegendItems, setLegendMode } from "./legend.js"; import { setLegendItems, setLegendMode } from "./legend.js";
@@ -21,7 +21,6 @@ let cableIdMap = new Map();
let cableStates = new Map(); let cableStates = new Map();
let cablesVisible = true; let cablesVisible = true;
let landingPointGeometry = null; let landingPointGeometry = null;
const landingPointWorldPosition = new THREE.Vector3();
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
@@ -33,24 +32,13 @@ function getLandingPointDistanceScale(point, camera) {
!camera || !camera ||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
) return 1; ) return 1;
point.getWorldPosition(landingPointWorldPosition);
const distanceToCamera = camera.position.distanceTo(landingPointWorldPosition); return getSurfaceMarkerCameraScale(camera, {
const referenceDistance = altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
CONFIG.defaultCameraZ - referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
CONFIG.earthRadius + min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
CABLE_CONFIG.landingPoint.altitudeOffset; max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
const referenceFovDeg = });
CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75;
const referenceFovRad = (referenceFovDeg * Math.PI) / 180;
const cameraFovRad =
(((camera.fov || referenceFovDeg)) * Math.PI) / 180;
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
return clamp(
worldPerPixel / referenceWorldPerPixel,
CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
);
} }
function disposeMaterial(material) { function disposeMaterial(material) {

View File

@@ -1,13 +1,316 @@
const SVG_NS = "http://www.w3.org/2000/svg"; const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULT_CLASS_NAME = "info-card-cruise-link"; const DEFAULT_CLASS_NAME = "callout-connector";
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw"; const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
function createSvgElement(tagName) { function createSvgElement(tagName) {
return document.createElementNS(SVG_NS, tagName); return document.createElementNS(SVG_NS, tagName);
} }
function resolveElementAnchorSide(side) {
switch (side) {
case "right":
case "top":
case "bottom":
case "left":
return side;
default:
return "left";
}
}
function resolveElementAnchorAlignRatio(ratio) {
if (!Number.isFinite(ratio)) return 0.5;
return Math.min(Math.max(ratio, 0), 1);
}
function resolveAnchorElement(target) {
if (target instanceof HTMLElement) return target;
if (target?.element instanceof HTMLElement) return target.element;
if (typeof target?.selector === "string") {
const matched = document.querySelector(target.selector);
return matched instanceof HTMLElement ? matched : null;
}
return null;
}
function resolveRectElement(target) {
if (target instanceof HTMLElement) return target;
if (target?.element instanceof HTMLElement) return target.element;
if (typeof target?.selector === "string") {
const matched = document.querySelector(target.selector);
return matched instanceof HTMLElement ? matched : null;
}
return null;
}
function isFiniteRect(rect) {
return (
rect &&
Number.isFinite(rect.left) &&
Number.isFinite(rect.top) &&
Number.isFinite(rect.right) &&
Number.isFinite(rect.bottom)
);
}
function normalizeRect(rect) {
if (!rect) return null;
const left = Number(rect.left);
const top = Number(rect.top);
const right = Number(rect.right);
const bottom = Number(rect.bottom);
if (
!Number.isFinite(left) ||
!Number.isFinite(top) ||
!Number.isFinite(right) ||
!Number.isFinite(bottom)
) {
return null;
}
return {
left: Math.min(left, right),
top: Math.min(top, bottom),
right: Math.max(left, right),
bottom: Math.max(top, bottom),
};
}
function expandRect(rect, padding = 0) {
const normalized = normalizeRect(rect);
if (!normalized) return null;
if (typeof padding === "object" && padding !== null) {
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
return {
left: normalized.left - leftPadding,
top: normalized.top - topPadding,
right: normalized.right + rightPadding,
bottom: normalized.bottom + bottomPadding,
};
}
return {
left: normalized.left - padding,
top: normalized.top - padding,
right: normalized.right + padding,
bottom: normalized.bottom + padding,
};
}
function dedupeSequentialPoints(points) {
const nextPoints = [];
for (const point of points) {
const previous = nextPoints[nextPoints.length - 1];
if (
previous &&
Math.abs(previous.x - point.x) < 0.5 &&
Math.abs(previous.y - point.y) < 0.5
) {
continue;
}
nextPoints.push(point);
}
return nextPoints;
}
// Compute the anchor point on the nearest perimeter edge of rect to source.
// gap is applied outward from the edge, so the anchor is outside the rect.
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
const { left, top, right, bottom } = rect;
const midX = (left + right) * 0.5;
const midY = (top + bottom) * 0.5;
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
// Source inside rect: snap to nearest edge midpoint
const dLeft = source.x - left;
const dRight = right - source.x;
const dTop = source.y - top;
const dBottom = bottom - source.y;
const minD = Math.min(dLeft, dRight, dTop, dBottom);
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
return { x: midX, y: bottom + gap, side: "bottom" };
}
export function resolveConnectorObstacleRect(target, options = {}) {
if (!target) return null;
if (typeof target === "function") {
return resolveConnectorObstacleRect(target(), options);
}
const resolvedPadding =
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
? target.padding
: options.padding;
const padding =
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
? resolvedPadding
: 0;
if (isFiniteRect(target)) {
return expandRect(target, padding);
}
if (
Number.isFinite(target.x) &&
Number.isFinite(target.y) &&
Number.isFinite(target.width) &&
Number.isFinite(target.height)
) {
return expandRect(
{
left: Number(target.x),
top: Number(target.y),
right: Number(target.x) + Number(target.width),
bottom: Number(target.y) + Number(target.height),
},
padding,
);
}
const element = resolveRectElement(target);
if (!(element instanceof HTMLElement)) return null;
return expandRect(element.getBoundingClientRect(), padding);
}
export function resolveConnectorAnchor(target) {
if (!target) return null;
if (typeof target === "function") {
return resolveConnectorAnchor(target());
}
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
return { x: Number(target.x), y: Number(target.y) };
}
const element = resolveAnchorElement(target);
if (!(element instanceof HTMLElement)) return null;
const rect = element.getBoundingClientRect();
const side = resolveElementAnchorSide(target.side);
const alignRatio = resolveElementAnchorAlignRatio(
target.alignRatio ?? target.anchorRatio ?? target.ratio,
);
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
let x = rect.left + rect.width * 0.5;
let y = rect.top + rect.height * 0.5;
if (side === "left") {
x = rect.left;
y = rect.top + rect.height * alignRatio;
} else if (side === "right") {
x = rect.right;
y = rect.top + rect.height * alignRatio;
} else if (side === "top") {
x = rect.left + rect.width * alignRatio;
y = rect.top;
} else if (side === "bottom") {
x = rect.left + rect.width * alignRatio;
y = rect.bottom;
}
return {
x: x + offsetX,
y: y + offsetY,
};
}
function resolveConnectorRect(target) {
return resolveConnectorObstacleRect(target, { padding: 0 });
}
function createRectSideMidpoint(rect, side, gap = 0) {
const normalizedRect = normalizeRect(rect);
if (!normalizedRect) return null;
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
if (side === "left") {
return { x: normalizedRect.left - gap, y: midpointY, side };
}
if (side === "right") {
return { x: normalizedRect.right + gap, y: midpointY, side };
}
if (side === "top") {
return { x: midpointX, y: normalizedRect.top - gap, side };
}
if (side === "bottom") {
return { x: midpointX, y: normalizedRect.bottom + gap, side };
}
return null;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function createRectEdgeAnchor(rect, side, position, gap = 0) {
const normalizedRect = normalizeRect(rect);
if (!normalizedRect) return null;
const x =
side === "left"
? normalizedRect.left - gap
: side === "right"
? normalizedRect.right + gap
: clamp(
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
normalizedRect.left,
normalizedRect.right,
);
const y =
side === "top"
? normalizedRect.top - gap
: side === "bottom"
? normalizedRect.bottom + gap
: clamp(
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
normalizedRect.top,
normalizedRect.bottom,
);
return { x, y, side };
}
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
if (!startPoint || !endPoint) return null;
const normalizedDirections = directions.filter(Boolean);
if (!normalizedDirections.length) {
return dedupeSequentialPoints([startPoint, endPoint]);
}
const firstDirection = normalizedDirections[0];
const corner =
firstDirection === "left" || firstDirection === "right"
? { x: endPoint.x, y: startPoint.y }
: { x: startPoint.x, y: endPoint.y };
return dedupeSequentialPoints([startPoint, corner, endPoint]);
}
function resolveSourceAnchorGapPx(sourceGapPx) {
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
}
export function createElbowConnectorPoints(source, target, options = {}) { export function createElbowConnectorPoints(source, target, options = {}) {
if (!source || !target) return null; const resolvedSource = resolveConnectorAnchor(source);
const resolvedTarget = resolveConnectorAnchor(target);
if (!resolvedSource || !resolvedTarget) return null;
const { const {
startFrom = "source", startFrom = "source",
@@ -17,8 +320,8 @@ export function createElbowConnectorPoints(source, target, options = {}) {
elbowDropPx = 14, elbowDropPx = 14,
} = options; } = options;
const sourcePoint = { x: Number(source.x), y: Number(source.y) }; const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
const targetPoint = { x: Number(target.x), y: Number(target.y) }; const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
if ( if (
!Number.isFinite(sourcePoint.x) || !Number.isFinite(sourcePoint.x) ||
!Number.isFinite(sourcePoint.y) || !Number.isFinite(sourcePoint.y) ||
@@ -53,6 +356,163 @@ export function createElbowConnectorPoints(source, target, options = {}) {
}; };
} }
function createAdaptiveConnectorPoints(source, target, options = {}) {
const resolvedSource = resolveConnectorAnchor(source);
if (!resolvedSource) return null;
const {
startFrom = "source",
sourceGapPx = 12,
targetGapPx = 8,
obstacleClearancePx = 8,
obstacles = [],
targetAnchor = null,
sourceRect = null,
} = options;
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
.filter(Boolean);
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
const resolvedTarget =
resolveConnectorAnchor(targetAnchor ?? target) ||
(fallbackTargetRect
? computeNearestPerimeterAnchor(
sp,
fallbackTargetRect,
Math.max(targetGapPx, obstacleClearancePx + 1),
)
: null);
if (!resolvedTarget) return null;
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
const primaryObstacle = normalizedObstacles[0] || null;
const relationRect = fallbackTargetRect || primaryObstacle;
if (!relationRect) return null;
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
const isTargetAbove = relationRect.bottom < sp.y;
const isTargetBelow = relationRect.top > sp.y;
const isSourceWithinAnchorHorizontalRange =
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
let directions = [];
let targetSide = null;
const isTargetCenterWithinSourceVerticalRange =
sourceRelationRect &&
targetCenterY >= sourceRelationRect.top &&
targetCenterY <= sourceRelationRect.bottom;
const isTargetCenterWithinSourceHorizontalRange =
sourceRelationRect &&
targetCenterX >= sourceRelationRect.left &&
targetCenterX <= sourceRelationRect.right;
if (isRightMidpointLeftOfSource) {
targetSide = "right";
if (isTargetCenterWithinSourceVerticalRange) {
directions = ["left"];
} else if (rightMidpoint.y < sp.y) {
directions = ["top", "left"];
} else if (rightMidpoint.y > sp.y) {
directions = ["bottom", "left"];
} else {
directions = ["left"];
}
} else if (isLeftMidpointRightOfSource) {
targetSide = "left";
if (isTargetCenterWithinSourceVerticalRange) {
directions = ["right"];
} else if (leftMidpoint.y < sp.y) {
directions = ["top", "right"];
} else if (leftMidpoint.y > sp.y) {
directions = ["bottom", "right"];
} else {
directions = ["right"];
}
} else if (isSourceWithinAnchorHorizontalRange) {
if (isTargetAbove) {
targetSide = "bottom";
directions = isTargetCenterWithinSourceHorizontalRange
? ["top"]
: targetCenterX >= sp.x
? ["right", "top"]
: ["left", "top"];
} else if (isTargetBelow) {
targetSide = "top";
directions = isTargetCenterWithinSourceHorizontalRange
? ["bottom"]
: targetCenterX >= sp.x
? ["right", "bottom"]
: ["left", "bottom"];
}
}
if (!targetSide || !directions.length) {
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
}
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
const targetPoint =
resolvedTarget && targetAnchor
? end
: derivedTargetAnchor || end;
const sourceSide = directions[0] || null;
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
const shouldSlideSourceAnchorAlongEdge =
(sourceSide === "left" || sourceSide === "right") &&
isTargetCenterWithinSourceVerticalRange ||
(sourceSide === "top" || sourceSide === "bottom") &&
isTargetCenterWithinSourceHorizontalRange;
const derivedSourceAnchor =
sourceRelationRect && sourceSide
? shouldSlideSourceAnchorAlongEdge
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
: null;
const startPoint = derivedSourceAnchor || sp;
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
if (!pts) {
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
}
pts = dedupeSequentialPoints(pts);
return {
points: startFrom === "target" ? pts.slice().reverse() : pts,
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
};
}
export function createConnectorPath(source, target, options = {}) {
const {
routingMode = "simple",
} = options;
if (routingMode === "adaptive") {
return createAdaptiveConnectorPoints(source, target, options);
}
if (routingMode === "simple") {
return createElbowConnectorPoints(source, target, options);
}
return createElbowConnectorPoints(source, target, options);
}
export class CalloutConnector { export class CalloutConnector {
constructor({ constructor({
container = null, container = null,

View File

@@ -1,17 +1,73 @@
import * as THREE from "three"; import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js"; import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group(); const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = []; const computeCenterMarkers = [];
const textureCache = new Map(); const textureCache = new Map();
const scratchMarkerWorldPosition = new THREE.Vector3();
let showComputeCenters = true; let showComputeCenters = true;
let supercomputerCount = 0; let supercomputerCount = 0;
let gpuClusterCount = 0; let gpuClusterCount = 0;
function buildComputeCenterMarkerData(feature) {
const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || [];
const longitude = Number(coordinates[0]);
const latitude = Number(coordinates[1]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
...props,
latitude,
longitude,
displayLatitude: latitude,
displayLongitude: longitude,
site_type: normalizeSiteType(props.site_type),
};
}
function spreadComputeCenterPositions(markers) {
const groups = new Map();
const precision = COMPUTE_CENTER_CONFIG.overlapSpread.groupPrecision;
markers.forEach((marker) => {
const key = `${marker.latitude.toFixed(precision)}|${marker.longitude.toFixed(precision)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(marker);
});
groups.forEach((group) => {
if (group.length <= 1) return;
const radius = COMPUTE_CENTER_CONFIG.overlapSpread.radius;
const offsetStep = COMPUTE_CENTER_CONFIG.overlapSpread.offsetStep;
group.forEach((marker, index) => {
const angle = (Math.PI * 2 * index) / group.length;
marker.displayLatitude =
marker.latitude + Math.sin(angle) * radius * offsetStep;
marker.displayLongitude =
marker.longitude + Math.cos(angle) * radius * offsetStep;
marker.isSpread = true;
marker.groupSize = group.length;
});
});
markers.forEach((marker) => {
if (marker.isSpread) return;
marker.displayLatitude = marker.latitude;
marker.displayLongitude = marker.longitude;
marker.isSpread = false;
marker.groupSize = 1;
});
return markers;
}
function createMarkerTexture(siteType, isEstimated = false) { function createMarkerTexture(siteType, isEstimated = false) {
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`; const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
if (textureCache.has(textureKey)) { if (textureCache.has(textureKey)) {
@@ -106,21 +162,12 @@ function getDistanceScale(marker, camera) {
return 1; return 1;
} }
const distanceToCamera = camera.position.distanceTo( return getSurfaceMarkerCameraScale(camera, {
marker.getWorldPosition(scratchMarkerWorldPosition), altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
); referenceFov: 75,
const referenceDistance = min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
CONFIG.defaultCameraZ - CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset; max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
const referenceFovRad = (75 * Math.PI) / 180; });
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
const scale = worldPerPixel / referenceWorldPerPixel;
return THREE.MathUtils.clamp(
scale,
COMPUTE_CENTER_CONFIG.sizeStabilization.min,
COMPUTE_CENTER_CONFIG.sizeStabilization.max,
);
} }
function clearGroup(group) { function clearGroup(group) {
@@ -131,18 +178,10 @@ function clearGroup(group) {
} }
} }
function createComputeCenterMarker(feature) { function createComputeCenterMarker(markerData) {
const props = feature?.properties || {}; const siteType = markerData.site_type;
const coordinates = feature?.geometry?.coordinates || [];
const longitude = Number(coordinates[0]);
const latitude = Number(coordinates[1]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const siteType = normalizeSiteType(props.site_type);
const material = new THREE.SpriteMaterial({ const material = new THREE.SpriteMaterial({
map: createMarkerTexture(siteType, Boolean(props.is_estimated)), map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity, opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
@@ -151,8 +190,8 @@ function createComputeCenterMarker(feature) {
const baseScale = getBaseScale(siteType); const baseScale = getBaseScale(siteType);
marker.position.copy( marker.position.copy(
latLonToVector3( latLonToVector3(
latitude, markerData.displayLatitude,
longitude, markerData.displayLongitude,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset, CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
), ),
); );
@@ -160,9 +199,7 @@ function createComputeCenterMarker(feature) {
marker.renderOrder = 8; marker.renderOrder = 8;
marker.visible = showComputeCenters; marker.visible = showComputeCenters;
marker.userData = { marker.userData = {
...props, ...markerData,
latitude,
longitude,
site_type: siteType, site_type: siteType,
type: "compute_center", type: "compute_center",
baseScale, baseScale,
@@ -275,10 +312,14 @@ export async function loadComputeCenters(_scene, earth) {
clearComputeCenterData(earth); clearComputeCenterData(earth);
features spreadComputeCenterPositions(
features
.map((feature) => buildComputeCenterMarkerData(feature))
.filter(Boolean),
)
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers) .slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
.forEach((feature) => { .forEach((markerData) => {
const marker = createComputeCenterMarker(feature); const marker = createComputeCenterMarker(markerData);
if (!marker) return; if (!marker) return;
if (marker.userData.site_type === "supercomputer") { if (marker.userData.site_type === "supercomputer") {
supercomputerCount += 1; supercomputerCount += 1;

View File

@@ -25,12 +25,12 @@ export const CRUISE_CONFIG = {
maxPolledEvents: 200, maxPolledEvents: 200,
cardAnchorXRatio: 0.68, cardAnchorXRatio: 0.68,
cardAnchorYRatio: 0.24, cardAnchorYRatio: 0.24,
linkMarkerGapPx: 18, };
linkPanelGapPx: 12,
linkElbowOffsetPx: 72, export const CONNECTOR_CONFIG = {
linkAnchorHeightRatio: 0.26, markerGapPx: 18,
linkForcedBendPx: 34, panelGapPx: 12,
linkElbowDropPx: 24, obstacleClearancePx: 8,
}; };
export const HUD_CONFIG = { export const HUD_CONFIG = {
@@ -165,6 +165,11 @@ export const PATHS = {
export const COMPUTE_CENTER_CONFIG = { export const COMPUTE_CENTER_CONFIG = {
altitudeOffset: 0.48, altitudeOffset: 0.48,
maxRenderedMarkers: 300, maxRenderedMarkers: 300,
overlapSpread: {
groupPrecision: 4,
radius: 1.4,
offsetStep: 0.28,
},
marker: { marker: {
baseOpacity: 0.88, baseOpacity: 0.88,
supercomputerScale: 12, supercomputerScale: 12,
@@ -177,8 +182,8 @@ export const COMPUTE_CENTER_CONFIG = {
pulseAmplitude: 0.03, pulseAmplitude: 0.03,
}, },
colors: { colors: {
supercomputer: "#fb923c", supercomputer: "#38bdf8",
gpu_cluster: "#60a5fa", gpu_cluster: "#2dd4bf",
linked: "#f8fafc", linked: "#f8fafc",
}, },
sizeStabilization: { sizeStabilization: {

View File

@@ -3,7 +3,12 @@
import * as THREE from "three"; import * as THREE from "three";
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js"; import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js"; import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain, setDayNightEnabled } from "./earth.js"; import {
toggleTerrain,
setDayNightEnabled,
toggleGridLines,
getShowGridLines,
} from "./earth.js";
import { setCelestialDayNightEnabled } from "./celestial.js"; import { setCelestialDayNightEnabled } from "./celestial.js";
import { import {
ensureTerrainReady, ensureTerrainReady,
@@ -86,7 +91,8 @@ const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
const SETTINGS_SHEET_MIN_SCALE = 0.06; const SETTINGS_SHEET_MIN_SCALE = 0.06;
const SETTINGS_SHEET_MAX_SCALE_X = 0.22; const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18; const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1"; const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const DEFAULT_EARTH_ZOOM_STEP = 0.01; const DEFAULT_EARTH_ZOOM_STEP = 0.01;
let settingsModalTimer = null; let settingsModalTimer = null;
let settingsSheetAnimation = null; let settingsSheetAnimation = null;
@@ -95,6 +101,7 @@ let terrainPrefetchStarted = false;
let terrainPrefetchScheduled = false; let terrainPrefetchScheduled = false;
let focusViewAnimationToken = 0; let focusViewAnimationToken = 0;
let earthSettingsDefaults = null; let earthSettingsDefaults = null;
let earthSettingsState = null;
let layerRegistry = new Map(); let layerRegistry = new Map();
let layerPanelInitialized = false; let layerPanelInitialized = false;
let layoutMode = "desktop"; let layoutMode = "desktop";
@@ -118,6 +125,10 @@ function detectLayoutMode() {
return "desktop"; return "desktop";
} }
function getSettingsViewportScope(mode = layoutMode) {
return mode === "mobile" ? "mobile" : "desktop";
}
export function getLayoutMode() { export function getLayoutMode() {
return layoutMode; return layoutMode;
} }
@@ -225,6 +236,7 @@ function closeTransientMobileOverlays({ except = null } = {}) {
} }
function applyResponsiveLayout() { function applyResponsiveLayout() {
const previousLayoutMode = layoutMode;
layoutMode = detectLayoutMode(); layoutMode = detectLayoutMode();
const isMobile = isMobileLayout(); const isMobile = isMobileLayout();
@@ -233,6 +245,7 @@ function applyResponsiveLayout() {
document.documentElement.classList.toggle("layout-mode-mobile", isMobile); document.documentElement.classList.toggle("layout-mode-mobile", isMobile);
document.documentElement.classList.toggle("layout-mode-compact", isCompact); document.documentElement.classList.toggle("layout-mode-compact", isCompact);
document.documentElement.dataset.earthLayoutMode = layoutMode;
document.body.classList.toggle("layout-mode-mobile", isMobile); document.body.classList.toggle("layout-mode-mobile", isMobile);
document.body.classList.toggle("layout-mode-compact", isCompact); document.body.classList.toggle("layout-mode-compact", isCompact);
container?.classList.toggle("layout-mode-mobile", isMobile); container?.classList.toggle("layout-mode-mobile", isMobile);
@@ -243,6 +256,16 @@ function applyResponsiveLayout() {
mobileDrawerOpen = false; mobileDrawerOpen = false;
} }
if (previousLayoutMode !== layoutMode) {
if (isMobile) {
resetDesktopHudPanelsForMobile();
}
if (earthSettingsState) {
applyCurrentViewportPanelVisibility({ persist: false });
}
}
syncMobileDrawerState(); syncMobileDrawerState();
} }
@@ -281,6 +304,10 @@ function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard
ensureTVPanelReady().catch((error) => { ensureTVPanelReady().catch((error) => {
console.error("初始化媒体抽屉失败:", error); console.error("初始化媒体抽屉失败:", error);
}); });
} else if (mobileDrawerCard === "news") {
ensureNewsPanelReady().catch((error) => {
console.error("初始化新闻抽屉失败:", error);
});
} }
} }
@@ -593,18 +620,19 @@ function canUseLocalStorage() {
} }
} }
function getCurrentSettingsSnapshot() { function getCurrentPanelVisibilitySnapshot() {
const panelVisibility = Object.fromEntries( return Object.fromEntries(
HUD_PANEL_IDS.map((panelId) => { HUD_PANEL_IDS.map((panelId) => {
const panel = document.getElementById(panelId); const panel = document.getElementById(panelId);
const visible = !panel?.classList.contains("hud-panel-hidden"); const visible = !panel?.classList.contains("hud-panel-hidden");
return [panelId, visible]; return [panelId, visible];
}), }),
); );
}
function getCurrentSharedSettingsSnapshot() {
return { return {
rotationMode, rotationMode,
panelVisibility,
layerVisibility: Object.fromEntries( layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]), getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
), ),
@@ -616,37 +644,87 @@ function getCurrentSettingsSnapshot() {
function captureEarthSettingsDefaults() { function captureEarthSettingsDefaults() {
if (!earthSettingsDefaults) { if (!earthSettingsDefaults) {
earthSettingsDefaults = getCurrentSettingsSnapshot(); const panelVisibility = getCurrentPanelVisibilitySnapshot();
const shared = getCurrentSharedSettingsSnapshot();
earthSettingsDefaults = {
version: 2,
shared,
views: {
desktop: {
panelVisibility: { ...panelVisibility },
},
mobile: {
panelVisibility: { ...panelVisibility },
},
},
};
} }
return earthSettingsDefaults; return earthSettingsDefaults;
} }
function cloneEarthSettings(settings) { function cloneEarthSettings(settings) {
return { return {
rotationMode: settings.rotationMode, version: 2,
terrainOpacity: settings.terrainOpacity, shared: {
dayNightEnabled: settings.dayNightEnabled, rotationMode: settings.shared.rotationMode,
defaultEarthZoom: settings.defaultEarthZoom, terrainOpacity: settings.shared.terrainOpacity,
panelVisibility: { ...(settings.panelVisibility || {}) }, dayNightEnabled: settings.shared.dayNightEnabled,
layerVisibility: { ...(settings.layerVisibility || {}) }, defaultEarthZoom: settings.shared.defaultEarthZoom,
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
desktop: {
panelVisibility: {
...(settings.views?.desktop?.panelVisibility || {}),
},
},
mobile: {
panelVisibility: {
...(settings.views?.mobile?.panelVisibility || {}),
},
},
},
}; };
} }
function normalizeEarthSettings(rawSettings, defaults) { function normalizeEarthSettings(rawSettings, defaults) {
const normalizedPanelVisibility = { ...defaults.panelVisibility }; const normalizedDesktopPanelVisibility = {
const normalizedLayerVisibility = { ...defaults.layerVisibility }; ...defaults.views.desktop.panelVisibility,
const inputPanelVisibility = };
rawSettings && typeof rawSettings.panelVisibility === "object" const normalizedMobilePanelVisibility = {
? rawSettings.panelVisibility ...defaults.views.mobile.panelVisibility,
};
const normalizedLayerVisibility = {
...defaults.shared.layerVisibility,
};
const sharedSettings =
rawSettings && typeof rawSettings.shared === "object"
? rawSettings.shared
: rawSettings;
const inputDesktopPanelVisibility =
rawSettings?.views?.desktop && typeof rawSettings.views.desktop.panelVisibility === "object"
? rawSettings.views.desktop.panelVisibility
: rawSettings && typeof rawSettings.panelVisibility === "object"
? rawSettings.panelVisibility
: {};
const inputMobilePanelVisibility =
rawSettings?.views?.mobile && typeof rawSettings.views.mobile.panelVisibility === "object"
? rawSettings.views.mobile.panelVisibility
: {}; : {};
const inputLayerVisibility = const inputLayerVisibility =
rawSettings && typeof rawSettings.layerVisibility === "object" sharedSettings && typeof sharedSettings.layerVisibility === "object"
? rawSettings.layerVisibility ? sharedSettings.layerVisibility
: {}; : {};
Object.entries(inputPanelVisibility).forEach(([panelId, visible]) => { Object.entries(inputDesktopPanelVisibility).forEach(([panelId, visible]) => {
if (panelId in normalizedPanelVisibility) { if (panelId in normalizedDesktopPanelVisibility) {
normalizedPanelVisibility[panelId] = Boolean(visible); normalizedDesktopPanelVisibility[panelId] = Boolean(visible);
}
});
Object.entries(inputMobilePanelVisibility).forEach(([panelId, visible]) => {
if (panelId in normalizedMobilePanelVisibility) {
normalizedMobilePanelVisibility[panelId] = Boolean(visible);
} }
}); });
@@ -657,26 +735,36 @@ function normalizeEarthSettings(rawSettings, defaults) {
}); });
const nextRotationMode = const nextRotationMode =
rawSettings?.rotationMode === ROTATION_MODE.CRUISE sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE
: defaults.rotationMode; : defaults.shared.rotationMode;
const nextTerrainOpacity = Number.parseFloat(rawSettings?.terrainOpacity); const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof rawSettings?.dayNightEnabled === "boolean" const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? rawSettings.dayNightEnabled ? sharedSettings.dayNightEnabled
: defaults.dayNightEnabled; : defaults.shared.dayNightEnabled;
const nextDefaultEarthZoom = clampEarthZoomLevel( const nextDefaultEarthZoom = clampEarthZoomLevel(
rawSettings?.defaultEarthZoom ?? defaults.defaultEarthZoom, sharedSettings?.defaultEarthZoom ?? defaults.shared.defaultEarthZoom,
); );
return { return {
rotationMode: nextRotationMode, version: 2,
panelVisibility: normalizedPanelVisibility, shared: {
layerVisibility: normalizedLayerVisibility, rotationMode: nextRotationMode,
terrainOpacity: Number.isFinite(nextTerrainOpacity) layerVisibility: normalizedLayerVisibility,
? nextTerrainOpacity terrainOpacity: Number.isFinite(nextTerrainOpacity)
: defaults.terrainOpacity, ? nextTerrainOpacity
dayNightEnabled: nextDayNightEnabled, : defaults.shared.terrainOpacity,
defaultEarthZoom: nextDefaultEarthZoom, dayNightEnabled: nextDayNightEnabled,
defaultEarthZoom: nextDefaultEarthZoom,
},
views: {
desktop: {
panelVisibility: normalizedDesktopPanelVisibility,
},
mobile: {
panelVisibility: normalizedMobilePanelVisibility,
},
},
}; };
} }
@@ -701,8 +789,10 @@ function loadEarthSettings() {
try { try {
const rawValue = window.localStorage.getItem(EARTH_SETTINGS_STORAGE_KEY); const rawValue = window.localStorage.getItem(EARTH_SETTINGS_STORAGE_KEY);
if (!rawValue) return defaults; const legacyRawValue = window.localStorage.getItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
const parsedValue = JSON.parse(rawValue); const sourceValue = rawValue || legacyRawValue;
if (!sourceValue) return defaults;
const parsedValue = JSON.parse(sourceValue);
return normalizeEarthSettings(parsedValue, defaults); return normalizeEarthSettings(parsedValue, defaults);
} catch (error) { } catch (error) {
console.warn("读取 Earth 设置失败,已回退默认值:", error); console.warn("读取 Earth 设置失败,已回退默认值:", error);
@@ -710,13 +800,33 @@ function loadEarthSettings() {
} }
} }
function getViewportPanelVisibility(settings, mode = layoutMode) {
const scope = getSettingsViewportScope(mode);
return settings?.views?.[scope]?.panelVisibility || {};
}
function syncEarthSettingsStateFromRuntime() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
const nextSettings = earthSettingsState
? cloneEarthSettings(earthSettingsState)
: defaults;
const scope = getSettingsViewportScope();
nextSettings.shared = getCurrentSharedSettingsSnapshot();
nextSettings.views[scope].panelVisibility = getCurrentPanelVisibilitySnapshot();
earthSettingsState = nextSettings;
return nextSettings;
}
function persistEarthSettings() { function persistEarthSettings() {
if (!canUseLocalStorage()) return; if (!canUseLocalStorage()) return;
try { try {
const nextSettings = syncEarthSettingsStateFromRuntime();
window.localStorage.setItem( window.localStorage.setItem(
EARTH_SETTINGS_STORAGE_KEY, EARTH_SETTINGS_STORAGE_KEY,
JSON.stringify(getCurrentSettingsSnapshot()), JSON.stringify(nextSettings),
); );
window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
} catch (error) { } catch (error) {
console.warn("保存 Earth 设置失败:", error); console.warn("保存 Earth 设置失败:", error);
} }
@@ -767,15 +877,11 @@ function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = tr
async function applyEarthSettings(settings) { async function applyEarthSettings(settings) {
if (!settings) return; if (!settings) return;
earthSettingsState = cloneEarthSettings(settings);
HUD_PANEL_IDS.forEach((panelId) => { applyCurrentViewportPanelVisibility({ persist: false });
const visible = settings.panelVisibility?.[panelId];
if (typeof visible === "boolean") {
setHudPanelVisibility(panelId, visible, { persist: false });
}
});
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity); const appliedOpacity = setTerrainOpacity(settings.shared.terrainOpacity);
document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => { document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => {
if (slider instanceof HTMLInputElement) { if (slider instanceof HTMLInputElement) {
slider.value = appliedOpacity.toFixed(2); slider.value = appliedOpacity.toFixed(2);
@@ -787,18 +893,18 @@ async function applyEarthSettings(settings) {
} }
}); });
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true }); setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
if (typeof settings.dayNightEnabled === "boolean") { if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.dayNightEnabled, { persist: false }); applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
} }
setDefaultEarthZoom(settings.defaultEarthZoom, { setDefaultEarthZoom(settings.shared.defaultEarthZoom, {
persist: false, persist: false,
applyToCurrentView: true, applyToCurrentView: true,
}); });
await applyLayerVisibilitySettings(settings.layerVisibility, { await applyLayerVisibilitySettings(settings.shared.layerVisibility, {
persist: false, persist: false,
silent: true, silent: true,
}); });
@@ -806,9 +912,11 @@ async function applyEarthSettings(settings) {
function resetEarthSettings() { function resetEarthSettings() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults()); const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
earthSettingsState = cloneEarthSettings(defaults);
if (canUseLocalStorage()) { if (canUseLocalStorage()) {
try { try {
window.localStorage.removeItem(EARTH_SETTINGS_STORAGE_KEY); window.localStorage.removeItem(EARTH_SETTINGS_STORAGE_KEY);
window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
} catch (error) { } catch (error) {
console.warn("移除 Earth 设置失败:", error); console.warn("移除 Earth 设置失败:", error);
} }
@@ -896,6 +1004,20 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
} }
} }
function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleGridLines(enabled);
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏经纬线" : "显示经纬线",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
}
return enabled;
}
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled); clearSelectionIfHiding(!enabled);
toggleBGP(enabled); toggleBGP(enabled);
@@ -987,6 +1109,22 @@ function getBuiltinLayerDefinitions() {
setVisible: (visible, options = {}) => setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options), setTerrainEnabled(getLayerButton("terrain"), visible, options),
}, },
{
id: "gridLines",
buttonId: "toggle-grid-lines",
icon: "grid_4x4",
label: "经纬线",
meta: "Graticule",
keywords: "经纬线 graticule 经纬 latitude longitude",
defaultActive: true,
startupPriority: null,
startupMode: "visible",
startupLabel: "经纬线",
startupMessage: "",
getVisible: () => getShowGridLines(),
setVisible: (visible, options = {}) =>
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
},
{ {
id: "satellites", id: "satellites",
buttonId: "toggle-satellites", buttonId: "toggle-satellites",
@@ -1443,6 +1581,10 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
const panel = document.getElementById(panelId); const panel = document.getElementById(panelId);
if (!panel) return; if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible); panel.classList.toggle("hud-panel-hidden", !visible);
const scope = getSettingsViewportScope();
if (earthSettingsState?.views?.[scope]?.panelVisibility) {
earthSettingsState.views[scope].panelVisibility[panelId] = visible;
}
if (!visible && activeMobileDrawerId === panelId) { if (!visible && activeMobileDrawerId === panelId) {
activeMobileDrawerId = null; activeMobileDrawerId = null;
syncMobileDrawerState(); syncMobileDrawerState();
@@ -1451,10 +1593,13 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
if (panelId === "media-panel") { if (panelId === "media-panel") {
updateTVToggleUI(visible); updateTVToggleUI(visible);
updateNewsToggleUI(visible); updateNewsToggleUI(visible);
if (visible) { if (visible && !isMobileLayout()) {
ensureTVPanelReady().catch((error) => { ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error); console.error("初始化电视直播面板失败:", error);
}); });
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
} }
} }
if (persist) { if (persist) {
@@ -1462,6 +1607,24 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
} }
} }
function resetDesktopHudPanelsForMobile() {
document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR).forEach((panel) => {
if (!(panel instanceof HTMLElement)) return;
resetPanelInlineLayout(panel);
});
}
function applyCurrentViewportPanelVisibility({ persist = false } = {}) {
const panelVisibility = getViewportPanelVisibility(earthSettingsState, layoutMode);
HUD_PANEL_IDS.forEach((panelId) => {
const visible = panelVisibility?.[panelId];
if (typeof visible === "boolean") {
setHudPanelVisibility(panelId, visible, { persist });
}
});
syncAllHudPanelToggles();
}
function syncSettingsToggle(panelId, visible) { function syncSettingsToggle(panelId, visible) {
const input = document.querySelector( const input = document.querySelector(
`[data-settings-panel="${panelId}"]`, `[data-settings-panel="${panelId}"]`,
@@ -2424,17 +2587,19 @@ function setupTerrainControls() {
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info"); showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
}); });
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden"); const mediaVisible =
!isMobileLayout() &&
!document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
updateTVToggleUI(mediaVisible); updateTVToggleUI(mediaVisible);
if (mediaVisible) { if (mediaVisible) {
ensureTVPanelReady().catch((error) => { ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error); console.error("初始化电视直播面板失败:", error);
}); });
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
} }
updateNewsToggleUI(mediaVisible); updateNewsToggleUI(mediaVisible);
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
applyResponsiveLayout(); applyResponsiveLayout();
updateLayoutUI(container); updateLayoutUI(container);
} }

View File

@@ -7,6 +7,7 @@ import { latLonToVector3 } from './utils.js';
export let earth = null; export let earth = null;
export let clouds = null; export let clouds = null;
export let terrain = null; export let terrain = null;
let showGridLines = true;
const textureLoader = new THREE.TextureLoader(); const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null; let _earthMaterial = null;
@@ -321,6 +322,7 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points); const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial); const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'latitude', value: lat }; line.userData = { type: 'latitude', value: lat };
line.visible = showGridLines;
earthObj.add(line); earthObj.add(line);
latitudeLines.push(line); latitudeLines.push(line);
} }
@@ -335,11 +337,26 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points); const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial); const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'longitude', value: lon }; line.userData = { type: 'longitude', value: lon };
line.visible = showGridLines;
earthObj.add(line); earthObj.add(line);
longitudeLines.push(line); longitudeLines.push(line);
} }
} }
export function toggleGridLines(visible) {
showGridLines = visible;
latitudeLines.forEach((line) => {
line.visible = visible;
});
longitudeLines.forEach((line) => {
line.visible = visible;
});
}
export function getShowGridLines() {
return showGridLines;
}
export function getEarth() { export function getEarth() {
return earth; return earth;
} }

View File

@@ -32,7 +32,7 @@ function getMobilePopupSubtitle(type, data) {
} }
} }
function positionMobilePopup(popup, touchX, touchY) { function positionMobilePopup(popup, touchX, touchY, options = {}) {
const margin = 14; const margin = 14;
const drawerClearance = 52; const drawerClearance = 52;
const vpW = window.innerWidth; const vpW = window.innerWidth;
@@ -46,6 +46,14 @@ function positionMobilePopup(popup, touchX, touchY) {
const popW = popup.offsetWidth || 200; const popW = popup.offsetWidth || 200;
const popH = popup.offsetHeight || 68; const popH = popup.offsetHeight || 68;
if (options.absolute === true) {
const left = Math.max(margin, Math.min(touchX, vpW - popW - margin));
const top = Math.max(margin, Math.min(touchY, bottomBound - popH - margin));
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
return;
}
const gap = 22; const gap = 22;
const spaceRight = vpW - touchX; const spaceRight = vpW - touchX;
const spaceLeft = touchX; const spaceLeft = touchX;
@@ -81,7 +89,7 @@ function positionMobilePopup(popup, touchX, touchY) {
let popupShowToken = 0; let popupShowToken = 0;
function showMobilePopup(type, data, x, y) { function showMobilePopup(type, data, x, y, options = {}) {
// Require coordinates — skip if called without position (e.g. from handleCableClick) // Require coordinates — skip if called without position (e.g. from handleCableClick)
if (x == null || y == null) return; if (x == null || y == null) return;
@@ -102,15 +110,19 @@ function showMobilePopup(type, data, x, y) {
popupShowToken += 1; popupShowToken += 1;
const token = popupShowToken; const token = popupShowToken;
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
popup.removeAttribute('hidden'); popup.removeAttribute('hidden');
popup.classList.remove('is-visible'); popup.classList.remove('is-visible');
requestAnimationFrame(() => { requestAnimationFrame(() => {
positionMobilePopup(popup, x, y); positionMobilePopup(popup, x, y, options);
requestAnimationFrame(() => { if (options.reveal === false) {
if (token !== popupShowToken) return; // superseded return;
popup.classList.add('is-visible'); }
}); if (token !== popupShowToken) return; // superseded
void popup.getBoundingClientRect();
popup.classList.add('is-visible');
}); });
} }
@@ -119,6 +131,8 @@ function hideMobilePopup() {
if (!popup) return; if (!popup) return;
popupShowToken += 1; // invalidate any pending show popupShowToken += 1; // invalidate any pending show
popup.classList.remove('is-visible'); popup.classList.remove('is-visible');
popup.classList.remove('earth-mobile-popup--anchor-stable');
delete popup.dataset.dockSide;
popup.addEventListener('transitionend', () => { popup.addEventListener('transitionend', () => {
if (!popup.classList.contains('is-visible')) { if (!popup.classList.contains('is-visible')) {
popup.setAttribute('hidden', ''); popup.setAttribute('hidden', '');
@@ -139,6 +153,19 @@ function ensurePopupClickHandler() {
let dragged = false; let dragged = false;
const DRAG_THRESHOLD = 10; const DRAG_THRESHOLD = 10;
const emitDragEvent = (dragging) => {
const rect = popup.getBoundingClientRect();
window.dispatchEvent(new CustomEvent('earth:info-card-drag', {
detail: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
dragging,
},
}));
};
popup.addEventListener('pointerdown', (e) => { popup.addEventListener('pointerdown', (e) => {
if (e.button > 0) return; if (e.button > 0) return;
e.stopPropagation(); e.stopPropagation();
@@ -164,6 +191,7 @@ function ensurePopupClickHandler() {
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin)); const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
popup.style.left = `${left}px`; popup.style.left = `${left}px`;
popup.style.top = `${top}px`; popup.style.top = `${top}px`;
emitDragEvent(true);
}); });
document.addEventListener('pointerup', (e) => { document.addEventListener('pointerup', (e) => {
@@ -171,6 +199,7 @@ function ensurePopupClickHandler() {
const wasDragged = dragged; const wasDragged = dragged;
dragPointerId = null; dragPointerId = null;
dragged = false; dragged = false;
emitDragEvent(false);
if (!wasDragged) { if (!wasDragged) {
window.dispatchEvent(new CustomEvent('earth:open-details-tab')); window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
} }
@@ -180,6 +209,7 @@ function ensurePopupClickHandler() {
if (e.pointerId === dragPointerId) { if (e.pointerId === dragPointerId) {
dragPointerId = null; dragPointerId = null;
dragged = false; dragged = false;
emitDragEvent(false);
} }
}); });
@@ -331,6 +361,21 @@ function setupInfoCardDrag(panel) {
let startLeft = 0; let startLeft = 0;
let startTop = 0; let startTop = 0;
const emitDragEvent = () => {
const rect = panel.getBoundingClientRect();
window.dispatchEvent(
new CustomEvent('earth:info-card-drag', {
detail: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
dragging: isDragging,
},
})
);
};
const stopDragging = (event) => { const stopDragging = (event) => {
if ( if (
event && event &&
@@ -344,6 +389,7 @@ function setupInfoCardDrag(panel) {
activePointerId = null; activePointerId = null;
panel.classList.remove('is-dragging'); panel.classList.remove('is-dragging');
document.body.style.userSelect = ''; document.body.style.userSelect = '';
emitDragEvent();
}; };
const onMove = (event) => { const onMove = (event) => {
@@ -362,6 +408,7 @@ function setupInfoCardDrag(panel) {
); );
panel.style.left = `${nextLeft}px`; panel.style.left = `${nextLeft}px`;
panel.style.top = `${nextTop}px`; panel.style.top = `${nextTop}px`;
emitDragEvent();
}; };
handle.addEventListener('pointerdown', (event) => { handle.addEventListener('pointerdown', (event) => {
@@ -382,6 +429,7 @@ function setupInfoCardDrag(panel) {
panel.classList.add('is-dragging'); panel.classList.add('is-dragging');
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
handle.setPointerCapture?.(event.pointerId); handle.setPointerCapture?.(event.pointerId);
emitDragEvent();
}); });
// Listen in capture phase so card-level stopPropagation used to shield the // Listen in capture phase so card-level stopPropagation used to shield the
@@ -402,6 +450,8 @@ function mountCard() {
panel.id = 'info-panel'; panel.id = 'info-panel';
panel.className = 'hud-panel hud-panel-info'; panel.className = 'hud-panel hud-panel-info';
panel.setAttribute('aria-live', 'polite'); panel.setAttribute('aria-live', 'polite');
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('hidden', '');
panel.innerHTML = ` panel.innerHTML = `
<div id="info-card" class="info-card"> <div id="info-card" class="info-card">
<div class="info-card-header hud-panel-drag-handle"> <div class="info-card-header hud-panel-drag-handle">
@@ -516,8 +566,17 @@ function positionPanel(panel, x, y, options = {}) {
function showPanel(x, y, options = {}) { function showPanel(x, y, options = {}) {
const panel = getPanel(); const panel = getPanel();
if (!panel) return; if (!panel) return;
panel.classList.toggle('hud-panel-info--anchor-stable', options.anchorStable === true);
panel.removeAttribute('hidden');
panel.setAttribute('aria-hidden', 'false');
if (x != null && y != null) positionPanel(panel, x, y, options); if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible'); if (options.reveal === false) {
panel.classList.remove('is-visible');
return;
}
requestAnimationFrame(() => {
panel.classList.add('is-visible');
});
document.body.classList.add('earth-info-open'); document.body.classList.add('earth-info-open');
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } }) new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
@@ -526,7 +585,12 @@ function showPanel(x, y, options = {}) {
function hidePanel() { function hidePanel() {
const panel = getPanel(); const panel = getPanel();
if (panel) panel.classList.remove('is-visible'); if (panel) {
panel.classList.remove('is-visible');
panel.classList.remove('hud-panel-info--anchor-stable');
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('hidden', '');
}
document.body.classList.remove('earth-info-open'); document.body.classList.remove('earth-info-open');
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } }) new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
@@ -586,7 +650,7 @@ export function showInfoCard(type, data, options = {}) {
// Show the floating mini popup near the touch point (requires coordinates) // Show the floating mini popup near the touch point (requires coordinates)
if (options.x != null && options.y != null) { if (options.x != null && options.y != null) {
ensurePopupClickHandler(); ensurePopupClickHandler();
showMobilePopup(type, data, options.x, options.y); showMobilePopup(type, data, options.x, options.y, options);
document.body.classList.add('earth-info-open'); document.body.classList.add('earth-info-open');
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } }) new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })

View File

@@ -222,7 +222,7 @@ let satelliteToggleToken = 0;
let satelliteHydrationToken = 0; let satelliteHydrationToken = 0;
let sceneLights = null; let sceneLights = null;
let cruisePollTimerId = null; let cruisePollTimerId = null;
let cruiseConnector = null; let calloutConnector = null;
let cruiseBGPAdapter = null; let cruiseBGPAdapter = null;
let cruiseSequencer = null; let cruiseSequencer = null;
let activeDragPointerId = null; let activeDragPointerId = null;
@@ -1210,11 +1210,11 @@ function updateBGPHud(bgpResult) {
setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult)); setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult));
} }
function ensureCruiseConnector() { function ensureCalloutConnector() {
if (!cruiseConnector) { if (!calloutConnector) {
cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" }); calloutConnector = new CalloutConnector({ className: "callout-connector" });
} }
return cruiseConnector; return calloutConnector;
} }
function ensureBGPCruiseAdapter() { function ensureBGPCruiseAdapter() {
@@ -1223,7 +1223,7 @@ function ensureBGPCruiseAdapter() {
cruiseBGPAdapter = createBGPCruiseAdapter({ cruiseBGPAdapter = createBGPCruiseAdapter({
camera, camera,
getMarkers: () => getBGPAnomalyMarkers(), getMarkers: () => getBGPAnomalyMarkers(),
connector: ensureCruiseConnector(), connector: ensureCalloutConnector(),
focusView: (options) => focusEarthView(camera, options), focusView: (options) => focusEarthView(camera, options),
setMarkerLocked: (marker) => { setMarkerLocked: (marker) => {
setLegendMode("bgp"); setLegendMode("bgp");
@@ -1242,7 +1242,8 @@ function ensureBGPCruiseAdapter() {
showMarkerInfo: showBGPInfo, showMarkerInfo: showBGPInfo,
hideInfo: hideInfoCard, hideInfo: hideInfoCard,
isInfoVisible: () => isInfoVisible: () =>
document.getElementById("info-panel")?.classList.contains("is-visible") === true, document.getElementById("info-panel")?.classList.contains("is-visible") === true ||
document.getElementById("earth-mobile-popup")?.classList.contains("is-visible") === true,
getLockedObject: () => lockedObject, getLockedObject: () => lockedObject,
refreshMarkers: async () => { refreshMarkers: async () => {
const bgpResult = await loadBGPAnomalies(scene, getEarth()); const bgpResult = await loadBGPAnomalies(scene, getEarth());
@@ -2155,11 +2156,13 @@ function setupEventListeners() {
const handleClick = (event) => onClick(event); const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy(); const handlePageHide = () => destroy();
const handleRotationMode = (event) => handleRotationModeChange(event); const handleRotationMode = (event) => handleRotationModeChange(event);
const handleInfoCardDrag = () => repositionCruiseConnector();
bindListener(window, "resize", handleResize); bindListener(window, "resize", handleResize);
bindListener(window, "pagehide", handlePageHide); bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide); bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode); bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
bindListener(renderer.domElement, "pointerdown", handlePointerDown); bindListener(renderer.domElement, "pointerdown", handlePointerDown);
bindListener(window, "pointermove", handlePointerMove); bindListener(window, "pointermove", handlePointerMove);
bindListener(window, "pointerup", handlePointerUp); bindListener(window, "pointerup", handlePointerUp);

View File

@@ -4,6 +4,10 @@ import * as THREE from "three";
import { CONFIG } from "./constants.js"; import { CONFIG } from "./constants.js";
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
// Convert latitude/longitude to 3D vector // Convert latitude/longitude to 3D vector
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) { export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
const phi = (90 - lat) * (Math.PI / 180); const phi = (90 - lat) * (Math.PI / 180);
@@ -90,3 +94,31 @@ export function calculateDistance(
return radius * c; return radius * c;
} }
export function getSurfaceMarkerCameraScale(camera, options = {}) {
if (!camera) return 1;
const {
altitudeOffset = 0,
referenceFov = 75,
min = 0.12,
max = 3.0,
} = options;
const cameraDistanceFromCenter = camera.position.length();
const surfaceDistance = Math.max(
1,
cameraDistanceFromCenter - (CONFIG.earthRadius + altitudeOffset),
);
const referenceSurfaceDistance = Math.max(
1,
CONFIG.defaultCameraZ - (CONFIG.earthRadius + altitudeOffset),
);
const cameraFovRad = (((camera.fov || referenceFov)) * Math.PI) / 180;
const referenceFovRad = (referenceFov * Math.PI) / 180;
const worldPerPixel = surfaceDistance * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel =
referenceSurfaceDistance * Math.tan(referenceFovRad / 2);
return clamp(worldPerPixel / referenceWorldPerPixel, min, max);
}

View File

@@ -1025,7 +1025,14 @@ start_postgres_service() {
# Backend lifecycle helpers # Backend lifecycle helpers
cleanup_backend_processes() { cleanup_backend_processes() {
pkill -f "uvicorn" 2>/dev/null || true local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
terminate_backend_processes TERM "$backend_port"
if ! wait_for_port_release "$backend_port"; then
terminate_backend_processes KILL "$backend_port"
wait_for_port_release "$backend_port" || true
fi
} }
start_backend_with_retry() { start_backend_with_retry() {
@@ -1033,7 +1040,7 @@ start_backend_with_retry() {
local retry=1 local retry=1
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
cleanup_backend_processes cleanup_backend_processes "$backend_port"
cd "$SCRIPT_DIR/backend" cd "$SCRIPT_DIR/backend"
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
BACKEND_PID=$! BACKEND_PID=$!
@@ -1242,6 +1249,39 @@ collect_port_pids() {
return 1 return 1
} }
terminate_process_group() {
local signal="$1"
local pid="$2"
local pgid=""
[ -n "$pid" ] || return 0
kill -0 "$pid" 2>/dev/null || return 0
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
[ -n "$pgid" ] || return 0
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
}
terminate_backend_processes() {
local signal="$1"
local backend_port="$2"
local pids=""
local pid=""
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done
pids="$(collect_port_pids "$backend_port" || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done
}
terminate_process_tree() { terminate_process_tree() {
local signal="$1" local signal="$1"
local pid="$2" local pid="$2"
@@ -1521,8 +1561,8 @@ stop_container_if_running() {
} }
stop_backend_service() { stop_backend_service() {
if pgrep -f "uvicorn" >/dev/null 2>&1; then if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
cleanup_backend_processes cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
log_halt "后端服务已停止" log_halt "后端服务已停止"
fi fi
} }

View File

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

2
uv.lock generated
View File

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