release: bump version to 0.27.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ DEFAULT_TV_SETTINGS = {
|
||||
"auto_fallback": True,
|
||||
"sources": [
|
||||
{
|
||||
"id": DEFAULT_TV_SOURCE_ID,
|
||||
"id": "cctv4",
|
||||
"name": "CCTV-4 中文国际",
|
||||
"provider": "CCTV",
|
||||
"region": "China",
|
||||
|
||||
@@ -7,6 +7,31 @@ This project follows the repository versioning rule:
|
||||
- `feature` -> `+0.1.0`
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## 0.27.0
|
||||
|
||||
Released: 2026-04-14
|
||||
|
||||
### Highlights
|
||||
|
||||
- 全面重构 Earth HUD 布局:品牌面板、图层控制面板、信息详情卡片各自独立,支持拖拽与折叠,信息卡片改为跟随点击位置悬浮显示。
|
||||
- 新增图层控制面板(Layer Panel),海缆、卫星、地形、BGP 等图层集中管理,支持关键字搜索过滤。
|
||||
- Earth 大气层渲染升级,引入 Fresnel 着色器双层辉光效果和深度遮挡球体。
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- 新增独立 Layer Panel(`js/controls.js`, `css/layer-panel.css`),图层开关、搜索过滤、折叠收起,取代原工具栏弹出菜单
|
||||
- 信息详情面板(info-panel)改为点击时定位到鼠标附近(`js/info-card.js`),悬停改为轻量 tooltip,降低视觉干扰
|
||||
- Earth 材质重构(`js/earth.js`, `js/constants.js`):新增 `EARTH_MATERIAL_CONFIG`,Fresnel 内外大气层辉光、深度遮挡球体,纹理加载独立为 `loadEarthTexture()`
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- Earth Stats 面板改为 2 列 KPI 网格布局,支持拖拽和关闭(`css/earth-stats.css`)
|
||||
- 数据加载改为分步串行(登陆点 → 海缆 → 卫星 → BGP → 纹理),每步之间 yield 帧,改善视觉渐现体验(`js/main.js`)
|
||||
- 图层按钮状态更新逻辑统一至 `updateLayerButtonState()`,消除重复实现
|
||||
- 海缆状态识别新增 `active` 枚举值(兼容旧 `In Service`)
|
||||
|
||||
---
|
||||
|
||||
## 0.26.1
|
||||
|
||||
Released: 2026-04-12
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.26.2`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.27.0` | feature | `dev` | — | Earth HUD 重构:图层面板、信息卡片悬浮定位、Fresnel 大气层渲染 |
|
||||
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |
|
||||
| `0.1.0` | feature | `main` | `6cb4398f` | Modularize 3D Earth page with ES Modules |
|
||||
| `0.2.0` | feature | `main` | `aaae6a53` | Add cable graph service and data collectors |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.26.2",
|
||||
"version": "0.27.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,26 +1,133 @@
|
||||
/* earth-stats */
|
||||
/* earth-stats.css — compact KPI grid panel */
|
||||
|
||||
.hud-panel-stats {
|
||||
bottom: var(--hud-offset);
|
||||
top: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
border-radius: var(--hud-radius);
|
||||
padding: var(--hud-panel-padding);
|
||||
width: calc(264px * var(--hud-scale));
|
||||
border-radius: 0; /* square / angular — matches layer panel */
|
||||
padding: 0;
|
||||
width: min(calc(240px * var(--hud-scale)), calc(100vw - 32px));
|
||||
z-index: 10;
|
||||
font-size: var(--hud-font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud-panel-stats .stats-item {
|
||||
margin-bottom: 0;
|
||||
padding: calc(9px * var(--hud-scale)) 0;
|
||||
border-bottom: 1px solid rgba(214, 229, 245, 0.06);
|
||||
/* ── Thin drag bar ────────────────────────────────────────────── */
|
||||
|
||||
.stats-drag-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hud-panel-stats .stats-item:last-child {
|
||||
.stats-drag-bar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.stats-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Reuse hud-panel-close — just override size to match kicker line */
|
||||
.stats-drag-bar .hud-panel-close {
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.stats-drag-bar .hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(12px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── 2-column KPI grid ────────────────────────────────────────── */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border-right: 1px solid var(--hud-line);
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
/* Right column cells: remove right border */
|
||||
.stat-cell:nth-child(even) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Bottom row cells: remove bottom border */
|
||||
.stat-cell:nth-last-child(-n+2) {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.hud-panel-stats .stats-item:first-of-type {
|
||||
padding-top: 0;
|
||||
.stat-num {
|
||||
color: var(--hud-title);
|
||||
font-size: calc(1.3rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Smaller number for multi-word values like "256/314" */
|
||||
.stat-num--sm {
|
||||
font-size: calc(0.94rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── BGP status footer ────────────────────────────────────────── */
|
||||
|
||||
.stats-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
padding: calc(7px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
border-top: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.stats-footer-dot {
|
||||
flex-shrink: 0;
|
||||
width: calc(5px * var(--hud-scale));
|
||||
height: calc(5px * var(--hud-scale));
|
||||
border-radius: 50%;
|
||||
background: var(--hud-accent);
|
||||
box-shadow: 0 0 5px var(--hud-accent);
|
||||
}
|
||||
|
||||
.stats-footer-text {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
line-height: 1.3;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Layout-expanded: slide off-screen ───────────────────────── */
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-stats:not([data-dragged="true"]) {
|
||||
top: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||
}
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
height: calc(var(--hud-title-size) * 1.24);
|
||||
min-width: calc(var(--hud-title-size) * 1.24);
|
||||
padding: 0;
|
||||
border: 1px solid rgba(220, 234, 248, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -119,7 +119,6 @@
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hud-panel.is-dragging {
|
||||
@@ -453,26 +452,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-info {
|
||||
top: var(--hud-offset);
|
||||
left: var(--hud-offset);
|
||||
transform: translate(calc(-100% + var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-coordinates {
|
||||
top: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-legend {
|
||||
left: var(--hud-offset);
|
||||
bottom: var(--hud-offset);
|
||||
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-stats {
|
||||
right: var(--hud-offset);
|
||||
bottom: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
/* .earth-left-column layout-expanded rule lives in info-panel.css */
|
||||
/* .hud-panel-legend layout-expanded rule lives in legend.css */
|
||||
/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */
|
||||
/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */
|
||||
/* .hud-panel-tv layout-expanded rule lives in tv-panel.css */
|
||||
|
||||
@@ -1,146 +1,175 @@
|
||||
/* info-panel */
|
||||
/* info-panel.css — brand panel + detail card */
|
||||
|
||||
.hud-panel-info {
|
||||
/* ── Left column wrapper ──────────────────────────────────────── */
|
||||
|
||||
.earth-left-column {
|
||||
position: absolute;
|
||||
top: var(--hud-offset);
|
||||
left: var(--hud-offset);
|
||||
border-radius: var(--hud-radius);
|
||||
padding: calc(22px * var(--hud-scale));
|
||||
width: calc(336px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
font-size: var(--hud-font-size);
|
||||
pointer-events: none;
|
||||
/* Width is set by the widest child (brand or info card) */
|
||||
max-width: min(calc(340px * var(--hud-scale)), calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand {
|
||||
margin-bottom: calc(12px * var(--hud-scale));
|
||||
.earth-left-column > * {
|
||||
position: relative; /* override .hud-panel position:absolute */
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Brand panel ──────────────────────────────────────────────── */
|
||||
|
||||
.hud-panel-brand {
|
||||
border-radius: 0;
|
||||
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Reserve full panel height before brand images load */
|
||||
min-height: calc(66px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand__logo {
|
||||
.hud-panel-brand .earth-brand__logo {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: min(calc(148px * var(--hud-scale)), 41%);
|
||||
height: auto;
|
||||
width: calc(128px * var(--hud-scale));
|
||||
height: calc(128px * var(--hud-scale));
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand__copy {
|
||||
.hud-panel-brand .earth-brand__copy {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand__title {
|
||||
.hud-panel-brand .earth-brand__title {
|
||||
display: block;
|
||||
width: min(100%, calc(196px * var(--hud-scale)));
|
||||
width: min(100%, calc(160px * var(--hud-scale)));
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: calc(20px * var(--hud-scale));
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand__meta {
|
||||
text-align: left;
|
||||
.hud-panel-brand .earth-brand__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: calc(4px * var(--hud-scale));
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand__subtitle {
|
||||
.hud-panel-brand .earth-brand__subtitle {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1.38;
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.015em;
|
||||
letter-spacing: 0.01em;
|
||||
/* Prevent text from pushing brand wider than logo column */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand__description {
|
||||
.hud-panel-brand .earth-brand__description {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
line-height: 1.35;
|
||||
font-size: calc(0.6rem * var(--hud-scale));
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.08em;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand--en .earth-brand__logo {
|
||||
width: min(calc(140px * var(--hud-scale)), 39%);
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__title {
|
||||
width: min(100%, calc(172px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand--en .earth-brand__title {
|
||||
width: min(100%, calc(208px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand--en .earth-brand__meta {
|
||||
gap: calc(3px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand--en .earth-brand__subtitle,
|
||||
.hud-panel-info .earth-brand--en .earth-brand__description {
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__subtitle,
|
||||
.hud-panel-brand .earth-brand--en .earth-brand__description {
|
||||
font-family: "Roboto Condensed", "Arial Narrow", "Trebuchet MS", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.hud-panel-info .earth-brand--en .earth-brand__subtitle {
|
||||
line-height: 1.26;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
/* ── Info detail panel (floating, positioned near click by JS) ── */
|
||||
|
||||
.hud-panel-info .earth-brand--en .earth-brand__description {
|
||||
line-height: 1.24;
|
||||
letter-spacing: 0.045em;
|
||||
}
|
||||
|
||||
/* Info Card - Unified details panel (inside info-panel) */
|
||||
.info-card {
|
||||
display: none;
|
||||
margin-top: var(--hud-gap-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 48%),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
border-radius: calc(18px * var(--hud-scale));
|
||||
border: 1px solid rgba(214, 229, 245, 0.08);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 12px 28px rgba(0, 0, 0, 0.14);
|
||||
.hud-panel-info {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
width: min(calc(300px * var(--hud-scale)), calc(100vw - 32px));
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
/* Start hidden */
|
||||
opacity: 0;
|
||||
transform: scale(0.94) translateY(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel-info.is-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.info-card.no-border {
|
||||
background: transparent;
|
||||
border: none;
|
||||
/* ── Info Card ────────────────────────────────────────────────── */
|
||||
|
||||
.info-card {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
gap: var(--hud-gap-sm);
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.info-card-icon {
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-card-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: calc(0.96rem * var(--hud-scale));
|
||||
font-size: calc(0.92rem * var(--hud-scale));
|
||||
color: var(--hud-title);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.info-card-close {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-card-content {
|
||||
padding: calc(12px * var(--hud-scale)) calc(14px * var(--hud-scale));
|
||||
max-height: 40vh;
|
||||
padding: calc(8px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
max-height: 58vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.34) transparent;
|
||||
@@ -148,7 +177,7 @@
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar-track {
|
||||
@@ -160,15 +189,11 @@
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.info-card-content::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(226, 236, 246, 0.28), rgba(136, 164, 196, 0.34));
|
||||
}
|
||||
|
||||
.info-card-property {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: calc(8px * var(--hud-scale)) 0;
|
||||
padding: calc(6px * var(--hud-scale)) 0;
|
||||
border-bottom: 1px solid rgba(214, 229, 245, 0.06);
|
||||
pointer-events: auto;
|
||||
gap: var(--hud-gap-sm);
|
||||
@@ -180,10 +205,11 @@
|
||||
|
||||
.info-card-label {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -194,35 +220,34 @@
|
||||
.info-card-value {
|
||||
color: var(--hud-text);
|
||||
font-weight: 600;
|
||||
font-size: calc(0.88rem * var(--hud-scale));
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
max-width: calc(190px * var(--hud-scale));
|
||||
max-width: calc(180px * var(--hud-scale));
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Cable type */
|
||||
.info-card.cable {
|
||||
border-color: rgba(255, 200, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Type-specific header accent colors */
|
||||
.info-card.cable .info-card-header {
|
||||
background: rgba(255, 200, 0, 0.15);
|
||||
}
|
||||
|
||||
.info-card.cable .info-card-header h3 {
|
||||
color: #ffc800;
|
||||
}
|
||||
|
||||
/* Satellite type */
|
||||
.info-card.satellite {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
background: rgba(255, 200, 0, 0.12);
|
||||
border-bottom-color: rgba(255, 200, 0, 0.15);
|
||||
}
|
||||
.info-card.cable .info-card-header h3 { color: #ffc800; }
|
||||
|
||||
.info-card.satellite .info-card-header {
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
border-bottom-color: rgba(0, 229, 255, 0.15);
|
||||
}
|
||||
.info-card.satellite .info-card-header h3 { color: #00e5ff; }
|
||||
|
||||
.info-card.satellite .info-card-header h3 {
|
||||
color: #00e5ff;
|
||||
.info-card.bgp .info-card-header {
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
border-bottom-color: rgba(120, 180, 255, 0.15);
|
||||
}
|
||||
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
|
||||
|
||||
/* ── Layout-expanded: slide left column off-screen ────────────── */
|
||||
|
||||
.earth-app.layout-expanded .earth-left-column {
|
||||
transform: translate(calc(-100% + var(--hud-offset)), 0);
|
||||
}
|
||||
|
||||
260
frontend/public/earth/css/layer-panel.css
Normal file
260
frontend/public/earth/css/layer-panel.css
Normal file
@@ -0,0 +1,260 @@
|
||||
/* layer-panel.css — layer toggle panel (below brand, in left column) */
|
||||
|
||||
.hud-panel-layers {
|
||||
/* Lives inside .earth-left-column — position is relative via column rule */
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
margin-top: calc(6px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Header / drag handle ─────────────────────────────────────── */
|
||||
|
||||
.layer-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
padding: calc(8px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.layer-panel-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.layer-panel-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(15px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-panel-title {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
color: var(--hud-title);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── Collapse / generic icon button ──────────────────────────── */
|
||||
|
||||
.layer-panel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(22px * var(--hud-scale));
|
||||
height: calc(22px * var(--hud-scale));
|
||||
min-width: calc(22px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
|
||||
.layer-panel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* Chevron rotates when collapsed */
|
||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ── Search bar ───────────────────────────────────────────────── */
|
||||
|
||||
.layer-panel-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.layer-panel-search-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-panel-search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layer-panel-search-input::placeholder {
|
||||
color: var(--hud-text-soft);
|
||||
}
|
||||
|
||||
/* ── Empty search state ───────────────────────────────────────── */
|
||||
|
||||
.layer-panel-empty {
|
||||
padding: calc(12px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Collapsible body ─────────────────────────────────────────── */
|
||||
|
||||
.layer-panel-body {
|
||||
overflow: hidden;
|
||||
max-height: 600px;
|
||||
transition: max-height 0.24s ease, opacity 0.18s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-panel--collapsed .layer-panel-body {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Layer rows ───────────────────────────────────────────────── */
|
||||
|
||||
.layer-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
|
||||
.layer-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.layer-row-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(15px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
/* Icon brightens when layer is active */
|
||||
.layer-row:has(.layer-row-toggle.active) .layer-row-icon {
|
||||
color: var(--hud-accent);
|
||||
}
|
||||
|
||||
.layer-row-copy {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(1px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.layer-row-label {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-row:has(.layer-row-toggle.active) .layer-row-label {
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
.layer-row-meta {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.58rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── Toggle switch ────────────────────────────────────────────── */
|
||||
|
||||
.layer-row-toggle {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: calc(34px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-row-toggle-track {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(215, 229, 242, 0.12);
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.layer-row-toggle-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(2px * var(--hud-scale));
|
||||
left: calc(2px * var(--hud-scale));
|
||||
width: calc(14px * var(--hud-scale));
|
||||
height: calc(14px * var(--hud-scale));
|
||||
border-radius: 50%;
|
||||
background: #c8d8ea;
|
||||
box-shadow: 0 2px 6px rgba(1, 8, 18, 0.3);
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
/* Active (ON) state */
|
||||
.layer-row-toggle.active .layer-row-toggle-track {
|
||||
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
||||
border-color: rgba(223, 236, 252, 0.28);
|
||||
}
|
||||
|
||||
.layer-row-toggle.active .layer-row-toggle-track::after {
|
||||
background: #f0f6ff;
|
||||
transform: translateX(calc(14px * var(--hud-scale)));
|
||||
}
|
||||
|
||||
/* Layout-expanded: layer panel slides off with .earth-left-column — no
|
||||
individual rule needed since the whole column translates together. */
|
||||
@@ -1,85 +1,177 @@
|
||||
/* legend */
|
||||
/* legend.css — compact tab-strip legend */
|
||||
|
||||
.hud-panel-legend {
|
||||
bottom: var(--hud-offset);
|
||||
left: var(--hud-offset);
|
||||
border-radius: var(--hud-radius);
|
||||
padding: var(--hud-panel-padding);
|
||||
width: calc(236px * var(--hud-scale));
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
width: min(calc(200px * var(--hud-scale)), calc(100vw - 32px));
|
||||
z-index: 10;
|
||||
font-size: var(--hud-font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* ── Drag bar ─────────────────────────────────────────────────── */
|
||||
|
||||
.hud-panel-legend .legend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(9px * var(--hud-scale));
|
||||
max-height: calc(214px * var(--hud-scale));
|
||||
overflow-y: auto;
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.32) transparent;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 225, 242, 0.2), rgba(126, 154, 185, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-item {
|
||||
.legend-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
padding: calc(9px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
border-radius: calc(14px * var(--hud-scale));
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent),
|
||||
rgba(255, 255, 255, 0.018);
|
||||
border: 1px solid rgba(214, 229, 245, 0.07);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
justify-content: space-between;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
padding: calc(6px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-color {
|
||||
width: calc(16px * var(--hud-scale));
|
||||
height: calc(16px * var(--hud-scale));
|
||||
border-radius: 999px;
|
||||
margin-right: 0;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||||
0 0 0 3px rgba(255, 255, 255, 0.04);
|
||||
.legend-bar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel-legend .legend-item span {
|
||||
/* ── Mode tabs ────────────────────────────────────────────────── */
|
||||
|
||||
.legend-tabs {
|
||||
display: flex;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-tab {
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.legend-color-americas {
|
||||
background-color: #ff4444;
|
||||
.legend-tab--active {
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
border-color: rgba(120, 180, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.legend-color-au-a {
|
||||
background-color: #44ff44;
|
||||
/* ── Bar action buttons ───────────────────────────────────────── */
|
||||
|
||||
.legend-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-color-au-b {
|
||||
background-color: #4444ff;
|
||||
.legend-bar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
|
||||
.legend-color-default {
|
||||
background-color: #ffff44;
|
||||
.legend-bar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-bar-btn .material-symbols-rounded {
|
||||
font-size: calc(13px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Collapse chevron */
|
||||
#legend-collapse .material-symbols-rounded {
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
.legend--collapsed #legend-collapse .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ── Collapsible list body ────────────────────────────────────── */
|
||||
|
||||
.legend-body {
|
||||
max-height: calc(220px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend--collapsed .legend-body {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Item list ────────────────────────────────────────────────── */
|
||||
|
||||
.legend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(4px * var(--hud-scale)) 0;
|
||||
overflow-y: auto;
|
||||
max-height: calc(220px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 186, 216, 0.28) transparent;
|
||||
}
|
||||
|
||||
.legend-list::-webkit-scrollbar { width: 3px; }
|
||||
.legend-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.legend-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(160, 186, 216, 0.26);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
padding: calc(5px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
flex-shrink: 0;
|
||||
width: calc(7px * var(--hud-scale));
|
||||
height: calc(7px * var(--hud-scale));
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Layout-expanded ──────────────────────────────────────────── */
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-legend:not([data-dragged="true"]) {
|
||||
left: var(--hud-offset);
|
||||
bottom: var(--hud-offset);
|
||||
transform: translate(calc(-100% + var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
@@ -388,20 +388,106 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.earth-info-toolbar .earth-toolbar-btn:nth-child(1) {
|
||||
animation-delay: 0.34s;
|
||||
.earth-info-toolbar {
|
||||
width: min(280px, calc(100vw - 36px));
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, rgba(16, 29, 48, 0.96), rgba(8, 18, 33, 0.94));
|
||||
border: 1px solid rgba(211, 228, 246, 0.14);
|
||||
box-shadow:
|
||||
0 20px 40px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(135%);
|
||||
}
|
||||
|
||||
.earth-info-toolbar .earth-toolbar-btn:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
.earth-layer-toolbar-header {
|
||||
width: 100%;
|
||||
padding: 2px 4px 8px;
|
||||
border-bottom: 1px solid rgba(201, 225, 247, 0.08);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.earth-info-toolbar .earth-toolbar-btn:nth-child(3) {
|
||||
animation-delay: 0.1s;
|
||||
.earth-layer-toolbar-title {
|
||||
display: block;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-info-toolbar .earth-toolbar-btn:nth-child(4) {
|
||||
animation-delay: 0s;
|
||||
.earth-layer-toolbar-subtitle {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-layer-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.earth-layer-btn__copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-layer-btn__label {
|
||||
color: var(--hud-text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.earth-layer-btn__meta {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-layer-btn__state {
|
||||
flex: 0 0 auto;
|
||||
min-width: 42px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.earth-layer-btn.active .earth-layer-btn__state {
|
||||
color: #dff4ff;
|
||||
border-color: rgba(220, 240, 255, 0.24);
|
||||
background: rgba(131, 197, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-layer-btn .earth-toolbar-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn,
|
||||
@@ -458,6 +544,7 @@
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
}
|
||||
|
||||
|
||||
.earth-app.layout-expanded .earth-toolbar-group {
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* tv-panel */
|
||||
|
||||
.hud-panel-tv {
|
||||
top: calc(96px * var(--hud-scale));
|
||||
right: calc(92px * var(--hud-scale));
|
||||
width: min(calc(460px * var(--hud-scale)), calc(100vw - 32px));
|
||||
min-width: calc(360px * var(--hud-scale));
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
width: calc(420px * var(--hud-scale));
|
||||
max-width: calc(100vw - 32px);
|
||||
min-width: calc(300px * var(--hud-scale));
|
||||
min-height: calc(340px * var(--hud-scale));
|
||||
padding: calc(18px * var(--hud-scale));
|
||||
display: flex;
|
||||
@@ -73,6 +74,18 @@
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action--icon {
|
||||
padding: calc(9px * var(--hud-scale));
|
||||
border-radius: calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-action--icon .material-symbols-rounded {
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.2);
|
||||
@@ -184,39 +197,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv {
|
||||
top: calc(50% + 12px);
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: min(calc(760px * var(--hud-scale)), calc(100vw - 48px));
|
||||
min-height: calc(410px * var(--hud-scale));
|
||||
transform: translateX(-50%);
|
||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hud-panel-tv {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
top: auto;
|
||||
bottom: 16px;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: min(48vh, 360px);
|
||||
}
|
||||
|
||||
.tv-panel-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.tv-panel-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* TV panel keeps its fixed width on all screen sizes.
|
||||
Responsive stretching removed — width only changes if user manually resizes. */
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
<link rel="stylesheet" href="css/hud.css">
|
||||
<link rel="stylesheet" href="css/toolbar.css">
|
||||
<link rel="stylesheet" href="css/info-panel.css">
|
||||
<link rel="stylesheet" href="css/coordinates-display.css">
|
||||
<link rel="stylesheet" href="css/legend.css">
|
||||
<link rel="stylesheet" href="css/earth-stats.css">
|
||||
<link rel="stylesheet" href="css/tv-panel.css">
|
||||
<link rel="stylesheet" href="css/layer-panel.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||
</head>
|
||||
@@ -50,17 +50,111 @@
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="container" class="earth-app">
|
||||
<div id="info-panel" class="hud-panel hud-panel-info">
|
||||
<div id="brand-root"></div>
|
||||
|
||||
<div class="earth-left-column">
|
||||
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
||||
<div id="brand-root"></div>
|
||||
</div>
|
||||
|
||||
<div id="layer-toggles" class="hud-panel hud-panel-layers hud-panel-draggable" data-panel-key="layer-toggles">
|
||||
<!-- Header / drag handle -->
|
||||
<div class="layer-panel-header hud-panel-drag-handle">
|
||||
<span class="material-symbols-rounded layer-panel-icon">layers</span>
|
||||
<span class="layer-panel-title">图层</span>
|
||||
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible body -->
|
||||
<div class="layer-panel-body" id="layer-panel-body">
|
||||
<!-- Search -->
|
||||
<div class="layer-panel-search">
|
||||
<span class="material-symbols-rounded layer-panel-search-icon">search</span>
|
||||
<input
|
||||
type="search"
|
||||
id="layer-search-input"
|
||||
class="layer-panel-search-input"
|
||||
placeholder="搜索图层..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button id="layer-search-clear" class="layer-panel-btn layer-search-clear" type="button" aria-label="清除搜索" title="清除" hidden>
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Layer rows -->
|
||||
<div class="layer-panel-list" id="layer-panel-list">
|
||||
<div class="layer-row" data-layer-name="地形 terrain">
|
||||
<span class="material-symbols-rounded layer-row-icon">landscape</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">地形</span>
|
||||
<span class="layer-row-meta">Terrain</span>
|
||||
</div>
|
||||
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="卫星 satellites">
|
||||
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">卫星</span>
|
||||
<span class="layer-row-meta">Satellites</span>
|
||||
</div>
|
||||
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="轨迹 trails">
|
||||
<span class="material-symbols-rounded layer-row-icon">timeline</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">轨迹</span>
|
||||
<span class="layer-row-meta">Trails</span>
|
||||
</div>
|
||||
<button id="toggle-trails" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换轨迹显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="海缆 subsea cables">
|
||||
<span class="material-symbols-rounded layer-row-icon">cable</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">海缆</span>
|
||||
<span class="layer-row-meta">Subsea Cables</span>
|
||||
</div>
|
||||
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layer-row" data-layer-name="bgp观测 routing signals">
|
||||
<span class="material-symbols-rounded layer-row-icon">hub</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">BGP观测</span>
|
||||
<span class="layer-row-meta">Routing Signals</span>
|
||||
</div>
|
||||
<button id="toggle-bgp" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换BGP观测显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty search state -->
|
||||
<div class="layer-panel-empty" id="layer-panel-empty" hidden>无匹配图层</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating detail panel — positioned near click by JS -->
|
||||
<div id="info-panel" class="hud-panel hud-panel-info hud-panel-draggable" aria-live="polite">
|
||||
<div id="info-card" class="info-card">
|
||||
<div class="info-card-header">
|
||||
<div class="info-card-header hud-panel-drag-handle">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
<button class="info-card-close hud-panel-close" type="button" aria-label="关闭详情">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="info-card-content" class="info-card-content"></div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="hud-error-message"></div>
|
||||
</div>
|
||||
|
||||
@@ -82,52 +176,12 @@
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
<div id="info-control-group" class="earth-toolbar-popover earth-info-group">
|
||||
<button id="info-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="显示控制">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">info</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">显示控制</span>
|
||||
</button>
|
||||
<div id="info-toolbar" class="earth-stack-toolbar earth-info-toolbar">
|
||||
<button id="toggle-terrain" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="显示/隐藏地形">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">terrain</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">显示/隐藏地形</span>
|
||||
</button>
|
||||
<button id="toggle-trails" class="floating-btn liquid-glass-surface active earth-toolbar-btn" title="显示/隐藏轨迹">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">timeline</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">隐藏轨迹</span>
|
||||
</button>
|
||||
<button id="toggle-satellites" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="显示/隐藏卫星">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">satellite_alt</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">显示卫星</span>
|
||||
</button>
|
||||
<button id="toggle-bgp" class="floating-btn liquid-glass-surface active earth-toolbar-btn" title="显示/隐藏BGP观测">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">radar</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">隐藏BGP观测</span>
|
||||
</button>
|
||||
<button id="toggle-cables" class="floating-btn liquid-glass-surface active earth-toolbar-btn" title="显示/隐藏线缆">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">cable</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">隐藏线缆</span>
|
||||
</button>
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">live_tv</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
@@ -172,102 +226,80 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="coordinates-display" class="hud-panel hud-panel-coordinates hud-panel-draggable" data-panel-key="coordinates-display">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<h3 class="hud-panel-title">坐标信息</h3>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="coordinates-display" aria-label="关闭坐标信息">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="coord-item hud-panel-row">
|
||||
<span class="coord-label hud-panel-label">经度:</span>
|
||||
<span id="longitude-value" class="coord-value hud-panel-value">0.00°</span>
|
||||
</div>
|
||||
<div class="coord-item hud-panel-row">
|
||||
<span class="coord-label hud-panel-label">纬度:</span>
|
||||
<span id="latitude-value" class="coord-value hud-panel-value">0.00°</span>
|
||||
</div>
|
||||
<div id="zoom-level" class="earth-zoom-level">缩放: 1.0x</div>
|
||||
<div class="earth-mouse-coords" id="mouse-coords">鼠标位置: 无</div>
|
||||
</div>
|
||||
|
||||
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<h3 class="legend-title hud-panel-title">线缆图例</h3>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
<!-- Drag bar: mode tabs + collapse + close -->
|
||||
<div class="legend-bar hud-panel-drag-handle">
|
||||
<div class="legend-tabs" id="legend-tabs">
|
||||
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button>
|
||||
<button class="legend-tab" data-legend-mode="satellites">卫星</button>
|
||||
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
|
||||
</div>
|
||||
<div class="legend-bar-actions">
|
||||
<button id="legend-collapse" class="legend-bar-btn" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend-list">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color legend-color-americas"></div>
|
||||
<span>Americas II</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color legend-color-au-a"></div>
|
||||
<span>AU Aleutian A</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color legend-color-au-b"></div>
|
||||
<span>AU Aleutian B</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color legend-color-default"></div>
|
||||
<span>其他电缆</span>
|
||||
</div>
|
||||
<!-- Collapsible list -->
|
||||
<div id="legend-body" class="legend-body">
|
||||
<div class="legend-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="earth-stats" class="hud-panel hud-panel-stats hud-panel-draggable" data-panel-key="earth-stats">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<h3 class="hud-panel-title">地球信息</h3>
|
||||
<!-- Thin drag bar with kicker + close -->
|
||||
<div class="stats-drag-bar hud-panel-drag-handle">
|
||||
<span class="stats-kicker">全球态势</span>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="earth-stats" aria-label="关闭地球信息">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">电缆系统:</span>
|
||||
<span class="stats-value hud-panel-value" id="cable-count">0个</span>
|
||||
|
||||
<!-- 2-col KPI grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="cable-count">—</span>
|
||||
<span class="stat-label">海缆系统</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="landing-point-count">—</span>
|
||||
<span class="stat-label">登陆点</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="satellite-count">—</span>
|
||||
<span class="stat-label">在轨卫星</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-anomaly-count">—</span>
|
||||
<span class="stat-label">BGP 事件</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num" id="bgp-collector-count">—</span>
|
||||
<span class="stat-label">BGP 观测站</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-num stat-num--sm" id="cable-status-summary">—</span>
|
||||
<span class="stat-label">运行中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">状态:</span>
|
||||
<span class="stats-value hud-panel-value" id="cable-status-summary">-</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">登陆点:</span>
|
||||
<span class="stats-value hud-panel-value" id="landing-point-count">0个</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">地形:</span>
|
||||
<span class="stats-value hud-panel-value" id="terrain-status">开启</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">卫星:</span>
|
||||
<span class="stats-value hud-panel-value" id="satellite-count">0 颗</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">BGP事件:</span>
|
||||
<span class="stats-value hud-panel-value" id="bgp-anomaly-count">0 条</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">观测站:</span>
|
||||
<span class="stats-value hud-panel-value" id="bgp-collector-count">0 个</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">BGP态势:</span>
|
||||
<span class="stats-value hud-panel-value" id="bgp-status-summary">暂无观测数据</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">视角距离:</span>
|
||||
<span class="stats-value hud-panel-value" id="camera-distance">300 km</span>
|
||||
</div>
|
||||
<div class="stats-item hud-panel-row">
|
||||
<span class="stats-label hud-panel-label">纹理质量:</span>
|
||||
<span class="stats-value hud-panel-value" id="texture-quality">8K 卫星图</span>
|
||||
|
||||
<!-- BGP status footer -->
|
||||
<div class="stats-footer">
|
||||
<span class="stats-footer-dot"></span>
|
||||
<span id="bgp-status-summary" class="stats-footer-text">暂无观测数据</span>
|
||||
</div>
|
||||
|
||||
<!-- hidden elements kept for JS compatibility -->
|
||||
<span id="terrain-status" hidden></span>
|
||||
<span id="texture-quality" hidden></span>
|
||||
<span id="camera-distance" hidden></span>
|
||||
</div>
|
||||
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable hud-panel-hidden" data-panel-key="tv-panel">
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<div class="tv-panel-header-copy">
|
||||
<h3 class="hud-panel-title">新闻直播</h3>
|
||||
@@ -280,8 +312,12 @@
|
||||
<div class="tv-panel-controls">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-actions">
|
||||
<button id="tv-refresh" class="tv-panel-action" type="button">刷新</button>
|
||||
<button id="tv-open-external" class="tv-panel-action" type="button">官网</button>
|
||||
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tv-panel-meta">
|
||||
@@ -334,6 +370,16 @@
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<div class="earth-settings-list">
|
||||
<label class="earth-settings-item" for="toggle-view-layers">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">图层控制</span>
|
||||
<span class="earth-settings-item-subtitle">控制右侧图层控制面板显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-layers" type="checkbox" data-settings-panel="layer-toggles" checked>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-legend">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">图例</span>
|
||||
@@ -344,20 +390,10 @@
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-coordinates">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">坐标信息</span>
|
||||
<span class="earth-settings-item-subtitle">控制右上角坐标信息面板显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-coordinates" type="checkbox" data-settings-panel="coordinates-display">
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-stats">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地球信息</span>
|
||||
<span class="earth-settings-item-subtitle">控制右下角统计信息面板显示</span>
|
||||
<span class="earth-settings-item-title">全球态势</span>
|
||||
<span class="earth-settings-item-subtitle">控制右上角全球态势统计面板显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-stats" type="checkbox" data-settings-panel="earth-stats">
|
||||
|
||||
@@ -31,12 +31,16 @@ export function renderBrand(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
src="assets/brand/earth-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
>
|
||||
<div class="earth-brand__copy">
|
||||
<img
|
||||
class="earth-brand__title"
|
||||
src="${config.titleSrc}"
|
||||
alt="${config.titleAlt}"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
>
|
||||
<div class="earth-brand__meta">
|
||||
<span class="earth-brand__subtitle">${config.subtitle}</span>
|
||||
|
||||
@@ -327,7 +327,9 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
const cableCount = data.features.length;
|
||||
const inServiceCount = data.features.filter(
|
||||
(feature) =>
|
||||
feature.properties && feature.properties.status === "In Service",
|
||||
feature.properties &&
|
||||
(feature.properties.status === "active" ||
|
||||
feature.properties.status === "In Service"),
|
||||
).length;
|
||||
|
||||
const cableCountEl = document.getElementById("cable-count");
|
||||
|
||||
@@ -222,3 +222,38 @@ export const GRID_CONFIG = {
|
||||
longitudeStep: 30,
|
||||
gridStep: 5
|
||||
};
|
||||
|
||||
export const EARTH_MATERIAL_CONFIG = {
|
||||
// Diffuse color multiplies with texture — pure white = full saturation,
|
||||
// slightly grey-blue pulls perceived saturation down without a custom shader.
|
||||
color: 0xcdd8e6,
|
||||
specular: 0x1a2d45,
|
||||
shininess: 12,
|
||||
emissive: 0x050a12,
|
||||
opacity: 0.96,
|
||||
|
||||
// Depth-mask occluder keeps far-side objects hidden behind the earth
|
||||
occluderRadiusFactor: 0.999,
|
||||
occluderSegments: 48,
|
||||
|
||||
// Fresnel atmosphere glow — inner rim
|
||||
atmosInnerRadiusFactor: 1.018,
|
||||
atmosInnerSegments: 64,
|
||||
atmosInnerColor: [0.25, 0.62, 1.0],
|
||||
atmosInnerRimPower: 3.2,
|
||||
atmosInnerIntensity: 0.72,
|
||||
|
||||
// Fresnel atmosphere glow — outer corona
|
||||
atmosOuterRadiusFactor: 1.07,
|
||||
atmosOuterSegments: 48,
|
||||
atmosOuterColor: [0.18, 0.45, 0.9],
|
||||
atmosOuterRimPower: 5.0,
|
||||
atmosOuterIntensity: 0.28,
|
||||
|
||||
// Texture candidates — tried in order, first success wins
|
||||
textureUrls: [
|
||||
'./assets/8k_earth_daymap.jpg',
|
||||
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
],
|
||||
};
|
||||
|
||||
132
frontend/public/earth/js/controls.js
vendored
132
frontend/public/earth/js/controls.js
vendored
@@ -29,9 +29,9 @@ let listeners = [];
|
||||
let cleanupFns = [];
|
||||
const HUD_PANEL_IDS = [
|
||||
"legend",
|
||||
"coordinates-display",
|
||||
"earth-stats",
|
||||
"tv-panel",
|
||||
"layer-toggles",
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
const PANEL_LAYOUT_ANIMATION_MS = 420;
|
||||
@@ -39,7 +39,6 @@ const PANEL_LAYOUT_ANIMATION_MS = 420;
|
||||
function getFloatingGroups() {
|
||||
return [
|
||||
document.getElementById("zoom-control-group"),
|
||||
document.getElementById("info-control-group"),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -212,12 +211,22 @@ function setupDraggableHudPanels() {
|
||||
};
|
||||
|
||||
bindListener(handle, "pointerdown", (event) => {
|
||||
if (event.target.closest(".hud-panel-close")) return;
|
||||
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close")) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
const appRect = app.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
|
||||
// If panel is inside a flow container (not a direct child of app), reparent
|
||||
// it so absolute positioning is relative to the app container.
|
||||
if (panel.parentElement !== app) {
|
||||
const capturedWidth = panelRect.width;
|
||||
panel.style.position = "absolute";
|
||||
panel.style.width = `${capturedWidth}px`;
|
||||
app.appendChild(panel);
|
||||
}
|
||||
|
||||
startLeft = panelRect.left - appRect.left;
|
||||
startTop = panelRect.top - appRect.top;
|
||||
panel.style.left = `${startLeft}px`;
|
||||
@@ -265,6 +274,17 @@ function setButtonTooltip(button, text) {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLayerButtonState(button, isActive) {
|
||||
if (!button) return;
|
||||
button.classList.toggle("active", isActive);
|
||||
button.setAttribute("aria-checked", isActive ? "true" : "false");
|
||||
// Legacy badge text (kept for compatibility)
|
||||
const state = button.querySelector(".earth-layer-btn__state");
|
||||
if (state) {
|
||||
state.textContent = isActive ? "ON" : "OFF";
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectionIfHiding(shouldHide) {
|
||||
if (shouldHide) {
|
||||
clearLockedObject();
|
||||
@@ -555,11 +575,91 @@ function setupRotateControls(camera) {
|
||||
});
|
||||
}
|
||||
|
||||
function filterLayerRows(query, emptyStateEl, clearBtn) {
|
||||
const rows = document.querySelectorAll("#layer-panel-list .layer-row");
|
||||
let visibleCount = 0;
|
||||
rows.forEach((row) => {
|
||||
const name = (row.dataset.layerName || "").toLowerCase();
|
||||
const matches = !query || name.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visibleCount++;
|
||||
});
|
||||
if (emptyStateEl) emptyStateEl.hidden = visibleCount > 0;
|
||||
if (clearBtn) clearBtn.hidden = !query;
|
||||
}
|
||||
|
||||
function setupLayerPanel() {
|
||||
const panel = document.getElementById("layer-toggles");
|
||||
const collapseBtn = document.getElementById("layer-panel-collapse");
|
||||
const searchInput = document.getElementById("layer-search-input");
|
||||
const searchClear = document.getElementById("layer-search-clear");
|
||||
const emptyState = document.getElementById("layer-panel-empty");
|
||||
if (!panel) return;
|
||||
|
||||
bindListener(collapseBtn, "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isCollapsed = panel.classList.toggle("layer-panel--collapsed");
|
||||
collapseBtn.title = isCollapsed ? "展开" : "折叠";
|
||||
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
|
||||
const icon = collapseBtn.querySelector(".material-symbols-rounded");
|
||||
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
|
||||
});
|
||||
|
||||
if (searchInput) {
|
||||
bindListener(searchInput, "input", () => {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
filterLayerRows(query, emptyState, searchClear);
|
||||
});
|
||||
|
||||
if (searchClear) {
|
||||
bindListener(searchClear, "click", () => {
|
||||
searchInput.value = "";
|
||||
filterLayerRows("", emptyState, searchClear);
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLayerRow({ id, icon, label, meta, defaultActive }) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "layer-row";
|
||||
row.dataset.layerName = `${label} ${meta || ""}`.trim().toLowerCase();
|
||||
row.innerHTML = `
|
||||
<span class="material-symbols-rounded layer-row-icon">${icon}</span>
|
||||
<div class="layer-row-copy">
|
||||
<span class="layer-row-label">${label}</span>
|
||||
${meta ? `<span class="layer-row-meta">${meta}</span>` : ""}
|
||||
</div>
|
||||
<button id="${id}" class="layer-row-toggle${defaultActive ? " active" : ""}" type="button"
|
||||
role="switch" aria-checked="${defaultActive ? "true" : "false"}" title="切换${label}显示">
|
||||
<span class="layer-row-toggle-track"></span>
|
||||
</button>
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
export function registerLayer({ id, icon, label, meta = "", defaultActive = false, onToggle }) {
|
||||
const list = document.getElementById("layer-panel-list");
|
||||
if (!list) return;
|
||||
if (document.getElementById(id)) return; // avoid duplicates
|
||||
|
||||
const row = createLayerRow({ id, icon, label, meta, defaultActive });
|
||||
list.appendChild(row);
|
||||
|
||||
const btn = row.querySelector("button");
|
||||
if (btn && typeof onToggle === "function") {
|
||||
btn.addEventListener("click", function () {
|
||||
const isActive = this.classList.toggle("active");
|
||||
this.setAttribute("aria-checked", isActive ? "true" : "false");
|
||||
onToggle(isActive);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupTerrainControls() {
|
||||
const container = document.getElementById("container");
|
||||
const searchBtn = document.getElementById("search-action");
|
||||
const infoGroup = document.getElementById("info-control-group");
|
||||
const infoTrigger = document.getElementById("info-trigger");
|
||||
const terrainBtn = document.getElementById("toggle-terrain");
|
||||
const satellitesBtn = document.getElementById("toggle-satellites");
|
||||
const bgpBtn = document.getElementById("toggle-bgp");
|
||||
@@ -572,9 +672,10 @@ function setupTerrainControls() {
|
||||
setupSettingsControls();
|
||||
setupHudPanelControls();
|
||||
setupDraggableHudPanels();
|
||||
setupLayerPanel();
|
||||
|
||||
if (trailsBtn) {
|
||||
trailsBtn.classList.add("active");
|
||||
updateLayerButtonState(trailsBtn, true);
|
||||
setButtonTooltip(trailsBtn, "隐藏轨迹");
|
||||
}
|
||||
|
||||
@@ -585,7 +686,7 @@ function setupTerrainControls() {
|
||||
bindListener(terrainBtn, "click", function () {
|
||||
showTerrain = !showTerrain;
|
||||
toggleTerrain(showTerrain);
|
||||
this.classList.toggle("active", showTerrain);
|
||||
updateLayerButtonState(this, showTerrain);
|
||||
setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus)
|
||||
@@ -615,7 +716,7 @@ function setupTerrainControls() {
|
||||
const showNextBGP = !getShowBGP();
|
||||
clearSelectionIfHiding(!showNextBGP);
|
||||
toggleBGP(showNextBGP);
|
||||
this.classList.toggle("active", showNextBGP);
|
||||
updateLayerButtonState(this, showNextBGP);
|
||||
setButtonTooltip(this, showNextBGP ? "隐藏BGP观测" : "显示BGP观测");
|
||||
const bgpCountEl = document.getElementById("bgp-anomaly-count");
|
||||
if (bgpCountEl) {
|
||||
@@ -628,7 +729,7 @@ function setupTerrainControls() {
|
||||
const isActive = this.classList.contains("active");
|
||||
const nextShowTrails = !isActive;
|
||||
toggleTrails(nextShowTrails);
|
||||
this.classList.toggle("active", nextShowTrails);
|
||||
updateLayerButtonState(this, nextShowTrails);
|
||||
setButtonTooltip(this, nextShowTrails ? "隐藏轨迹" : "显示轨迹");
|
||||
showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
|
||||
});
|
||||
@@ -648,10 +749,9 @@ function setupTerrainControls() {
|
||||
});
|
||||
|
||||
bindFloatingMenu(zoomTrigger, zoomGroup);
|
||||
bindFloatingMenu(infoTrigger, infoGroup);
|
||||
|
||||
bindListener(document, "click", (event) => {
|
||||
const openGroups = [zoomGroup, infoGroup].filter((group) =>
|
||||
const openGroups = [zoomGroup].filter((group) =>
|
||||
group?.classList.contains("open"),
|
||||
);
|
||||
if (openGroups.length === 0) return;
|
||||
@@ -673,9 +773,13 @@ function setupTerrainControls() {
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
});
|
||||
|
||||
updateTVToggleUI(
|
||||
!document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"),
|
||||
);
|
||||
const tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(tvVisible);
|
||||
if (tvVisible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
@@ -9,82 +9,108 @@ export let clouds = null;
|
||||
export let terrain = null;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
|
||||
export function createEarth(scene) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
|
||||
|
||||
const C = EARTH_MATERIAL_CONFIG;
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0xffffff,
|
||||
specular: 0x111111,
|
||||
shininess: 10,
|
||||
emissive: 0x000000,
|
||||
color: C.color,
|
||||
specular: C.specular,
|
||||
shininess: C.shininess,
|
||||
emissive: C.emissive,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide
|
||||
opacity: C.opacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
});
|
||||
|
||||
_earthMaterial = material;
|
||||
|
||||
earth = new THREE.Mesh(geometry, material);
|
||||
earth.renderOrder = 0;
|
||||
earth.rotation.x = EARTH_CONFIG.tiltRad;
|
||||
scene.add(earth);
|
||||
|
||||
const textureUrls = [
|
||||
'./assets/8k_earth_daymap.jpg',
|
||||
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://assets.codepen.io/982762/earth_texture_2048.jpg'
|
||||
];
|
||||
|
||||
let textureLoaded = false;
|
||||
|
||||
textureLoader.load(
|
||||
textureUrls[0],
|
||||
function(texture) {
|
||||
console.log('高分辨率地球纹理加载成功');
|
||||
textureLoaded = true;
|
||||
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.anisotropy = 16;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
},
|
||||
function(xhr) {
|
||||
console.log('纹理加载中: ' + (xhr.loaded / xhr.total * 100) + '%');
|
||||
},
|
||||
function(err) {
|
||||
console.log('第一个纹理加载失败,尝试第二个...');
|
||||
|
||||
textureLoader.load(
|
||||
textureUrls[1],
|
||||
function(texture) {
|
||||
console.log('第二个纹理加载成功');
|
||||
textureLoaded = true;
|
||||
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.anisotropy = 16;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
},
|
||||
null,
|
||||
function(err) {
|
||||
console.log('所有纹理加载失败');
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Depth-mask occluder — invisible sphere slightly inside the earth,
|
||||
// writes to the depth buffer so far-side cables/satellites are occluded.
|
||||
const occluderGeometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius * C.occluderRadiusFactor,
|
||||
C.occluderSegments,
|
||||
C.occluderSegments,
|
||||
);
|
||||
|
||||
const occluderMaterial = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial);
|
||||
occluder.renderOrder = -1;
|
||||
earth.add(occluder);
|
||||
|
||||
// Shared Fresnel vertex shader for both atmosphere layers
|
||||
const ATMOS_VERTEX_SHADER = `
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Fresnel atmosphere — inner rim
|
||||
const [ir, ig, ib] = C.atmosInnerColor;
|
||||
const atmosInnerGeo = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius * C.atmosInnerRadiusFactor,
|
||||
C.atmosInnerSegments,
|
||||
C.atmosInnerSegments,
|
||||
);
|
||||
const atmosInnerMat = new THREE.ShaderMaterial({
|
||||
vertexShader: ATMOS_VERTEX_SHADER,
|
||||
fragmentShader: `
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0)));
|
||||
float intensity = pow(rim, ${C.atmosInnerRimPower.toFixed(1)});
|
||||
gl_FragColor = vec4(${ir.toFixed(2)}, ${ig.toFixed(2)}, ${ib.toFixed(2)}, intensity * ${C.atmosInnerIntensity.toFixed(2)});
|
||||
}
|
||||
`,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.BackSide,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const atmosInner = new THREE.Mesh(atmosInnerGeo, atmosInnerMat);
|
||||
atmosInner.renderOrder = 1;
|
||||
earth.add(atmosInner);
|
||||
|
||||
// Fresnel atmosphere — outer corona
|
||||
const [outerR, outerG, outerB] = C.atmosOuterColor;
|
||||
const atmosOuterGeo = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius * C.atmosOuterRadiusFactor,
|
||||
C.atmosOuterSegments,
|
||||
C.atmosOuterSegments,
|
||||
);
|
||||
const atmosOuterMat = new THREE.ShaderMaterial({
|
||||
vertexShader: ATMOS_VERTEX_SHADER,
|
||||
fragmentShader: `
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0)));
|
||||
float intensity = pow(rim, ${C.atmosOuterRimPower.toFixed(1)});
|
||||
gl_FragColor = vec4(${outerR.toFixed(2)}, ${outerG.toFixed(2)}, ${outerB.toFixed(2)}, intensity * ${C.atmosOuterIntensity.toFixed(2)});
|
||||
}
|
||||
`,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.BackSide,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const atmosOuter = new THREE.Mesh(atmosOuterGeo, atmosOuterMat);
|
||||
atmosOuter.renderOrder = 1;
|
||||
earth.add(atmosOuter);
|
||||
|
||||
// Texture is loaded separately via loadEarthTexture() for staged loading
|
||||
return earth;
|
||||
}
|
||||
|
||||
@@ -238,3 +264,40 @@ export function getEarth() {
|
||||
export function getClouds() {
|
||||
return clouds;
|
||||
}
|
||||
|
||||
export function clearEarthTexture() {
|
||||
if (!_earthMaterial) return;
|
||||
_earthMaterial.map = null;
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function loadEarthTexture() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
|
||||
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
|
||||
const tryLoad = (index) => {
|
||||
if (index >= urls.length) {
|
||||
console.warn('所有地球纹理加载失败');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
textureLoader.load(
|
||||
urls[index],
|
||||
(texture) => {
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.anisotropy = 16;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
_earthMaterial.map = texture;
|
||||
_earthMaterial.needsUpdate = true;
|
||||
resolve();
|
||||
},
|
||||
null,
|
||||
() => tryLoad(index + 1),
|
||||
);
|
||||
};
|
||||
tryLoad(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,6 +101,47 @@ const CARD_CONFIG = {
|
||||
}
|
||||
};
|
||||
|
||||
function getPanel() {
|
||||
return document.getElementById('info-panel');
|
||||
}
|
||||
|
||||
function positionPanel(panel, x, y) {
|
||||
if (!panel) return;
|
||||
const margin = 12;
|
||||
const offset = 14;
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
|
||||
const scale = parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
|
||||
) || 1;
|
||||
const estW = Math.min(300 * scale, vpW - 32);
|
||||
const estH = Math.min(420 * scale, vpH * 0.7);
|
||||
|
||||
let left = x + offset;
|
||||
let top = y + offset;
|
||||
|
||||
if (left + estW > vpW - margin) left = x - estW - offset;
|
||||
if (top + estH > vpH - margin) top = Math.max(margin, vpH - estH - margin);
|
||||
|
||||
panel.style.left = `${Math.max(margin, left)}px`;
|
||||
panel.style.top = `${Math.max(margin, top)}px`;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
function showPanel(x, y) {
|
||||
const panel = getPanel();
|
||||
if (!panel) return;
|
||||
if (x != null && y != null) positionPanel(panel, x, y);
|
||||
panel.classList.add('is-visible');
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = getPanel();
|
||||
if (panel) panel.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
export function initInfoCard() {
|
||||
const card = document.getElementById('info-card');
|
||||
const content = document.getElementById('info-card-content');
|
||||
@@ -128,6 +169,15 @@ export function initInfoCard() {
|
||||
card.addEventListener(eventName, stopEvent, { passive: false });
|
||||
});
|
||||
|
||||
// Close button wires the panel hide
|
||||
const closeBtn = card.querySelector('.info-card-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
hideInfoCard();
|
||||
});
|
||||
}
|
||||
|
||||
card.dataset.interactionBound = 'true';
|
||||
}
|
||||
|
||||
@@ -165,7 +215,7 @@ export function setInfoCardNoBorder(noBorder = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function showInfoCard(type, data) {
|
||||
export function showInfoCard(type, data, options = {}) {
|
||||
const config = CARD_CONFIG[type];
|
||||
if (!config) {
|
||||
console.warn('Unknown info card type:', type);
|
||||
@@ -185,17 +235,17 @@ export function showInfoCard(type, data) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
@@ -205,14 +255,11 @@ export function showInfoCard(type, data) {
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
card.style.display = 'block';
|
||||
showPanel(options.x, options.y);
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
const card = document.getElementById('info-card');
|
||||
if (card) {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
hidePanel();
|
||||
currentType = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
const LEGEND_MODES = {
|
||||
cables: {
|
||||
title: "线缆图例",
|
||||
},
|
||||
satellites: {
|
||||
title: "卫星图例",
|
||||
},
|
||||
bgp: {
|
||||
title: "BGP观测图例",
|
||||
},
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
bgp: { title: "BGP" },
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
@@ -18,13 +12,35 @@ let legendItemsByMode = {
|
||||
};
|
||||
|
||||
export function initLegend() {
|
||||
// Tab click → switch mode
|
||||
const tabsEl = document.getElementById("legend-tabs");
|
||||
if (tabsEl) {
|
||||
tabsEl.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".legend-tab");
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.legendMode;
|
||||
if (mode) setLegendMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse toggle
|
||||
const collapseBtn = document.getElementById("legend-collapse");
|
||||
const legend = document.getElementById("legend");
|
||||
if (collapseBtn && legend) {
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
legend.classList.toggle("legend--collapsed");
|
||||
});
|
||||
}
|
||||
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
|
||||
export function setLegendMode(mode) {
|
||||
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
|
||||
currentLegendMode = nextMode;
|
||||
renderLegend(currentLegendMode);
|
||||
syncTabs(nextMode);
|
||||
renderLegend(nextMode);
|
||||
}
|
||||
|
||||
export function getLegendMode() {
|
||||
@@ -43,26 +59,25 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const legend = document.getElementById("legend");
|
||||
if (!legend) return;
|
||||
function syncTabs(mode) {
|
||||
const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
|
||||
tabs.forEach((tab) => {
|
||||
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const listEl = document.querySelector("#legend .legend-list");
|
||||
if (!listEl) return;
|
||||
|
||||
const config = LEGEND_MODES[mode] || LEGEND_MODES.cables;
|
||||
const items = legendItemsByMode[mode] || [];
|
||||
const titleEl = legend.querySelector(".legend-title");
|
||||
const listEl = legend.querySelector(".legend-list");
|
||||
if (!titleEl || !listEl) return;
|
||||
const itemsHtml = items
|
||||
listEl.innerHTML = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: ${item.color};"></div>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`,
|
||||
<span class="legend-dot" style="background:${item.color}"></span>
|
||||
<span class="legend-label">${item.label}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
titleEl.textContent = config.title;
|
||||
listEl.innerHTML = itemsHtml;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
createStars,
|
||||
createGridLines,
|
||||
getEarth,
|
||||
loadEarthTexture,
|
||||
clearEarthTexture,
|
||||
} from "./earth.js";
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
@@ -110,12 +112,12 @@ import {
|
||||
resetView,
|
||||
getZoomLevel,
|
||||
teardownControls,
|
||||
updateLayerButtonState,
|
||||
} from "./controls.js";
|
||||
import {
|
||||
initInfoCard,
|
||||
showInfoCard,
|
||||
hideInfoCard,
|
||||
setInfoCardNoBorder,
|
||||
} from "./info-card.js";
|
||||
import {
|
||||
initLegend,
|
||||
@@ -177,13 +179,15 @@ const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||
const INERTIA_DAMPING = 0.92;
|
||||
const INERTIA_MIN_VELOCITY = 0.00008;
|
||||
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
|
||||
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
|
||||
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
|
||||
const HUD_INTERACTIVE_SELECTORS = [
|
||||
".earth-left-column",
|
||||
".earth-left-column *",
|
||||
"#info-panel",
|
||||
"#info-panel *",
|
||||
"#right-toolbar-group",
|
||||
"#right-toolbar-group *",
|
||||
"#coordinates-display",
|
||||
"#coordinates-display *",
|
||||
"#legend",
|
||||
"#legend *",
|
||||
"#earth-stats",
|
||||
@@ -415,7 +419,7 @@ function getPrimaryBGPClickTarget(
|
||||
return anomalyMarker;
|
||||
}
|
||||
|
||||
function showCableInfo(cable) {
|
||||
function showCableInfo(cable, coords) {
|
||||
setLegendMode("cables");
|
||||
showInfoCard("cable", {
|
||||
name: cable.userData.name,
|
||||
@@ -424,10 +428,16 @@ function showCableInfo(cable) {
|
||||
length: cable.userData.length,
|
||||
coords: cable.userData.coords,
|
||||
rfs: cable.userData.rfs,
|
||||
});
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function showSatelliteInfo(props) {
|
||||
function getCableBriefHtml(cable) {
|
||||
const name = cable.userData.name || "未知海缆";
|
||||
const status = cable.userData.status || "";
|
||||
return `<strong>${name}</strong>${status ? `<br>${status}` : ""}`;
|
||||
}
|
||||
|
||||
function showSatelliteInfo(props, coords) {
|
||||
const meanMotion = props?.mean_motion || 0;
|
||||
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
|
||||
const ecc = props?.eccentricity || 0;
|
||||
@@ -444,10 +454,16 @@ function showSatelliteInfo(props) {
|
||||
period,
|
||||
perigee,
|
||||
apogee,
|
||||
});
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function showBGPInfo(marker) {
|
||||
function getSatelliteBriefHtml(props) {
|
||||
const name = props?.name || "未知卫星";
|
||||
const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : "";
|
||||
return `<strong>${name}</strong>${id ? `<br>${id}` : ""}`;
|
||||
}
|
||||
|
||||
function showBGPInfo(marker, coords) {
|
||||
setLegendMode("bgp");
|
||||
const impactedRegions =
|
||||
Array.isArray(marker.userData.impacted_regions) &&
|
||||
@@ -512,10 +528,18 @@ function showBGPInfo(marker) {
|
||||
formatBGPLocation(marker.userData.city, marker.userData.country),
|
||||
created_at: formatBGPObservedTime(marker.userData.created_at_raw),
|
||||
summary: narrative,
|
||||
});
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function showBGPCollectorInfo(marker) {
|
||||
function getBGPBriefHtml(marker) {
|
||||
const type = formatBGPAnomalyTypeLabel(
|
||||
marker.userData.incident_type || marker.userData.anomaly_type,
|
||||
);
|
||||
const collector = marker.userData.collector || "";
|
||||
return `<strong>${type}</strong>${collector ? `<br>${collector}` : ""}`;
|
||||
}
|
||||
|
||||
function showBGPCollectorInfo(marker, coords) {
|
||||
setLegendMode("bgp");
|
||||
showInfoCard("bgp_collector", {
|
||||
collector: marker.userData.collector,
|
||||
@@ -533,7 +557,13 @@ function showBGPCollectorInfo(marker) {
|
||||
latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at),
|
||||
baseline_scope: formatBGPScope(marker.userData.baseline_scope),
|
||||
status: formatBGPCollectorStatus(marker.userData.status || "online"),
|
||||
});
|
||||
}, coords);
|
||||
}
|
||||
|
||||
function getBGPCollectorBriefHtml(marker) {
|
||||
const name = marker.userData.collector || "观测站";
|
||||
const count = marker.userData.anomaly_count ?? 0;
|
||||
return `<strong>${name}</strong><br>${count} 条事件`;
|
||||
}
|
||||
|
||||
function getBGPStatusText(bgpResult) {
|
||||
@@ -711,7 +741,7 @@ function buildLoadErrorMessage(errors) {
|
||||
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
|
||||
const satBtn = document.getElementById("toggle-satellites");
|
||||
if (satBtn) {
|
||||
satBtn.classList.toggle("active", enabled);
|
||||
updateLayerButtonState(satBtn, enabled);
|
||||
const tooltip = satBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星";
|
||||
}
|
||||
@@ -725,7 +755,7 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
|
||||
function updateCableToggleUi(enabled) {
|
||||
const cableBtn = document.getElementById("toggle-cables");
|
||||
if (cableBtn) {
|
||||
cableBtn.classList.toggle("active", enabled);
|
||||
updateLayerButtonState(cableBtn, enabled);
|
||||
const tooltip = cableBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) tooltip.textContent = enabled ? "隐藏线缆" : "显示线缆";
|
||||
}
|
||||
@@ -761,10 +791,9 @@ async function ensureCablesEnabled() {
|
||||
const requestToken = ++cableToggleToken;
|
||||
|
||||
clearCableData(earth);
|
||||
const [cableCount] = await Promise.all([
|
||||
loadGeoJSONFromPath(scene, earth),
|
||||
loadLandingPoints(scene, earth),
|
||||
]);
|
||||
// Load landing points first so they appear before cable lines
|
||||
await loadLandingPoints(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
|
||||
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
|
||||
clearCableData(earth);
|
||||
@@ -942,7 +971,10 @@ function addLights() {
|
||||
scene.add(pointLight);
|
||||
}
|
||||
|
||||
async function loadData(showWhiteSphere = false) {
|
||||
// Yield control to the browser so the renderer can paint a frame before the next step
|
||||
const yieldFrame = (ms = 60) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function loadData() {
|
||||
if (!scene || !camera || !renderer) return;
|
||||
if (isDataLoading) return;
|
||||
|
||||
@@ -952,48 +984,104 @@ async function loadData(showWhiteSphere = false) {
|
||||
const loadToken = ++currentLoadToken;
|
||||
isDataLoading = true;
|
||||
hideError();
|
||||
setLoadingMessage(
|
||||
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
|
||||
showWhiteSphere
|
||||
? "重新同步卫星、海底光缆、登陆点与BGP态势数据"
|
||||
: "同步卫星、海底光缆、登陆点与BGP态势数据",
|
||||
);
|
||||
setLoading(true);
|
||||
clearSelectionAndInfo();
|
||||
|
||||
if (showWhiteSphere && earth.material) {
|
||||
earthTexture = earth.material.map;
|
||||
earth.material.map = null;
|
||||
earth.material.color.setHex(0xffffff);
|
||||
earth.material.needsUpdate = true;
|
||||
}
|
||||
// Always begin as a white sphere so every layer appears explicitly
|
||||
clearEarthTexture();
|
||||
clearBGPData(earth);
|
||||
clearCableData(earth);
|
||||
clearSatelliteData();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
cablesEnabled ? ensureCablesEnabled() : Promise.resolve(0),
|
||||
satellitesEnabled ? ensureSatellitesEnabled() : Promise.resolve(0),
|
||||
(async () => {
|
||||
clearBGPData(earth);
|
||||
const bgpResult = await loadBGPAnomalies(scene, earth);
|
||||
toggleBGP(true);
|
||||
updateBGPHud(bgpResult);
|
||||
return bgpResult;
|
||||
})(),
|
||||
]);
|
||||
|
||||
if (loadToken !== currentLoadToken) {
|
||||
isDataLoading = false;
|
||||
return;
|
||||
}
|
||||
setLoadingMessage("正在初始化...", "清除旧数据");
|
||||
setLoading(true);
|
||||
await yieldFrame();
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
|
||||
const errors = [];
|
||||
if (results[0].status === "rejected") {
|
||||
errors.push({ label: "电缆", reason: results[0].reason });
|
||||
|
||||
// Step 1 — Landing points
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载登陆点...", "同步海底光缆登陆站数据");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadLandingPoints(scene, earth);
|
||||
} catch (err) {
|
||||
errors.push({ label: "登陆点", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
}
|
||||
if (results[1].status === "rejected") {
|
||||
errors.push({ label: "卫星", reason: results[1].reason });
|
||||
|
||||
// Step 2 — Cables
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载海缆...", "同步海底光缆网络数据");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
if (loadToken === currentLoadToken && cablesEnabled) {
|
||||
toggleCables(true);
|
||||
updateCableToggleUi(true);
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ label: "海缆", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
}
|
||||
if (results[2].status === "rejected") {
|
||||
errors.push({ label: "BGP态势", reason: results[2].reason });
|
||||
|
||||
// Step 3 — Satellites
|
||||
if (satellitesEnabled) {
|
||||
setLoadingMessage("正在加载卫星...", "同步在轨卫星轨道数据");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
clearSatelliteData();
|
||||
const satelliteCount = await loadSatellites();
|
||||
if (loadToken === currentLoadToken && satellitesEnabled) {
|
||||
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
|
||||
toggleSatellites(true);
|
||||
updateSatelliteToggleUi(true, satelliteCount);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ label: "卫星", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
}
|
||||
|
||||
// Step 4 — BGP
|
||||
setLoadingMessage("正在加载BGP态势...", "同步全球路由观测数据");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const bgpResult = await loadBGPAnomalies(scene, earth);
|
||||
if (loadToken === currentLoadToken) {
|
||||
toggleBGP(true);
|
||||
updateBGPHud(bgpResult);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ label: "BGP态势", reason: err });
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
|
||||
// Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
|
||||
setLoadingMessage("正在加载地球纹理...", "加载8K卫星地图");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadEarthTexture();
|
||||
} catch (err) {
|
||||
// texture failure is non-fatal
|
||||
}
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
await yieldFrame();
|
||||
|
||||
// Step 6 — Terrain (if enabled)
|
||||
if (getShowTerrain()) {
|
||||
setLoadingMessage("正在渲染地形...", "生成地表高程数据");
|
||||
await yieldFrame(50);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -1002,7 +1090,7 @@ async function loadData(showWhiteSphere = false) {
|
||||
showStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
showStatusMessage("数据已重新加载", "success");
|
||||
showStatusMessage("数据已加载", "success");
|
||||
}
|
||||
|
||||
updateStatsSummary();
|
||||
@@ -1014,18 +1102,12 @@ async function loadData(showWhiteSphere = false) {
|
||||
refreshLegend();
|
||||
setLoading(false);
|
||||
isDataLoading = false;
|
||||
|
||||
if (showWhiteSphere && earth.material) {
|
||||
earth.material.map = earthTexture;
|
||||
earth.material.color.setHex(0xffffff);
|
||||
earth.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
const POSITION_UPDATE_FORCE_DELTA = 250;
|
||||
|
||||
export async function reloadData() {
|
||||
await loadData(true);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
export async function setCablesEnabled(enabled) {
|
||||
@@ -1176,18 +1258,12 @@ function onMouseMove(event) {
|
||||
|
||||
if (isEventOnHud(event)) {
|
||||
clearTransientHoverState();
|
||||
|
||||
// Info card stays visible at its click position; just maintain BGP visual state
|
||||
if (lockedObjectType === "bgp" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
showBGPInfo(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
showBGPCollectorInfo(lockedObject);
|
||||
} else if (lockedObjectType === "cable" && lockedObject) {
|
||||
showCableInfo(lockedObject);
|
||||
} else if (lockedObjectType === "satellite" && lockedSatellite) {
|
||||
showSatelliteInfo(lockedSatellite.properties);
|
||||
} else {
|
||||
} else if (!lockedObject && !lockedSatellite) {
|
||||
hideInfoCard();
|
||||
}
|
||||
hideTooltip();
|
||||
@@ -1271,6 +1347,8 @@ function onMouseMove(event) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
|
||||
let objectTooltipShown = false;
|
||||
|
||||
if (
|
||||
hoveredBGPMarker &&
|
||||
getShowBGP() &&
|
||||
@@ -1279,21 +1357,19 @@ function onMouseMove(event) {
|
||||
) {
|
||||
applyBGPHoverState(hoveredBGPMarker);
|
||||
if (hoveredBGPMarker.userData?.type === "bgp") {
|
||||
showBGPInfo(hoveredBGPMarker);
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPBriefHtml(hoveredBGPMarker));
|
||||
} else {
|
||||
showBGPCollectorInfo(hoveredBGPMarker);
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPCollectorBriefHtml(hoveredBGPMarker));
|
||||
}
|
||||
setInfoCardNoBorder(true);
|
||||
hideTooltip();
|
||||
objectTooltipShown = true;
|
||||
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||
const cable = cableIntersects[0].object;
|
||||
hoveredCable = cable;
|
||||
if (!isSameCable(cable, lockedObject)) {
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
|
||||
}
|
||||
showCableInfo(cable);
|
||||
setInfoCardNoBorder(true);
|
||||
hideTooltip();
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getCableBriefHtml(cable));
|
||||
objectTooltipShown = true;
|
||||
} else if (hoveredSat?.properties) {
|
||||
hoveredSatellite = hoveredSat;
|
||||
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
|
||||
@@ -1307,50 +1383,38 @@ function onMouseMove(event) {
|
||||
);
|
||||
}
|
||||
}
|
||||
showSatelliteInfo(hoveredSat.properties);
|
||||
setInfoCardNoBorder(true);
|
||||
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
|
||||
objectTooltipShown = true;
|
||||
} else if (lockedObjectType === "bgp" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
showBGPInfo(lockedObject);
|
||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||
applyBGPHoverState(lockedObject);
|
||||
showBGPCollectorInfo(lockedObject);
|
||||
} else if (lockedObjectType === "cable" && lockedObject) {
|
||||
showCableInfo(lockedObject);
|
||||
} else if (lockedObjectType === "satellite" && lockedSatellite) {
|
||||
const satPositions = getSatellitePositions();
|
||||
if (lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex]) {
|
||||
setSatelliteRingState(
|
||||
lockedSatelliteIndex,
|
||||
"locked",
|
||||
satPositions[lockedSatelliteIndex].current,
|
||||
);
|
||||
}
|
||||
showSatelliteInfo(lockedSatellite.properties);
|
||||
} else {
|
||||
} else if (!lockedObjectType) {
|
||||
resetTransientBGPStates();
|
||||
hideInfoCard();
|
||||
}
|
||||
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
);
|
||||
if (earthPoint) {
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||||
showTooltip(
|
||||
event.clientX + 10,
|
||||
event.clientY + 10,
|
||||
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${coords.alt.toFixed(1)} km`,
|
||||
if (!objectTooltipShown) {
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
);
|
||||
} else {
|
||||
hideTooltip();
|
||||
if (earthPoint) {
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_COORDS_OFFSET,
|
||||
event.clientY + TOOLTIP_COORDS_OFFSET,
|
||||
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${coords.alt.toFixed(1)} km`,
|
||||
);
|
||||
} else {
|
||||
hideTooltip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,7 +1501,7 @@ function onClick(event) {
|
||||
highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
|
||||
}
|
||||
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
|
||||
showBGPInfo(clickedMarker);
|
||||
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
`已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`,
|
||||
"info",
|
||||
@@ -1460,7 +1524,7 @@ function onClick(event) {
|
||||
setAutoRotate(false);
|
||||
showBGPCollectorCoverageOverlay(clickedMarker, earth);
|
||||
clickedMarker.userData.related_satellite_count = 0;
|
||||
showBGPCollectorInfo(clickedMarker);
|
||||
showBGPCollectorInfo(clickedMarker, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage(
|
||||
`已选择观测站: ${clickedMarker.userData.collector}`,
|
||||
"info",
|
||||
@@ -1479,6 +1543,7 @@ function onClick(event) {
|
||||
lockedObjectType = "cable";
|
||||
setAutoRotate(false);
|
||||
handleCableClick(clickedCable);
|
||||
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1532,13 +1597,14 @@ function onClick(event) {
|
||||
);
|
||||
}
|
||||
|
||||
showSatelliteInfo(sat.properties);
|
||||
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
|
||||
showStatusMessage("已选择: " + sat.properties.name, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLongDrag) {
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
setAutoRotate(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,8 +512,8 @@ function resolveInitialSourceId() {
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
renderSourceOptions();
|
||||
currentSourceId = resolveInitialSourceId();
|
||||
renderSourceOptions();
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.26.2"
|
||||
version = "0.27.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user