release: bump version to 0.60.0
This commit is contained in:
@@ -1290,6 +1290,53 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tab {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
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: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
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-tab:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tab.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
|
||||
linear-gradient(180deg, rgba(122, 180, 255, 0.18), rgba(82, 123, 186, 0.24));
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2164,6 +2211,52 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
gap: var(--hud-gap-md);
|
||||
}
|
||||
|
||||
.earth-settings-tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: calc(6px * var(--settings-scale));
|
||||
padding: calc(2px * var(--settings-scale)) 0 calc(4px * var(--settings-scale));
|
||||
}
|
||||
|
||||
.earth-settings-tab {
|
||||
min-width: 0;
|
||||
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(7px * var(--settings-scale)) calc(10px * var(--settings-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-tab:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-tab.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.22), rgba(72, 101, 139, 0.28));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.earth-settings-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
@@ -2193,6 +2286,8 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-inline: 8px;
|
||||
padding-right: 10px;
|
||||
@@ -2656,6 +2751,154 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-shortcut-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-shortcut-category {
|
||||
margin-top: 4px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-shortcut-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.earth-shortcut-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-shortcut-label {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.earth-shortcut-alias {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.earth-shortcut-key {
|
||||
min-width: calc(68px * var(--hud-scale));
|
||||
border: 1px solid rgba(122, 180, 255, 0.24);
|
||||
border-radius: calc(6px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.16), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.16), rgba(72, 101, 139, 0.2));
|
||||
color: var(--hud-title);
|
||||
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-shortcut-key.is-capturing {
|
||||
border-color: rgba(110, 226, 188, 0.52);
|
||||
color: #d8fff3;
|
||||
}
|
||||
|
||||
.earth-shortcut-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(28px * var(--hud-scale));
|
||||
height: calc(28px * var(--hud-scale));
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--hud-text-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: calc(34px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable .earth-settings-switch-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable .earth-settings-switch-track::after {
|
||||
width: calc(14px * var(--hud-scale));
|
||||
height: calc(14px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-shortcut-enable input:checked + .earth-settings-switch-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);
|
||||
}
|
||||
|
||||
.earth-shortcut-enable input:checked + .earth-settings-switch-track::after {
|
||||
transform: translate(calc(14px * var(--hud-scale)), -50%);
|
||||
}
|
||||
|
||||
.earth-shortcut-reset .material-symbols-rounded {
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-shortcut-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-category {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-label {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-alias {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-key {
|
||||
min-width: 70px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-reset {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.hud-panel-motion-debug {
|
||||
position: absolute;
|
||||
right: calc(24px * var(--hud-scale));
|
||||
|
||||
@@ -92,6 +92,16 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__title-text {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(1rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.18;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -849,8 +849,16 @@
|
||||
<span class="earth-mobile-page-kicker">Settings</span>
|
||||
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">旋转</div>
|
||||
<div class="earth-mobile-settings-tabs" role="tablist" aria-label="移动端设置分类">
|
||||
<button type="button" class="earth-mobile-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="runtime">
|
||||
<div class="earth-mobile-settings-title">运行</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>
|
||||
@@ -877,8 +885,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">视图</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="display" hidden>
|
||||
<div class="earth-mobile-settings-title">显示</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>
|
||||
@@ -950,10 +958,115 @@
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="earth-mobile-settings-card"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<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">用于重置视角、缩放重置和巡航视图</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
data-default-earth-size-slider
|
||||
aria-label="移动端调整地球默认大小"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||
</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">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
data-terrain-opacity-slider
|
||||
aria-label="移动端调整地形透明度"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||
</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" data-boundary-precision-status>国界精度</span>
|
||||
<span class="earth-mobile-settings-subtitle" data-boundary-precision-detail>正在读取高清国界状态...</span>
|
||||
</div>
|
||||
<div class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
|
||||
<div class="earth-boundary-progress__track">
|
||||
<div class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
|
||||
</div>
|
||||
<span class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
|
||||
</div>
|
||||
<div class="earth-settings-boundary-action-row">
|
||||
<div class="earth-mobile-settings-segmented earth-mobile-settings-segmented--boundary" role="group" aria-label="移动端选择国界精度">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-boundary-precision-build aria-pressed="false">高精</button>
|
||||
</div>
|
||||
<button type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="panels" hidden>
|
||||
<div class="earth-mobile-settings-title">面板</div>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">图层控制</span>
|
||||
<span class="earth-mobile-settings-subtitle">控制图层控制面板显示</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-settings-panel="layer-toggles" checked>
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">图例</span>
|
||||
<span class="earth-mobile-settings-subtitle">控制图例面板显示</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-settings-panel="legend">
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">全球态势</span>
|
||||
<span class="earth-mobile-settings-subtitle">控制全球态势统计面板显示</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-settings-panel="earth-stats">
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">新闻直播</span>
|
||||
<span class="earth-mobile-settings-subtitle">控制 Live 新闻面板显示</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-settings-panel="media-panel">
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="motion" hidden>
|
||||
<div class="earth-mobile-settings-title">动捕</div>
|
||||
<label
|
||||
class="earth-mobile-settings-card"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">动捕调试模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
|
||||
@@ -976,49 +1089,21 @@
|
||||
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
|
||||
<div class="earth-mobile-settings-title">快捷键</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">用于重置视角、缩放重置和巡航视图</span>
|
||||
<span class="earth-mobile-settings-label">键盘控制</span>
|
||||
<span class="earth-mobile-settings-subtitle">点击按键后按下新的快捷键;Esc 取消录入</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
data-default-earth-size-slider
|
||||
aria-label="移动端调整地球默认大小"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||
<div class="earth-shortcut-list" data-shortcut-list></div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button" data-shortcut-reset-all>恢复默认快捷键</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">地形</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">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
data-terrain-opacity-slider
|
||||
aria-label="移动端调整地形透明度"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="system" hidden>
|
||||
<div class="earth-mobile-settings-title">系统</div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||
@@ -1100,9 +1185,17 @@
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-tabs" role="tablist" aria-label="设置分类">
|
||||
<button type="button" class="earth-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
|
||||
</div>
|
||||
<div class="earth-settings-content hud-panel__body">
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">旋转</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="runtime">
|
||||
<div class="earth-settings-section-title">运行</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
@@ -1194,8 +1287,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="display" hidden>
|
||||
<div class="earth-settings-section-title">显示</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
@@ -1303,33 +1396,70 @@
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="earth-settings-item"
|
||||
for="toggle-motion-debug"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<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">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
|
||||
<span class="earth-settings-item-title">地球默认大小</span>
|
||||
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="earth-settings-item earth-settings-item--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">动捕输入源</span>
|
||||
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaS;Motion Agent 适合双摄、RTSP/HTTP 和客户端</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
|
||||
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
|
||||
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="default-earth-size-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
aria-label="调整地球默认大小"
|
||||
>
|
||||
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
|
||||
</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">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title" id="boundary-precision-status" data-boundary-precision-status>国界精度</span>
|
||||
<span class="earth-settings-item-subtitle" id="boundary-precision-detail" data-boundary-precision-detail>正在读取高清国界状态...</span>
|
||||
</div>
|
||||
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
|
||||
<div class="earth-boundary-progress__track">
|
||||
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
|
||||
</div>
|
||||
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
|
||||
</div>
|
||||
<div class="earth-settings-boundary-action-row">
|
||||
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
|
||||
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
|
||||
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" data-boundary-precision-build aria-pressed="false">高精</button>
|
||||
</div>
|
||||
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="panels" hidden>
|
||||
<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>
|
||||
@@ -1372,81 +1502,54 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="motion" hidden>
|
||||
<div class="earth-settings-section-title">动捕</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<label
|
||||
class="earth-settings-item"
|
||||
for="toggle-motion-debug"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<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>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="default-earth-size-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
aria-label="调整地球默认大小"
|
||||
>
|
||||
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="earth-settings-item earth-settings-item--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">动捕输入源</span>
|
||||
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaS;Motion Agent 适合双摄、RTSP/HTTP 和客户端</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
|
||||
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
|
||||
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>
|
||||
<div class="earth-settings-section-title">快捷键</div>
|
||||
<div class="earth-settings-list">
|
||||
<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">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
<span class="earth-settings-item-title">键盘控制</span>
|
||||
<span class="earth-settings-item-subtitle">点击按键后按下新的快捷键;Esc 取消录入,冲突快捷键不会保存</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
<div class="earth-shortcut-list" data-shortcut-list></div>
|
||||
<div class="earth-settings-shortcut-actions">
|
||||
<button class="earth-settings-reload-action earth-settings-reload-action--text" type="button" data-shortcut-reset-all>恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">国界精度</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title" id="boundary-precision-status">国界精度</span>
|
||||
<span class="earth-settings-item-subtitle" id="boundary-precision-detail">正在读取高清国界状态...</span>
|
||||
</div>
|
||||
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" hidden>
|
||||
<div class="earth-boundary-progress__track">
|
||||
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar"></div>
|
||||
</div>
|
||||
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value">0%</span>
|
||||
</div>
|
||||
<div class="earth-settings-boundary-action-row">
|
||||
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
|
||||
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" aria-pressed="true">低精</button>
|
||||
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" aria-pressed="false">高精</button>
|
||||
</div>
|
||||
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<section class="earth-settings-section" data-settings-tab-panel="system" hidden>
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
<a
|
||||
|
||||
@@ -1,57 +1,110 @@
|
||||
const DEFAULT_BRAND_LANGUAGE = "zh";
|
||||
const EARTH_BRAND_API = "/api/v1/earth/brand";
|
||||
|
||||
const BRANDS = {
|
||||
zh: {
|
||||
ariaLabel: "智能星球计划品牌标识",
|
||||
titleAlt: "智能星球计划",
|
||||
titleSrc: "assets/brand/title-zh.png",
|
||||
logoSrc: "/earth/assets/brand/earth-logo.png",
|
||||
titleSrc: "/earth/assets/brand/title-zh.png",
|
||||
titleText: "智能星球计划",
|
||||
subtitle: "现实层宇宙全息感知系统",
|
||||
description: "卫星 · 海底光缆 · 算力基础设施",
|
||||
},
|
||||
en: {
|
||||
ariaLabel: "Intelligent Planet Program brand banner",
|
||||
titleAlt: "Intelligent Planet Program",
|
||||
titleSrc: "assets/brand/title-en.png",
|
||||
logoSrc: "/earth/assets/brand/earth-logo.png",
|
||||
titleSrc: "/earth/assets/brand/title-en.png",
|
||||
titleText: "Intelligent Planet Program",
|
||||
subtitle: "Physical-Universe Holography",
|
||||
description: "Satellites · Cables · Compute Infra",
|
||||
},
|
||||
};
|
||||
|
||||
function getBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
export function getDefaultBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
|
||||
}
|
||||
|
||||
export function renderBrand(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const config = getBrandConfig(variant);
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizeBrandConfig(config = {}, variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const defaults = getDefaultBrandConfig(variant);
|
||||
const normalized = {
|
||||
...defaults,
|
||||
...config,
|
||||
ariaLabel: config.aria_label ?? config.ariaLabel ?? defaults.ariaLabel,
|
||||
titleAlt: config.title_alt ?? config.titleAlt ?? defaults.titleAlt,
|
||||
logoSrc: config.logo_src ?? config.logoSrc ?? defaults.logoSrc,
|
||||
titleSrc: config.title_src ?? config.titleSrc ?? defaults.titleSrc,
|
||||
titleText: config.title_text ?? config.titleText ?? defaults.titleText,
|
||||
};
|
||||
|
||||
if (!normalized.titleText) normalized.titleText = defaults.titleText;
|
||||
if (!normalized.ariaLabel) normalized.ariaLabel = normalized.titleText;
|
||||
if (!normalized.titleAlt) normalized.titleAlt = normalized.titleText;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function renderBrand(configOrVariant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const variant =
|
||||
typeof configOrVariant === "string"
|
||||
? configOrVariant
|
||||
: configOrVariant.variant ?? DEFAULT_BRAND_LANGUAGE;
|
||||
const config =
|
||||
typeof configOrVariant === "string"
|
||||
? normalizeBrandConfig({}, variant)
|
||||
: normalizeBrandConfig(configOrVariant, variant);
|
||||
const titleMarkup = config.titleSrc
|
||||
? `
|
||||
<img
|
||||
class="earth-brand__title"
|
||||
src="${escapeHtml(config.titleSrc)}"
|
||||
alt="${escapeHtml(config.titleAlt)}"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
>
|
||||
`
|
||||
: `<div class="earth-brand__title-text">${escapeHtml(config.titleText)}</div>`;
|
||||
|
||||
return `
|
||||
<div class="earth-brand earth-brand--${variant}" aria-label="${config.ariaLabel}">
|
||||
<div class="earth-brand earth-brand--${escapeHtml(variant)}" aria-label="${escapeHtml(config.ariaLabel)}">
|
||||
<img
|
||||
class="earth-brand__logo"
|
||||
src="assets/brand/earth-logo.png"
|
||||
src="${escapeHtml(config.logoSrc)}"
|
||||
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"
|
||||
>
|
||||
${titleMarkup}
|
||||
<div class="earth-brand__meta">
|
||||
<span class="earth-brand__subtitle">${config.subtitle}</span>
|
||||
<span class="earth-brand__description">${config.description}</span>
|
||||
<span class="earth-brand__subtitle">${escapeHtml(config.subtitle)}</span>
|
||||
<span class="earth-brand__description">${escapeHtml(config.description)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export function mountBrand(target, variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
export function mountBrand(target, configOrVariant = DEFAULT_BRAND_LANGUAGE) {
|
||||
if (!target) return;
|
||||
target.innerHTML = renderBrand(variant);
|
||||
target.innerHTML = renderBrand(configOrVariant);
|
||||
}
|
||||
|
||||
export async function fetchEarthBrandConfig() {
|
||||
const response = await fetch(EARTH_BRAND_API, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Earth brand config request failed: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
return payload?.brand ?? getDefaultBrandConfig();
|
||||
}
|
||||
|
||||
852
frontend/public/earth/js/controls.js
vendored
852
frontend/public/earth/js/controls.js
vendored
@@ -153,7 +153,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
|
||||
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||
const EARTH_SETTINGS_VERSION = 11;
|
||||
const EARTH_SETTINGS_VERSION = 13;
|
||||
const GRID_LINES_DEFAULT_VERSION = 3;
|
||||
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
|
||||
const MEDIA_PANEL_DEFAULT_VERSION = 5;
|
||||
@@ -163,8 +163,14 @@ const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
|
||||
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
|
||||
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
|
||||
const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11;
|
||||
const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
|
||||
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
|
||||
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
|
||||
const KEYBOARD_ROTATION_ACCELERATION = 3.2;
|
||||
const KEYBOARD_ROTATION_MAX_SPEED = 2.8;
|
||||
const KEYBOARD_ROTATION_FRICTION = 5.4;
|
||||
const KEYBOARD_ROTATION_STOP_SPEED = 0.012;
|
||||
const KEYBOARD_ZOOM_STEP = 0.1;
|
||||
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
|
||||
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
|
||||
let settingsModalTimer = null;
|
||||
@@ -186,6 +192,13 @@ let mobileDrawerOpen = false;
|
||||
let mobileDrawerCard = "layers";
|
||||
let mobileDrawerHintTimer = null;
|
||||
let toolbarHubController = null;
|
||||
let keyboardShortcuts = {};
|
||||
let capturingShortcutActionId = null;
|
||||
let keyboardRotationFrameId = null;
|
||||
let keyboardRotationLastFrameAt = 0;
|
||||
let keyboardRotationOriginalAutoRotate = null;
|
||||
const activeKeyboardRotationActions = new Set();
|
||||
const keyboardRotationVelocity = { x: 0, y: 0 };
|
||||
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
|
||||
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
|
||||
Object.values(SATELLITE_DISPLAY_STYLES),
|
||||
@@ -202,6 +215,36 @@ const CRUISE_MODULE_LABELS = {
|
||||
[CRUISE_MODULES.SATELLITES]: "卫星",
|
||||
};
|
||||
|
||||
const KEYBOARD_SHORTCUT_DEFINITIONS = [
|
||||
{ id: "rotateUp", label: "向上旋转", category: "视角控制", defaultBinding: "W", aliases: ["ArrowUp"] },
|
||||
{ id: "rotateLeft", label: "向左旋转", category: "视角控制", defaultBinding: "A", aliases: ["ArrowLeft"] },
|
||||
{ id: "rotateDown", label: "向下旋转", category: "视角控制", defaultBinding: "S", aliases: ["ArrowDown"] },
|
||||
{ id: "rotateRight", label: "向右旋转", category: "视角控制", defaultBinding: "D", aliases: ["ArrowRight"] },
|
||||
{ id: "zoomIn", label: "放大", category: "视角控制", defaultBinding: "Plus", aliases: ["="] },
|
||||
{ id: "zoomOut", label: "缩小", category: "视角控制", defaultBinding: "-", aliases: ["_"] },
|
||||
{ id: "closeFocus", label: "关闭当前焦点菜单", category: "工具", defaultBinding: "Escape" },
|
||||
{ id: "openSearch", label: "打开搜索", category: "工具", defaultBinding: "F" },
|
||||
{ id: "resetView", label: "重置视角", category: "工具", defaultBinding: "R" },
|
||||
{ id: "toggleLayerPanel", label: "打开/关闭图层面板", category: "工具", defaultBinding: "L" },
|
||||
{ id: "toggleLayoutExpanded", label: "最大化布局", category: "工具", defaultBinding: "M" },
|
||||
{ id: "toggleMediaPanel", label: "打开/关闭新闻直播", category: "工具", defaultBinding: "Ctrl+M" },
|
||||
{ id: "toggleAutoRotate", label: "暂停/恢复旋转", category: "工具", defaultBinding: "Space" },
|
||||
{ id: "toggleLayer:cables", label: "切换海缆", category: "图层", defaultBinding: "Ctrl+1" },
|
||||
{ id: "toggleLayer:satellites", label: "切换卫星", category: "图层", defaultBinding: "Ctrl+2" },
|
||||
{ id: "toggleLayer:computeCenters", label: "切换算力中心", category: "图层", defaultBinding: "Ctrl+3" },
|
||||
{ id: "toggleLayer:vessels", label: "切换船只", category: "图层", defaultBinding: "Ctrl+4" },
|
||||
{ id: "toggleLayer:bgp", label: "切换 BGP观测", category: "图层", defaultBinding: "Ctrl+5" },
|
||||
{ id: "toggleLayer:terrain", label: "切换地形", category: "图层", defaultBinding: "Ctrl+6" },
|
||||
{ id: "toggleLayer:earthHighResTexture", label: "切换高清材质", category: "图层", defaultBinding: "Ctrl+7" },
|
||||
{ id: "toggleLayer:atmosphereClouds", label: "切换大气云图", category: "图层", defaultBinding: "Ctrl+8" },
|
||||
{ id: "toggleLayer:countryBoundaries", label: "切换国界线", category: "图层", defaultBinding: "Ctrl+9" },
|
||||
{ id: "toggleLayer:gridLines", label: "切换经纬线", category: "图层", defaultBinding: "Ctrl+0" },
|
||||
];
|
||||
|
||||
const KEYBOARD_SHORTCUT_DEFINITION_BY_ID = new Map(
|
||||
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
function normalizeMediaPanelActiveTab(tab) {
|
||||
return tab === "news" ? "news" : "live";
|
||||
}
|
||||
@@ -212,6 +255,431 @@ function normalizeSurfaceHoverInfoMode(mode) {
|
||||
: DEFAULT_SURFACE_HOVER_INFO_MODE;
|
||||
}
|
||||
|
||||
function getDefaultKeyboardShortcuts() {
|
||||
return Object.fromEntries(
|
||||
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [
|
||||
definition.id,
|
||||
{
|
||||
binding: definition.defaultBinding,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeShortcutBinding(binding) {
|
||||
if (typeof binding !== "string") return "";
|
||||
if (binding.trim() === "+") return "+";
|
||||
const parts = binding
|
||||
.split("+")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return "";
|
||||
|
||||
const key = parts[parts.length - 1];
|
||||
const modifiers = new Set(
|
||||
parts.slice(0, -1).map((part) => {
|
||||
const normalized = part.toLowerCase();
|
||||
if (normalized === "control") return "Ctrl";
|
||||
if (normalized === "cmd" || normalized === "command") return "Meta";
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
}),
|
||||
);
|
||||
const orderedModifiers = ["Ctrl", "Alt", "Shift", "Meta"].filter((modifier) =>
|
||||
modifiers.has(modifier),
|
||||
);
|
||||
return [...orderedModifiers, normalizeShortcutKeyName(key)].join("+");
|
||||
}
|
||||
|
||||
function normalizeShortcutKeyName(key) {
|
||||
if (!key) return "";
|
||||
if (key === " ") return "Space";
|
||||
if (key === "+") return "Plus";
|
||||
if (key === "_") return "-";
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
const lowered = key.toLowerCase();
|
||||
if (lowered === "esc") return "Escape";
|
||||
if (lowered === "spacebar") return "Space";
|
||||
if (lowered.startsWith("arrow")) {
|
||||
return `Arrow${lowered.slice(5, 6).toUpperCase()}${lowered.slice(6)}`;
|
||||
}
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
}
|
||||
|
||||
function normalizeKeyboardShortcuts(rawShortcuts = {}) {
|
||||
const defaults = getDefaultKeyboardShortcuts();
|
||||
const source =
|
||||
rawShortcuts && typeof rawShortcuts === "object"
|
||||
? rawShortcuts
|
||||
: {};
|
||||
return Object.fromEntries(
|
||||
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => {
|
||||
const rawShortcut = source[definition.id] || {};
|
||||
const binding = normalizeShortcutBinding(rawShortcut.binding);
|
||||
return [
|
||||
definition.id,
|
||||
{
|
||||
binding: binding || defaults[definition.id].binding,
|
||||
enabled:
|
||||
typeof rawShortcut.enabled === "boolean"
|
||||
? rawShortcut.enabled
|
||||
: defaults[definition.id].enabled,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getShortcutDisplayLabel(binding) {
|
||||
const normalized = normalizeShortcutBinding(binding);
|
||||
if (!normalized) return "未设置";
|
||||
return normalized
|
||||
.replaceAll("ArrowUp", "↑")
|
||||
.replaceAll("ArrowDown", "↓")
|
||||
.replaceAll("ArrowLeft", "←")
|
||||
.replaceAll("ArrowRight", "→")
|
||||
.replaceAll("Space", "空格")
|
||||
.replaceAll("Escape", "Esc")
|
||||
.replaceAll("Plus", "+")
|
||||
.replaceAll("Ctrl", "Ctrl")
|
||||
.replaceAll("Meta", "⌘");
|
||||
}
|
||||
|
||||
function getShortcutAliasesLabel(definition) {
|
||||
const aliases = Array.isArray(definition?.aliases) ? definition.aliases : [];
|
||||
if (aliases.length === 0) return "";
|
||||
return `备用:${aliases.map(getShortcutDisplayLabel).join(" / ")}`;
|
||||
}
|
||||
|
||||
function getShortcutForAction(actionId) {
|
||||
return keyboardShortcuts[actionId] || getDefaultKeyboardShortcuts()[actionId] || {
|
||||
binding: "",
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getShortcutOwnerByBinding(binding, { excludeActionId = null } = {}) {
|
||||
const normalized = normalizeShortcutBinding(binding);
|
||||
if (!normalized) return null;
|
||||
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
|
||||
if (definition.id === excludeActionId) continue;
|
||||
const shortcut = getShortcutForAction(definition.id);
|
||||
if (!shortcut.enabled) continue;
|
||||
const bindings = [
|
||||
shortcut.binding,
|
||||
...(Array.isArray(definition.aliases) ? definition.aliases : []),
|
||||
].map(normalizeShortcutBinding);
|
||||
if (bindings.includes(normalized)) {
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getShortcutChordFromEvent(event) {
|
||||
if (!(event instanceof KeyboardEvent)) return "";
|
||||
let key = event.key;
|
||||
if (!key || key === "Unidentified" || key === "Dead") return "";
|
||||
if (key === " ") key = "Space";
|
||||
const ignoreShift = key === "+" || key === "_";
|
||||
if (key.length === 1) key = key.toUpperCase();
|
||||
const modifiers = [];
|
||||
if (event.ctrlKey) modifiers.push("Ctrl");
|
||||
if (event.altKey) modifiers.push("Alt");
|
||||
if (event.shiftKey && key.length !== 1 && !ignoreShift) modifiers.push("Shift");
|
||||
if (event.metaKey) modifiers.push("Meta");
|
||||
return [...modifiers, normalizeShortcutKeyName(key)].join("+");
|
||||
}
|
||||
|
||||
function isEditableShortcutTarget(target) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
if (target.closest("[data-shortcut-capture]")) return false;
|
||||
return Boolean(
|
||||
target.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"),
|
||||
);
|
||||
}
|
||||
|
||||
function isShortcutSuppressedBySettingsUi(target) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("#settings-modal, .earth-mobile-page--settings"));
|
||||
}
|
||||
|
||||
function getShortcutDefinitionForChord(chord) {
|
||||
const normalizedChord = normalizeShortcutBinding(chord);
|
||||
if (!normalizedChord) return null;
|
||||
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
|
||||
const shortcut = getShortcutForAction(definition.id);
|
||||
if (!shortcut.enabled) continue;
|
||||
const bindings = [
|
||||
shortcut.binding,
|
||||
...(Array.isArray(definition.aliases) ? definition.aliases : []),
|
||||
].map(normalizeShortcutBinding);
|
||||
if (bindings.includes(normalizedChord)) {
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeCurrentFocusOverlay() {
|
||||
if (capturingShortcutActionId) {
|
||||
capturingShortcutActionId = null;
|
||||
syncShortcutCaptureUi();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSearchPanelOpen()) {
|
||||
closeSearchPanel();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSettingsModalOpen()) {
|
||||
closeSettingsModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMobileLayout() && mobileDrawerOpen) {
|
||||
setMobileDrawerState({ open: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isFloatingMenuVisible()) {
|
||||
closeFloatingMenus();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (toolbarHubController?.isOpen?.()) {
|
||||
toolbarHubController.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
clearLockedObjectAndInfo();
|
||||
return true;
|
||||
}
|
||||
|
||||
function isKeyboardRotationAction(actionId) {
|
||||
return (
|
||||
actionId === "rotateUp" ||
|
||||
actionId === "rotateDown" ||
|
||||
actionId === "rotateLeft" ||
|
||||
actionId === "rotateRight"
|
||||
);
|
||||
}
|
||||
|
||||
function getKeyboardRotationDirection() {
|
||||
const direction = { x: 0, y: 0 };
|
||||
if (activeKeyboardRotationActions.has("rotateUp")) direction.x -= 1;
|
||||
if (activeKeyboardRotationActions.has("rotateDown")) direction.x += 1;
|
||||
if (activeKeyboardRotationActions.has("rotateLeft")) direction.y -= 1;
|
||||
if (activeKeyboardRotationActions.has("rotateRight")) direction.y += 1;
|
||||
const magnitude = Math.hypot(direction.x, direction.y);
|
||||
if (magnitude > 1) {
|
||||
direction.x /= magnitude;
|
||||
direction.y /= magnitude;
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
|
||||
function clampKeyboardRotationVelocity() {
|
||||
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
|
||||
if (speed <= KEYBOARD_ROTATION_MAX_SPEED) return;
|
||||
const scale = KEYBOARD_ROTATION_MAX_SPEED / speed;
|
||||
keyboardRotationVelocity.x *= scale;
|
||||
keyboardRotationVelocity.y *= scale;
|
||||
}
|
||||
|
||||
function applyKeyboardRotationFrame(timestamp) {
|
||||
keyboardRotationFrameId = null;
|
||||
if (!earthObj) {
|
||||
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaSeconds = keyboardRotationLastFrameAt
|
||||
? Math.min((timestamp - keyboardRotationLastFrameAt) / 1000, 0.05)
|
||||
: 0.016;
|
||||
keyboardRotationLastFrameAt = timestamp;
|
||||
|
||||
const direction = getKeyboardRotationDirection();
|
||||
const hasInput = direction.x !== 0 || direction.y !== 0;
|
||||
|
||||
if (hasInput) {
|
||||
keyboardRotationVelocity.x += direction.x * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
|
||||
keyboardRotationVelocity.y += direction.y * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
|
||||
clampKeyboardRotationVelocity();
|
||||
} else {
|
||||
const decay = Math.exp(-KEYBOARD_ROTATION_FRICTION * deltaSeconds);
|
||||
keyboardRotationVelocity.x *= decay;
|
||||
keyboardRotationVelocity.y *= decay;
|
||||
}
|
||||
|
||||
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
|
||||
if (!hasInput && speed < KEYBOARD_ROTATION_STOP_SPEED) {
|
||||
keyboardRotationVelocity.x = 0;
|
||||
keyboardRotationVelocity.y = 0;
|
||||
restoreKeyboardRotationAutoRotate();
|
||||
keyboardRotationLastFrameAt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
earthObj.rotation.x = THREE.MathUtils.clamp(
|
||||
earthObj.rotation.x + keyboardRotationVelocity.x * deltaSeconds,
|
||||
-Math.PI / 2,
|
||||
Math.PI / 2,
|
||||
);
|
||||
earthObj.rotation.y += keyboardRotationVelocity.y * deltaSeconds;
|
||||
|
||||
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
|
||||
}
|
||||
|
||||
function ensureKeyboardRotationFrame() {
|
||||
if (keyboardRotationFrameId !== null) return;
|
||||
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
|
||||
}
|
||||
|
||||
function restoreKeyboardRotationAutoRotate() {
|
||||
if (keyboardRotationOriginalAutoRotate === true && !autoRotate) {
|
||||
setAutoRotate(true);
|
||||
}
|
||||
keyboardRotationOriginalAutoRotate = null;
|
||||
}
|
||||
|
||||
function startKeyboardRotationControl(actionId) {
|
||||
if (!earthObj || !isKeyboardRotationAction(actionId)) return;
|
||||
if (keyboardRotationOriginalAutoRotate === null) {
|
||||
keyboardRotationOriginalAutoRotate = autoRotate;
|
||||
}
|
||||
if (autoRotate) {
|
||||
setAutoRotate(false);
|
||||
}
|
||||
activeKeyboardRotationActions.add(actionId);
|
||||
clearLockedObject();
|
||||
ensureKeyboardRotationFrame();
|
||||
}
|
||||
|
||||
function stopKeyboardRotationControl({ actionId = null, restoreAutoRotate = false, clearVelocity = false } = {}) {
|
||||
if (actionId) {
|
||||
activeKeyboardRotationActions.delete(actionId);
|
||||
} else {
|
||||
activeKeyboardRotationActions.clear();
|
||||
}
|
||||
if (clearVelocity) {
|
||||
keyboardRotationVelocity.x = 0;
|
||||
keyboardRotationVelocity.y = 0;
|
||||
}
|
||||
if (restoreAutoRotate && activeKeyboardRotationActions.size === 0) {
|
||||
restoreKeyboardRotationAutoRotate();
|
||||
}
|
||||
if (clearVelocity && keyboardRotationFrameId !== null) {
|
||||
window.cancelAnimationFrame(keyboardRotationFrameId);
|
||||
keyboardRotationFrameId = null;
|
||||
keyboardRotationLastFrameAt = 0;
|
||||
} else if (activeKeyboardRotationActions.size === 0 && Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y) > 0) {
|
||||
ensureKeyboardRotationFrame();
|
||||
}
|
||||
}
|
||||
|
||||
function applyKeyboardZoom(direction) {
|
||||
setZoomLevel(zoomLevel + direction * KEYBOARD_ZOOM_STEP, activeCamera);
|
||||
showZoomStatusCapsule({ force: true });
|
||||
}
|
||||
|
||||
function openSearchFromShortcut() {
|
||||
if (isMobileLayout()) {
|
||||
setMobileDrawerState({ open: true, card: "search" });
|
||||
return;
|
||||
}
|
||||
closeTransientMobileOverlays({ except: "search" });
|
||||
openSearchPanel();
|
||||
}
|
||||
|
||||
function toggleLayerPanelFromShortcut() {
|
||||
if (isMobileLayout()) {
|
||||
const nextOpen = !(mobileDrawerOpen && mobileDrawerCard === "layers");
|
||||
setMobileDrawerState({ open: nextOpen, card: "layers" });
|
||||
return;
|
||||
}
|
||||
const panel = document.getElementById("layer-toggles");
|
||||
const currentlyVisible = !panel?.classList.contains("hud-panel-hidden");
|
||||
setHudPanelVisibility("layer-toggles", !currentlyVisible);
|
||||
}
|
||||
|
||||
function toggleMediaPanelFromShortcut() {
|
||||
if (isMobileLayout()) {
|
||||
const nextOpen = !(mobileDrawerOpen && (mobileDrawerCard === "tv" || mobileDrawerCard === "news"));
|
||||
setMobileDrawerState({ open: nextOpen, card: "tv" });
|
||||
return;
|
||||
}
|
||||
const nextVisible = !isTVPanelVisible();
|
||||
setTVPanelVisible(nextVisible);
|
||||
}
|
||||
|
||||
function toggleLayoutExpandedFromShortcut() {
|
||||
const container = document.getElementById("container");
|
||||
if (!(container instanceof HTMLElement)) return;
|
||||
const expanded = toggleLayoutExpanded(container);
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
}
|
||||
|
||||
async function toggleLayerFromShortcut(layerId) {
|
||||
const definition = getLayerDefinition(layerId);
|
||||
if (!definition) return;
|
||||
const button = getLayerButton(layerId);
|
||||
if (button?.disabled || button?.classList.contains("is-disabled")) {
|
||||
showStatusMessage(`${definition.label}当前不可用`, "warning");
|
||||
return;
|
||||
}
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
showStatusMessage(`${definition.label}${definition.getVisible() ? "已显示" : "已隐藏"}`, "info");
|
||||
}
|
||||
|
||||
function executeKeyboardShortcut(actionId) {
|
||||
if (actionId === "closeFocus") {
|
||||
closeCurrentFocusOverlay();
|
||||
return;
|
||||
}
|
||||
if (isKeyboardRotationAction(actionId)) {
|
||||
startKeyboardRotationControl(actionId);
|
||||
return;
|
||||
}
|
||||
if (actionId === "zoomIn") {
|
||||
applyKeyboardZoom(1);
|
||||
return;
|
||||
}
|
||||
if (actionId === "zoomOut") {
|
||||
applyKeyboardZoom(-1);
|
||||
return;
|
||||
}
|
||||
if (actionId === "openSearch") {
|
||||
openSearchFromShortcut();
|
||||
return;
|
||||
}
|
||||
if (actionId === "resetView") {
|
||||
resetView(activeCamera);
|
||||
return;
|
||||
}
|
||||
if (actionId === "toggleLayerPanel") {
|
||||
toggleLayerPanelFromShortcut();
|
||||
return;
|
||||
}
|
||||
if (actionId === "toggleLayoutExpanded") {
|
||||
toggleLayoutExpandedFromShortcut();
|
||||
return;
|
||||
}
|
||||
if (actionId === "toggleMediaPanel") {
|
||||
toggleMediaPanelFromShortcut();
|
||||
return;
|
||||
}
|
||||
if (actionId === "toggleAutoRotate") {
|
||||
const isRotating = toggleAutoRotate();
|
||||
showStatusMessage(isRotating ? "旋转已恢复" : "旋转已暂停", "info");
|
||||
return;
|
||||
}
|
||||
if (actionId.startsWith("toggleLayer:")) {
|
||||
void toggleLayerFromShortcut(actionId.slice("toggleLayer:".length));
|
||||
}
|
||||
}
|
||||
|
||||
function detectLayoutMode() {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
@@ -819,6 +1287,7 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
|
||||
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
|
||||
surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
|
||||
keyboardShortcuts: normalizeKeyboardShortcuts(keyboardShortcuts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -879,6 +1348,7 @@ function cloneEarthSettings(settings) {
|
||||
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
|
||||
settings.shared.surfaceHoverInfoMode,
|
||||
),
|
||||
keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts),
|
||||
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
|
||||
},
|
||||
views: {
|
||||
@@ -1017,6 +1487,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
(rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION
|
||||
? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode)
|
||||
: defaults.shared.surfaceHoverInfoMode;
|
||||
const nextKeyboardShortcuts =
|
||||
(rawSettings?.version || 0) >= KEYBOARD_SHORTCUTS_DEFAULT_VERSION
|
||||
? normalizeKeyboardShortcuts(sharedSettings?.keyboardShortcuts)
|
||||
: defaults.shared.keyboardShortcuts;
|
||||
const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean"
|
||||
? sharedSettings.trailsEnabled
|
||||
: typeof inputLayerVisibility.trails === "boolean"
|
||||
@@ -1046,6 +1520,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
|
||||
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
|
||||
surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
|
||||
keyboardShortcuts: nextKeyboardShortcuts,
|
||||
},
|
||||
views: {
|
||||
desktop: {
|
||||
@@ -1550,6 +2025,8 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
|
||||
suppressStatus: true,
|
||||
});
|
||||
setActiveTVTab(settings.shared.mediaPanelActiveTab);
|
||||
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
|
||||
renderShortcutSettings();
|
||||
|
||||
if (!applyLayers) {
|
||||
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
|
||||
@@ -2659,12 +3136,14 @@ function applyCurrentViewportPanelVisibility({ persist = false } = {}) {
|
||||
}
|
||||
|
||||
function syncSettingsToggle(panelId, visible) {
|
||||
const input = document.querySelector(
|
||||
const inputs = document.querySelectorAll(
|
||||
`[data-settings-panel="${panelId}"]`,
|
||||
);
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
inputs.forEach((input) => {
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncAllHudPanelToggles() {
|
||||
@@ -2717,14 +3196,14 @@ export function setDayNightInteractable(enabled) {
|
||||
|
||||
function getBoundaryPrecisionEls() {
|
||||
return {
|
||||
status: document.getElementById("boundary-precision-status"),
|
||||
detail: document.getElementById("boundary-precision-detail"),
|
||||
progressWrap: document.getElementById("boundary-precision-progress-wrap"),
|
||||
progressBar: document.getElementById("boundary-precision-progress-bar"),
|
||||
progressValue: document.getElementById("boundary-precision-progress-value"),
|
||||
buildButton: document.getElementById("boundary-precision-build"),
|
||||
rebuildButton: document.getElementById("boundary-precision-rebuild"),
|
||||
disableButton: document.getElementById("boundary-precision-disable"),
|
||||
statuses: Array.from(document.querySelectorAll("[data-boundary-precision-status]")),
|
||||
details: Array.from(document.querySelectorAll("[data-boundary-precision-detail]")),
|
||||
progressWraps: Array.from(document.querySelectorAll("[data-boundary-precision-progress-wrap]")),
|
||||
progressBars: Array.from(document.querySelectorAll("[data-boundary-precision-progress-bar]")),
|
||||
progressValues: Array.from(document.querySelectorAll("[data-boundary-precision-progress-value]")),
|
||||
buildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-build]")),
|
||||
rebuildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-rebuild]")),
|
||||
disableButtons: Array.from(document.querySelectorAll("[data-boundary-precision-disable]")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2764,45 +3243,48 @@ function renderBoundaryPrecisionStatus(payload = {}) {
|
||||
? `高清国界更新源未配置完整:${job.message || job.code}`
|
||||
: `高清国界下载失败:${job.message || job.code || "请检查更新源"}`;
|
||||
|
||||
if (els.status) {
|
||||
els.status.textContent = "国界精度";
|
||||
}
|
||||
if (els.detail) {
|
||||
els.detail.textContent = running
|
||||
els.statuses.forEach((status) => {
|
||||
status.textContent = "国界精度";
|
||||
});
|
||||
els.details.forEach((detail) => {
|
||||
detail.textContent = running
|
||||
? (job.message || "正在准备高清国界")
|
||||
: failed
|
||||
? failureMessage
|
||||
: highReady
|
||||
? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。")
|
||||
: "当前使用低精国界;切到高精会下载并构建。";
|
||||
}
|
||||
if (els.progressWrap) {
|
||||
els.progressWrap.hidden = !running;
|
||||
}
|
||||
if (els.progressBar) {
|
||||
els.progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
|
||||
}
|
||||
if (els.progressValue) {
|
||||
els.progressValue.textContent = job.status === "failed"
|
||||
});
|
||||
els.progressWraps.forEach((progressWrap) => {
|
||||
progressWrap.hidden = !running;
|
||||
});
|
||||
els.progressBars.forEach((progressBar) => {
|
||||
progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
|
||||
});
|
||||
els.progressValues.forEach((progressValue) => {
|
||||
progressValue.textContent = job.status === "failed"
|
||||
? `失败:${job.message || job.code || "构建失败"}`
|
||||
: `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
|
||||
}
|
||||
if (els.buildButton instanceof HTMLButtonElement) {
|
||||
els.buildButton.disabled = running;
|
||||
els.buildButton.textContent = "高精";
|
||||
els.buildButton.classList.toggle("is-active", enabled || running);
|
||||
els.buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
|
||||
}
|
||||
if (els.rebuildButton instanceof HTMLButtonElement) {
|
||||
});
|
||||
els.buildButtons.forEach((buildButton) => {
|
||||
if (!(buildButton instanceof HTMLButtonElement)) return;
|
||||
buildButton.disabled = running;
|
||||
buildButton.textContent = "高精";
|
||||
buildButton.classList.toggle("is-active", enabled || running);
|
||||
buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
|
||||
});
|
||||
els.rebuildButtons.forEach((rebuildButton) => {
|
||||
if (!(rebuildButton instanceof HTMLButtonElement)) return;
|
||||
const shouldShowRebuild = highReady && enabled && !running;
|
||||
els.rebuildButton.hidden = !shouldShowRebuild;
|
||||
els.rebuildButton.disabled = !shouldShowRebuild;
|
||||
}
|
||||
if (els.disableButton instanceof HTMLButtonElement) {
|
||||
els.disableButton.disabled = running;
|
||||
els.disableButton.classList.toggle("is-active", !enabled && !running);
|
||||
els.disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
|
||||
}
|
||||
rebuildButton.hidden = !shouldShowRebuild;
|
||||
rebuildButton.disabled = !shouldShowRebuild;
|
||||
});
|
||||
els.disableButtons.forEach((disableButton) => {
|
||||
if (!(disableButton instanceof HTMLButtonElement)) return;
|
||||
disableButton.disabled = running;
|
||||
disableButton.classList.toggle("is-active", !enabled && !running);
|
||||
disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
|
||||
});
|
||||
syncSegmentedControlSliders();
|
||||
}
|
||||
|
||||
@@ -2855,7 +3337,7 @@ async function startBoundaryPrecisionBuild() {
|
||||
|
||||
async function setupBoundaryPrecisionControls() {
|
||||
const els = getBoundaryPrecisionEls();
|
||||
if (!els.buildButton && !els.disableButton) return;
|
||||
if (els.buildButtons.length === 0 && els.disableButtons.length === 0) return;
|
||||
try {
|
||||
const payload = await refreshBoundaryPrecisionStatus();
|
||||
const jobStatus = payload.current_job?.status;
|
||||
@@ -2867,8 +3349,9 @@ async function setupBoundaryPrecisionControls() {
|
||||
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
|
||||
}
|
||||
|
||||
if (els.buildButton instanceof HTMLButtonElement) {
|
||||
bindListener(els.buildButton, "click", async () => {
|
||||
els.buildButtons.forEach((buildButton) => {
|
||||
if (!(buildButton instanceof HTMLButtonElement)) return;
|
||||
bindListener(buildButton, "click", async () => {
|
||||
try {
|
||||
const statusPayload = await refreshBoundaryPrecisionStatus();
|
||||
const job = statusPayload.job || statusPayload.current_job || {};
|
||||
@@ -2887,9 +3370,10 @@ async function setupBoundaryPrecisionControls() {
|
||||
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (els.rebuildButton instanceof HTMLButtonElement) {
|
||||
bindListener(els.rebuildButton, "click", async () => {
|
||||
});
|
||||
els.rebuildButtons.forEach((rebuildButton) => {
|
||||
if (!(rebuildButton instanceof HTMLButtonElement)) return;
|
||||
bindListener(rebuildButton, "click", async () => {
|
||||
try {
|
||||
await startBoundaryPrecisionBuild();
|
||||
} catch (error) {
|
||||
@@ -2897,9 +3381,10 @@ async function setupBoundaryPrecisionControls() {
|
||||
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (els.disableButton instanceof HTMLButtonElement) {
|
||||
bindListener(els.disableButton, "click", async () => {
|
||||
});
|
||||
els.disableButtons.forEach((disableButton) => {
|
||||
if (!(disableButton instanceof HTMLButtonElement)) return;
|
||||
bindListener(disableButton, "click", async () => {
|
||||
try {
|
||||
if (!getHighPrecisionBoundariesEnabled()) return;
|
||||
setHighPrecisionBoundariesEnabled(false);
|
||||
@@ -2910,7 +3395,204 @@ async function setupBoundaryPrecisionControls() {
|
||||
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setSettingsTab(root, tabId) {
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
const nextTab = tabId || "runtime";
|
||||
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const active = button.dataset.settingsTab === nextTab;
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-selected", active ? "true" : "false");
|
||||
});
|
||||
root.querySelectorAll("[data-settings-tab-panel]").forEach((panel) => {
|
||||
if (!(panel instanceof HTMLElement)) return;
|
||||
panel.hidden = panel.dataset.settingsTabPanel !== nextTab;
|
||||
});
|
||||
}
|
||||
|
||||
function setupSettingsTabs() {
|
||||
const roots = document.querySelectorAll(".earth-settings-sheet, .earth-mobile-page--settings");
|
||||
roots.forEach((root) => {
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
setSettingsTab(root, "runtime");
|
||||
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
bindListener(button, "click", () => {
|
||||
setSettingsTab(root, button.dataset.settingsTab || "runtime");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderShortcutSettings() {
|
||||
const lists = document.querySelectorAll("[data-shortcut-list]");
|
||||
if (lists.length === 0) return;
|
||||
const groupedDefinitions = KEYBOARD_SHORTCUT_DEFINITIONS.reduce((groups, definition) => {
|
||||
if (!groups.has(definition.category)) {
|
||||
groups.set(definition.category, []);
|
||||
}
|
||||
groups.get(definition.category).push(definition);
|
||||
return groups;
|
||||
}, new Map());
|
||||
|
||||
lists.forEach((list) => {
|
||||
if (!(list instanceof HTMLElement)) return;
|
||||
list.innerHTML = "";
|
||||
groupedDefinitions.forEach((definitions, category) => {
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "earth-shortcut-category";
|
||||
heading.textContent = category;
|
||||
list.appendChild(heading);
|
||||
|
||||
definitions.forEach((definition) => {
|
||||
const shortcut = getShortcutForAction(definition.id);
|
||||
const row = document.createElement("div");
|
||||
row.className = "earth-shortcut-row";
|
||||
row.dataset.shortcutAction = definition.id;
|
||||
row.innerHTML = `
|
||||
<div class="earth-shortcut-copy">
|
||||
<span class="earth-shortcut-label">${definition.label}</span>
|
||||
<span class="earth-shortcut-alias">${getShortcutAliasesLabel(definition) || " "}</span>
|
||||
</div>
|
||||
<button class="earth-shortcut-key" type="button" data-shortcut-capture="${definition.id}">${getShortcutDisplayLabel(shortcut.binding)}</button>
|
||||
<label class="earth-shortcut-enable" title="启用快捷键">
|
||||
<input type="checkbox" data-shortcut-enabled="${definition.id}" ${shortcut.enabled ? "checked" : ""}>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</label>
|
||||
<button class="earth-shortcut-reset" type="button" data-shortcut-reset="${definition.id}" aria-label="重置${definition.label}" title="重置">
|
||||
<span class="material-symbols-rounded">restart_alt</span>
|
||||
</button>
|
||||
`;
|
||||
row.classList.toggle("is-disabled", !shortcut.enabled);
|
||||
list.appendChild(row);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
syncShortcutCaptureUi();
|
||||
}
|
||||
|
||||
function syncShortcutCaptureUi() {
|
||||
document.querySelectorAll("[data-shortcut-capture]").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
const actionId = button.dataset.shortcutCapture;
|
||||
const isCapturing = Boolean(actionId && actionId === capturingShortcutActionId);
|
||||
button.classList.toggle("is-capturing", isCapturing);
|
||||
if (isCapturing) {
|
||||
button.textContent = "按键...";
|
||||
} else if (actionId) {
|
||||
button.textContent = getShortcutDisplayLabel(getShortcutForAction(actionId).binding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setShortcutBinding(actionId, binding, { persist = true } = {}) {
|
||||
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
|
||||
const normalizedBinding = normalizeShortcutBinding(binding);
|
||||
if (!normalizedBinding) return false;
|
||||
const owner = getShortcutOwnerByBinding(normalizedBinding, { excludeActionId: actionId });
|
||||
if (owner) {
|
||||
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
|
||||
return false;
|
||||
}
|
||||
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
nextShortcuts[actionId] = {
|
||||
...nextShortcuts[actionId],
|
||||
binding: normalizedBinding,
|
||||
enabled: true,
|
||||
};
|
||||
keyboardShortcuts = nextShortcuts;
|
||||
if (earthSettingsState?.shared) {
|
||||
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
}
|
||||
renderShortcutSettings();
|
||||
if (persist) persistEarthSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetShortcutBinding(actionId, { persist = true } = {}) {
|
||||
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(actionId);
|
||||
if (!definition) return;
|
||||
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
nextShortcuts[actionId] = {
|
||||
binding: definition.defaultBinding,
|
||||
enabled: true,
|
||||
};
|
||||
keyboardShortcuts = nextShortcuts;
|
||||
if (earthSettingsState?.shared) {
|
||||
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
}
|
||||
renderShortcutSettings();
|
||||
if (persist) persistEarthSettings();
|
||||
}
|
||||
|
||||
function setShortcutEnabled(actionId, enabled, { persist = true } = {}) {
|
||||
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
|
||||
const currentShortcut = getShortcutForAction(actionId);
|
||||
if (enabled) {
|
||||
const owner = getShortcutOwnerByBinding(currentShortcut.binding, { excludeActionId: actionId });
|
||||
if (owner) {
|
||||
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
|
||||
renderShortcutSettings();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
nextShortcuts[actionId] = {
|
||||
...nextShortcuts[actionId],
|
||||
enabled: Boolean(enabled),
|
||||
};
|
||||
keyboardShortcuts = nextShortcuts;
|
||||
if (earthSettingsState?.shared) {
|
||||
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
}
|
||||
renderShortcutSettings();
|
||||
if (persist) persistEarthSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetAllShortcutBindings() {
|
||||
keyboardShortcuts = getDefaultKeyboardShortcuts();
|
||||
if (earthSettingsState?.shared) {
|
||||
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
|
||||
}
|
||||
capturingShortcutActionId = null;
|
||||
renderShortcutSettings();
|
||||
persistEarthSettings();
|
||||
showStatusMessage("快捷键已恢复默认", "info");
|
||||
}
|
||||
|
||||
function setupShortcutSettingsControls() {
|
||||
document.querySelectorAll("[data-shortcut-list]").forEach((list) => {
|
||||
bindListener(list, "click", (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const captureButton = target?.closest("[data-shortcut-capture]");
|
||||
const resetButton = target?.closest("[data-shortcut-reset]");
|
||||
if (captureButton instanceof HTMLButtonElement) {
|
||||
capturingShortcutActionId = captureButton.dataset.shortcutCapture || null;
|
||||
syncShortcutCaptureUi();
|
||||
} else if (resetButton instanceof HTMLButtonElement) {
|
||||
const actionId = resetButton.dataset.shortcutReset;
|
||||
if (actionId) resetShortcutBinding(actionId);
|
||||
}
|
||||
});
|
||||
bindListener(list, "change", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
const actionId = target.dataset.shortcutEnabled;
|
||||
if (!actionId) return;
|
||||
setShortcutEnabled(actionId, target.checked);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-shortcut-reset-all]").forEach((button) => {
|
||||
bindListener(button, "click", () => {
|
||||
resetAllShortcutBindings();
|
||||
});
|
||||
});
|
||||
renderShortcutSettings();
|
||||
}
|
||||
|
||||
function setupSettingsControls() {
|
||||
@@ -2936,6 +3618,9 @@ function setupSettingsControls() {
|
||||
resetEarthSettings();
|
||||
});
|
||||
|
||||
setupSettingsTabs();
|
||||
setupShortcutSettingsControls();
|
||||
|
||||
bindListener(settingsModal, "click", (event) => {
|
||||
const sheet = event.target.closest(".earth-settings-sheet");
|
||||
if (!sheet) {
|
||||
@@ -4008,34 +4693,53 @@ function setupTerrainControls() {
|
||||
|
||||
function setupKeyboardControls() {
|
||||
bindListener(document, "keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
if (isSearchPanelOpen()) {
|
||||
closeSearchPanel();
|
||||
if (capturingShortcutActionId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
capturingShortcutActionId = null;
|
||||
syncShortcutCaptureUi();
|
||||
return;
|
||||
}
|
||||
const nextBinding = getShortcutChordFromEvent(event);
|
||||
if (!nextBinding) return;
|
||||
if (setShortcutBinding(capturingShortcutActionId, nextBinding)) {
|
||||
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(capturingShortcutActionId);
|
||||
showStatusMessage(`${definition?.label || "快捷键"}已设置为 ${getShortcutDisplayLabel(nextBinding)}`, "info");
|
||||
capturingShortcutActionId = null;
|
||||
syncShortcutCaptureUi();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSettingsModalOpen()) {
|
||||
closeSettingsModal();
|
||||
const chord = getShortcutChordFromEvent(event);
|
||||
const definition = getShortcutDefinitionForChord(chord);
|
||||
if (!definition) return;
|
||||
if (isEditableShortcutTarget(event.target) && definition.id !== "closeFocus") return;
|
||||
if (
|
||||
definition.id !== "closeFocus" &&
|
||||
(isShortcutSuppressedBySettingsUi(event.target) ||
|
||||
isSettingsModalOpen() ||
|
||||
(isMobileLayout() && mobileDrawerOpen && mobileDrawerCard === "settings"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobileLayout() && mobileDrawerOpen) {
|
||||
setMobileDrawerState({ open: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFloatingMenuVisible()) {
|
||||
closeFloatingMenus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolbarHubController?.isOpen?.()) {
|
||||
toolbarHubController.close();
|
||||
return;
|
||||
}
|
||||
|
||||
clearLockedObjectAndInfo();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
executeKeyboardShortcut(definition.id);
|
||||
});
|
||||
bindListener(document, "keyup", (event) => {
|
||||
const chord = getShortcutChordFromEvent(event);
|
||||
const definition = getShortcutDefinitionForChord(chord);
|
||||
if (!definition || !isKeyboardRotationAction(definition.id)) return;
|
||||
stopKeyboardRotationControl({ actionId: definition.id });
|
||||
});
|
||||
bindListener(window, "blur", () => {
|
||||
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
|
||||
});
|
||||
cleanupFns.push(() => {
|
||||
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ import {
|
||||
refreshLegend,
|
||||
setLegendItems,
|
||||
} from "./legend.js";
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
import { initSearchPanel } from "./search.js";
|
||||
@@ -3569,6 +3569,13 @@ export function init() {
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
fetchEarthBrandConfig()
|
||||
.then((brandConfig) => {
|
||||
mountBrand(brandRoot, brandConfig);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Earth brand config unavailable, using defaults.", error);
|
||||
});
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
initSearchPanel({
|
||||
|
||||
@@ -328,7 +328,7 @@ function renderTicker(nextPayload) {
|
||||
.map((item) => `
|
||||
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
|
||||
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
|
||||
<span>${escapeTickerText(getNewsDisplayTitle(item))}</span>
|
||||
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
|
||||
</span>
|
||||
`)
|
||||
.join("");
|
||||
@@ -434,11 +434,12 @@ function renderPayload(nextPayload) {
|
||||
: "news-story-card";
|
||||
const title = getNewsDisplayTitle(item);
|
||||
const summaryText = getNewsDisplaySummary(item);
|
||||
const leadText = summaryText || title;
|
||||
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
|
||||
const feedLabel = getNewsFeedLabel(item.feed_name);
|
||||
const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status);
|
||||
const summary = summaryText
|
||||
? `<div class="news-story-summary">${escapeNewsHtml(summaryText)}</div>`
|
||||
const summary = title && title !== leadText
|
||||
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
@@ -446,7 +447,7 @@ function renderPayload(nextPayload) {
|
||||
<span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${escapeNewsHtml(title)}</div>
|
||||
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
|
||||
|
||||
Reference in New Issue
Block a user