diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index 44924d68..deb1f81e 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -26,13 +26,19 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
-import { ensureTVPanelReady } from "./tv.js";
+import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
-import { openSearchPanel } from "./search.js";
+import {
+ closeSearchPanel,
+ focusSearchInput,
+ isSearchPanelOpen,
+ openSearchPanel,
+ refreshSearchResults,
+} from "./search.js";
import {
setButtonTooltip,
setLayerButtonState,
@@ -86,6 +92,436 @@ let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let layerRegistry = new Map();
let layerPanelInitialized = false;
+let layoutMode = "desktop";
+let activeMobileDrawerId = null;
+let mobileDrawerOpen = false;
+let mobileDrawerCard = "layers";
+let mobileDrawerHintTimer = null;
+
+function detectLayoutMode() {
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+
+ if (width <= 820) {
+ return "mobile";
+ }
+
+ if (width <= 1080 || height <= 760) {
+ return "compact";
+ }
+
+ return "desktop";
+}
+
+export function getLayoutMode() {
+ return layoutMode;
+}
+
+export function isMobileLayout() {
+ return layoutMode === "mobile";
+}
+
+function isCompactLayout() {
+ return layoutMode === "compact";
+}
+
+function syncMobileDrawerState() {
+ const shell = document.getElementById("mobile-drawer-shell");
+ const overlay = document.getElementById("mobile-drawer-overlay");
+ const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
+ const tabs = document.querySelectorAll("[data-drawer-card]");
+ const slots = document.querySelectorAll("[data-drawer-slot]");
+ const isMobile = isMobileLayout();
+
+ document.body.classList.toggle(
+ "earth-mobile-drawer-open",
+ isMobile && mobileDrawerOpen,
+ );
+
+ if (shell instanceof HTMLElement) {
+ shell.setAttribute("aria-hidden", (!isMobile).toString());
+ }
+ if (overlay instanceof HTMLElement) {
+ overlay.hidden = !isMobile;
+ }
+
+ tabs.forEach((tab) => {
+ if (!(tab instanceof HTMLButtonElement)) return;
+ const isActive = isMobile && tab.dataset.drawerCard === mobileDrawerCard;
+ tab.classList.toggle("is-active", isActive);
+ tab.setAttribute("aria-selected", String(isActive));
+ });
+
+ slots.forEach((slot) => {
+ if (!(slot instanceof HTMLElement)) return;
+ slot.classList.toggle(
+ "is-active",
+ isMobile && slot.dataset.drawerSlot === mobileDrawerCard,
+ );
+ });
+
+ const layerPanel = document.getElementById("layer-toggles");
+ if (layerPanel instanceof HTMLElement) {
+ layerPanel.classList.toggle("is-mobile-open", false);
+ }
+
+ if (sheet instanceof HTMLElement) {
+ if (isMobile && !mobileDrawerOpen) {
+ if (!mobileDrawerHintTimer) {
+ mobileDrawerHintTimer = setInterval(() => {
+ if (mobileDrawerOpen) return;
+ sheet.classList.remove("is-hinting");
+ void sheet.offsetWidth;
+ sheet.classList.add("is-hinting");
+ }, 5000);
+ }
+ } else {
+ clearInterval(mobileDrawerHintTimer);
+ mobileDrawerHintTimer = null;
+ sheet.classList.remove("is-hinting");
+ }
+ }
+}
+
+function setMobileDrawerOpen(panelId, open) {
+ if (!isMobileLayout()) return;
+ if (panelId === "layer-toggles") {
+ mobileDrawerCard = "layers";
+ }
+ mobileDrawerOpen = open;
+ activeMobileDrawerId = open ? panelId : null;
+ syncMobileDrawerState();
+}
+
+function closeTransientMobileOverlays({ except = null } = {}) {
+ if (isMobileLayout()) {
+ if (!except) {
+ mobileDrawerOpen = false;
+ syncMobileDrawerState();
+ }
+ return;
+ }
+
+ if (except !== "search" && isSearchPanelOpen()) {
+ closeSearchPanel();
+ }
+
+ if (except !== "settings" && isSettingsModalOpen()) {
+ closeSettingsModal();
+ }
+
+ if (except !== "layer-toggles" && activeMobileDrawerId === "layer-toggles") {
+ setMobileDrawerOpen("layer-toggles", false);
+ }
+
+ if (except !== "media" && isTVPanelVisible()) {
+ setTVPanelVisible(false);
+ }
+}
+
+function applyResponsiveLayout() {
+ layoutMode = detectLayoutMode();
+
+ const isMobile = isMobileLayout();
+ const isCompact = isCompactLayout();
+ const container = document.getElementById("container");
+
+ document.documentElement.classList.toggle("layout-mode-mobile", isMobile);
+ document.documentElement.classList.toggle("layout-mode-compact", isCompact);
+ document.body.classList.toggle("layout-mode-mobile", isMobile);
+ document.body.classList.toggle("layout-mode-compact", isCompact);
+ container?.classList.toggle("layout-mode-mobile", isMobile);
+ container?.classList.toggle("layout-mode-compact", isCompact);
+
+ if (!isMobile) {
+ activeMobileDrawerId = null;
+ mobileDrawerOpen = false;
+ }
+
+ syncMobileDrawerState();
+}
+
+function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard } = {}) {
+ mobileDrawerOpen = Boolean(open);
+ mobileDrawerCard = card || "layers";
+ activeMobileDrawerId = mobileDrawerOpen && mobileDrawerCard === "layers"
+ ? "layer-toggles"
+ : null;
+ syncMobileDrawerState();
+
+ if (!isMobileLayout()) {
+ return;
+ }
+
+ if (mobileDrawerOpen) {
+ closeFloatingMenus();
+ closeSearchPanel();
+ if (isSettingsModalOpen()) {
+ closeSettingsModal();
+ }
+ }
+
+ if (!mobileDrawerOpen) {
+ return;
+ }
+
+ if (mobileDrawerCard === "search") {
+ window.setTimeout(() => {
+ focusSearchInput({ select: true });
+ refreshSearchResults().catch((error) => {
+ console.warn("刷新抽屉搜索失败:", error);
+ });
+ }, 16);
+ } else if (mobileDrawerCard === "tv") {
+ ensureTVPanelReady().catch((error) => {
+ console.error("初始化媒体抽屉失败:", error);
+ });
+ }
+}
+
+function getMobileLayerButtons(layerId) {
+ return Array.from(
+ document.querySelectorAll(`[data-mobile-layer-button="${layerId}"]`),
+ ).filter((button) => button instanceof HTMLButtonElement);
+}
+
+function syncMobileLayerCards() {
+ const summary = document.getElementById("mobile-layer-summary");
+ const definitions = getSortedLayerDefinitions();
+ let activeCount = 0;
+
+ definitions.forEach((definition) => {
+ const visible = Boolean(definition.getVisible?.());
+ if (visible) {
+ activeCount += 1;
+ }
+ getMobileLayerButtons(definition.id).forEach((button) => {
+ button.classList.toggle("is-active", visible);
+ button.setAttribute("aria-checked", visible ? "true" : "false");
+ const status = button.querySelector("[data-mobile-layer-status]");
+ if (status) {
+ status.textContent = visible ? "开启" : "关闭";
+ }
+ });
+ });
+
+ if (summary) {
+ summary.textContent = `已启用 ${activeCount} 个图层`;
+ }
+}
+
+function renderMobileLayerCards() {
+ const list = document.getElementById("mobile-layer-list");
+ if (!(list instanceof HTMLElement)) return;
+
+ const definitions = getSortedLayerDefinitions();
+ list.innerHTML = definitions
+ .map((definition) => `
+
+ `)
+ .join("");
+
+ list.querySelectorAll("[data-mobile-layer-button]").forEach((button) => {
+ bindListener(button, "click", async (event) => {
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLButtonElement)) return;
+ const layerId = target.dataset.mobileLayerButton;
+ const definition = layerId ? getLayerDefinition(layerId) : null;
+ if (!definition) return;
+ await definition.setVisible(!definition.getVisible());
+ syncMobileLayerCards();
+ });
+ });
+
+ syncMobileLayerCards();
+}
+
+function setupMobileDrawerShell() {
+ const overlay = document.getElementById("mobile-drawer-overlay");
+ const shell = document.getElementById("mobile-drawer-shell");
+ const handle = document.getElementById("mobile-drawer-handle");
+ const tabs = document.querySelectorAll("[data-drawer-card]");
+ const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
+
+ bindListener(overlay, "click", () => {
+ setMobileDrawerState({ open: false });
+ });
+
+ tabs.forEach((tab) => {
+ bindListener(tab, "click", (event) => {
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLButtonElement)) return;
+ const card = target.dataset.drawerCard || "layers";
+ setMobileDrawerState({ open: true, card });
+ });
+ });
+
+ if (handle instanceof HTMLElement && sheet instanceof HTMLElement) {
+ const DRAWER_HANDLE_PX = 36;
+ const SWIPE_OPEN_VELOCITY = 0.3; // px/ms upward → open regardless of position
+ const SWIPE_IDLE_VELOCITY = 0.05; // px/ms threshold below which position decides
+ const SWIPE_CLOSE_VELOCITY = 0.5; // px/ms downward on content → close
+
+ let startY = 0;
+ let startTranslate = 0;
+ let dragging = false;
+ let activePointerId = null;
+ let lastMoveY = 0;
+ let lastMoveTime = 0;
+ let velocityY = 0;
+
+ sheet.addEventListener("animationend", () => {
+ sheet.classList.remove("is-hinting");
+ });
+
+ const getClosedOffset = () => {
+ const safeBottom = Number.parseFloat(
+ getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
+ ) || 0;
+ return Math.max(sheet.offsetHeight - DRAWER_HANDLE_PX - safeBottom, 0);
+ };
+
+ const applyTranslate = (value) => {
+ sheet.style.transition = "none";
+ sheet.style.transform = `translateY(${Math.max(0, Math.min(getClosedOffset(), value))}px)`;
+ };
+
+ const stopDragging = (event) => {
+ if (!dragging) return;
+ if (
+ event &&
+ activePointerId !== null &&
+ "pointerId" in event &&
+ event.pointerId !== activePointerId
+ ) {
+ return;
+ }
+
+ const currentTransform = sheet.style.transform;
+ const match = currentTransform.match(/translateY\(([-\d.]+)px\)/);
+ const finalOffset = match ? Number.parseFloat(match[1]) : startTranslate;
+ const closedOffset = getClosedOffset();
+ const velocity = velocityY;
+
+ dragging = false;
+ activePointerId = null;
+ velocityY = 0;
+ lastMoveY = 0;
+ lastMoveTime = 0;
+ sheet.style.transition = "";
+ sheet.style.transform = "";
+
+ const shouldOpen =
+ velocity < -SWIPE_OPEN_VELOCITY
+ || (velocity <= SWIPE_IDLE_VELOCITY && finalOffset < closedOffset * 0.5);
+
+ if (shouldOpen) {
+ setMobileDrawerState({ open: true, card: mobileDrawerCard || "layers" });
+ } else {
+ setMobileDrawerState({ open: false });
+ }
+ };
+
+ bindListener(handle, "click", () => {
+ if (dragging) return;
+ setMobileDrawerState({ open: !mobileDrawerOpen, card: mobileDrawerCard || "layers" });
+ });
+
+ bindListener(handle, "pointerdown", (event) => {
+ if (!isMobileLayout()) return;
+ sheet.classList.remove("is-hinting");
+ dragging = true;
+ activePointerId = event.pointerId;
+ startY = event.clientY;
+ lastMoveY = event.clientY;
+ lastMoveTime = performance.now();
+ velocityY = 0;
+ startTranslate = mobileDrawerOpen ? 0 : getClosedOffset();
+ applyTranslate(startTranslate);
+ handle.setPointerCapture?.(event.pointerId);
+ event.preventDefault();
+ });
+
+ bindListener(window, "pointermove", (event) => {
+ if (!dragging) return;
+ if (activePointerId !== null && event.pointerId !== activePointerId) return;
+ const now = performance.now();
+ const dt = now - lastMoveTime;
+ if (dt > 0) {
+ velocityY = (event.clientY - lastMoveY) / dt;
+ }
+ lastMoveY = event.clientY;
+ lastMoveTime = now;
+ const deltaY = event.clientY - startY;
+ applyTranslate(startTranslate + deltaY);
+ event.preventDefault();
+ }, { passive: false });
+
+ bindListener(window, "pointerup", stopDragging);
+ bindListener(window, "pointercancel", stopDragging);
+ bindListener(handle, "lostpointercapture", stopDragging);
+ }
+
+ bindListener(window, "earth:open-details-tab", () => {
+ if (isMobileLayout()) setMobileDrawerState({ open: true, card: "details" });
+ });
+
+ const content = shell?.querySelector(".earth-mobile-drawer-content");
+ if (content instanceof HTMLElement) {
+ let contentStartY = 0;
+ let contentLastY = 0;
+ let contentLastTime = 0;
+ let contentVelocityY = 0;
+ let contentTracking = false;
+
+ bindListener(content, "pointerdown", (event) => {
+ if (!isMobileLayout() || !mobileDrawerOpen) return;
+ contentStartY = event.clientY;
+ contentLastY = event.clientY;
+ contentLastTime = performance.now();
+ contentVelocityY = 0;
+ contentTracking = true;
+ });
+
+ bindListener(content, "pointermove", (event) => {
+ if (!contentTracking) return;
+ const now = performance.now();
+ const dt = now - contentLastTime;
+ if (dt > 0) {
+ contentVelocityY = (event.clientY - contentLastY) / dt;
+ }
+ contentLastY = event.clientY;
+ contentLastTime = now;
+ });
+
+ const endContentTrack = () => {
+ if (!contentTracking) return;
+ contentTracking = false;
+ const activeSlot = content.querySelector(".earth-mobile-drawer-slot.is-active");
+ const atTop = !activeSlot || activeSlot.scrollTop <= 2;
+ if (atTop && contentVelocityY > SWIPE_CLOSE_VELOCITY) {
+ setMobileDrawerState({ open: false });
+ }
+ contentVelocityY = 0;
+ };
+
+ bindListener(content, "pointerup", endContentTrack);
+ bindListener(content, "pointercancel", endContentTrack);
+ }
+}
function compareLayerDefinitionsByStartupPriority(left, right) {
const leftPriority = Number.isFinite(left?.startupPriority)
@@ -282,20 +718,23 @@ function persistEarthSettings() {
}
function syncDefaultEarthZoomUi(nextZoom) {
- const slider = document.getElementById("default-earth-size-slider");
- const value = document.getElementById("default-earth-size-value");
+ 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]");
const zoomValue = document.getElementById("zoom-value");
const tooltipText = getZoomResetTooltipText(nextZoom);
- if (slider instanceof HTMLInputElement) {
+ sliders.forEach((slider) => {
+ if (!(slider instanceof HTMLInputElement)) return;
slider.min = CONFIG.minZoom.toString();
slider.max = CONFIG.maxZoom.toString();
slider.step = DEFAULT_EARTH_ZOOM_STEP.toString();
slider.value = nextZoom.toFixed(2);
- }
- if (value) {
- value.textContent = formatZoomPercent(nextZoom);
- }
+ });
+ values.forEach((value) => {
+ if (value instanceof HTMLElement) {
+ value.textContent = formatZoomPercent(nextZoom);
+ }
+ });
if (zoomValue instanceof HTMLElement) {
zoomValue.title = tooltipText;
const tooltip = zoomValue.querySelector(".tooltip");
@@ -332,14 +771,16 @@ async function applyEarthSettings(settings) {
});
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity);
- const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
- const terrainOpacityValue = document.getElementById("terrain-opacity-value");
- if (terrainOpacitySlider instanceof HTMLInputElement) {
- terrainOpacitySlider.value = appliedOpacity.toFixed(2);
- }
- if (terrainOpacityValue) {
- terrainOpacityValue.textContent = `${Math.round(appliedOpacity * 100)}%`;
- }
+ document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => {
+ if (slider instanceof HTMLInputElement) {
+ slider.value = appliedOpacity.toFixed(2);
+ }
+ });
+ document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]").forEach((value) => {
+ if (value instanceof HTMLElement) {
+ value.textContent = `${Math.round(appliedOpacity * 100)}%`;
+ }
+ });
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true });
@@ -377,6 +818,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
if (!enabled) {
applyTerrainUiState(button, false);
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("地形已隐藏", "info");
@@ -399,6 +841,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
if (toggleToken !== terrainToggleToken) return showTerrain;
applyTerrainUiState(button, true);
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形已显示", "success");
@@ -407,6 +850,7 @@ async function setTerrainEnabled(button, enabled, { persist = true, silent = fal
} catch (error) {
console.error("加载真实地形失败:", error);
applyTerrainUiState(button, false);
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形暂时不可用", "error");
@@ -429,11 +873,14 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
if (!enabled && !silent) {
showStatusMessage("卫星已隐藏", "info");
} else if (enabled) {
- const satelliteCountEl = document.getElementById("satellite-count");
- if (satelliteCountEl) {
- satelliteCountEl.textContent = `${getSatelliteCount()} 颗`;
- }
+ ["satellite-count", "mobile-satellite-count"].forEach((id) => {
+ const satelliteCountEl = document.getElementById(id);
+ if (satelliteCountEl) {
+ satelliteCountEl.textContent = `${getSatelliteCount()} 颗`;
+ }
+ });
}
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
@@ -443,6 +890,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
loading: false,
tooltip: "显示卫星",
});
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
@@ -462,6 +910,7 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
if (bgpCountEl) {
bgpCountEl.textContent = `${getBGPCount()} 条`;
}
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
@@ -475,6 +924,7 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
active: enabled,
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
});
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
@@ -486,10 +936,12 @@ async function setCablesLayerEnabled(button, enabled, { persist = true, silent =
clearSelectionIfHiding(!enabled);
try {
await setCablesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换线缆显示失败:", error);
+ syncMobileLayerCards();
if (persist) persistEarthSettings();
return getShowCables();
}
@@ -661,6 +1113,7 @@ function registerLayerDefinition(definition, options = {}) {
if (row && layerPanelInitialized) {
bindLayerButton(row, normalizedDefinition);
}
+ renderMobileLayerCards();
return normalizedDefinition;
}
@@ -749,6 +1202,15 @@ export function applyImmediateView(targetEarthObj, camera, options = {}) {
}
}
+export function setZoomLevel(nextZoom, camera = activeCamera) {
+ zoomLevel = clampEarthZoomLevel(nextZoom);
+ if (camera) {
+ camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
+ updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
+ }
+ return zoomLevel;
+}
+
function cancelSettingsSheetAnimation() {
if (settingsSheetAnimation) {
settingsSheetAnimation.cancel();
@@ -892,6 +1354,10 @@ function closeFloatingMenus() {
}
function openSettingsModal() {
+ if (isMobileLayout()) {
+ setMobileDrawerState({ open: true, card: "settings" });
+ return;
+ }
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
@@ -901,7 +1367,9 @@ function openSettingsModal() {
settingsModalTimer = null;
}
closeFloatingMenus();
+ closeTransientMobileOverlays({ except: "settings" });
cancelSettingsSheetAnimation();
+ document.body.classList.add("earth-settings-open");
modal.classList.remove("is-closing");
modal.classList.add("is-opening");
modal.classList.add("is-open");
@@ -918,11 +1386,15 @@ function openSettingsModal() {
}
function closeSettingsModal() {
+ if (isMobileLayout()) {
+ return;
+ }
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
cancelSettingsSheetAnimation();
+ document.body.classList.remove("earth-settings-open");
modal.classList.remove("is-open");
modal.classList.add("is-closing");
if (sheet instanceof HTMLElement) {
@@ -942,6 +1414,10 @@ function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
+ if (!visible && activeMobileDrawerId === panelId) {
+ activeMobileDrawerId = null;
+ syncMobileDrawerState();
+ }
syncSettingsToggle(panelId, visible);
if (panelId === "media-panel") {
updateTVToggleUI(visible);
@@ -974,10 +1450,11 @@ function syncAllHudPanelToggles() {
}
function syncDayNightToggle(enabled) {
- const input = document.getElementById("toggle-daynight");
- if (input instanceof HTMLInputElement) {
- input.checked = enabled;
- }
+ document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
+ if (input instanceof HTMLInputElement) {
+ input.checked = enabled;
+ }
+ });
}
function applyDayNightEnabled(enabled, { persist = true } = {}) {
@@ -1029,24 +1506,29 @@ function setupSettingsControls() {
});
});
- const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
- const terrainOpacityValue = document.getElementById("terrain-opacity-value");
- const defaultEarthSizeSlider = document.getElementById("default-earth-size-slider");
+ const terrainOpacitySliders = document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]");
+ 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 syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
- if (terrainOpacitySlider instanceof HTMLInputElement) {
- terrainOpacitySlider.value = nextOpacity.toFixed(2);
- }
- if (terrainOpacityValue) {
- terrainOpacityValue.textContent = `${safeOpacity}%`;
- }
+ terrainOpacitySliders.forEach((slider) => {
+ if (slider instanceof HTMLInputElement) {
+ slider.value = nextOpacity.toFixed(2);
+ }
+ });
+ terrainOpacityValues.forEach((value) => {
+ if (value instanceof HTMLElement) {
+ value.textContent = `${safeOpacity}%`;
+ }
+ });
};
syncTerrainOpacityUi(getTerrainOpacity());
syncDefaultEarthZoomUi(defaultEarthZoom);
- if (terrainOpacitySlider instanceof HTMLInputElement) {
+ terrainOpacitySliders.forEach((terrainOpacitySlider) => {
+ if (!(terrainOpacitySlider instanceof HTMLInputElement)) return;
bindListener(terrainOpacitySlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
@@ -1057,9 +1539,10 @@ function setupSettingsControls() {
syncTerrainOpacityUi(appliedOpacity);
persistEarthSettings();
});
- }
+ });
- if (defaultEarthSizeSlider instanceof HTMLInputElement) {
+ defaultEarthSizeSliders.forEach((defaultEarthSizeSlider) => {
+ if (!(defaultEarthSizeSlider instanceof HTMLInputElement)) return;
bindListener(defaultEarthSizeSlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
@@ -1069,7 +1552,7 @@ function setupSettingsControls() {
{ persist: true, applyToCurrentView: true },
);
});
- }
+ });
rotationModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
@@ -1081,12 +1564,17 @@ function setupSettingsControls() {
});
});
- const dayNightToggle = document.getElementById("toggle-daynight");
- if (dayNightToggle instanceof HTMLInputElement) {
+ document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
+ if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
applyDayNightEnabled(dayNightToggle.checked);
});
- }
+ });
+
+ const mobileSettingsReset = document.getElementById("mobile-settings-reset");
+ bindListener(mobileSettingsReset, "click", () => {
+ resetEarthSettings();
+ });
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
@@ -1104,6 +1592,10 @@ function setupHudPanelControls() {
if (!(target instanceof HTMLElement)) return;
const panelId = target.dataset.closePanel;
if (!panelId) return;
+ if (isMobileLayout() && panelId === "layer-toggles") {
+ setMobileDrawerOpen(panelId, false);
+ return;
+ }
setHudPanelVisibility(panelId, false);
});
});
@@ -1231,19 +1723,32 @@ function setupDraggableHudPanels() {
if (!handle) return;
let isDragging = false;
+ let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
- const stopDragging = () => {
+ const stopDragging = (event) => {
+ if (
+ event &&
+ activePointerId !== null &&
+ "pointerId" in event &&
+ event.pointerId !== activePointerId
+ ) {
+ return;
+ }
isDragging = false;
+ activePointerId = null;
panel.classList.remove("is-dragging");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!isDragging) return;
+ if (activePointerId !== null && event.pointerId !== activePointerId) return;
+ if (isMobileLayout()) return;
+ event.preventDefault();
const desiredLeft = startLeft + (event.clientX - startPointerX);
const desiredTop = startTop + (event.clientY - startPointerY);
capturePanelAnchor(app, panel, desiredLeft, desiredTop);
@@ -1263,8 +1768,11 @@ function setupDraggableHudPanels() {
};
bindListener(handle, "pointerdown", (event) => {
+ if (isMobileLayout()) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
+ event.preventDefault();
isDragging = true;
+ activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -1299,9 +1807,9 @@ function setupDraggableHudPanels() {
handle.setPointerCapture?.(event.pointerId);
});
- bindListener(handle, "pointermove", onMove);
- bindListener(handle, "pointerup", stopDragging);
- bindListener(handle, "pointercancel", stopDragging);
+ bindListener(window, "pointermove", onMove, { passive: false });
+ bindListener(window, "pointerup", stopDragging);
+ bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
});
@@ -1394,6 +1902,7 @@ export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
activeCamera = camera;
earthObj = earth;
+ applyResponsiveLayout();
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
@@ -1401,6 +1910,21 @@ export function setupControls(camera, renderer, scene, earth) {
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
+ bindListener(window, "resize", () => {
+ applyResponsiveLayout();
+ });
+ bindListener(window, "earth:search-open-change", (event) => {
+ if (event instanceof CustomEvent && event.detail?.open) {
+ closeTransientMobileOverlays({ except: "search" });
+ }
+ });
+ bindListener(window, "earth:tv-visibility-change", (event) => {
+ if (event instanceof CustomEvent && event.detail?.visible) {
+ closeTransientMobileOverlays({ except: "media" });
+ }
+ });
+ // No longer auto-navigates to details tab on mobile — popup handles the display.
+ // Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup.
}
function setupZoomControls(camera) {
@@ -1786,6 +2310,7 @@ export function getStartupLoadLayers() {
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
+ const layerBtn = document.getElementById("layer-action");
const searchBtn = document.getElementById("search-action");
const terrainBtn = getLayerButton("terrain");
const layoutBtn = document.getElementById("layout-toggle");
@@ -1796,8 +2321,29 @@ function setupTerrainControls() {
setupHudPanelControls();
setupDraggableHudPanels();
setupLayerPanel();
+ setupMobileDrawerShell();
+ renderMobileLayerCards();
+
+ bindListener(layerBtn, "click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ 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);
+ });
bindListener(searchBtn, "click", () => {
+ if (isMobileLayout()) {
+ setMobileDrawerState({ open: true, card: "search" });
+ return;
+ }
+ closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
});
@@ -1821,6 +2367,15 @@ function setupTerrainControls() {
const openGroups = [zoomGroup].filter((group) =>
group?.classList.contains("open"),
);
+ if (
+ isMobileLayout() &&
+ mobileDrawerOpen &&
+ event.target instanceof Element &&
+ !event.target.closest("#mobile-drawer-shell")
+ ) {
+ setMobileDrawerState({ open: false });
+ }
+
if (openGroups.length === 0) return;
const clickedInsideOpenGroup = openGroups.some((group) =>
@@ -1851,6 +2406,7 @@ function setupTerrainControls() {
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
+ applyResponsiveLayout();
updateLayoutUI(container);
}
@@ -1858,11 +2414,21 @@ function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
+ if (isSearchPanelOpen()) {
+ closeSearchPanel();
+ return;
+ }
+
if (isSettingsModalOpen()) {
closeSettingsModal();
return;
}
+ if (isMobileLayout() && mobileDrawerOpen) {
+ setMobileDrawerState({ open: false });
+ return;
+ }
+
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
@@ -1970,9 +2536,12 @@ function setupToolbarHubCluster() {
let collapseTimer = null;
let expandedToolbarBounds = null;
let refreshBoundsFrameId = 0;
+ let hubPinnedOpen = false;
const layoutToolbarOrbs = () => {
- const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
+ const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter(
+ (orb) => getComputedStyle(orb).display !== "none",
+ );
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
@@ -2056,6 +2625,7 @@ function setupToolbarHubCluster() {
};
const scheduleCollapse = () => {
+ if (hubPinnedOpen) return;
if (collapseTimer) clearTimeout(collapseTimer);
collapseTimer = window.setTimeout(() => {
setExpanded(false);
@@ -2094,6 +2664,19 @@ function setupToolbarHubCluster() {
setExpanded(true);
});
+ bindListener(hub, "click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ cancelCollapse();
+ if (isMobileLayout()) {
+ hubPinnedOpen = !cluster.classList.contains("is-expanded");
+ setExpanded(hubPinnedOpen);
+ return;
+ }
+ hubPinnedOpen = !cluster.classList.contains("is-expanded");
+ setExpanded(hubPinnedOpen);
+ });
+
const HOVER_PADDING_PX = 12;
const collectExpandedToolbarBounds = () => {
const rects = [];
@@ -2153,6 +2736,7 @@ function setupToolbarHubCluster() {
};
bindListener(document, "mousemove", (event) => {
+ if (hubPinnedOpen) return;
if (!cluster.classList.contains("is-expanded")) return;
if (
event.target instanceof Element &&
@@ -2188,6 +2772,24 @@ function setupToolbarHubCluster() {
scheduleExpandedToolbarBoundsRefresh();
}
});
+
+ cluster.querySelectorAll(".earth-toolbar-orb > button").forEach((button) => {
+ if (!(button instanceof HTMLButtonElement) || button === hub) return;
+ bindListener(button, "click", () => {
+ if (hubPinnedOpen) {
+ hubPinnedOpen = false;
+ setExpanded(false);
+ }
+ });
+ });
+
+ bindListener(document, "pointerdown", (event) => {
+ if (!hubPinnedOpen) return;
+ if (!(event.target instanceof Element)) return;
+ if (event.target.closest("#toolbar-cluster")) return;
+ hubPinnedOpen = false;
+ setExpanded(false);
+ });
}
export function teardownControls() {
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
index 26018c1a..ca89bb04 100644
--- a/frontend/public/earth/js/info-card.js
+++ b/frontend/public/earth/js/info-card.js
@@ -4,6 +4,189 @@ import { showStatusMessage } from './ui.js';
let currentType = null;
let cardMounted = false;
+// ── Mobile popup ─────────────────────────────────────────────
+
+function getMobilePopupTitle(type, data) {
+ switch (type) {
+ case 'cable': return data.name || '海缆';
+ case 'landing_point': return data.name || '登陆点';
+ case 'satellite': return data.name || '卫星';
+ case 'bgp': return data.anomaly_type || 'BGP事件';
+ case 'bgp_collector': return data.collector || 'BGP观测站';
+ case 'supercomputer': return data.name || '超算';
+ case 'gpu_cluster': return data.name || 'GPU集群';
+ default: return '详情';
+ }
+}
+
+function getMobilePopupSubtitle(type, data) {
+ switch (type) {
+ case 'cable': return data.owner || data.status || '海缆';
+ case 'landing_point': return data.country || '登陆点';
+ case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星';
+ case 'bgp': return data.severity || 'BGP路由异常';
+ case 'bgp_collector': return data.location || 'BGP观测站';
+ case 'supercomputer': return data.country || '超级计算机';
+ case 'gpu_cluster': return data.country || 'GPU集群';
+ default: return '';
+ }
+}
+
+function positionMobilePopup(popup, touchX, touchY) {
+ const margin = 14;
+ const drawerClearance = 52;
+ const vpW = window.innerWidth;
+ const vpH = window.innerHeight;
+ const safeBottom = parseFloat(
+ getComputedStyle(document.documentElement).getPropertyValue('--safe-bottom')
+ ) || 0;
+ const bottomBound = vpH - drawerClearance - safeBottom;
+
+ // Measure actual popup size (it's rendered but invisible via opacity)
+ const popW = popup.offsetWidth || 200;
+ const popH = popup.offsetHeight || 68;
+
+ const gap = 22;
+ const spaceRight = vpW - touchX;
+ const spaceLeft = touchX;
+ const spaceBottom = bottomBound - touchY;
+ const spaceTop = touchY;
+
+ let left, top;
+
+ // Horizontal: side with more room
+ if (spaceRight >= popW + gap + margin) {
+ left = touchX + gap;
+ } else if (spaceLeft >= popW + gap + margin) {
+ left = touchX - gap - popW;
+ } else {
+ left = Math.max(margin, Math.min(touchX - popW / 2, vpW - popW - margin));
+ }
+
+ // Vertical: prefer above touch, then below
+ if (spaceTop >= popH + gap + margin) {
+ top = touchY - gap - popH;
+ } else if (spaceBottom >= popH + gap + margin) {
+ top = touchY + gap;
+ } else {
+ top = Math.max(margin, Math.min(touchY - popH / 2, bottomBound - popH - margin));
+ }
+
+ left = Math.max(margin, Math.min(left, vpW - popW - margin));
+ top = Math.max(margin, Math.min(top, bottomBound - popH - margin));
+
+ popup.style.left = `${left}px`;
+ popup.style.top = `${top}px`;
+}
+
+let popupShowToken = 0;
+
+function showMobilePopup(type, data, x, y) {
+ // Require coordinates — skip if called without position (e.g. from handleCableClick)
+ if (x == null || y == null) return;
+
+ const popup = document.getElementById('earth-mobile-popup');
+ const iconEl = document.getElementById('earth-mobile-popup-icon');
+ const titleEl = document.getElementById('earth-mobile-popup-title');
+ const subEl = document.getElementById('earth-mobile-popup-sub');
+ if (!popup || !iconEl || !titleEl || !subEl) return;
+
+ const config = CARD_CONFIG[type];
+ if (!config) return;
+
+ iconEl.textContent = config.icon;
+ titleEl.textContent = getMobilePopupTitle(type, data);
+ subEl.textContent = getMobilePopupSubtitle(type, data);
+
+ // Invalidate any in-flight hide listener
+ popupShowToken += 1;
+ const token = popupShowToken;
+
+ popup.removeAttribute('hidden');
+ popup.classList.remove('is-visible');
+
+ requestAnimationFrame(() => {
+ positionMobilePopup(popup, x, y);
+ requestAnimationFrame(() => {
+ if (token !== popupShowToken) return; // superseded
+ popup.classList.add('is-visible');
+ });
+ });
+}
+
+function hideMobilePopup() {
+ const popup = document.getElementById('earth-mobile-popup');
+ if (!popup) return;
+ popupShowToken += 1; // invalidate any pending show
+ popup.classList.remove('is-visible');
+ popup.addEventListener('transitionend', () => {
+ if (!popup.classList.contains('is-visible')) {
+ popup.setAttribute('hidden', '');
+ }
+ }, { once: true });
+}
+
+let popupClickBound = false;
+function ensurePopupClickHandler() {
+ if (popupClickBound) return;
+ popupClickBound = true;
+ const popup = document.getElementById('earth-mobile-popup');
+ if (!popup) return;
+
+ let dragPointerId = null;
+ let startX = 0, startY = 0;
+ let startLeft = 0, startTop = 0;
+ let dragged = false;
+ const DRAG_THRESHOLD = 10;
+
+ popup.addEventListener('pointerdown', (e) => {
+ if (e.button > 0) return;
+ e.stopPropagation();
+ dragPointerId = e.pointerId;
+ startX = e.clientX;
+ startY = e.clientY;
+ const rect = popup.getBoundingClientRect();
+ startLeft = rect.left;
+ startTop = rect.top;
+ dragged = false;
+ });
+
+ // Track drag at document level so pointer can leave popup bounds
+ document.addEventListener('pointermove', (e) => {
+ if (e.pointerId !== dragPointerId) return;
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
+ dragged = true;
+ e.stopPropagation();
+ const margin = 8;
+ const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
+ const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
+ popup.style.left = `${left}px`;
+ popup.style.top = `${top}px`;
+ });
+
+ document.addEventListener('pointerup', (e) => {
+ if (e.pointerId !== dragPointerId) return;
+ const wasDragged = dragged;
+ dragPointerId = null;
+ dragged = false;
+ if (!wasDragged) {
+ window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
+ }
+ });
+
+ document.addEventListener('pointercancel', (e) => {
+ if (e.pointerId === dragPointerId) {
+ dragPointerId = null;
+ dragged = false;
+ }
+ });
+
+ // Block click from bubbling to document (which would close the drawer)
+ popup.addEventListener('click', (e) => e.stopPropagation());
+}
+
const CARD_CONFIG = {
cable: {
icon: '🛥️',
@@ -126,19 +309,31 @@ function setupInfoCardDrag(panel) {
if (!handle) return;
let isDragging = false;
+ let activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
- const stopDragging = () => {
+ const stopDragging = (event) => {
+ if (
+ event &&
+ activePointerId !== null &&
+ "pointerId" in event &&
+ event.pointerId !== activePointerId
+ ) {
+ return;
+ }
isDragging = false;
+ activePointerId = null;
panel.classList.remove('is-dragging');
document.body.style.userSelect = '';
};
const onMove = (event) => {
if (!isDragging) return;
+ if (activePointerId !== null && event.pointerId !== activePointerId) return;
+ event.preventDefault();
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextLeft = Math.min(
@@ -155,7 +350,9 @@ function setupInfoCardDrag(panel) {
handle.addEventListener('pointerdown', (event) => {
if (event.target.closest('.hud-panel-close, .info-card-close')) return;
+ event.preventDefault();
isDragging = true;
+ activePointerId = event.pointerId;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
@@ -171,9 +368,9 @@ function setupInfoCardDrag(panel) {
handle.setPointerCapture?.(event.pointerId);
});
- handle.addEventListener('pointermove', onMove);
- handle.addEventListener('pointerup', stopDragging);
- handle.addEventListener('pointercancel', stopDragging);
+ window.addEventListener('pointermove', onMove, { passive: false });
+ window.addEventListener('pointerup', stopDragging);
+ window.addEventListener('pointercancel', stopDragging);
handle.addEventListener('lostpointercapture', stopDragging);
}
@@ -252,6 +449,13 @@ function mountCard() {
function positionPanel(panel, x, y, options = {}) {
if (!panel) return;
+ if (document.body.classList.contains('layout-mode-mobile')) {
+ panel.style.left = '8px';
+ panel.style.right = '8px';
+ panel.style.top = 'auto';
+ panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
+ return;
+ }
const margin = 12;
const offset = 14;
const vpW = window.innerWidth;
@@ -296,11 +500,19 @@ function showPanel(x, y, options = {}) {
if (!panel) return;
if (x != null && y != null) positionPanel(panel, x, y, options);
panel.classList.add('is-visible');
+ document.body.classList.add('earth-info-open');
+ window.dispatchEvent(
+ new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
+ );
}
function hidePanel() {
const panel = getPanel();
if (panel) panel.classList.remove('is-visible');
+ document.body.classList.remove('earth-info-open');
+ window.dispatchEvent(
+ new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
+ );
}
// No-op: event binding now happens lazily in mountCard()
@@ -320,6 +532,51 @@ export function showInfoCard(type, data, options = {}) {
return;
}
+ if (document.body.classList.contains('layout-mode-mobile')) {
+ currentType = type;
+
+ // Fill drawer details slot (accessible when user taps popup → opens details tab)
+ const icon = document.getElementById('mobile-info-card-icon');
+ const title = document.getElementById('mobile-info-card-title');
+ const typeLabel = document.getElementById('mobile-info-card-type');
+ 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 (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 += `
+
+ ${field.label}
+ ${value}
+
+ `;
+ }
+ content.innerHTML = html;
+ }
+
+ // Show the floating mini popup near the touch point (requires coordinates)
+ if (options.x != null && options.y != null) {
+ ensurePopupClickHandler();
+ showMobilePopup(type, data, options.x, options.y);
+ document.body.classList.add('earth-info-open');
+ window.dispatchEvent(
+ new CustomEvent('earth:info-card-visibility-change', { detail: { visible: true } })
+ );
+ }
+ return;
+ }
+
mountCard();
currentType = type;
@@ -359,6 +616,15 @@ export function showInfoCard(type, data, options = {}) {
}
export function hideInfoCard() {
+ if (document.body.classList.contains('layout-mode-mobile')) {
+ hideMobilePopup();
+ document.body.classList.remove('earth-info-open');
+ window.dispatchEvent(
+ new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
+ );
+ currentType = null;
+ return;
+ }
hidePanel();
currentType = null;
}
diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js
index 67567139..2a5db9a0 100644
--- a/frontend/public/earth/js/legend.js
+++ b/frontend/public/earth/js/legend.js
@@ -62,17 +62,18 @@ export function setLegendItems(mode, items) {
}
function syncCurrentLabel(mode) {
- const labelEl = document.getElementById("legend-current-label");
- if (!labelEl) return;
- labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
+ const nextLabel = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
+ [document.getElementById("legend-current-label"), document.getElementById("mobile-situation-legend-mode")]
+ .forEach((labelEl) => {
+ if (labelEl) {
+ labelEl.textContent = nextLabel;
+ }
+ });
}
function renderLegend(mode) {
- const listEl = document.querySelector("#legend .legend-list");
- if (!listEl) return;
-
const items = legendItemsByMode[mode] || [];
- listEl.innerHTML = items
+ const html = items
.map(
(item) => `
@@ -81,4 +82,14 @@ function renderLegend(mode) {
`,
)
.join("");
+
+ const desktopList = document.querySelector("#legend .legend-list");
+ if (desktopList) {
+ desktopList.innerHTML = html;
+ }
+
+ const mobileList = document.getElementById("mobile-situation-legend-list");
+ if (mobileList) {
+ mobileList.innerHTML = html;
+ }
}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 49695a1c..918b42ce 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -137,6 +137,7 @@ import {
applyImmediateView,
focusEarthView,
getZoomLevel,
+ setZoomLevel,
teardownControls,
} from "./controls.js";
import {
@@ -206,6 +207,11 @@ let cruisePollTimerId = null;
let cruiseConnector = null;
let cruiseBGPAdapter = null;
let cruiseSequencer = null;
+let activeDragPointerId = null;
+let activeTouchPoints = new Map();
+let pinchGesture = null;
+let pointerDragDistance = 0;
+let suppressNextClick = false;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
@@ -226,6 +232,7 @@ const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
+const DRAG_POINTER_THRESHOLD_PX = 8;
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
@@ -239,6 +246,8 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
+ "#mobile-drawer-shell",
+ "#mobile-drawer-shell *",
];
function bindListener(target, eventName, handler, options) {
@@ -276,6 +285,13 @@ function getDragRotationFactor() {
return CONFIG.dragRotationFactorBase * scale;
}
+function getTouchDistance(firstPoint, secondPoint) {
+ return Math.hypot(
+ secondPoint.clientX - firstPoint.clientX,
+ secondPoint.clientY - firstPoint.clientY,
+ );
+}
+
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
@@ -1026,10 +1042,12 @@ function updateBGPHud(bgpResult) {
bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`;
}
- const bgpStatusEl = document.getElementById("bgp-status-summary");
- if (bgpStatusEl) {
- bgpStatusEl.textContent = getBGPStatusText(bgpResult);
- }
+ ["bgp-status-summary", "mobile-bgp-status-summary"].forEach((id) => {
+ const bgpStatusEl = document.getElementById(id);
+ if (bgpStatusEl) {
+ bgpStatusEl.textContent = getBGPStatusText(bgpResult);
+ }
+ });
}
function ensureCruiseConnector() {
@@ -1392,10 +1410,12 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
});
}
- const satelliteCountEl = document.getElementById("satellite-count");
- if (satelliteCountEl) {
- satelliteCountEl.textContent = `${satelliteCount} 颗`;
- }
+ ["satellite-count", "mobile-satellite-count"].forEach((id) => {
+ const satelliteCountEl = document.getElementById(id);
+ if (satelliteCountEl) {
+ satelliteCountEl.textContent = `${satelliteCount} 颗`;
+ }
+ });
}
function updateCableToggleUi(enabled) {
@@ -1974,9 +1994,9 @@ export async function setSatellitesEnabled(
function setupEventListeners() {
const handleResize = () => onWindowResize();
- const handleMouseMove = (event) => onMouseMove(event);
- const handleMouseDown = (event) => onMouseDown(event);
- const handleMouseUp = () => onMouseUp();
+ const handlePointerMove = (event) => onPointerMove(event);
+ const handlePointerDown = (event) => onPointerDown(event);
+ const handlePointerUp = (event) => onPointerUp(event);
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
@@ -1986,11 +2006,15 @@ function setupEventListeners() {
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
- bindListener(window, "mousemove", handleMouseMove);
- bindListener(renderer.domElement, "mousedown", handleMouseDown);
- bindListener(window, "mouseup", handleMouseUp);
+ bindListener(renderer.domElement, "pointerdown", handlePointerDown);
+ bindListener(window, "pointermove", handlePointerMove);
+ bindListener(window, "pointerup", handlePointerUp);
+ bindListener(window, "pointercancel", handlePointerUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
+ if (renderer?.domElement) {
+ renderer.domElement.style.touchAction = "none";
+ }
}
function updateHudScale() {
@@ -2078,6 +2102,9 @@ function onMouseMove(event) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
}
+ if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
+ isLongDrag = true;
+ }
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
@@ -2254,6 +2281,100 @@ function onMouseUp() {
document.getElementById("container")?.classList.remove("dragging");
}
+function onPointerDown(event) {
+ if (isEventOnHud(event)) return;
+ if (event.pointerType !== "touch" && event.button !== 0) return;
+
+ if (event.pointerType === "touch") {
+ activeTouchPoints.set(event.pointerId, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ renderer?.domElement?.setPointerCapture?.(event.pointerId);
+
+ if (activeTouchPoints.size === 2) {
+ const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
+ pinchGesture = {
+ distance: getTouchDistance(firstPoint, secondPoint),
+ startZoom: getZoomLevel(),
+ };
+ activeDragPointerId = null;
+ onMouseUp();
+ return;
+ }
+ }
+
+ activeDragPointerId = event.pointerId;
+ pointerDragDistance = 0;
+ suppressNextClick = false;
+ onMouseDown(event);
+}
+
+function onPointerMove(event) {
+ if (event.pointerType === "touch") {
+ if (activeTouchPoints.has(event.pointerId)) {
+ activeTouchPoints.set(event.pointerId, {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ }
+
+ if (pinchGesture && activeTouchPoints.size >= 2) {
+ const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
+ const nextDistance = getTouchDistance(firstPoint, secondPoint);
+ if (pinchGesture.distance > 0) {
+ const scale = nextDistance / pinchGesture.distance;
+ setZoomLevel(pinchGesture.startZoom * scale, camera);
+ suppressNextClick = true;
+ hideTooltip();
+ }
+ return;
+ }
+
+ if (activeDragPointerId === event.pointerId && isDragging) {
+ const deltaX = event.clientX - previousMousePosition.x;
+ const deltaY = event.clientY - previousMousePosition.y;
+ pointerDragDistance = Math.max(
+ pointerDragDistance,
+ Math.hypot(deltaX, deltaY),
+ );
+ onMouseMove(event);
+ return;
+ }
+
+ return;
+ }
+
+ if (activeDragPointerId === event.pointerId && isDragging) {
+ const deltaX = event.clientX - previousMousePosition.x;
+ const deltaY = event.clientY - previousMousePosition.y;
+ pointerDragDistance = Math.max(
+ pointerDragDistance,
+ Math.hypot(deltaX, deltaY),
+ );
+ }
+ onMouseMove(event);
+}
+
+function onPointerUp(event) {
+ if (event.pointerType === "touch") {
+ activeTouchPoints.delete(event.pointerId);
+ if (activeTouchPoints.size < 2) {
+ pinchGesture = null;
+ }
+ }
+
+ if (activeDragPointerId === event.pointerId) {
+ if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
+ suppressNextClick = true;
+ isLongDrag = true;
+ }
+ activeDragPointerId = null;
+ pointerDragDistance = 0;
+ onMouseUp();
+ }
+}
+
function onMouseLeave() {
hideTooltip();
}
@@ -2262,6 +2383,10 @@ function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
+ if (suppressNextClick) {
+ suppressNextClick = false;
+ return;
+ }
updatePointerFromEvent(event);
diff --git a/frontend/public/earth/js/news.js b/frontend/public/earth/js/news.js
index e7478870..26a2a17b 100644
--- a/frontend/public/earth/js/news.js
+++ b/frontend/public/earth/js/news.js
@@ -18,17 +18,18 @@ let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
function getElements() {
+ const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
- refreshBtn: document.getElementById("news-refresh"),
- openBtn: document.getElementById("news-open-external"),
- status: document.getElementById("news-board-status"),
- focusLabel: document.getElementById("news-focus-label"),
- focusCoords: document.getElementById("news-focus-coords"),
- sourceCount: document.getElementById("news-source-count"),
+ refreshBtn: document.getElementById(isMobile ? "mobile-news-refresh" : "news-refresh"),
+ openBtn: document.getElementById(isMobile ? "mobile-news-open-external" : "news-open-external"),
+ status: document.getElementById(isMobile ? "mobile-news-board-status" : "news-board-status"),
+ focusLabel: document.getElementById(isMobile ? "mobile-news-focus-label" : "news-focus-label"),
+ focusCoords: document.getElementById(isMobile ? "mobile-news-focus-coords" : "news-focus-coords"),
+ sourceCount: document.getElementById(isMobile ? "mobile-news-source-count" : "news-source-count"),
regionChip: document.getElementById("news-region-chip"),
- board: document.getElementById("news-board-list"),
- empty: document.getElementById("news-board-empty"),
- feedAnchor: document.getElementById("news-feed-anchor"),
+ board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
+ empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
+ feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
};
}
@@ -81,18 +82,33 @@ function renderPayload(nextPayload) {
openBtn,
feedAnchor,
} = getElements();
-
- if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
- return;
- }
-
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
+ if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
+ if (document.body.classList.contains("layout-mode-mobile")) {
+ // Mobile page omits the region chip shell, but the rest of the page is still renderable.
+ if (!board || !status || !focusLabel || !focusCoords || !sourceCount) {
+ return;
+ }
+ } else {
+ return;
+ }
+ }
+
+ if (regionChip) {
+ regionChip.textContent = focus.region || "global";
+ regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
+ }
+
+ if (document.body.classList.contains("layout-mode-mobile")) {
+ // Mobile page does not show the compact chip row.
+ } else if (!regionChip) {
+ return;
+ }
+
focusLabel.textContent = focus.label || "全球焦点";
- regionChip.textContent = focus.region || "global";
- regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
@@ -275,8 +291,6 @@ export function initNewsPanel() {
if (initialized) return;
initialized = true;
- const { refreshBtn, openBtn } = getElements();
-
updateNewsToggleUI(isTVPanelVisible());
renderEmptyState("正在准备全球态势新闻聚合源...");
@@ -287,16 +301,22 @@ export function initNewsPanel() {
updateNewsToggleUI(Boolean(event.detail?.visible));
});
- refreshBtn?.addEventListener("click", async () => {
- try {
- await refreshNews(lastFocus?.lat, lastFocus?.lon);
- showStatusMessage("态势新闻已刷新", "info");
- } catch {
- showStatusMessage("态势新闻刷新失败", "error");
- }
+ ["news-refresh", "mobile-news-refresh"].forEach((id) => {
+ const refreshBtn = document.getElementById(id);
+ refreshBtn?.addEventListener("click", async () => {
+ try {
+ await refreshNews(lastFocus?.lat, lastFocus?.lon);
+ showStatusMessage("态势新闻已刷新", "info");
+ } catch {
+ showStatusMessage("态势新闻刷新失败", "error");
+ }
+ });
});
- openBtn?.addEventListener("click", openCurrentSourceHomepage);
+ ["news-open-external", "mobile-news-open-external"].forEach((id) => {
+ const openBtn = document.getElementById(id);
+ openBtn?.addEventListener("click", openCurrentSourceHomepage);
+ });
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}
diff --git a/frontend/public/earth/js/search.js b/frontend/public/earth/js/search.js
index 8f139aea..0e779751 100644
--- a/frontend/public/earth/js/search.js
+++ b/frontend/public/earth/js/search.js
@@ -4,6 +4,7 @@ let onSelectResultFn = null;
let currentResults = [];
let activeIndex = -1;
let searchTimerId = null;
+let isOpen = false;
function escapeHtml(value) {
return String(value)
@@ -15,14 +16,25 @@ function escapeHtml(value) {
}
function getElements() {
+ const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
modal: document.getElementById("search-modal"),
backdrop: document.getElementById("search-backdrop"),
- input: document.getElementById("earth-search-input"),
- clear: document.getElementById("earth-search-clear"),
- meta: document.getElementById("earth-search-meta"),
- results: document.getElementById("earth-search-results"),
- empty: document.getElementById("earth-search-empty"),
+ input: document.getElementById(
+ isMobile ? "mobile-earth-search-input" : "earth-search-input",
+ ),
+ clear: document.getElementById(
+ isMobile ? "mobile-earth-search-clear" : "earth-search-clear",
+ ),
+ meta: document.getElementById(
+ isMobile ? "mobile-earth-search-meta" : "earth-search-meta",
+ ),
+ results: document.getElementById(
+ isMobile ? "mobile-earth-search-results" : "earth-search-results",
+ ),
+ empty: document.getElementById(
+ isMobile ? "mobile-earth-search-empty" : "earth-search-empty",
+ ),
close: document.getElementById("search-close"),
};
}
@@ -154,7 +166,8 @@ function scheduleSearch() {
function handleKeydown(event) {
const { modal, input } = getElements();
- if (!modal?.classList.contains("is-open")) return;
+ const isMobile = document.body.classList.contains("layout-mode-mobile");
+ if (!isMobile && !modal?.classList.contains("is-open")) return;
if (event.key === "Escape") {
event.preventDefault();
@@ -185,15 +198,28 @@ export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
if (initialized) return;
initialized = true;
- const { input, clear, close, backdrop } = getElements();
- input?.addEventListener("input", scheduleSearch);
- input?.addEventListener("keydown", handleKeydown);
- clear?.addEventListener("click", () => {
- if (!input) return;
- input.value = "";
- input.focus();
- runSearch().catch((error) => {
- console.warn("Clearing search failed:", error);
+ const inputs = ["earth-search-input", "mobile-earth-search-input"]
+ .map((id) => document.getElementById(id))
+ .filter((node) => node instanceof HTMLInputElement);
+ const clears = ["earth-search-clear", "mobile-earth-search-clear"]
+ .map((id) => document.getElementById(id))
+ .filter((node) => node instanceof HTMLButtonElement);
+ const close = document.getElementById("search-close");
+ const backdrop = document.getElementById("search-backdrop");
+
+ inputs.forEach((input) => {
+ input.addEventListener("input", scheduleSearch);
+ input.addEventListener("keydown", handleKeydown);
+ });
+ clears.forEach((clear) => {
+ clear.addEventListener("click", () => {
+ const { input } = getElements();
+ if (!input) return;
+ input.value = "";
+ input.focus();
+ runSearch().catch((error) => {
+ console.warn("Clearing search failed:", error);
+ });
});
});
close?.addEventListener("click", () => {
@@ -207,9 +233,15 @@ export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
export function openSearchPanel() {
const { modal, input } = getElements();
- if (!modal) return;
- modal.classList.add("is-open");
- modal.setAttribute("aria-hidden", "false");
+ if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
+ if (isOpen) return;
+ isOpen = true;
+ document.body.classList.add("earth-search-open");
+ modal?.classList.add("is-open");
+ modal?.setAttribute("aria-hidden", "false");
+ window.dispatchEvent(
+ new CustomEvent("earth:search-open-change", { detail: { open: true } }),
+ );
window.setTimeout(() => {
input?.focus();
input?.select();
@@ -219,9 +251,32 @@ export function openSearchPanel() {
}, 16);
}
+export function focusSearchInput({ select = false } = {}) {
+ const { input } = getElements();
+ if (!(input instanceof HTMLInputElement)) return;
+ input.focus();
+ if (select) {
+ input.select();
+ }
+}
+
+export function refreshSearchResults() {
+ return runSearch();
+}
+
export function closeSearchPanel() {
const { modal } = getElements();
- if (!modal) return;
- modal.classList.remove("is-open");
- modal.setAttribute("aria-hidden", "true");
+ if (!modal && !document.body.classList.contains("layout-mode-mobile")) return;
+ if (!isOpen) return;
+ isOpen = false;
+ document.body.classList.remove("earth-search-open");
+ modal?.classList.remove("is-open");
+ modal?.setAttribute("aria-hidden", "true");
+ window.dispatchEvent(
+ new CustomEvent("earth:search-open-change", { detail: { open: false } }),
+ );
+}
+
+export function isSearchPanelOpen() {
+ return isOpen;
}
diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js
index 53b75767..d27754a7 100644
--- a/frontend/public/earth/js/tv.js
+++ b/frontend/public/earth/js/tv.js
@@ -56,21 +56,22 @@ const HLS_RETRY_CONFIG = {
};
function getElements() {
+ const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
// Outer media shell node.
panel: document.getElementById("media-panel"),
toggleBtn: document.getElementById("toggle-tv"),
- select: document.getElementById("tv-source-select"),
- title: document.getElementById("tv-source-title"),
- meta: document.getElementById("tv-source-meta"),
- catalog: document.getElementById("tv-source-catalog"),
- status: document.getElementById("tv-source-status"),
- notes: document.getElementById("tv-source-notes"),
- iframe: document.getElementById("tv-iframe"),
- video: document.getElementById("tv-video"),
- empty: document.getElementById("tv-empty-state"),
- refreshBtn: document.getElementById("tv-refresh"),
- openBtn: document.getElementById("tv-open-external"),
+ select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
+ title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
+ meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
+ catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
+ status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
+ notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
+ iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
+ video: document.getElementById(isMobile ? "mobile-tv-video" : "tv-video"),
+ 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"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
@@ -351,6 +352,7 @@ function setPanelVisible(visible) {
const { panel } = getElements();
if (!panel) return;
mediaPanel?.setVisible(visible);
+ document.body.classList.toggle("earth-media-open", visible);
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
@@ -1071,12 +1073,16 @@ export function initTVPanel() {
showStatusMessage("已切换到态势新闻", "info");
});
- select?.addEventListener("change", (event) => {
- const target = event.currentTarget;
- if (!(target instanceof HTMLSelectElement)) return;
- currentSourceId = target.value;
- renderSource(findSourceById(currentSourceId));
- });
+ [select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
+ .filter((element, index, array) => element && array.indexOf(element) === index)
+ .forEach((selectEl) => {
+ selectEl?.addEventListener("change", (event) => {
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLSelectElement)) return;
+ currentSourceId = target.value;
+ renderSource(findSourceById(currentSourceId));
+ });
+ });
metaToggle?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
@@ -1084,9 +1090,13 @@ export function initTVPanel() {
setMetaCollapsed(isNowCollapsed);
});
- refreshBtn?.addEventListener("click", () => {
- refreshTVPanel();
- });
+ [refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]
+ .filter((element, index, array) => element && array.indexOf(element) === index)
+ .forEach((refreshEl) => {
+ refreshEl?.addEventListener("click", () => {
+ refreshTVPanel();
+ });
+ });
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
@@ -1095,24 +1105,32 @@ export function initTVPanel() {
setActiveTab("news");
});
- iframe?.addEventListener("load", () => {
- if (iframe.hidden) return;
- clearSourceFailed(currentSourceId);
- setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
- });
+ [iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
+ .filter((element, index, array) => element && array.indexOf(element) === index)
+ .forEach((iframeEl) => {
+ iframeEl?.addEventListener("load", () => {
+ if (iframeEl.hidden) return;
+ clearSourceFailed(currentSourceId);
+ setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
+ });
+ });
- video?.addEventListener("loadedmetadata", () => {
- if (video.hidden) return;
- clearSourceFailed(currentSourceId);
- setPanelMessage(TV_STATUS_MESSAGE.videoReady);
- });
+ [video, document.getElementById("mobile-tv-video"), document.getElementById("tv-video")]
+ .filter((element, index, array) => element && array.indexOf(element) === index)
+ .forEach((videoEl) => {
+ videoEl?.addEventListener("loadedmetadata", () => {
+ if (videoEl.hidden) return;
+ clearSourceFailed(currentSourceId);
+ setPanelMessage(TV_STATUS_MESSAGE.videoReady);
+ });
- video?.addEventListener("error", () => {
- const currentSource = getCurrentSource();
- if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
- setPanelMessage(TV_STATUS_MESSAGE.videoError);
- }
- });
+ videoEl?.addEventListener("error", () => {
+ const currentSource = getCurrentSource();
+ if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
+ setPanelMessage(TV_STATUS_MESSAGE.videoError);
+ }
+ });
+ });
setupResizeHandle();
syncPanelActiveTab("live");
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index d0d628bb..1963b88c 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -19,6 +19,15 @@ function getElement(id) {
return document.getElementById(id);
}
+function setTextTargets(ids, value) {
+ ids.forEach((id) => {
+ const element = getElement(id);
+ if (element) {
+ element.textContent = value;
+ }
+ });
+}
+
function setElementDisplay(element, visible, displayValue = "block") {
if (!element) return;
element.style.display = visible ? displayValue : "none";
@@ -171,27 +180,13 @@ export function updateZoomDisplay(zoomLevel, distance) {
// Update earth stats
export function updateEarthStats(stats) {
- const cableCountEl = getElement("cable-count");
- const landingPointCountEl = getElement("landing-point-count");
- const bgpAnomalyCountEl = getElement("bgp-anomaly-count");
- const bgpCollectorCountEl = getElement("bgp-collector-count");
- const bgpStatusSummaryEl = getElement("bgp-status-summary");
- const terrainStatusEl = getElement("terrain-status");
- const textureQualityEl = getElement("texture-quality");
-
- if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
- if (landingPointCountEl)
- landingPointCountEl.textContent = stats.landingPointCount || 0;
- if (bgpAnomalyCountEl)
- bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
- if (bgpCollectorCountEl)
- bgpCollectorCountEl.textContent = stats.bgpCollectorCount || 0;
- if (bgpStatusSummaryEl)
- bgpStatusSummaryEl.textContent = stats.bgpStatusSummary || "-";
- if (terrainStatusEl)
- terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
- if (textureQualityEl)
- textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
+ setTextTargets(["cable-count", "mobile-cable-count"], String(stats.cableCount || 0));
+ setTextTargets(["landing-point-count", "mobile-landing-point-count"], String(stats.landingPointCount || 0));
+ setTextTargets(["bgp-anomaly-count", "mobile-bgp-anomaly-count"], String(stats.bgpAnomalyCount || 0));
+ setTextTargets(["bgp-collector-count"], String(stats.bgpCollectorCount || 0));
+ setTextTargets(["bgp-status-summary", "mobile-bgp-status-summary"], stats.bgpStatusSummary || "-");
+ setTextTargets(["terrain-status"], stats.terrainOn ? "开启" : "关闭");
+ setTextTargets(["texture-quality"], stats.textureQuality || "8K 卫星图");
}
// Show/hide loading via status message
diff --git a/pyproject.toml b/pyproject.toml
index a7ce40b2..94747434 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planet"
-version = "0.34.0"
+version = "0.35.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
diff --git a/uv.lock b/uv.lock
index 827e8a57..7885e459 100644
--- a/uv.lock
+++ b/uv.lock
@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
-version = "0.34.0"
+version = "0.35.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },