release: bump version to 0.37.0
This commit is contained in:
8
TODO.md
8
TODO.md
@@ -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/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
||||||
|
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
|
||||||
|
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ 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.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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
第一阶段不建议做这些内容:
|
第一阶段不建议做这些内容:
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.36.0`
|
- `dev` 当前开发分支历史推导到:`0.37.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `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 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.36.0",
|
"version": "0.37.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -435,6 +451,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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
242
frontend/public/earth/js/controls.js
vendored
242
frontend/public/earth/js/controls.js
vendored
@@ -86,7 +86,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 +96,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 +120,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 +231,7 @@ function closeTransientMobileOverlays({ except = null } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyResponsiveLayout() {
|
function applyResponsiveLayout() {
|
||||||
|
const previousLayoutMode = layoutMode;
|
||||||
layoutMode = detectLayoutMode();
|
layoutMode = detectLayoutMode();
|
||||||
|
|
||||||
const isMobile = isMobileLayout();
|
const isMobile = isMobileLayout();
|
||||||
@@ -233,6 +240,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 +251,16 @@ function applyResponsiveLayout() {
|
|||||||
mobileDrawerOpen = false;
|
mobileDrawerOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previousLayoutMode !== layoutMode) {
|
||||||
|
if (isMobile) {
|
||||||
|
resetDesktopHudPanelsForMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (earthSettingsState) {
|
||||||
|
applyCurrentViewportPanelVisibility({ persist: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
syncMobileDrawerState();
|
syncMobileDrawerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +299,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 +615,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 +639,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 +730,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 +784,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 +795,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 +872,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 +888,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 +907,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);
|
||||||
}
|
}
|
||||||
@@ -1443,6 +1546,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 +1558,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 +1572,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 +2552,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.36.0"
|
version = "0.37.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user