release: bump version to 0.27.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rayd1o
2026-04-14 07:37:45 +08:00
parent 2ee4773f4f
commit 7cd29cf9c0
24 changed files with 1565 additions and 630 deletions

View File

@@ -1 +1 @@
0.26.2
0.27.0

View File

@@ -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",

View File

@@ -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

View File

@@ -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 |

View File

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

View File

@@ -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)));
}

View File

@@ -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 */

View File

@@ -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);
}

View 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. */

View File

@@ -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)));
}

View File

@@ -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%);

View File

@@ -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. */

View File

@@ -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">

View File

@@ -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>

View File

@@ -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");

View File

@@ -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',
],
};

View File

@@ -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);
}

View File

@@ -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);
});
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -512,8 +512,8 @@ function resolveInitialSourceId() {
}
function renderPanel() {
renderSourceOptions();
currentSourceId = resolveInitialSourceId();
renderSourceOptions();
renderSource(findSourceById(currentSourceId));
}

View File

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

2
uv.lock generated
View File

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