release: bump version to 0.38.0

This commit is contained in:
linkong
2026-04-23 17:57:35 +08:00
parent 195a8bf71c
commit d5f3784ffb
39 changed files with 4958 additions and 146 deletions

View File

@@ -421,8 +421,10 @@
.earth-mobile-drawer-sheet {
position: relative;
display: flex;
flex-direction: column;
min-height: calc(240px + var(--safe-bottom));
max-height: min(78vh, calc(100vh - 72px - var(--safe-top)));
height: min(78vh, calc(100vh - 72px - var(--safe-top)));
padding: 10px 14px calc(14px + var(--safe-bottom)) 14px;
border-top-left-radius: 28px;
border-top-right-radius: 28px;
@@ -438,6 +440,8 @@
}
.earth-mobile-drawer-header {
order: 1;
flex: 0 0 auto;
padding: 10px 4px 8px;
cursor: ns-resize;
user-select: none;
@@ -452,45 +456,173 @@
background: rgba(225, 239, 255, 0.18);
}
.earth-mobile-drawer-tabs {
.earth-mobile-drawer-nav {
order: 3;
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
padding-bottom: 12px;
margin-top: 8px;
padding-top: 10px;
border-top: 1px solid rgba(214, 231, 247, 0.08);
}
.earth-mobile-drawer-nav-copy {
display: none;
}
.earth-mobile-drawer-nav-kicker {
color: rgba(167, 194, 223, 0.58);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.earth-mobile-drawer-nav-title {
color: var(--hud-title);
font-size: 0.92rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.earth-mobile-drawer-tabs-shell {
position: relative;
padding: 8px 8px 2px;
border: 1px solid rgba(207, 226, 245, 0.06);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)),
rgba(6, 16, 31, 0.32);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 10px 22px rgba(2, 8, 18, 0.12);
overflow: hidden;
}
.earth-mobile-drawer-tabs {
display: flex;
align-items: stretch;
gap: 10px;
padding: 2px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
}
.earth-mobile-drawer-tabs-fade {
position: absolute;
top: 8px;
bottom: 8px;
width: 22px;
z-index: 2;
pointer-events: none;
}
.earth-mobile-drawer-tabs-fade--left {
left: 0;
background: linear-gradient(90deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0));
}
.earth-mobile-drawer-tabs-fade--right {
right: 0;
background: linear-gradient(270deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0));
}
.earth-mobile-drawer-tab {
min-height: 40px;
border: 1px solid rgba(201, 225, 247, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
flex: 0 0 auto;
min-width: 68px;
min-height: 58px;
padding: 8px 11px 9px;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid rgba(201, 225, 247, 0.06);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015)),
rgba(7, 18, 34, 0.38);
color: var(--hud-text-muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
scroll-snap-align: start;
position: relative;
z-index: 1;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
}
.earth-mobile-drawer-tab.is-active {
color: var(--hud-title);
background: rgba(122, 180, 255, 0.14);
border-color: rgba(122, 180, 255, 0.24);
background:
linear-gradient(180deg, rgba(111, 174, 255, 0.18), rgba(74, 126, 210, 0.08)),
rgba(10, 26, 52, 0.58);
border-color: rgba(122, 180, 255, 0.22);
box-shadow:
0 8px 18px rgba(11, 22, 40, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.earth-mobile-drawer-tab:hover {
color: var(--hud-text);
border-color: rgba(201, 225, 247, 0.14);
}
.earth-mobile-drawer-tabs::-webkit-scrollbar {
display: none;
}
.earth-mobile-drawer-tab-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-size: 17px;
color: rgba(221, 235, 248, 0.88);
background: rgba(255, 255, 255, 0.06);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.earth-mobile-drawer-tab.is-active .earth-mobile-drawer-tab-icon {
color: #f6fbff;
background:
linear-gradient(180deg, rgba(161, 207, 255, 0.26), rgba(95, 154, 237, 0.18)),
rgba(255, 255, 255, 0.08);
}
.earth-mobile-drawer-tab-label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
}
.earth-mobile-drawer-content {
order: 2;
flex: 1 1 auto;
position: relative;
min-height: 0;
max-height: calc(78vh - 108px - var(--safe-bottom));
max-height: none;
overflow: hidden;
}
.earth-mobile-drawer-slot {
display: none;
min-height: 0;
max-height: inherit;
height: 100%;
max-height: none;
overflow: auto;
overscroll-behavior: contain;
padding-bottom: 6px;
}
.earth-mobile-drawer-slot.is-active {
@@ -615,6 +747,14 @@
min-height: 0;
}
.earth-mobile-page--tv {
gap: 8px;
}
.earth-mobile-page--tv .earth-mobile-page-intro {
gap: 2px;
}
.earth-mobile-page-intro {
display: flex;
flex-direction: column;
@@ -634,6 +774,11 @@
line-height: 1.4;
}
.earth-mobile-page--tv .earth-mobile-page-summary {
font-size: 0.72rem;
line-height: 1.3;
}
.earth-mobile-layer-list,
.earth-mobile-news-board-list,
.earth-mobile-search-results {
@@ -765,6 +910,10 @@
gap: 10px;
}
.earth-mobile-page--situation .earth-mobile-stats-grid > * {
min-width: 0;
}
.earth-mobile-stat-card,
.earth-mobile-situation-card,
.earth-mobile-news-focus,
@@ -785,12 +934,23 @@
gap: 6px;
}
.earth-mobile-page--situation .earth-mobile-stat-card {
min-width: 0;
padding: 12px 12px;
gap: 4px;
}
.earth-mobile-stat-num {
color: var(--hud-title);
font-size: 1.26rem;
font-weight: 700;
}
.earth-mobile-page--situation .earth-mobile-stat-num {
font-size: 1.08rem;
line-height: 1.1;
}
.earth-mobile-stat-label,
.earth-mobile-situation-card-subtitle,
.earth-mobile-news-focus-kicker,
@@ -802,6 +962,52 @@
text-transform: uppercase;
}
.earth-mobile-page--situation .earth-mobile-stat-label {
font-size: 0.64rem;
letter-spacing: 0.04em;
line-height: 1.25;
white-space: normal;
word-break: break-word;
}
@media (orientation: landscape) and (max-height: 540px) {
.earth-mobile-page--situation {
gap: 8px;
}
.earth-mobile-page--situation .earth-mobile-page-intro {
gap: 1px;
}
.earth-mobile-page--situation .earth-mobile-page-kicker {
font-size: 0.6rem;
}
.earth-mobile-page--situation .earth-mobile-page-summary {
font-size: 0.68rem;
line-height: 1.2;
}
.earth-mobile-page--situation .earth-mobile-stats-grid {
gap: 8px;
}
.earth-mobile-page--situation .earth-mobile-stat-card {
padding: 10px 10px;
gap: 3px;
border-radius: 15px;
}
.earth-mobile-page--situation .earth-mobile-stat-num {
font-size: 0.98rem;
}
.earth-mobile-page--situation .earth-mobile-stat-label {
font-size: 0.6rem;
line-height: 1.15;
}
}
.earth-mobile-situation-card {
display: flex;
flex-direction: column;
@@ -857,17 +1063,124 @@
width: 100%;
}
.earth-mobile-tv-overview {
border-radius: 14px;
border: 1px solid rgba(212, 227, 244, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent),
rgba(255, 255, 255, 0.03);
padding: 8px 10px;
}
.earth-mobile-tv-overview-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
}
.earth-mobile-tv-overview-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.earth-mobile-tv-overview-kicker {
color: var(--hud-text-muted);
font-size: 0.58rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.earth-mobile-tv-overview-headline {
color: var(--hud-title);
font-size: 0.84rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.earth-mobile-tv-overview-summary {
color: var(--hud-text-soft);
font-size: 0.66rem;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.earth-mobile-tv-overview-tags {
display: flex;
flex-wrap: nowrap;
gap: 4px;
margin-top: 1px;
overflow: hidden;
}
.earth-mobile-tv-overview-tag {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 6px;
border-radius: 999px;
border: 1px solid rgba(212, 227, 244, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--hud-text);
font-size: 0.62rem;
line-height: 1;
white-space: nowrap;
}
.earth-mobile-tv-overview-tag--status {
color: var(--hud-accent-strong);
border-color: rgba(122, 180, 255, 0.2);
background: rgba(122, 180, 255, 0.12);
}
.earth-mobile-tv-overview-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.earth-mobile-tv-overview-bar:focus-visible {
outline: 2px solid rgba(122, 180, 255, 0.5);
outline-offset: 4px;
border-radius: 12px;
}
.earth-mobile-tv-meta-wrap {
overflow: hidden;
max-height: 240px;
opacity: 1;
margin-top: 10px;
transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease;
}
.earth-mobile-tv-meta-wrap.is-collapsed {
max-height: 0;
opacity: 0;
pointer-events: none;
margin-top: 0;
}
.earth-mobile-tv-select {
border: 1px solid rgba(201, 225, 247, 0.14);
border-radius: 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--hud-text);
padding: 12px 14px;
padding: 10px 12px;
}
.earth-mobile-tv-player {
position: relative;
min-height: 220px;
aspect-ratio: 16 / 9;
min-height: 0;
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(201, 225, 247, 0.1);
@@ -912,6 +1225,28 @@
font-weight: 600;
}
.earth-mobile-action-btn:disabled {
opacity: 0.42;
cursor: default;
}
.earth-mobile-action-btn--compact {
min-width: 32px;
min-height: 32px;
padding: 0;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
border-color: rgba(212, 227, 244, 0.14);
color: var(--hud-text);
flex: 0 0 auto;
}
.earth-mobile-action-btn--compact .material-symbols-rounded {
font-size: 0.92rem;
}
.earth-mobile-action-btn--ghost {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(212, 227, 244, 0.1);
@@ -961,6 +1296,43 @@
background: rgba(122, 180, 255, 0.14);
}
.earth-mobile-settings-chip-group {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.earth-mobile-settings-chip {
border: 1px solid rgba(212, 227, 244, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--hud-text-soft);
padding: 9px 14px;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.earth-mobile-settings-chip:hover {
color: var(--hud-text);
transform: translateY(-1px);
}
.earth-mobile-settings-chip.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.24);
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
linear-gradient(180deg, rgba(122, 180, 255, 0.16), rgba(82, 123, 186, 0.22));
}
.earth-mobile-settings-switch {
position: relative;
display: inline-flex;
@@ -1042,6 +1414,81 @@
gap: 10px;
}
.earth-mobile-news-detail {
display: grid;
gap: 12px;
}
.earth-mobile-news-detail-kicker {
color: rgba(255, 215, 122, 0.82);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.earth-mobile-news-detail-title {
color: var(--hud-title);
font-size: 1rem;
line-height: 1.5;
font-weight: 700;
}
.earth-mobile-news-detail-summary-shell {
position: relative;
padding: 12px 13px;
border: 1px solid rgba(255, 215, 122, 0.12);
background:
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
rgba(7, 15, 29, 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 10px 28px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.earth-mobile-news-detail-summary-shell::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
opacity: 0.42;
transform: translateX(-100%);
animation: infoCardNewsScan 3.2s linear infinite;
pointer-events: none;
}
.earth-mobile-news-detail-summary-label {
color: rgba(188, 212, 238, 0.72);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
margin-bottom: 7px;
}
.earth-mobile-news-detail-summary {
color: #d7e6f7;
font-size: 0.9rem;
line-height: 1.72;
min-height: 5.2em;
white-space: pre-wrap;
word-break: break-word;
}
.earth-mobile-news-detail-summary.is-typing::after {
content: "";
display: inline-block;
width: 0.58em;
height: 1.05em;
margin-left: 0.16em;
vertical-align: -0.14em;
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
}
.earth-mobile-detail-row {
display: flex;
flex-direction: column;
@@ -1065,14 +1512,28 @@
.layout-mode-mobile .earth-status-message,
.layout-mode-mobile .earth-error-message {
top: calc(var(--safe-top) + 130px);
position: fixed;
top: calc(var(--safe-top) + 10px);
left: auto;
right: calc(8px + var(--safe-right));
transform: translate(0, -10px);
right: calc(10px + var(--safe-right));
transform: translate(12px, 0);
min-width: 0;
max-width: min(200px, 52vw);
font-size: 0.78rem;
padding: 6px 14px 6px 10px;
max-width: min(220px, 46vw);
border-radius: 16px;
font-size: 0.74rem;
line-height: 1.28;
padding: 7px 12px 7px 10px;
gap: 8px;
border-color: rgba(214, 230, 247, 0.1);
border-left-color: transparent;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06), transparent 52%),
linear-gradient(180deg, rgba(17, 29, 46, 0.92), rgba(7, 14, 24, 0.9));
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.22),
0 0 0 1px rgba(255, 255, 255, 0.03);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.layout-mode-mobile .earth-status-message.visible,
@@ -1080,6 +1541,34 @@
transform: translate(0, 0);
}
.layout-mode-mobile .earth-error-message {
top: calc(var(--safe-top) + 60px);
}
.layout-mode-mobile .earth-status-indicator {
gap: 4px;
}
.layout-mode-mobile .earth-status-dot {
width: 6px;
height: 6px;
box-shadow:
0 0 6px rgba(145, 186, 255, 0.48),
0 0 14px rgba(145, 186, 255, 0.16);
}
.layout-mode-mobile .earth-status-text {
font-weight: 600;
}
.layout-mode-mobile .earth-status-message.loading {
max-width: min(240px, 52vw);
}
.layout-mode-mobile .earth-status-message.loading .earth-status-text {
color: rgba(232, 242, 252, 0.92);
}
.hud-panel-row {
display: flex;
justify-content: space-between;
@@ -1754,6 +2243,50 @@
0 8px 18px rgba(0, 0, 0, 0.2);
}
.earth-settings-chip-group {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
align-self: flex-start;
}
.earth-settings-chip {
border: 1px solid rgba(212, 227, 244, 0.1);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
rgba(255, 255, 255, 0.025);
color: var(--hud-text-soft);
padding: calc(6px * var(--hud-scale)) calc(12px * var(--hud-scale));
font: inherit;
font-size: calc(0.7rem * var(--hud-scale));
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.earth-settings-chip:hover {
color: var(--hud-text);
transform: translateY(-1px);
}
.earth-settings-chip.is-active {
color: var(--hud-title);
border-color: rgba(122, 180, 255, 0.24);
background:
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(0, 0, 0, 0.16);
}
.earth-settings-slider {
flex: 1 1 auto;
width: 100%;

View File

@@ -381,6 +381,113 @@
}
.info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); }
.info-card.news .info-card-header {
background: rgba(255, 196, 92, 0.12);
border-bottom-color: rgba(255, 196, 92, 0.16);
}
.info-card.news .info-card-header h3 { color: #ffd77a; }
.info-card.news .info-card-content {
padding-top: calc(10px * var(--hud-scale));
padding-bottom: calc(12px * var(--hud-scale));
}
.info-card-news-layout {
display: grid;
gap: calc(10px * var(--hud-scale));
}
.info-card-news-kicker {
color: rgba(255, 215, 122, 0.82);
font-size: calc(0.62rem * var(--hud-scale));
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.info-card-news-title {
color: var(--hud-title);
font-size: calc(0.96rem * var(--hud-scale));
line-height: 1.45;
font-weight: 700;
text-wrap: balance;
}
.info-card-news-summary-shell {
position: relative;
padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale));
border: 1px solid rgba(255, 215, 122, 0.12);
background:
linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)),
radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%),
rgba(7, 15, 29, 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 10px 28px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.info-card-news-summary-shell::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%);
opacity: 0.42;
transform: translateX(-100%);
animation: infoCardNewsScan 3.2s linear infinite;
pointer-events: none;
}
.info-card-news-summary-label {
color: rgba(188, 212, 238, 0.72);
font-size: calc(0.6rem * var(--hud-scale));
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
margin-bottom: calc(6px * var(--hud-scale));
}
.info-card-news-summary {
position: relative;
color: #d7e6f7;
font-size: calc(0.8rem * var(--hud-scale));
line-height: 1.65;
min-height: calc(4.8em * var(--hud-scale));
white-space: pre-wrap;
word-break: break-word;
}
.info-card-news-summary.is-typing::after {
content: "";
display: inline-block;
width: 0.58em;
height: 1.05em;
margin-left: 0.16em;
vertical-align: -0.14em;
background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78));
box-shadow: 0 0 10px rgba(255, 215, 122, 0.28);
animation: infoCardNewsCaret 0.9s steps(1, end) infinite;
}
@keyframes infoCardNewsScan {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
@keyframes infoCardNewsCaret {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
/* ── Layout-expanded: slide left column off-screen ────────────── */
.earth-app.layout-expanded .earth-left-column {

View File

@@ -135,6 +135,15 @@
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
}
.news-story-card--cruise {
border-color: rgba(255, 213, 128, 0.42);
background:
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
box-shadow:
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
0 0 18px rgba(255, 184, 77, 0.12);
}
.news-story-meta,
.news-story-tags {
display: flex;

View File

@@ -475,13 +475,41 @@
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
</div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
<div class="earth-mobile-drawer-nav">
<div class="earth-mobile-drawer-nav-copy">
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
<span class="earth-mobile-drawer-nav-title">模块切换</span>
</div>
<div class="earth-mobile-drawer-tabs-shell">
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--left" aria-hidden="true"></div>
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
<span class="earth-mobile-drawer-tab-label">图层</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
<span class="earth-mobile-drawer-tab-label">搜索</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
<span class="earth-mobile-drawer-tab-label">态势</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
<span class="earth-mobile-drawer-tab-label">新闻</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
<span class="earth-mobile-drawer-tab-label">TV</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
<span class="earth-mobile-drawer-tab-label">设置</span>
</button>
</div>
</div>
</div>
<div class="earth-mobile-drawer-content">
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
@@ -589,13 +617,6 @@
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
@@ -608,9 +629,32 @@
></iframe>
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
</div>
<div class="earth-mobile-tv-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
<div id="mobile-tv-overview" class="earth-mobile-tv-overview">
<div id="mobile-tv-overview-bar" class="earth-mobile-tv-overview-bar" role="button" tabindex="0" aria-expanded="false" aria-controls="mobile-tv-meta-wrap">
<div class="earth-mobile-tv-overview-copy">
<span class="earth-mobile-tv-overview-kicker">频道信息</span>
<span id="mobile-tv-overview-headline" class="earth-mobile-tv-overview-headline">暂无可用频道</span>
<span id="mobile-tv-overview-summary" class="earth-mobile-tv-overview-summary">展开查看当前频道来源、目录和补充说明</span>
<div id="mobile-tv-overview-tags" class="earth-mobile-tv-overview-tags" aria-label="频道摘要标签"></div>
</div>
<div class="earth-mobile-tv-overview-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="刷新频道列表" title="刷新频道列表">
<span class="material-symbols-rounded" aria-hidden="true">refresh</span>
</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="访问频道官网" title="访问频道官网">
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
</button>
</div>
</div>
<div id="mobile-tv-meta-wrap" class="earth-mobile-tv-meta-wrap">
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
</div>
</div>
</div>
</section>
@@ -625,13 +669,23 @@
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">巡航模块</span>
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP新闻可按需加入。</span>
</div>
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
@@ -776,7 +830,7 @@
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">旋转模式</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
<button
@@ -797,6 +851,30 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">巡航模块</span>
<span class="earth-settings-item-subtitle">选择哪些业务模块参与巡航队列。默认 BGP新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
</div>
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
<button
type="button"
class="earth-settings-chip is-active"
data-cruise-module-toggle="bgp"
aria-pressed="true"
>
BGP
</button>
<button
type="button"
class="earth-settings-chip"
data-cruise-module-toggle="news"
aria-pressed="false"
>
新闻
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">

View File

@@ -0,0 +1,96 @@
import { PATHS } from "./constants.js";
const RECENT_EVENT_TTL_MS = 15_000;
const recentEventMap = new Map();
function normalizeErrorDetail(detail) {
if (!detail) return "";
if (detail instanceof Error) {
return detail.stack || detail.message || String(detail);
}
if (typeof detail === "string") {
return detail;
}
try {
return JSON.stringify(detail);
} catch {
return String(detail);
}
}
function dedupeKey(level, message, detail, category) {
return `${level}::${category || ""}::${message}::${detail}`;
}
function shouldSkip(level, message, detail, category) {
const key = dedupeKey(level, message, detail, category);
const now = Date.now();
const lastSeenAt = recentEventMap.get(key);
recentEventMap.set(key, now);
for (const [entryKey, entryTime] of recentEventMap.entries()) {
if (now - entryTime > RECENT_EVENT_TTL_MS) {
recentEventMap.delete(entryKey);
}
}
return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS;
}
export async function reportEarthClientLog({
level = "error",
message,
category = "runtime",
module = "earth",
detail = "",
}) {
if (!message) return;
const normalizedDetail = normalizeErrorDetail(detail);
if (shouldSkip(level, message, normalizedDetail, category)) {
return;
}
try {
await fetch(PATHS.earthClientLogsApi, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
level,
message,
category,
module,
url: window.location.href,
detail: normalizedDetail.slice(0, 4000),
}),
keepalive: true,
});
} catch {
// Swallow reporting failures to avoid recursive log noise.
}
}
export function registerEarthClientErrorHandlers() {
window.addEventListener("error", (event) => {
console.error("全局错误:", event.error);
void reportEarthClientLog({
level: "error",
category: "window-error",
module: "main",
message: event.message || "Earth 页面发生未捕获错误",
detail: event.error || `${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`,
});
});
window.addEventListener("unhandledrejection", (event) => {
console.error("未处理的 Promise 错误:", event.reason);
void reportEarthClientLog({
level: "error",
category: "unhandledrejection",
module: "main",
message: "Earth 页面发生未处理 Promise 错误",
detail: event.reason,
});
});
}

View File

@@ -18,6 +18,13 @@ export const ROTATION_MODE = {
CRUISE: "cruise",
};
export const CRUISE_MODULES = {
BGP: "bgp",
NEWS: "news",
};
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
export const CRUISE_CONFIG = {
dwellMs: 7_000,
focusDurationMs: 1_400,
@@ -160,6 +167,7 @@ export const PATHS = {
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
earthClientLogsApi: '/api/v1/system/logs/earth-client',
};
export const COMPUTE_CENTER_CONFIG = {

View File

@@ -1,7 +1,13 @@
// controls.js - Zoom, rotate and toggle controls
import * as THREE from "three";
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
import {
CONFIG,
CRUISE_MODULES,
DEFAULT_CRUISE_MODULES,
EARTH_CONFIG,
ROTATION_MODE,
} from "./constants.js";
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
import {
toggleTerrain,
@@ -109,6 +115,7 @@ let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
function detectLayoutMode() {
const width = window.innerWidth;
@@ -633,6 +640,7 @@ function getCurrentPanelVisibilitySnapshot() {
function getCurrentSharedSettingsSnapshot() {
return {
rotationMode,
cruiseModules: getCruiseModules(),
layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
),
@@ -667,6 +675,7 @@ function cloneEarthSettings(settings) {
version: 2,
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
terrainOpacity: settings.shared.terrainOpacity,
dayNightEnabled: settings.shared.dayNightEnabled,
defaultEarthZoom: settings.shared.defaultEarthZoom,
@@ -738,6 +747,14 @@ function normalizeEarthSettings(rawSettings, defaults) {
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
: defaults.shared.rotationMode;
const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules)
? sharedSettings.cruiseModules
: defaults.shared.cruiseModules;
const nextCruiseModules = Array.from(
new Set(
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
),
);
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? sharedSettings.dayNightEnabled
@@ -750,6 +767,9 @@ function normalizeEarthSettings(rawSettings, defaults) {
version: 2,
shared: {
rotationMode: nextRotationMode,
cruiseModules: nextCruiseModules.length > 0
? nextCruiseModules
: [...DEFAULT_CRUISE_MODULES],
layerVisibility: normalizedLayerVisibility,
terrainOpacity: Number.isFinite(nextTerrainOpacity)
? nextTerrainOpacity
@@ -832,6 +852,79 @@ function persistEarthSettings() {
}
}
function dispatchCruiseModulesChange() {
window.dispatchEvent(
new CustomEvent("earth:cruise-modules-change", {
detail: {
modules: getCruiseModules(),
},
}),
);
}
function normalizeCruiseModules(nextModules) {
const sourceModules = Array.isArray(nextModules) ? nextModules : DEFAULT_CRUISE_MODULES;
const normalizedModules = Array.from(
new Set(sourceModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId))),
);
return normalizedModules.length > 0
? normalizedModules
: [...DEFAULT_CRUISE_MODULES];
}
function syncCruiseModuleControls() {
const enabledModules = new Set(getCruiseModules());
document.querySelectorAll("[data-cruise-module-toggle]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const moduleId = button.dataset.cruiseModuleToggle || "";
const active = enabledModules.has(moduleId);
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
}
export function isCruiseModuleEnabled(moduleId) {
return getCruiseModules().includes(moduleId);
}
export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) {
const normalizedModules = normalizeCruiseModules(nextModules);
const previousModules = getCruiseModules();
const changed =
normalizedModules.length !== previousModules.length ||
normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId);
if (!changed) {
syncCruiseModuleControls();
return normalizedModules;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
earthSettingsState.shared.cruiseModules = [...normalizedModules];
syncCruiseModuleControls();
dispatchCruiseModulesChange();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const labels = normalizedModules.map((moduleId) =>
moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP",
);
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
}
return normalizedModules;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -894,6 +987,7 @@ async function applyEarthSettings(settings) {
});
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true });
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -1702,6 +1796,7 @@ function setupSettingsControls() {
const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]");
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
@@ -1756,6 +1851,24 @@ function setupSettingsControls() {
});
});
cruiseModuleButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const moduleId = target.dataset.cruiseModuleToggle;
if (!moduleId) return;
const currentModules = new Set(getCruiseModules());
if (currentModules.has(moduleId)) {
currentModules.delete(moduleId);
} else {
currentModules.add(moduleId);
}
setCruiseModules(Array.from(currentModules));
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
@@ -1772,6 +1885,7 @@ function setupSettingsControls() {
applyEarthSettings(loadEarthSettings());
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
syncDayNightToggle(dayNightEnabled);
}

View File

@@ -201,7 +201,6 @@ export function createClouds(scene, earthObj) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
const material = new THREE.MeshPhongMaterial({
transparent: true,
linewidth: 2,
opacity: 0.15,
depthTest: true,
depthWrite: false,
@@ -239,7 +238,6 @@ export function createTerrain(earthObj) {
specular: TERRAIN_CONFIG.specular,
shininess: TERRAIN_CONFIG.shininess,
vertexColors: true,
vertexAlphas: true,
transparent: true,
opacity: TERRAIN_CONFIG.opacity,
flatShading: false,

View File

@@ -3,6 +3,186 @@ import { showStatusMessage } from './ui.js';
let currentType = null;
let cardMounted = false;
let typewriterTimerId = null;
let typewriterToken = 0;
let pendingMobileDetailState = null;
let mobileDetailsListenerBound = false;
let renderedMobileDetailKey = null;
function getNewsSummaryText(data) {
return (data?.summary || data?.title || '').trim() || '暂无摘要';
}
function getNewsSummaryPreview(data, maxLength = 34) {
const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim();
if (text.length <= maxLength) return text;
return `${text.slice(0, Math.max(0, maxLength - 1))}`;
}
function stopTypewriterAnimation() {
typewriterToken += 1;
if (typewriterTimerId) {
window.clearTimeout(typewriterTimerId);
typewriterTimerId = null;
}
}
function startTypewriterAnimation(target, text, options = {}) {
if (!(target instanceof HTMLElement)) return;
stopTypewriterAnimation();
const content = typeof text === 'string' ? text : '';
const token = typewriterToken;
const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22;
const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90;
target.textContent = '';
target.classList.add('is-typing');
let index = 0;
const tick = () => {
if (token !== typewriterToken) return;
index += 1;
target.textContent = content.slice(0, index);
if (index < content.length) {
typewriterTimerId = window.setTimeout(tick, stepMs);
return;
}
target.classList.remove('is-typing');
typewriterTimerId = null;
};
typewriterTimerId = window.setTimeout(() => {
if (token !== typewriterToken) return;
if (!content) {
target.classList.remove('is-typing');
typewriterTimerId = null;
return;
}
tick();
}, startDelayMs);
}
function renderNewsCardContent(content, data) {
if (!(content instanceof HTMLElement)) return;
const summary = getNewsSummaryText(data);
content.innerHTML = `
<div class="info-card-news-layout">
<div class="info-card-news-kicker">NEWS SIGNAL</div>
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
<div class="info-card-news-summary-shell">
<div class="info-card-news-summary-label">SUMMARY</div>
<div class="info-card-news-summary" data-news-summary></div>
</div>
</div>
`;
const summaryEl = content.querySelector('[data-news-summary]');
startTypewriterAnimation(summaryEl, summary);
}
function renderMobileNewsCardContent(content, data) {
if (!(content instanceof HTMLElement)) return;
const summary = getNewsSummaryText(data);
content.innerHTML = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
</div>
</div>
`;
const summaryEl = content.querySelector('[data-news-summary]');
startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 });
}
function renderMobileDetailContent(type, config, data) {
const content = document.getElementById('mobile-info-card-content');
if (!(content instanceof HTMLElement)) return;
stopTypewriterAnimation();
if (type === 'news') {
renderMobileNewsCardContent(content, data);
return;
}
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="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
}
function getMobileDetailRenderKey(type, data) {
if (type !== 'news') return null;
return [
type,
data?.id ?? '',
data?.url ?? '',
data?.published_at ?? '',
data?.title ?? '',
].join('|');
}
function ensureMobileDetailsListener() {
if (mobileDetailsListenerBound) return;
mobileDetailsListenerBound = true;
window.addEventListener('earth:open-details-tab', () => {
if (!document.body.classList.contains('layout-mode-mobile')) return;
if (!pendingMobileDetailState) return;
const nextKey = getMobileDetailRenderKey(
pendingMobileDetailState.type,
pendingMobileDetailState.data,
);
if (nextKey && nextKey === renderedMobileDetailKey) return;
renderMobileDetailContent(
pendingMobileDetailState.type,
pendingMobileDetailState.config,
pendingMobileDetailState.data,
);
renderedMobileDetailKey = nextKey;
});
}
function renderDefaultCardContent(content, config, 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>
<span class="info-card-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
}
// ── Mobile popup ─────────────────────────────────────────────
@@ -12,6 +192,7 @@ function getMobilePopupTitle(type, data) {
case 'landing_point': return data.name || '登陆点';
case 'satellite': return data.name || '卫星';
case 'bgp': return data.anomaly_type || 'BGP事件';
case 'news': return data.title || '新闻事件';
case 'bgp_collector': return data.collector || 'BGP观测站';
case 'supercomputer': return data.name || '超算';
case 'gpu_cluster': return data.name || 'GPU集群';
@@ -25,6 +206,7 @@ function getMobilePopupSubtitle(type, data) {
case 'landing_point': return data.country || '登陆点';
case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
case 'bgp': return data.severity || 'BGP路由异常';
case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻';
case 'bgp_collector': return data.location || 'BGP观测站';
case 'supercomputer': return data.country || '超级计算机';
case 'gpu_cluster': return data.country || 'GPU集群';
@@ -280,6 +462,20 @@ const CARD_CONFIG = {
{ key: 'summary', label: '摘要' }
]
},
news: {
icon: '📰',
title: '新闻事件详情',
className: 'news',
fields: [
{ key: 'source', label: '来源' },
{ key: 'published_at_display', label: '发布时间' },
{ key: 'location_label', label: '发生地' },
{ key: 'region_label', label: '区域' },
{ key: 'feed_name', label: '聚合源' },
{ key: 'summary', label: '摘要' },
{ key: 'url', label: '原文链接' }
]
},
bgp_collector: {
icon: '📍',
title: 'BGP观测站详情',
@@ -616,6 +812,8 @@ export function showInfoCard(type, data, options = {}) {
if (document.body.classList.contains('layout-mode-mobile')) {
currentType = type;
pendingMobileDetailState = { type, config, data };
ensureMobileDetailsListener();
// Fill drawer details slot (accessible when user taps popup → opens details tab)
const icon = document.getElementById('mobile-info-card-icon');
@@ -624,27 +822,20 @@ export function showInfoCard(type, data, options = {}) {
const content = document.getElementById('mobile-info-card-content');
if (icon) icon.textContent = config.icon;
if (title) title.textContent = config.title;
if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' ');
if (title) {
title.textContent = type === 'news'
? (data?.title || '新闻事件')
: config.title;
}
if (typeLabel) {
typeLabel.textContent = type === 'news'
? 'news signal'
: type.replaceAll('_', ' ');
}
if (content) {
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="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
if (content && type !== 'news') {
renderMobileDetailContent(type, config, data);
renderedMobileDetailKey = null;
}
// Show the floating mini popup near the touch point (requires coordinates)
@@ -667,37 +858,23 @@ export function showInfoCard(type, data, options = {}) {
const title = document.getElementById('info-card-title');
const content = document.getElementById('info-card-content');
stopTypewriterAnimation();
card.className = 'info-card ' + config.className;
icon.textContent = config.icon;
title.textContent = config.title;
title.textContent = type === 'news'
? (data?.title || '新闻事件')
: config.title;
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>
<span class="info-card-value">${value}</span>
</div>
`;
if (type === 'news') {
renderNewsCardContent(content, data);
} else {
renderDefaultCardContent(content, config, data);
}
content.innerHTML = html;
showPanel(options.x, options.y, options);
}
export function hideInfoCard() {
stopTypewriterAnimation();
if (document.body.classList.contains('layout-mode-mobile')) {
hideMobilePopup();
document.body.classList.remove('earth-info-open');
@@ -705,6 +882,8 @@ export function hideInfoCard() {
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
currentType = null;
pendingMobileDetailState = null;
renderedMobileDetailKey = null;
return;
}
hidePanel();

View File

@@ -2,6 +2,7 @@ import * as THREE from "three";
import {
CONFIG,
CRUISE_MODULES,
HUD_CONFIG,
CABLE_CONFIG,
CABLE_STATE,
@@ -147,6 +148,7 @@ import {
import {
setupControls,
getAutoRotate,
getCruiseModules,
getRotationMode,
getShowTerrain,
getStartupLoadLayers,
@@ -167,6 +169,7 @@ import {
import { CalloutConnector } from "./callout-connector.js";
import { CruiseSequencer } from "./cruise-sequencer.js";
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
import { createNewsCruiseAdapter } from "./news-cruise-adapter.js";
import {
initInfoCard,
showInfoCard,
@@ -182,6 +185,10 @@ import { mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js";
import {
registerEarthClientErrorHandlers,
reportEarthClientLog,
} from "./client-logs.js";
export let scene;
export let camera;
@@ -224,6 +231,7 @@ let sceneLights = null;
let cruisePollTimerId = null;
let calloutConnector = null;
let cruiseBGPAdapter = null;
let cruiseNewsAdapter = null;
let cruiseSequencer = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
@@ -1256,6 +1264,143 @@ function ensureBGPCruiseAdapter() {
return cruiseBGPAdapter;
}
function ensureNewsCruiseAdapter() {
if (cruiseNewsAdapter) return cruiseNewsAdapter;
cruiseNewsAdapter = createNewsCruiseAdapter({
camera,
earth: () => getEarth(),
connector: ensureCalloutConnector(),
focusView: (options) => focusEarthView(camera, options),
});
return cruiseNewsAdapter;
}
function getCruiseModuleDefinitions() {
return [
{
id: CRUISE_MODULES.BGP,
ensureAdapter: ensureBGPCruiseAdapter,
isQueueSourceActive: () => getShowBGP(),
getSortedQueueItems: () =>
ensureBGPCruiseAdapter()
.getSortedMarkers()
.map((marker) => {
const markerId = marker?.userData?.id;
if (!markerId) return null;
const parsed = marker?.userData?.created_at_raw || marker?.userData?.created_at;
const timestamp = parsed ? new Date(parsed).getTime() : 0;
return {
id: `bgp:${markerId}`,
moduleId: CRUISE_MODULES.BGP,
sortTimestamp: Number.isFinite(timestamp) ? timestamp : 0,
payload: marker,
};
})
.filter(Boolean),
focusQueueItem: (item, options = {}) =>
ensureBGPCruiseAdapter().focusMarker(item?.payload, options),
presentQueueItem: (item, options = {}) =>
ensureBGPCruiseAdapter().presentMarker(item?.payload, options),
hidePresentation: (options = {}) =>
ensureBGPCruiseAdapter().hidePresentation(options),
clearCurrentHighlight: () => ensureBGPCruiseAdapter().clearCurrentHighlight(),
resetPresentation: () => ensureBGPCruiseAdapter().resetPresentation(),
repositionPresentation: (item) =>
ensureBGPCruiseAdapter().repositionConnector(item?.payload),
syncKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(),
pollForNewQueueItemIds: async () => {
const nextIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
return nextIds.map((id) => `bgp:${id}`);
},
},
{
id: CRUISE_MODULES.NEWS,
ensureAdapter: ensureNewsCruiseAdapter,
isQueueSourceActive: () => true,
ensureItemsLoaded: () => ensureNewsCruiseAdapter().ensureItemsLoaded(),
getSortedQueueItems: () =>
ensureNewsCruiseAdapter()
.getSortedItems()
.map((item) => ({
...item,
moduleId: CRUISE_MODULES.NEWS,
payload: item,
})),
focusQueueItem: (item, options = {}) =>
ensureNewsCruiseAdapter().focusItem(item?.payload, options),
presentQueueItem: (item, options = {}) =>
ensureNewsCruiseAdapter().presentItem(item?.payload, options),
hidePresentation: (options = {}) =>
ensureNewsCruiseAdapter().hidePresentation(options),
clearCurrentHighlight: () => ensureNewsCruiseAdapter().clearCurrentHighlight(),
resetPresentation: () => ensureNewsCruiseAdapter().resetPresentation(),
repositionPresentation: (item) =>
ensureNewsCruiseAdapter().repositionConnector(item?.payload),
syncKnownEventIds: () => ensureNewsCruiseAdapter().syncKnownEventIds(),
externalEventName: "earth:news-payload-updated",
extractQueuedItemIdsFromEvent: (event) => {
const itemIds = Array.isArray(event?.detail?.itemIds) ? event.detail.itemIds : [];
if (itemIds.length === 0) return [];
const adapter = ensureNewsCruiseAdapter();
const newIds = adapter.diffNewEventIds(itemIds);
adapter.syncKnownEventIds();
return newIds;
},
},
];
}
function getCruiseModuleDefinition(moduleId) {
return getCruiseModuleDefinitions().find((module) => module.id === moduleId) || null;
}
function getEnabledCruiseModuleDefinitions() {
const enabledModuleIds = new Set(getCruiseModules());
return getCruiseModuleDefinitions().filter((module) => enabledModuleIds.has(module.id));
}
function sortCruiseQueueItems(items) {
return items.sort((left, right) => {
const timestampDiff = (right?.sortTimestamp || 0) - (left?.sortTimestamp || 0);
if (timestampDiff !== 0) return timestampDiff;
return String(left?.id || "").localeCompare(String(right?.id || ""));
});
}
function getCruiseQueueItemsSorted() {
const items = [];
getEnabledCruiseModuleDefinitions().forEach((module) => {
if (!module.isQueueSourceActive?.()) return;
items.push(...(module.getSortedQueueItems?.() || []));
});
return sortCruiseQueueItems(items);
}
function getCruiseModuleForItem(item) {
return getCruiseModuleDefinition(item?.moduleId);
}
function getCurrentCruiseBGPMarker() {
const item = cruiseSequencer?.getCurrentItem() ?? null;
return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null;
}
async function syncCruiseModuleKnownEventIds() {
const modules = getEnabledCruiseModuleDefinitions();
await Promise.all(
modules.map(async (module) => {
try {
await module.ensureItemsLoaded?.();
module.syncKnownEventIds?.();
} catch (error) {
console.warn(`同步巡航模块失败: ${module.id}`, error);
}
}),
);
}
function isCruisePresentationPinned() {
return cruiseSequencer?.isPresentationPinned() === true;
}
@@ -1265,22 +1410,23 @@ function setCruisePresentationVisible(visible) {
cruiseSequencer.setPresentationVisible(visible);
}
if (!visible) {
ensureBGPCruiseAdapter().resetPresentation();
getCruiseModuleDefinitions().forEach((module) => {
module.resetPresentation?.();
});
}
}
function clearCruiseMarkerHighlight() {
ensureBGPCruiseAdapter().clearCurrentHighlight();
}
function getCruiseMarkersSorted() {
return ensureBGPCruiseAdapter().getSortedMarkers();
function clearCruiseHighlights() {
getCruiseModuleDefinitions().forEach((module) => {
module.clearCurrentHighlight?.();
});
}
function repositionCruiseConnector() {
if (!isCruisePresentationPinned()) return;
const marker = cruiseSequencer?.getCurrentItem() ?? null;
ensureBGPCruiseAdapter().repositionConnector(marker);
const item = cruiseSequencer?.getCurrentItem() ?? null;
const module = getCruiseModuleForItem(item);
module?.repositionPresentation?.(item);
}
function isCruiseModeActive() {
@@ -1292,12 +1438,12 @@ function ensureCruiseSequencer() {
cruiseSequencer = new CruiseSequencer({
isActive: () => isCruiseModeActive() && getAutoRotate(),
getItems: () => getCruiseMarkersSorted(),
getItemId: (marker) => marker?.userData?.id || null,
getItems: () => getCruiseQueueItemsSorted(),
getItemId: (item) => item?.id || null,
dwellMs: CRUISE_CONFIG.dwellMs,
transitionGapMs: CRUISE_TRANSITION_GAP_MS,
clearCurrent: () => {
clearCruiseMarkerHighlight();
clearCruiseHighlights();
clearLockedObject();
hideInfoCard();
setCruisePresentationVisible(false);
@@ -1308,11 +1454,11 @@ function ensureCruiseSequencer() {
hideInfoCard();
}
},
focusItem: async (marker, { interrupt }) =>
ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }),
presentItem: async (marker, { context }) => {
focusItem: async (item, { interrupt }) =>
getCruiseModuleForItem(item)?.focusQueueItem?.(item, { interrupt }),
presentItem: async (item, { context }) => {
setCruisePresentationVisible(true);
const presented = await ensureBGPCruiseAdapter().presentMarker(marker, {
const presented = await getCruiseModuleForItem(item)?.presentQueueItem?.(item, {
context,
});
if (!presented) {
@@ -1320,8 +1466,8 @@ function ensureCruiseSequencer() {
}
return presented;
},
hideItem: async (_marker, { context }) => {
await ensureBGPCruiseAdapter().hidePresentation({ context });
hideItem: async (item, { context }) => {
await getCruiseModuleForItem(item)?.hidePresentation?.({ context });
setCruisePresentationVisible(false);
},
});
@@ -1347,10 +1493,23 @@ async function advanceCruiseEvent({ interrupt = false } = {}) {
}
async function pollCruiseEventsIfNeeded() {
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
if (!isCruiseModeActive() || !getAutoRotate()) return;
try {
const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
const modules = getEnabledCruiseModuleDefinitions().filter(
(module) => module.isQueueSourceActive?.() && typeof module.pollForNewQueueItemIds === "function",
);
const results = await Promise.all(
modules.map(async (module) => {
try {
return await module.pollForNewQueueItemIds();
} catch (error) {
console.warn(`巡航模式轮询失败: ${module.id}`, error);
return [];
}
}),
);
const newIds = results.flat();
if (newIds.length === 0) return;
ensureCruiseSequencer().enqueue(newIds);
@@ -1358,7 +1517,7 @@ async function pollCruiseEventsIfNeeded() {
await advanceCruiseEvent({ interrupt: true });
}
} catch (error) {
console.warn("巡航模式轮询 BGP 事件失败:", error);
console.warn("巡航模式轮询失败:", error);
}
}
@@ -1390,16 +1549,63 @@ function handleRotationModeChange(event) {
}
ensureCruisePolling();
ensureBGPCruiseAdapter().syncKnownEventIds();
if (!detailActive) {
stopCruiseMode({ preserveCard: true });
return;
}
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("启动巡航模式失败:", error);
syncCruiseModuleKnownEventIds()
.catch((error) => {
console.warn("同步巡航模块失败:", error);
})
.finally(() => {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("启动巡航模式失败:", error);
});
});
}
function handleCruiseModulesChange() {
interruptCruisePresentation({ resetLoop: true });
clearBGPSelection();
const syncPromise = syncCruiseModuleKnownEventIds().catch((error) => {
console.warn("刷新巡航模块失败:", error);
});
if (!isCruiseModeActive()) {
return;
}
if (!getAutoRotate()) {
stopCruiseMode({ preserveCard: true });
return;
}
syncPromise.finally(() => {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("切换巡航模块后推进失败:", error);
});
});
}
function handleCruiseModuleItemsUpdated(moduleId, event) {
const module = getCruiseModuleDefinition(moduleId);
if (!module) return;
const newIds = module.extractQueuedItemIdsFromEvent?.(event) || [];
if (newIds.length === 0) return;
if (!isCruiseModeActive() || !getAutoRotate() || !getCruiseModules().includes(moduleId)) {
return;
}
ensureCruiseSequencer().enqueue(newIds);
if (!ensureCruiseSequencer().isBusy()) {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn(`推进巡航模块失败: ${moduleId}`, error);
});
}
}
function clearSelectionAndInfo() {
@@ -1726,13 +1932,7 @@ function getCurrentViewCenterCoords() {
return vector3ToLatLon(scratchViewCenterWorld);
}
window.addEventListener("error", (event) => {
console.error("全局错误:", event.error);
});
window.addEventListener("unhandledrejection", (event) => {
console.error("未处理的 Promise 错误:", event.reason);
});
registerEarthClientErrorHandlers();
export function init() {
if (initialized && !destroyed) return;
@@ -1984,6 +2184,13 @@ async function loadData() {
getSatelliteHydrationToken: () => satelliteHydrationToken,
reportError: (label, reason) => {
errors.push({ label, reason });
void reportEarthClientLog({
level: "error",
category: "startup-load",
module: "layer-startup",
message: `${label}加载失败: ${reason?.message || String(reason)}`,
detail: reason,
});
},
});
@@ -2087,6 +2294,13 @@ export async function setCablesEnabled(
clearCableData(getEarth());
updateCableToggleUi(false);
const message = `线缆加载失败: ${error?.message || String(error)}`;
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "cables",
message,
detail: error,
});
if (!suppressLoadingUi) {
showError(message);
}
@@ -2133,6 +2347,13 @@ export async function setSatellitesEnabled(
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
const message = `卫星加载失败: ${error?.message || String(error)}`;
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "satellites",
message,
detail: error,
});
if (!suppressLoadingUi) {
showError(message);
}
@@ -2156,13 +2377,21 @@ function setupEventListeners() {
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
const handleRotationMode = (event) => handleRotationModeChange(event);
const handleCruiseModules = () => handleCruiseModulesChange();
const handleInfoCardDrag = () => repositionCruiseConnector();
bindListener(window, "resize", handleResize);
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(window, "earth:cruise-modules-change", handleCruiseModules);
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
getCruiseModuleDefinitions().forEach((module) => {
if (!module.externalEventName) return;
bindListener(window, module.externalEventName, (event) => {
handleCruiseModuleItemsUpdated(module.id, event);
});
});
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
bindListener(window, "pointermove", handlePointerMove);
bindListener(window, "pointerup", handlePointerUp);
@@ -2860,7 +3089,7 @@ function animate() {
applyCableVisualState();
const activeCruiseMarker =
isCruiseModeActive() && isCruisePresentationPinned()
? cruiseSequencer?.getCurrentItem() ?? null
? getCurrentCruiseBGPMarker()
: null;
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);

View File

@@ -0,0 +1,357 @@
import * as THREE from "three";
import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
import { showInfoCard, hideInfoCard } from "./info-card.js";
import { latLonToVector3 } from "./utils.js";
import {
createConnectorPath,
resolveConnectorAnchor,
} from "./callout-connector.js";
import {
ensureNewsPanelReady,
getNewsPayload,
selectNewsItem,
clearSelectedNewsItem,
} from "./news.js";
const CRUISE_PRESENTATION_HIDE_MS = 220;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
const CRUISE_CONNECTOR_DRAW_MS = 420;
const MOBILE_CARD_MARGIN_PX = 14;
const MOBILE_CARD_TOP_RATIO = 0.16;
const MOBILE_CARD_WIDTH_PX = 220;
const scratchNewsWorldPosition = new THREE.Vector3();
const REGION_LABELS = {
americas: "美洲",
europe: "欧洲",
"middle-east-africa": "中东与非洲",
"asia-pacific": "亚太",
global: "全球",
};
function getItemTimestamp(item) {
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
return Number.isFinite(parsed) ? parsed : 0;
}
function formatPublishedAt(rawValue) {
if (!rawValue) return "刚刚同步";
const parsed = new Date(rawValue);
if (Number.isNaN(parsed.getTime())) return "刚刚同步";
return parsed.toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function getCardPlacement() {
if (document.body.classList.contains("layout-mode-mobile")) {
const safeBottom =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2);
return {
x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX),
y: Math.max(
MOBILE_CARD_MARGIN_PX,
Math.min(
window.innerHeight * MOBILE_CARD_TOP_RATIO,
window.innerHeight - safeBottom - 120,
),
),
width,
};
}
const hudScale =
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
) || 1;
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
return {
x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5,
y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5,
width: estimatedCardWidth,
height: estimatedCardHeight,
};
}
function mapNewsItemToCruiseEvent(item) {
const latitude = Number(item?.latitude);
const longitude = Number(item?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
id: `news:${item.id}`,
sourceId: item.id,
type: "news",
title: item.title || "新闻事件",
summary: item.summary || "",
source: item.source || "",
feedName: item.feed_name || "",
region: item.region || "global",
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
url: item.url || "",
publishedAt: item.published_at || null,
publishedAtDisplay: formatPublishedAt(item.published_at),
latitude,
longitude,
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
sortTimestamp: getItemTimestamp(item),
};
}
export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) {
let currentItemId = null;
let knownEventIds = new Set();
let cardPlacement = null;
function getVisibleMobilePopup() {
const mobilePopup = document.getElementById("earth-mobile-popup");
return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")
? mobilePopup
: null;
}
function getVisibleInfoPanel() {
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
function getItemWorldPosition(item) {
const earthObj = earth?.();
if (!earthObj || !item) return null;
scratchNewsWorldPosition.copy(
latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3),
);
earthObj.localToWorld(scratchNewsWorldPosition);
return scratchNewsWorldPosition;
}
function getItemScreenCoords(item) {
if (!camera || !item) return null;
const worldPosition = getItemWorldPosition(item);
if (!worldPosition) return null;
const projected = worldPosition.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getCardAnchorTarget() {
const mobilePopup = getVisibleMobilePopup();
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
return {
element: mobilePopup,
side: mobilePopup.dataset.dockSide || "left",
alignRatio: 0.5,
};
}
return null;
}
function getCardObstacleTarget(fallbackPlacement = null) {
const mobilePopup = getVisibleMobilePopup();
if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) {
return { element: mobilePopup };
}
const infoPanel = getVisibleInfoPanel();
if (infoPanel) {
return { element: infoPanel };
}
if (fallbackPlacement) {
return {
x: fallbackPlacement.x,
y: fallbackPlacement.y,
width: fallbackPlacement.width ?? 0,
height: fallbackPlacement.height ?? 0,
};
}
return null;
}
function getConnectorPath(item) {
const itemCoords = getItemScreenCoords(item);
if (!itemCoords) return null;
const targetPlacement = cardPlacement || getCardPlacement();
if (document.body.classList.contains("layout-mode-mobile")) {
const anchorTarget = getCardAnchorTarget();
const anchorCoords = resolveConnectorAnchor(anchorTarget);
if (!anchorCoords) return null;
return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, {
routingMode: "adaptive",
obstacles: getCardObstacleTarget(targetPlacement),
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
});
}
const infoPanel = getVisibleInfoPanel();
const target =
infoPanel ||
{
x: targetPlacement.x,
y: targetPlacement.y,
width: targetPlacement.width,
height: targetPlacement.height,
};
return createConnectorPath(itemCoords, target, {
routingMode: "adaptive",
obstacles: getCardObstacleTarget(targetPlacement),
sourceGapPx: CONNECTOR_CONFIG.markerGapPx,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
});
}
function renderConnector(item, { animate = false } = {}) {
const path = getConnectorPath(item);
if (!path) return false;
return connector?.render(path, { animate }) === true;
}
function getSortedItems() {
const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : [];
return items
.map(mapNewsItemToCruiseEvent)
.filter(Boolean)
.sort((a, b) => b.sortTimestamp - a.sortTimestamp);
}
return {
async ensureItemsLoaded() {
await ensureNewsPanelReady();
},
getSortedItems,
clearCurrentHighlight() {
currentItemId = null;
clearSelectedNewsItem();
},
async focusItem(item, { interrupt = false } = {}) {
if (!item) return;
currentItemId = item.id;
await ensureNewsPanelReady();
await focusView({
lat: item.latitude,
lon: item.longitude,
rotLon: item.longitude - 270,
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
cardPlacement = getCardPlacement();
},
async presentItem(item, { context }) {
if (!item) return false;
selectNewsItem(item.sourceId);
const placement = cardPlacement || getCardPlacement();
cardPlacement = placement;
showInfoCard("news", {
title: item.title,
summary: item.summary || item.title || "",
}, {
x: placement.x,
y: placement.y,
absolute: true,
anchorStable: true,
reveal: false,
});
await context.nextFrame();
if (!context.isCurrent()) {
cardPlacement = null;
connector?.hide();
hideInfoCard();
return false;
}
const startedAt = performance.now();
let connectorReady = false;
while (context.isCurrent()) {
connectorReady = renderConnector(item, { animate: !connectorReady });
if (connectorReady) break;
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
break;
}
await context.nextFrame();
}
if (!connectorReady || !context.isCurrent()) {
cardPlacement = null;
connector?.hide();
hideInfoCard();
return false;
}
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
if (!connectorDelayCompleted || !context.isCurrent()) {
cardPlacement = null;
connector?.hide();
hideInfoCard();
return false;
}
showInfoCard("news", {
title: item.title,
summary: item.summary || item.title || "",
}, {
x: placement.x,
y: placement.y,
absolute: true,
anchorStable: true,
});
await context.nextFrame();
return context.isCurrent();
},
async hidePresentation({ context }) {
clearSelectedNewsItem();
connector?.hide();
hideInfoCard();
await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true });
cardPlacement = null;
},
repositionConnector(item) {
if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) {
return;
}
renderConnector(item, { animate: false });
},
resetPresentation() {
clearSelectedNewsItem();
cardPlacement = null;
connector?.hide();
},
syncKnownEventIds() {
knownEventIds = new Set(getSortedItems().map((item) => item.id));
return knownEventIds;
},
diffNewEventIds(itemIds = []) {
return itemIds
.map((itemId) => `news:${itemId}`)
.filter((itemId) => !knownEventIds.has(itemId));
},
};
}

View File

@@ -1,5 +1,5 @@
import { showStatusMessage } from "./ui.js";
import { isTVPanelVisible } from "./tv.js";
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
// News aggregation now lives inside the shared media panel:
// - outer shell: #media-panel
@@ -17,6 +17,7 @@ let payload = null;
let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
let selectedCruiseStoryId = null;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
@@ -154,7 +155,7 @@ function renderPayload(nextPayload) {
? `<div class="news-story-summary">${item.summary}</div>`
: "";
return `
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<div class="news-story-meta">
<span class="news-story-source">${item.source}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
@@ -169,6 +170,14 @@ function renderPayload(nextPayload) {
`;
})
.join("");
applyCruiseStorySelection();
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
detail: {
payload: nextPayload,
itemIds: items.map((item) => item.id),
},
}));
}
async function fetchNews(lat, lon) {
@@ -287,6 +296,44 @@ export async function ensureNewsPanelReady() {
return payload;
}
export function getNewsPayload() {
return payload;
}
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
if (!(board instanceof HTMLElement)) return;
const cards = board.querySelectorAll("[data-news-id]");
cards.forEach((card) => {
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
card.classList.toggle("news-story-card--cruise", matches);
if (matches && scrollIntoView) {
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
}
function applyCruiseStorySelection(options = {}) {
const desktopBoard = document.getElementById("news-board-list");
const mobileBoard = document.getElementById("mobile-news-board-list");
updateBoardSelection(desktopBoard, options);
updateBoardSelection(mobileBoard, options);
}
function revealSelectedCruiseStory() {
if (!selectedCruiseStoryId) return;
applyCruiseStorySelection({ scrollIntoView: true });
}
export function selectNewsItem(itemId, options = {}) {
selectedCruiseStoryId = itemId || null;
applyCruiseStorySelection(options);
}
export function clearSelectedNewsItem() {
selectedCruiseStoryId = null;
applyCruiseStorySelection();
}
export function initNewsPanel() {
if (initialized) return;
initialized = true;
@@ -296,9 +343,15 @@ export function initNewsPanel() {
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
if (getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
if (event.detail?.visible && getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
});
["news-refresh", "mobile-news-refresh"].forEach((id) => {

View File

@@ -36,6 +36,7 @@ const tabPanelState = {
live: null,
news: null,
};
let mobileMetaCollapsed = false;
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
@@ -72,8 +73,8 @@ function getElements() {
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"),
@@ -84,8 +85,43 @@ function getElements() {
};
}
function isMobileLayout() {
return document.body.classList.contains("layout-mode-mobile");
}
function syncMetaToggleState(collapsed) {
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
if (mobileOverviewBar instanceof HTMLElement) {
mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true");
}
const desktopToggle = document.getElementById("tv-meta-toggle");
if (desktopToggle instanceof HTMLButtonElement) {
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
desktopToggle.setAttribute(
"aria-label",
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
);
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
}
}
function isMetaCollapsed() {
if (isMobileLayout()) {
return mobileMetaCollapsed;
}
return mediaPanel?.isCollapsed() ?? false;
}
function setMetaCollapsed(collapsed) {
if (isMobileLayout()) {
const { metaWrap } = getElements();
mobileMetaCollapsed = Boolean(collapsed);
metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed);
syncMetaToggleState(mobileMetaCollapsed);
return;
}
mediaPanel?.setCollapsed(collapsed);
syncMetaToggleState(Boolean(collapsed));
}
function syncPanelActiveTab(tab = activeTab) {
@@ -120,6 +156,10 @@ function syncNewsDefaultMaxHeight() {
}
function autoExpandMeta() {
if (isMobileLayout()) {
clearTimeout(metaAutoCollapseTimer);
return;
}
clearTimeout(metaAutoCollapseTimer);
setMetaCollapsed(false);
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
@@ -154,7 +194,7 @@ function captureTabState(tab = activeTab) {
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
tab === "live" ? isMetaCollapsed() : null,
};
}
@@ -749,15 +789,59 @@ function getExternalUrl(source) {
}
function updateOpenButton(source) {
const { openBtn } = getElements();
if (!openBtn) return;
const targetUrl = getExternalUrl(source);
openBtn.disabled = !targetUrl;
openBtn.onclick = targetUrl
? () => {
window.open(targetUrl, "_blank", "noopener,noreferrer");
}
: null;
[
document.getElementById("mobile-tv-open-external"),
document.getElementById("tv-open-external"),
].forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
button.disabled = !targetUrl;
button.onclick = targetUrl
? () => {
window.open(targetUrl, "_blank", "noopener,noreferrer");
}
: null;
});
}
function syncMobileOverviewSummary(source) {
const headline = document.getElementById("mobile-tv-overview-headline");
const summary = document.getElementById("mobile-tv-overview-summary");
const tags = document.getElementById("mobile-tv-overview-tags");
if (headline instanceof HTMLElement) {
headline.textContent = source?.name || "暂无可用频道";
}
if (summary instanceof HTMLElement) {
if (!source) {
summary.textContent = "点击查看当前频道来源、目录和补充说明";
} else {
const parts = [
source.provider,
source.region,
source.language,
].filter(Boolean);
summary.textContent = parts.length
? parts.join(" · ")
: "点击查看完整频道信息";
}
}
if (tags instanceof HTMLElement) {
const tagValues = source
? [
{ label: source.source_type || "频道", kind: "status" },
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
source.region ? { label: source.region, kind: "" } : null,
].filter(Boolean).slice(0, 3)
: [{ label: "待加载", kind: "status" }];
tags.replaceChildren(
...tagValues.map(({ label, kind }) => {
const chip = document.createElement("span");
chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`;
chip.textContent = label;
return chip;
}),
);
}
}
function findSourceById(sourceId) {
@@ -926,6 +1010,7 @@ function renderSource(source) {
if (notes) {
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
}
syncMobileOverviewSummary(source);
if (!source || (!embeddedUrl && !videoUrl)) {
destroyHlsPlayer();
@@ -1031,6 +1116,7 @@ export function initTVPanel() {
liveTabBtn,
newsTabBtn,
} = getElements();
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
if (panel && metaToggle) {
mediaPanel = createHUDPanel({
@@ -1045,6 +1131,10 @@ export function initTVPanel() {
});
}
mobileMetaCollapsed = true;
syncMetaToggleState(isMetaCollapsed());
setMetaCollapsed(isMetaCollapsed());
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
@@ -1084,10 +1174,31 @@ export function initTVPanel() {
});
});
metaToggle?.addEventListener("click", () => {
[metaToggle, document.getElementById("tv-meta-toggle")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((toggleEl) => {
toggleEl?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !isMetaCollapsed();
setMetaCollapsed(isNowCollapsed);
});
});
const toggleMobileMeta = (event) => {
const interactiveTarget = event.target instanceof Element
? event.target.closest("button, a, select, input, textarea, video, iframe")
: null;
if (interactiveTarget) return;
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
const isNowCollapsed = !isMetaCollapsed();
setMetaCollapsed(isNowCollapsed);
};
mobileOverviewBar?.addEventListener("click", toggleMobileMeta);
mobileOverviewBar?.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggleMobileMeta(event);
});
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]