// controls.js - Zoom, rotate and toggle controls import * as THREE from "three"; import { CONFIG, CRUISE_MODULES, DEFAULT_SURFACE_HOVER_INFO_MODE, DEFAULT_SATELLITE_DISPLAY_STYLE, DEFAULT_CRUISE_MODULES, EARTH_CONFIG, ROTATION_MODE, SATELLITE_DISPLAY_STYLES, SURFACE_HOVER_INFO_MODES, } from "./constants.js"; import { setEarthStatValue, showGestureStatusMessage, showStatusMessage, updateZoomDisplay, } from "./ui.js"; import { toggleTerrain, setDayNightEnabled, toggleClouds, toggleGridLines, getShowGridLines, } from "./earth.js"; import { setCelestialDayNightEnabled } from "./celestial.js"; import { ensureTerrainReady, isTerrainReady, getTerrainOpacity, setTerrainOpacity, } from "./terrain.js"; import { reloadData, reloadCountryBoundaries, clearLockedObject, clearLockedObjectAndInfo, setCablesEnabled, setCountryBoundariesEnabled, setHighResTextureEnabled, getHighResTextureEnabled, setAtmosphereCloudsEnabled, getAtmosphereCloudsEnabled, setSatellitesEnabled, getSatellitesEnabled, setVesselsEnabled, getVesselsEnabled, } from "./main.js"; import { toggleTrails, getShowTrails, getSatelliteCount, getSatelliteDisplayStyle, getSatelliteIdleBreathingEnabled, getSatelliteRealAltitudeEnabled, setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled, setSatelliteRealAltitudeEnabled as applySatelliteRealAltitudeEnabled, setSatelliteDisplayStyle as applySatelliteDisplayStyle, } from "./satellites.js"; import { getInteractableCompactDotsEnabled, setInteractableCompactDotsEnabled as applyInteractableCompactDotsEnabled, } from "./interactable.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; import { getHighPrecisionBoundariesEnabled, getShowCountryBoundaries, setHighPrecisionBoundariesEnabled, toggleCountryBoundaries, } from "./country-boundaries.js"; import { toggleComputeCenters, getShowComputeCenters, getComputeCenterCount, } from "./compute-centers.js"; import { getShowVessels, getVesselCount, } from "./vessels.js"; import { ensureTVPanelReady, getActiveTVTab, isTVPanelVisible, setActiveTVTab, setTVPanelVisible, } from "./tv.js"; import { createHUDPanel } from "./hud-panels.js"; import { ensureNewsPanelReady, updateNewsToggleUI, } from "./news.js"; import { closeSearchPanel, focusSearchInput, isSearchPanelOpen, openSearchPanel, refreshSearchResults, } from "./search.js"; import { setButtonTooltip, setLayerButtonState, updateLayerButtonState, } from "./layer-button-state.js"; import { DEFAULT_MOTION_PROVIDER, normalizeMotionProvider, } from "./motion-protocol.js"; export let autoRotate = true; export let zoomLevel = 1.0; export let showTerrain = false; export let layoutExpanded = false; export let rotationMode = ROTATION_MODE.ROTATE; let dayNightEnabled = true; let defaultEarthZoom = CONFIG.defaultViewZoom; let motionDebugEnabled = false; let motionProvider = DEFAULT_MOTION_PROVIDER; let motionDebugSkeletonOnly = false; let activeCamera = null; let settingsApplyPromise = Promise.resolve(); let boundaryBuildPollTimer = null; let boundaryBuildAttemptedThisSession = false; let earthObj = null; let listeners = []; let cleanupFns = []; const HUD_PANEL_IDS = [ "legend", "earth-stats", "media-panel", "layer-toggles", ]; const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable"; const PANEL_LAYOUT_ANIMATION_MS = 420; const TOOLBAR_BASE_WIDTH_PX = 620; const TOOLBAR_MIN_SCALE = 0.68; const TOOLBAR_ORB_SIZE_PX = 46; const TOOLBAR_HUB_SIZE_PX = 58; const TOOLBAR_ORB_GAP_PX = 12; const TOOLBAR_ARCH_SPAN_PX = 232; const TOOLBAR_ARCH_RISE_PX = 40; const TOOLBAR_SIDE_PADDING_PX = 12; const TOOLBAR_BOTTOM_CLEARANCE_PX = 34; const TOOLBAR_EXTRA_HEIGHT_PX = 34; const HUD_EDGE_GAP_PX = 20; const SETTINGS_MODAL_OPEN_ANIMATION_MS = 420; const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320; const SETTINGS_SHEET_MIN_SCALE = 0.06; 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 = 13; const GRID_LINES_DEFAULT_VERSION = 3; const SATELLITE_DISPLAY_DEFAULT_VERSION = 4; const MEDIA_PANEL_DEFAULT_VERSION = 5; const MOTION_DEBUG_DEFAULT_VERSION = 6; const MOTION_PROVIDER_DEFAULT_VERSION = 7; 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; let settingsSheetAnimation = null; let terrainToggleToken = 0; let terrainPrefetchStarted = false; let terrainPrefetchTimer = null; let terrainPrefetchIdleHandle = null; let focusViewAnimationToken = 0; let earthSettingsDefaults = null; let lastZoomStatusUpdateTime = 0; let earthSettingsState = null; let deferredLayerVisibilitySettings = null; let layerRegistry = new Map(); let layerPanelInitialized = false; let layoutMode = "desktop"; let activeMobileDrawerId = null; 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), ); const ALLOWED_SURFACE_HOVER_INFO_MODES = new Set( Object.values(SURFACE_HOVER_INFO_MODES), ); const CRUISE_MODULE_LABELS = { [CRUISE_MODULES.BGP]: "BGP", [CRUISE_MODULES.NEWS]: "新闻", [CRUISE_MODULES.COMPUTE_CENTERS]: "算力中心", [CRUISE_MODULES.VESSELS]: "船只", [CRUISE_MODULES.CABLES]: "海缆", [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"; } function normalizeSurfaceHoverInfoMode(mode) { return ALLOWED_SURFACE_HOVER_INFO_MODES.has(mode) ? 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; if (width <= 820) { return "mobile"; } if (width <= 1080 || height <= 760) { return "compact"; } return "desktop"; } function getSettingsViewportScope(mode = layoutMode) { return mode === "mobile" ? "mobile" : "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" && except !== "search" && except !== "settings" && isTVPanelVisible() ) { setTVPanelVisible(false, { persist: false }); } } function applyResponsiveLayout() { const previousLayoutMode = layoutMode; 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.documentElement.dataset.earthLayoutMode = layoutMode; 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; } if (previousLayoutMode !== layoutMode) { if (isMobile) { resetDesktopHudPanelsForMobile(); } if (earthSettingsState) { applyCurrentViewportPanelVisibility({ persist: 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); }); } else if (mobileDrawerCard === "news") { ensureNewsPanelReady().catch((error) => { console.error("初始化新闻抽屉失败:", error); }); } } function getMobileLayerButtons(layerId) { return Array.from( document.querySelectorAll(`[data-mobile-layer-button="${layerId}"]`), ).filter((button) => button instanceof HTMLButtonElement); } function getLayerDisabledState(layerId) { if (layerId === "trails" && !getSatellitesEnabled()) { return { disabled: true, statusText: "不可用", tooltip: "卫星关闭时不可用", }; } if (layerId === "terrain" && !getHighResTextureEnabled()) { return { disabled: true, statusText: "不可用", tooltip: "高清材质关闭时不可用", }; } return { disabled: false, statusText: null, tooltip: null, }; } function syncMobileLayerCards() { const summary = document.getElementById("mobile-layer-summary"); const definitions = getDisplayLayerDefinitions(); let activeCount = 0; definitions.forEach((definition) => { const visible = Boolean(definition.getVisible?.()); if (visible) { activeCount += 1; } getMobileLayerButtons(definition.id).forEach((button) => { const disabledState = getLayerDisabledState(definition.id); button.classList.toggle("is-active", visible); button.classList.toggle("is-disabled", disabledState.disabled); button.disabled = disabledState.disabled; button.setAttribute("aria-checked", visible ? "true" : "false"); if (disabledState.tooltip) { button.title = disabledState.tooltip; } else { button.removeAttribute("title"); } const status = button.querySelector("[data-mobile-layer-status]"); if (status) { status.textContent = disabledState.statusText || (visible ? "开启" : "关闭"); } }); }); if (summary) { summary.textContent = `已启用 ${activeCount} 个图层`; } } function renderMobileLayerCards() { const list = document.getElementById("mobile-layer-list"); if (!(list instanceof HTMLElement)) return; const definitions = getDisplayLayerDefinitions(); 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; if (target.disabled || target.classList.contains("is-disabled")) 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) ? left.startupPriority : Number.POSITIVE_INFINITY; const rightPriority = Number.isFinite(right?.startupPriority) ? right.startupPriority : Number.POSITIVE_INFINITY; if (leftPriority !== rightPriority) { return leftPriority - rightPriority; } return String(left?.id || "").localeCompare(String(right?.id || "")); } function getSortedLayerDefinitions({ includeUnprioritized = true } = {}) { return Array.from(layerRegistry.values()) .filter((definition) => includeUnprioritized ? true : Number.isFinite(definition?.startupPriority), ) .sort(compareLayerDefinitionsByStartupPriority); } function getDisplayLayerDefinitions() { return Array.from(layerRegistry.values()).sort((left, right) => { const leftOrder = Number.isFinite(left?.displayOrder) ? left.displayOrder : Number.POSITIVE_INFINITY; const rightOrder = Number.isFinite(right?.displayOrder) ? right.displayOrder : Number.POSITIVE_INFINITY; if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return String(left?.id || "").localeCompare(String(right?.id || "")); }); } function shouldIncludeLayerInStartupLoad(definition) { if (!Number.isFinite(definition?.startupPriority)) { return false; } const persistedVisible = getPersistedLayerVisibilityOverride(definition.id); if (typeof persistedVisible === "boolean") { if (definition.startupMode === "preload" && definition.startupAlwaysLoad) { return true; } return persistedVisible; } if (definition.startupMode === "preload") { return true; } return Boolean(definition?.getVisible?.()); } function getPersistedLayerVisibilityOverride(layerId) { if (!layerId) return null; const layerVisibility = deferredLayerVisibilitySettings || earthSettingsState?.shared?.layerVisibility; const persistedVisible = layerVisibility?.[layerId]; return typeof persistedVisible === "boolean" ? persistedVisible : null; } function clampEarthZoomLevel(nextZoom) { const parsedZoom = Number.parseFloat(nextZoom); if (!Number.isFinite(parsedZoom)) { return CONFIG.defaultViewZoom; } return Math.min(CONFIG.maxZoom, Math.max(CONFIG.minZoom, parsedZoom)); } function formatZoomPercent(zoom) { return `${Math.round(zoom * 100)}%`; } function getZoomResetTooltipText(zoom) { return `重置缩放到${formatZoomPercent(zoom)}`; } function getZoomResetStatusMessage(zoom) { return `缩放已重置到${formatZoomPercent(zoom)}`; } function canUseLocalStorage() { try { return typeof window !== "undefined" && !!window.localStorage; } catch { return false; } } function getCurrentPanelVisibilitySnapshot() { return Object.fromEntries( HUD_PANEL_IDS.map((panelId) => { const panel = document.getElementById(panelId); const visible = !panel?.classList.contains("hud-panel-hidden"); return [panelId, visible]; }), ); } function getCurrentSharedSettingsSnapshot() { return { rotationMode, cruiseModules: getCruiseModules(), satelliteDisplayStyle: getSatelliteDisplayStyle(), trailsEnabled: getShowTrails(), layerVisibility: Object.fromEntries( getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]), ), terrainOpacity: getTerrainOpacity(), dayNightEnabled, defaultEarthZoom, motionDebugEnabled, motionProvider, motionDebugSkeletonOnly, mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()), satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(), satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(), interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(), surfaceHoverInfoMode: getSurfaceHoverInfoMode(), keyboardShortcuts: normalizeKeyboardShortcuts(keyboardShortcuts), }; } function getDefaultLayerVisibilitySnapshot() { return Object.fromEntries( getPersistedLayers().map((layer) => [layer.id, Boolean(layer.defaultActive)]), ); } function captureEarthSettingsDefaults() { if (!earthSettingsDefaults) { const panelVisibility = getCurrentPanelVisibilitySnapshot(); const shared = getCurrentSharedSettingsSnapshot(); earthSettingsDefaults = { version: EARTH_SETTINGS_VERSION, shared: { ...shared, layerVisibility: getDefaultLayerVisibilitySnapshot(), }, views: { desktop: { panelVisibility: { ...panelVisibility }, }, mobile: { panelVisibility: { ...panelVisibility }, }, }, }; } return earthSettingsDefaults; } function cloneEarthSettings(settings) { return { version: EARTH_SETTINGS_VERSION, shared: { rotationMode: settings.shared.rotationMode, cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)], satelliteDisplayStyle: settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE, trailsEnabled: settings.shared.trailsEnabled !== false, terrainOpacity: settings.shared.terrainOpacity, dayNightEnabled: settings.shared.dayNightEnabled, defaultEarthZoom: settings.shared.defaultEarthZoom, motionDebugEnabled: settings.shared.motionDebugEnabled, motionProvider: normalizeMotionProvider( settings.shared.motionProvider, DEFAULT_MOTION_PROVIDER, ), motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly), mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab), satelliteIdleBreathingEnabled: settings.shared.satelliteIdleBreathingEnabled !== false, satelliteRealAltitudeEnabled: settings.shared.satelliteRealAltitudeEnabled !== false, interactableCompactDotsEnabled: settings.shared.interactableCompactDotsEnabled !== false, surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode( settings.shared.surfaceHoverInfoMode, ), keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts), layerVisibility: { ...(settings.shared.layerVisibility || {}) }, }, views: { desktop: { panelVisibility: { ...(settings.views?.desktop?.panelVisibility || {}), }, }, mobile: { panelVisibility: { ...(settings.views?.mobile?.panelVisibility || {}), }, }, }, }; } function normalizeEarthSettings(rawSettings, defaults) { const normalizedDesktopPanelVisibility = { ...defaults.views.desktop.panelVisibility, }; const normalizedMobilePanelVisibility = { ...defaults.views.mobile.panelVisibility, }; const normalizedLayerVisibility = { ...defaults.shared.layerVisibility, }; const sharedSettings = rawSettings && typeof rawSettings.shared === "object" ? rawSettings.shared : rawSettings; const inputDesktopPanelVisibility = rawSettings?.views?.desktop && typeof rawSettings.views.desktop.panelVisibility === "object" ? rawSettings.views.desktop.panelVisibility : rawSettings && typeof rawSettings.panelVisibility === "object" ? rawSettings.panelVisibility : {}; const inputMobilePanelVisibility = rawSettings?.views?.mobile && typeof rawSettings.views.mobile.panelVisibility === "object" ? rawSettings.views.mobile.panelVisibility : {}; const inputLayerVisibility = sharedSettings && typeof sharedSettings.layerVisibility === "object" ? sharedSettings.layerVisibility : {}; Object.entries(inputDesktopPanelVisibility).forEach(([panelId, visible]) => { if (panelId in normalizedDesktopPanelVisibility) { normalizedDesktopPanelVisibility[panelId] = Boolean(visible); } }); Object.entries(inputMobilePanelVisibility).forEach(([panelId, visible]) => { if (panelId in normalizedMobilePanelVisibility) { normalizedMobilePanelVisibility[panelId] = Boolean(visible); } }); Object.entries(inputLayerVisibility).forEach(([layerId, visible]) => { if (layerId in normalizedLayerVisibility) { normalizedLayerVisibility[layerId] = Boolean(visible); } }); if ((rawSettings?.version || 0) < GRID_LINES_DEFAULT_VERSION && inputLayerVisibility.gridLines === true) { normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines; } if ((rawSettings?.version || 0) < MEDIA_PANEL_DEFAULT_VERSION) { normalizedDesktopPanelVisibility["media-panel"] = true; } const nextRotationMode = sharedSettings?.rotationMode === ROTATION_MODE.CRUISE || sharedSettings?.rotationMode === ROTATION_MODE.MOTION ? sharedSettings.rotationMode : defaults.shared.rotationMode; const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules) ? sharedSettings.cruiseModules : defaults.shared.cruiseModules; const nextCruiseModules = Array.from( new Set( requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)), ), ); let nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has( sharedSettings?.satelliteDisplayStyle, ) ? sharedSettings.satelliteDisplayStyle : defaults.shared.satelliteDisplayStyle; if ( (rawSettings?.version || 0) < SATELLITE_DISPLAY_DEFAULT_VERSION && nextSatelliteDisplayStyle === SATELLITE_DISPLAY_STYLES.SELF_GLOW ) { nextSatelliteDisplayStyle = defaults.shared.satelliteDisplayStyle; } const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity); const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean" ? sharedSettings.dayNightEnabled : defaults.shared.dayNightEnabled; const nextDefaultEarthZoom = clampEarthZoomLevel( sharedSettings?.defaultEarthZoom ?? defaults.shared.defaultEarthZoom, ); const nextMotionDebugEnabled = (rawSettings?.version || 0) >= MOTION_DEBUG_DEFAULT_VERSION && typeof sharedSettings?.motionDebugEnabled === "boolean" ? sharedSettings.motionDebugEnabled : defaults.shared.motionDebugEnabled; const nextMotionProvider = (rawSettings?.version || 0) >= MOTION_PROVIDER_DEFAULT_VERSION ? normalizeMotionProvider(sharedSettings?.motionProvider, defaults.shared.motionProvider) : defaults.shared.motionProvider; const nextMotionDebugSkeletonOnly = (rawSettings?.version || 0) >= MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION && typeof sharedSettings?.motionDebugSkeletonOnly === "boolean" ? sharedSettings.motionDebugSkeletonOnly : defaults.shared.motionDebugSkeletonOnly; const nextMediaPanelActiveTab = (rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION ? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab) : normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab); const nextSatelliteIdleBreathingEnabled = (rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION && typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean" ? sharedSettings.satelliteIdleBreathingEnabled : defaults.shared.satelliteIdleBreathingEnabled; const nextSatelliteRealAltitudeEnabled = typeof sharedSettings?.satelliteRealAltitudeEnabled === "boolean" ? sharedSettings.satelliteRealAltitudeEnabled : defaults.shared.satelliteRealAltitudeEnabled; const nextInteractableCompactDotsEnabled = (rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION && typeof sharedSettings?.interactableCompactDotsEnabled === "boolean" ? sharedSettings.interactableCompactDotsEnabled : defaults.shared.interactableCompactDotsEnabled; const nextSurfaceHoverInfoMode = (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" ? inputLayerVisibility.trails : defaults.shared.trailsEnabled; return { version: EARTH_SETTINGS_VERSION, shared: { rotationMode: nextRotationMode, cruiseModules: nextCruiseModules.length > 0 ? nextCruiseModules : [...DEFAULT_CRUISE_MODULES], satelliteDisplayStyle: nextSatelliteDisplayStyle, trailsEnabled: nextTrailsEnabled, layerVisibility: normalizedLayerVisibility, terrainOpacity: Number.isFinite(nextTerrainOpacity) ? nextTerrainOpacity : defaults.shared.terrainOpacity, dayNightEnabled: nextDayNightEnabled, defaultEarthZoom: nextDefaultEarthZoom, motionDebugEnabled: nextMotionDebugEnabled, motionProvider: nextMotionProvider, motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly, mediaPanelActiveTab: nextMediaPanelActiveTab, satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled, satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled, interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled, surfaceHoverInfoMode: nextSurfaceHoverInfoMode, keyboardShortcuts: nextKeyboardShortcuts, }, views: { desktop: { panelVisibility: normalizedDesktopPanelVisibility, }, mobile: { panelVisibility: normalizedMobilePanelVisibility, }, }, }; } function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) { const interactable = rotationMode === ROTATION_MODE.MOTION; document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = Boolean(nextEnabled); input.disabled = !interactable; const label = input.closest("label"); label?.classList.toggle("is-disabled", !interactable); if (label instanceof HTMLElement) { if (interactable) { label.removeAttribute("title"); } else { label.title = "切换到动捕模式后可开启调试面板"; } } } }); } function syncMotionProviderControls(nextProvider = motionProvider) { document.querySelectorAll("[data-motion-provider]").forEach((button) => { if (!(button instanceof HTMLButtonElement)) return; const active = normalizeMotionProvider(button.dataset.motionProvider) === nextProvider; button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); syncSegmentedControlSliders(); } function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) { document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = Boolean(nextEnabled); } }); } function dispatchMotionSettingsChange() { const effectiveDebugEnabled = rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled; window.dispatchEvent( new CustomEvent("earth:motion-debug-mode-change", { detail: { enabled: effectiveDebugEnabled, preferredEnabled: motionDebugEnabled, provider: motionProvider, skeletonOnly: motionDebugSkeletonOnly, }, }), ); } function getPersistedLayers() { return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false); } function getLayerDefinition(layerId) { return layerRegistry.get(layerId) || null; } function getLayerButton(layerId) { const definition = getLayerDefinition(layerId); if (!definition?.buttonId) return null; const button = document.getElementById(definition.buttonId); return button instanceof HTMLButtonElement ? button : null; } function loadEarthSettings() { const defaults = cloneEarthSettings(captureEarthSettingsDefaults()); if (!canUseLocalStorage()) return defaults; try { const rawValue = window.localStorage.getItem(EARTH_SETTINGS_STORAGE_KEY); const legacyRawValue = window.localStorage.getItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY); const sourceValue = rawValue || legacyRawValue; if (!sourceValue) return defaults; const parsedValue = JSON.parse(sourceValue); return normalizeEarthSettings(parsedValue, defaults); } catch (error) { console.warn("读取 Earth 设置失败,已回退默认值:", error); return defaults; } } function getViewportPanelVisibility(settings, mode = layoutMode) { const scope = getSettingsViewportScope(mode); return settings?.views?.[scope]?.panelVisibility || {}; } function syncEarthSettingsStateFromRuntime() { const defaults = cloneEarthSettings(captureEarthSettingsDefaults()); const nextSettings = earthSettingsState ? cloneEarthSettings(earthSettingsState) : defaults; const scope = getSettingsViewportScope(); nextSettings.shared = getCurrentSharedSettingsSnapshot(); // panelVisibility is maintained in earthSettingsState via setHudPanelVisibility. // Do not re-snapshot from DOM here: transient hides (e.g. closeTransientMobileOverlays) // change the DOM without going through setHudPanelVisibility and would corrupt the // user's persisted preference. earthSettingsState = nextSettings; return nextSettings; } function ensureMutableEarthSettingsState() { earthSettingsState = cloneEarthSettings( earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()), ); return earthSettingsState; } function persistEarthSettings() { if (!canUseLocalStorage()) return; try { const nextSettings = syncEarthSettingsStateFromRuntime(); window.localStorage.setItem( EARTH_SETTINGS_STORAGE_KEY, JSON.stringify(nextSettings), ); window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY); } catch (error) { console.warn("保存 Earth 设置失败:", error); } } function dispatchCruiseModulesChange() { window.dispatchEvent( new CustomEvent("earth:cruise-modules-change", { detail: { modules: getCruiseModules(), }, }), ); } function normalizeCruiseModules(nextModules) { const sourceModules = Array.isArray(nextModules) ? nextModules : DEFAULT_CRUISE_MODULES; const normalizedModules = Array.from( new Set(sourceModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId))), ); return normalizedModules.length > 0 ? normalizedModules : [...DEFAULT_CRUISE_MODULES]; } function syncCruiseModuleControls() { const enabledModules = new Set(getCruiseModules()); document.querySelectorAll("[data-cruise-module-toggle]").forEach((button) => { if (!(button instanceof HTMLButtonElement)) return; const moduleId = button.dataset.cruiseModuleToggle || ""; const active = enabledModules.has(moduleId); button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); } function syncSegmentedControlSliders() { document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => { if (!(segmented instanceof HTMLElement)) return; const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill")); const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active"))); segmented.style.setProperty("--item-count", String(Math.max(1, buttons.length))); segmented.style.setProperty("--active-index", String(activeIndex)); }); } function syncSatelliteDisplayStyleControls() { const activeStyle = getSatelliteDisplayStyle(); document.querySelectorAll("[data-satellite-display-style]").forEach((button) => { if (!(button instanceof HTMLButtonElement)) return; const styleId = button.dataset.satelliteDisplayStyle || ""; const active = styleId === activeStyle; button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); syncSegmentedControlSliders(); } function syncSatelliteIdleBreathingToggle() { const enabled = getSatelliteIdleBreathingEnabled(); document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = enabled; } }); } function syncSatelliteRealAltitudeToggle() { const enabled = getSatelliteRealAltitudeEnabled(); document.querySelectorAll("[data-satellite-real-altitude-toggle]").forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = enabled; } }); } function syncInteractableCompactDotsToggle() { const enabled = getInteractableCompactDotsEnabled(); document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = enabled; } }); } function syncSurfaceHoverInfoModeControls() { const activeMode = getSurfaceHoverInfoMode(); document.querySelectorAll("[data-surface-hover-info-mode]").forEach((button) => { if (!(button instanceof HTMLButtonElement)) return; const mode = normalizeSurfaceHoverInfoMode(button.dataset.surfaceHoverInfoMode); const active = mode === activeMode; button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); syncSegmentedControlSliders(); } export function getCruiseModules() { const configuredModules = earthSettingsState?.shared?.cruiseModules; return normalizeCruiseModules(configuredModules); } export function isCruiseModuleEnabled(moduleId) { return getCruiseModules().includes(moduleId); } export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) { const normalizedModules = normalizeCruiseModules(nextModules); const previousModules = getCruiseModules(); const changed = normalizedModules.length !== previousModules.length || normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId); if (!changed) { syncCruiseModuleControls(); return normalizedModules; } ensureMutableEarthSettingsState(); earthSettingsState.shared.cruiseModules = [...normalizedModules]; syncCruiseModuleControls(); dispatchCruiseModulesChange(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { const labels = normalizedModules.map((moduleId) => CRUISE_MODULE_LABELS[moduleId] || moduleId); showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info"); } return normalizedModules; } export function setSatelliteDisplayStyle( nextStyle, { persist = true, suppressStatus = false } = {}, ) { const normalizedStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(nextStyle) ? nextStyle : DEFAULT_SATELLITE_DISPLAY_STYLE; const previousStyle = getSatelliteDisplayStyle(); if (normalizedStyle === previousStyle) { syncSatelliteDisplayStyleControls(); return normalizedStyle; } ensureMutableEarthSettingsState(); earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle; applySatelliteDisplayStyle(normalizedStyle); syncSatelliteDisplayStyleControls(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { const nextLabel = normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT ? "真实地表覆盖" : "自身发光"; showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info"); } return normalizedStyle; } export function setSatelliteIdleBreathingEnabled( nextEnabled, { persist = true, suppressStatus = false } = {}, ) { const enabled = applySatelliteIdleBreathingEnabled(nextEnabled); ensureMutableEarthSettingsState(); earthSettingsState.shared.satelliteIdleBreathingEnabled = enabled; syncSatelliteIdleBreathingToggle(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info"); } return enabled; } export function setSatelliteRealAltitudeEnabled( nextEnabled, { persist = true, suppressStatus = false } = {}, ) { const enabled = applySatelliteRealAltitudeEnabled(nextEnabled); ensureMutableEarthSettingsState(); earthSettingsState.shared.satelliteRealAltitudeEnabled = enabled; syncSatelliteRealAltitudeToggle(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { showStatusMessage( enabled ? "卫星真实高度已开启" : "卫星已切换为旧版同层高度", "info", ); } return enabled; } export function setInteractableCompactDotsEnabled( nextEnabled, { persist = true, suppressStatus = false } = {}, ) { const enabled = applyInteractableCompactDotsEnabled(nextEnabled); ensureMutableEarthSettingsState(); earthSettingsState.shared.interactableCompactDotsEnabled = enabled; syncInteractableCompactDotsToggle(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info"); } return enabled; } export function getSurfaceHoverInfoMode() { return normalizeSurfaceHoverInfoMode(earthSettingsState?.shared?.surfaceHoverInfoMode); } export function setSurfaceHoverInfoMode( nextMode, { persist = true, suppressStatus = false } = {}, ) { const normalizedMode = normalizeSurfaceHoverInfoMode(nextMode); const previousMode = getSurfaceHoverInfoMode(); if (normalizedMode === previousMode) { syncSurfaceHoverInfoModeControls(); return normalizedMode; } ensureMutableEarthSettingsState(); earthSettingsState.shared.surfaceHoverInfoMode = normalizedMode; syncSurfaceHoverInfoModeControls(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { const label = normalizedMode === SURFACE_HOVER_INFO_MODES.COUNTRY ? "国家" : normalizedMode === SURFACE_HOVER_INFO_MODES.POSITION ? "位置" : "完整"; showStatusMessage(`悬停提示已切换为:${label}`, "info"); } return normalizedMode; } function syncDefaultEarthZoomUi(nextZoom) { const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]"); const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]"); const zoomValue = document.getElementById("zoom-value"); const tooltipText = getZoomResetTooltipText(nextZoom); 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); }); values.forEach((value) => { if (value instanceof HTMLElement) { value.textContent = formatZoomPercent(nextZoom); } }); if (zoomValue instanceof HTMLElement) { zoomValue.title = tooltipText; const tooltip = zoomValue.querySelector(".tooltip"); if (tooltip) { tooltip.textContent = tooltipText; } } } function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = true } = {}) { defaultEarthZoom = clampEarthZoomLevel(nextZoom); syncDefaultEarthZoomUi(defaultEarthZoom); if (applyToCurrentView && activeCamera) { zoomLevel = defaultEarthZoom; applyZoom(activeCamera); } if (persist) { persistEarthSettings(); } return defaultEarthZoom; } async function applyEarthSettings(settings, { applyLayers = true } = {}) { if (!settings) return; earthSettingsState = cloneEarthSettings(settings); applyCurrentViewportPanelVisibility({ persist: false }); const appliedOpacity = setTerrainOpacity(settings.shared.terrainOpacity); 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.shared.rotationMode, { persist: false, suppressStatus: true }); setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true }); setSatelliteDisplayStyle(settings.shared.satelliteDisplayStyle, { persist: false, suppressStatus: true, }); setTrailsDisplayEnabled(settings.shared.trailsEnabled, { persist: false, silent: true, }); setSatelliteIdleBreathingEnabled(settings.shared.satelliteIdleBreathingEnabled, { persist: false, suppressStatus: true, }); setSatelliteRealAltitudeEnabled(settings.shared.satelliteRealAltitudeEnabled, { persist: false, suppressStatus: true, }); setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, { persist: false, suppressStatus: true, }); setSurfaceHoverInfoMode(settings.shared.surfaceHoverInfoMode, { persist: false, suppressStatus: true, }); if (typeof settings.shared.dayNightEnabled === "boolean") { applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false }); } setDefaultEarthZoom(settings.shared.defaultEarthZoom, { persist: false, applyToCurrentView: true, }); setMotionDebugEnabled(settings.shared.motionDebugEnabled, { persist: false, suppressStatus: true, }); setMotionProvider(settings.shared.motionProvider, { persist: false, suppressStatus: true, }); setMotionDebugSkeletonOnly(settings.shared.motionDebugSkeletonOnly, { persist: false, suppressStatus: true, }); setActiveTVTab(settings.shared.mediaPanelActiveTab); keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts); renderShortcutSettings(); if (!applyLayers) { const layerVisibility = { ...(settings.shared.layerVisibility || {}) }; applyImmediateLayerVisibilityHints(layerVisibility); deferredLayerVisibilitySettings = layerVisibility; return; } deferredLayerVisibilitySettings = null; await applyLayerVisibilitySettings(settings.shared.layerVisibility, { persist: false, silent: true, }); } export function getMotionDebugEnabled() { return motionDebugEnabled; } export function getMotionProvider() { return motionProvider; } export function getMotionDebugSkeletonOnly() { return motionDebugSkeletonOnly; } export function setMotionDebugEnabled( nextEnabled, { persist = true, suppressStatus = false } = {}, ) { const normalized = Boolean(nextEnabled); const previousEffective = rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled; const changed = motionDebugEnabled !== normalized; motionDebugEnabled = normalized; syncMotionDebugToggle(motionDebugEnabled); ensureMutableEarthSettingsState(); earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled; const nextEffective = rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled; if (changed || previousEffective !== nextEffective) { dispatchMotionSettingsChange(); } if (persist) { persistEarthSettings(); } if (!suppressStatus && changed) { const message = motionDebugEnabled ? rotationMode === ROTATION_MODE.MOTION ? "动捕调试模式已开启" : "动捕调试模式将在下次进入动捕时开启" : "动捕调试模式已关闭"; showStatusMessage(message, "info"); } return motionDebugEnabled; } export function setMotionProvider( nextProvider, { persist = true, suppressStatus = false } = {}, ) { const normalized = normalizeMotionProvider(nextProvider, DEFAULT_MOTION_PROVIDER); const changed = motionProvider !== normalized; motionProvider = normalized; syncMotionProviderControls(motionProvider); ensureMutableEarthSettingsState(); earthSettingsState.shared.motionProvider = motionProvider; if (changed) { dispatchMotionSettingsChange(); } if (persist) { persistEarthSettings(); } if (!suppressStatus && changed) { showStatusMessage( motionProvider === "motion_agent" ? "动捕输入源已切换为 Motion Agent" : "动捕输入源已切换为浏览器摄像头", "info", ); } return motionProvider; } export function setMotionDebugSkeletonOnly( nextEnabled, { persist = true, suppressStatus = false } = {}, ) { const normalized = Boolean(nextEnabled); const changed = motionDebugSkeletonOnly !== normalized; motionDebugSkeletonOnly = normalized; syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly); ensureMutableEarthSettingsState(); earthSettingsState.shared.motionDebugSkeletonOnly = motionDebugSkeletonOnly; if (changed) { dispatchMotionSettingsChange(); } if (persist) { persistEarthSettings(); } if (!suppressStatus && changed) { showStatusMessage( motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面", "info", ); } return motionDebugSkeletonOnly; } export async function applyDeferredLayerVisibilitySettings(options = {}) { const layerVisibility = deferredLayerVisibilitySettings; deferredLayerVisibilitySettings = null; if (!layerVisibility) return; await applyLayerVisibilitySettings(layerVisibility, { persist: false, silent: true, ...options, }); } function resetEarthSettings() { const defaults = cloneEarthSettings(captureEarthSettingsDefaults()); earthSettingsState = cloneEarthSettings(defaults); if (canUseLocalStorage()) { try { window.localStorage.removeItem(EARTH_SETTINGS_STORAGE_KEY); window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY); } catch (error) { console.warn("移除 Earth 设置失败:", error); } } void applyEarthSettings(defaults).then(() => { showStatusMessage("Earth 设置已重置", "info"); }); } async function setTerrainEnabled(button, enabled, { persist = true, silent = false } = {}) { const toggleToken = ++terrainToggleToken; if (!enabled) { applyTerrainUiState(button, false); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage("地形已隐藏", "info"); } return false; } try { if (!isTerrainReady()) { setLayerButtonState(button, { loading: true, tooltip: "地形加载中...", statusText: "加载中", }); if (!silent) { showStatusMessage("正在加载真实地形数据...", "info"); } await ensureTerrainReady(); } if (toggleToken !== terrainToggleToken) return showTerrain; applyTerrainUiState(button, true); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage("真实地形已显示", "success"); } return true; } catch (error) { console.error("加载真实地形失败:", error); applyTerrainUiState(button, false); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage("真实地形暂时不可用", "error"); } return false; } } async function setSatellitesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { clearSelectionIfHiding(!enabled); try { if (enabled) { setLayerButtonState(button, { active: false, loading: true, tooltip: "卫星加载中...", }); } await setSatellitesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent }); if (!enabled && !silent) { showStatusMessage("卫星已隐藏", "info"); } else if (enabled) { setEarthStatValue("satellite-count", `${getSatelliteCount()} 颗`); } syncTrailsAvailability(); syncMobileLayerCards(); if (persist) persistEarthSettings(); return enabled; } catch (error) { console.error("切换卫星显示失败:", error); setLayerButtonState(button, { active: false, loading: false, tooltip: "显示卫星", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return false; } } function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { toggleGridLines(enabled); setLayerButtonState(button, { active: enabled, tooltip: enabled ? "隐藏经纬线" : "显示经纬线", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info"); } return enabled; } async function setCountryBoundariesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { try { if (enabled) { setLayerButtonState(button, { active: false, loading: true, tooltip: "国界加载中...", }); } await setCountryBoundariesEnabled(enabled, { suppressStatus: silent }); setLayerButtonState(button, { active: enabled, loading: false, tooltip: enabled ? "隐藏国界" : "显示国界", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return enabled; } catch (error) { console.error("切换国界显示失败:", error); setLayerButtonState(button, { active: false, loading: false, tooltip: "显示国界", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return false; } } async function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { if (enabled) { setLayerButtonState(button, { active: false, loading: true, tooltip: "高清材质加载中...", }); } await setHighResTextureEnabled(enabled, { suppressStatus: silent }); setLayerButtonState(button, { active: enabled, loading: false, tooltip: enabled ? "隐藏高清材质" : "显示高清材质", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return enabled; } async function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { if (enabled) { setLayerButtonState(button, { active: false, loading: true, tooltip: "大气云图加载中...", }); } await setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent }); setLayerButtonState(button, { active: enabled, loading: false, tooltip: enabled ? "隐藏大气云图" : "显示大气云图", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return enabled; } function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { clearSelectionIfHiding(!enabled); toggleBGP(enabled); if (!enabled && rotationMode === ROTATION_MODE.CRUISE && autoRotate) { setAutoRotate(false); } setLayerButtonState(button, { active: enabled, tooltip: enabled ? "隐藏BGP观测" : "显示BGP观测", }); setEarthStatValue("bgp-anomaly-count", `${getBGPCount()} 条`); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info"); } return enabled; } function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { clearSelectionIfHiding(!enabled); toggleComputeCenters(enabled); setLayerButtonState(button, { active: enabled, tooltip: enabled ? "隐藏算力中心" : "显示算力中心", }); setEarthStatValue("compute-center-count", `${getComputeCenterCount()} 个`); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage(enabled ? "算力中心已显示" : "算力中心已隐藏", "info"); } return enabled; } async function setVesselsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { clearSelectionIfHiding(!enabled); try { if (enabled) { setLayerButtonState(button, { active: false, loading: true, tooltip: "船只加载中...", }); } await setVesselsEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent }); setLayerButtonState(button, { active: enabled, loading: false, tooltip: enabled ? "隐藏船只" : "显示船只", }); setEarthStatValue("vessel-count", `${getVesselCount()} 艘`); syncMobileLayerCards(); if (persist) persistEarthSettings(); return enabled; } catch (error) { console.error("切换船只显示失败:", error); setLayerButtonState(button, { active: false, loading: false, tooltip: "显示船只", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return false; } } function syncTrailsDisplayControls() { const trailsEnabled = getShowTrails(); const disabledState = getLayerDisabledState("trails"); document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => { if (!(toggle instanceof HTMLInputElement)) return; toggle.checked = trailsEnabled; toggle.disabled = disabledState.disabled; const label = toggle.closest("label"); label?.classList.toggle("is-disabled", disabledState.disabled); if (label instanceof HTMLElement) { if (disabledState.disabled && disabledState.tooltip) { label.title = disabledState.tooltip; } else { label.removeAttribute("title"); } } }); } function setTrailsDisplayEnabled(enabled, { persist = true, silent = false } = {}) { toggleTrails(enabled); const disabledState = getLayerDisabledState("trails"); setLayerButtonState(getLayerButton("trails"), { active: enabled, disabled: disabledState.disabled, tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"), }); syncTrailsDisplayControls(); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info"); } return enabled; } function syncTrailsAvailability() { const trailsEnabled = getShowTrails(); const disabledState = getLayerDisabledState("trails"); setLayerButtonState(getLayerButton("trails"), { active: trailsEnabled, disabled: disabledState.disabled, tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"), }); syncTrailsDisplayControls(); syncMobileLayerCards(); } async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { 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(); } } async function applyLayerVisibilitySettings(layerVisibility = {}, options = {}) { for (const layer of getPersistedLayers()) { const nextVisible = layerVisibility?.[layer.id]; if (typeof nextVisible !== "boolean") continue; await layer.setVisible(nextVisible, options); } } function applyImmediateLayerVisibilityHints(layerVisibility = {}) { if (typeof layerVisibility.gridLines === "boolean") { setGridLinesLayerEnabled(getLayerButton("gridLines"), layerVisibility.gridLines, { persist: false, silent: true, }); } if (typeof layerVisibility.countryBoundaries === "boolean") { toggleCountryBoundaries(layerVisibility.countryBoundaries, { showLandFill: true, }); setLayerButtonState(getLayerButton("countryBoundaries"), { active: layerVisibility.countryBoundaries, loading: false, tooltip: layerVisibility.countryBoundaries ? "隐藏国界线" : "显示国界线", }); } if (layerVisibility.earthHighResTexture === false) { void setHighResTextureEnabled(false, { suppressStatus: true }); setLayerButtonState(getLayerButton("earthHighResTexture"), { active: false, loading: false, tooltip: "显示高清材质", }); } if (typeof layerVisibility.atmosphereClouds === "boolean") { toggleClouds(layerVisibility.atmosphereClouds); setLayerButtonState(getLayerButton("atmosphereClouds"), { active: layerVisibility.atmosphereClouds, loading: false, tooltip: layerVisibility.atmosphereClouds ? "隐藏大气云图" : "显示大气云图", }); } } function getBuiltinLayerDefinitions() { return [ { id: "gridLines", buttonId: "toggle-grid-lines", icon: "grid_4x4", label: "经纬线", meta: "Graticule", keywords: "经纬线 graticule 经纬 latitude longitude", defaultActive: false, displayOrder: 100, startupPriority: 10, startupMode: "visible", startupLabel: "经纬线", startupMessage: "", getVisible: () => getShowGridLines(), setVisible: (visible, options = {}) => setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options), }, { id: "countryBoundaries", buttonId: "toggle-country-boundaries", icon: "public", label: "国界线", meta: "Country Borders", keywords: "国界 国家 borders countries boundary", defaultActive: true, displayOrder: 90, startupPriority: 20, startupMode: "preload", startupAlwaysLoad: true, startupLabel: "海陆基座", startupMessage: "正在加载海陆基座...", getVisible: () => getShowCountryBoundaries(), setVisible: (visible, options = {}) => setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options), }, { id: "earthHighResTexture", buttonId: "toggle-earth-high-res-texture", icon: "globe", label: "高清材质", meta: "High-Res Texture", keywords: "高清 材质 纹理 texture hd 地表 earth", defaultActive: true, displayOrder: 70, startupPriority: 30, startupMode: "visible", startupLabel: "高清材质", startupMessage: "正在启用高清材质...", getVisible: () => getHighResTextureEnabled(), setVisible: (visible, options = {}) => setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options), }, { id: "atmosphereClouds", buttonId: "toggle-atmosphere-clouds", icon: "cloud", label: "大气云图", meta: "Cloud Layer", keywords: "大气 云图 云层 clouds atmosphere", defaultActive: true, displayOrder: 80, startupPriority: 40, startupMode: "visible", startupLabel: "大气云图", startupMessage: "", getVisible: () => getAtmosphereCloudsEnabled(), setVisible: (visible, options = {}) => setAtmosphereCloudsLayerEnabled(getLayerButton("atmosphereClouds"), visible, options), }, { id: "cables", buttonId: "toggle-cables", icon: "cable", label: "海缆", meta: "Subsea Cables", keywords: "海缆 subsea cables", defaultActive: true, displayOrder: 10, startupPriority: 50, startupMode: "visible", startupLabel: "海缆", startupMessage: { prepare: "正在加载登陆点...", load: "正在加载海缆...", }, getVisible: () => getShowCables(), setVisible: (visible, options = {}) => setCablesLayerEnabled(getLayerButton("cables"), visible, options), }, { id: "computeCenters", buttonId: "toggle-compute-centers", icon: "memory", label: "算力中心", meta: "Compute Centers", keywords: "算力中心 compute centers gpu 超算", defaultActive: true, displayOrder: 40, startupPriority: 60, startupMode: "preload", startupLabel: "算力中心", startupMessage: "正在加载算力中心...", getVisible: () => getShowComputeCenters(), setVisible: (visible, options = {}) => setComputeCentersLayerEnabled(getLayerButton("computeCenters"), visible, options), }, { id: "bgp", buttonId: "toggle-bgp", icon: "hub", label: "BGP观测", meta: "Routing Signals", keywords: "bgp观测 routing signals", defaultActive: true, displayOrder: 50, startupPriority: 70, startupMode: "preload", startupLabel: "BGP态势", startupMessage: "正在加载BGP态势...", getVisible: () => getShowBGP(), setVisible: (visible, options = {}) => setBGPLayerEnabled(getLayerButton("bgp"), visible, options), }, { id: "vessels", buttonId: "toggle-vessels", icon: "directions_boat", label: "船只", meta: "AIS Vessels", keywords: "船只 船舶 ais vessels ships maritime", defaultActive: false, displayOrder: 45, startupPriority: 65, startupMode: "visible", startupLabel: "船只", startupMessage: "正在加载船只...", getVisible: () => getVesselsEnabled(), setVisible: (visible, options = {}) => setVesselsLayerEnabled(getLayerButton("vessels"), visible, options), }, { id: "satellites", buttonId: "toggle-satellites", icon: "satellite_alt", label: "卫星", meta: "Satellites", keywords: "卫星 satellites", defaultActive: false, displayOrder: 30, startupPriority: 80, startupMode: "visible", startupLabel: "卫星", startupMessage: "正在加载卫星...", getVisible: () => getSatellitesEnabled(), setVisible: (visible, options = {}) => setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options), }, { id: "terrain", buttonId: "toggle-terrain", icon: "landscape", label: "地形", meta: "Terrain", keywords: "地形 terrain", defaultActive: false, displayOrder: 60, startupPriority: null, startupMode: "visible", startupLabel: "地形", startupMessage: "正在渲染地形...", statusTarget: "terrain-status", getVisible: () => showTerrain, setVisible: (visible, options = {}) => setTerrainEnabled(getLayerButton("terrain"), visible, options), }, ]; } function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) { const list = document.getElementById("layer-panel-list"); let row = definition.buttonId ? document.getElementById(definition.buttonId)?.closest(".layer-row") : null; if (!row && appendIfMissing && list) { row = createLayerRow(definition); list.appendChild(row); } if (!row) return null; row.dataset.layerId = definition.id; row.dataset.layerName = (definition.keywords || `${definition.label} ${definition.meta || ""}`) .trim() .toLowerCase(); const icon = row.querySelector(".layer-row-icon"); const label = row.querySelector(".layer-row-label"); const meta = row.querySelector(".layer-row-meta"); const button = row.querySelector("button"); if (icon) icon.textContent = definition.icon; if (label) label.textContent = definition.label; if (definition.meta) { if (meta) { meta.textContent = definition.meta; } else { const copy = row.querySelector(".layer-row-copy"); if (copy) { const metaEl = document.createElement("span"); metaEl.className = "layer-row-meta"; metaEl.textContent = definition.meta; copy.appendChild(metaEl); } } } else if (meta) { meta.remove(); } if (button instanceof HTMLButtonElement) { button.id = definition.buttonId; button.title = `切换${definition.label}显示`; if (definition.statusTarget) { button.dataset.statusTarget = definition.statusTarget; } else { delete button.dataset.statusTarget; } } return row; } function registerLayerDefinition(definition, options = {}) { const normalizedDefinition = { persist: true, displayOrder: null, startupPriority: null, startupMode: "visible", startupLabel: "", startupMessage: "", ...definition, }; layerRegistry.set(normalizedDefinition.id, normalizedDefinition); const row = syncLayerRowDefinition(normalizedDefinition, options); if (row && layerPanelInitialized) { bindLayerButton(row, normalizedDefinition); } renderMobileLayerCards(); return normalizedDefinition; } function initializeLayerRegistry() { layerRegistry = new Map(); getBuiltinLayerDefinitions().forEach((definition) => { registerLayerDefinition(definition); }); } function getViewRotation(targetLat, targetRotLon) { const latRot = (targetLat * Math.PI) / 180; return { x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient, y: -((targetRotLon * Math.PI) / 180), }; } function dispatchRotationModeChange() { window.dispatchEvent( new CustomEvent("earth:rotation-mode-change", { detail: { mode: rotationMode, active: autoRotate, }, }), ); } function applyTerrainUiState(button, enabled) { showTerrain = enabled; toggleTerrain(enabled); setLayerButtonState(button, { active: enabled, loading: false, tooltip: enabled ? "隐藏地形" : "显示地形", statusText: enabled ? "开启" : "关闭", }); } function prewarmTerrainIfNeeded() { if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) return; terrainPrefetchStarted = true; ensureTerrainReady().catch((error) => { terrainPrefetchStarted = false; console.warn("地形预热加载失败:", error); }); } function clearScheduledTerrainPrefetch() { if (terrainPrefetchTimer !== null) { window.clearTimeout(terrainPrefetchTimer); terrainPrefetchTimer = null; } if ( terrainPrefetchIdleHandle !== null && typeof window !== "undefined" && "cancelIdleCallback" in window ) { window.cancelIdleCallback(terrainPrefetchIdleHandle); } terrainPrefetchIdleHandle = null; } export function scheduleTerrainPrefetch({ delayMs = 4500, idleTimeoutMs = 6000 } = {}) { clearScheduledTerrainPrefetch(); if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) { return; } const runPrefetch = () => { terrainPrefetchIdleHandle = null; prewarmTerrainIfNeeded(); }; terrainPrefetchTimer = window.setTimeout(() => { terrainPrefetchTimer = null; if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) { return; } if (typeof window !== "undefined" && "requestIdleCallback" in window) { terrainPrefetchIdleHandle = window.requestIdleCallback(runPrefetch, { timeout: idleTimeoutMs, }); return; } runPrefetch(); }, delayMs); } export function applyImmediateView(targetEarthObj, camera, options = {}) { if (!targetEarthObj) return; const { lat = EARTH_CONFIG.chinaLat, rotLon = EARTH_CONFIG.chinaRotLon, zoom = getDefaultEarthZoomLevel(), } = options; const nextRotation = getViewRotation(lat, rotLon); targetEarthObj.rotation.x = nextRotation.x; targetEarthObj.rotation.y = nextRotation.y; zoomLevel = zoom; if (camera) { camera.position.z = CONFIG.defaultCameraZ / zoomLevel; updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0)); } } 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; } export function showZoomStatusCapsule({ force = false } = {}) { const now = Date.now(); if (!force && now - lastZoomStatusUpdateTime < ZOOM_STATUS_UPDATE_INTERVAL_MS) { return; } lastZoomStatusUpdateTime = now; showGestureStatusMessage(`缩放 ${Math.round(zoomLevel * 100)}%`, "info"); } function cancelSettingsSheetAnimation() { if (settingsSheetAnimation) { settingsSheetAnimation.cancel(); settingsSheetAnimation = null; } } function getSettingsSheetAnimationState(trigger, sheet) { if (!(trigger instanceof HTMLElement) || !(sheet instanceof HTMLElement)) { return null; } const triggerRect = trigger.getBoundingClientRect(); const sheetRect = sheet.getBoundingClientRect(); const triggerCenterX = triggerRect.left + triggerRect.width / 2; const triggerCenterY = triggerRect.top + triggerRect.height / 2; const sheetCenterX = sheetRect.left + sheetRect.width / 2; const sheetCenterY = sheetRect.top + sheetRect.height / 2; return { translateX: triggerCenterX - sheetCenterX, translateY: triggerCenterY - sheetCenterY, scaleX: Math.max( SETTINGS_SHEET_MIN_SCALE, Math.min( SETTINGS_SHEET_MAX_SCALE_X, triggerRect.width / Math.max(sheetRect.width, 1), ), ), scaleY: Math.max( SETTINGS_SHEET_MIN_SCALE, Math.min( SETTINGS_SHEET_MAX_SCALE_Y, triggerRect.height / Math.max(sheetRect.height, 1), ), ), radius: `${Math.max(triggerRect.width, triggerRect.height).toFixed(2)}px`, }; } function animateSettingsSheet(sheet, trigger, opening) { const animationState = getSettingsSheetAnimationState(trigger, sheet); if (!animationState || typeof sheet.animate !== "function") { return; } cancelSettingsSheetAnimation(); const fromTransform = `translate(${animationState.translateX.toFixed(2)}px, ${animationState.translateY.toFixed(2)}px) scale(${animationState.scaleX.toFixed(4)}, ${animationState.scaleY.toFixed(4)})`; const toTransform = "translate(0px, 0px) scale(1, 1)"; const keyframes = opening ? [ { transform: fromTransform, opacity: 0.22, filter: "blur(10px)", borderRadius: animationState.radius, }, { transform: "translate(0px, 0px) scale(1.015, 1.015)", opacity: 1, filter: "blur(0px)", borderRadius: "0px", offset: 0.76, }, { transform: toTransform, opacity: 1, filter: "blur(0px)", borderRadius: "0px", }, ] : [ { transform: toTransform, opacity: 1, filter: "blur(0px)", borderRadius: "0px", }, { transform: fromTransform, opacity: 0.08, filter: "blur(10px)", borderRadius: animationState.radius, }, ]; settingsSheetAnimation = sheet.animate(keyframes, { duration: opening ? SETTINGS_MODAL_OPEN_ANIMATION_MS : SETTINGS_MODAL_CLOSE_ANIMATION_MS, easing: opening ? "cubic-bezier(0.16, 1, 0.3, 1)" : "cubic-bezier(0.4, 0, 0.2, 1)", fill: "both", }); settingsSheetAnimation.onfinish = () => { sheet.style.transform = ""; sheet.style.opacity = ""; sheet.style.filter = ""; sheet.style.borderRadius = ""; settingsSheetAnimation = null; }; settingsSheetAnimation.oncancel = () => { settingsSheetAnimation = null; }; } function getFloatingGroups() { return [ document.getElementById("zoom-control-group"), ].filter(Boolean); } function isFloatingMenuVisible() { return getFloatingGroups().some((group) => { return ( group.classList.contains("open") || group.matches(":hover") || group.matches(":focus-within") ); }); } function isSettingsModalOpen() { return document .getElementById("settings-modal") ?.classList.contains("is-open"); } function closeFloatingMenus() { getFloatingGroups().forEach((group) => { group.classList.remove("open"); group.classList.add("force-closed"); }); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } } 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"); if (!modal) return; if (settingsModalTimer) { clearTimeout(settingsModalTimer); 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"); modal.setAttribute("aria-hidden", "false"); requestAnimationFrame(() => { if (sheet instanceof HTMLElement) { animateSettingsSheet(sheet, trigger, true); } window.setTimeout(() => { modal.classList.remove("is-opening"); }, SETTINGS_MODAL_OPEN_ANIMATION_MS); }); } 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) { animateSettingsSheet(sheet, trigger, false); } if (settingsModalTimer) { clearTimeout(settingsModalTimer); } settingsModalTimer = window.setTimeout(() => { modal.classList.remove("is-closing", "is-opening"); modal.setAttribute("aria-hidden", "true"); settingsModalTimer = null; }, SETTINGS_MODAL_CLOSE_ANIMATION_MS); } function setHudPanelVisibility(panelId, visible, { persist = true } = {}) { const panel = document.getElementById(panelId); if (!panel) return; panel.classList.toggle("hud-panel-hidden", !visible); const scope = getSettingsViewportScope(); if (earthSettingsState?.views?.[scope]?.panelVisibility) { earthSettingsState.views[scope].panelVisibility[panelId] = visible; } if (!visible && activeMobileDrawerId === panelId) { activeMobileDrawerId = null; syncMobileDrawerState(); } syncSettingsToggle(panelId, visible); if (panelId === "media-panel") { updateTVToggleUI(visible); updateNewsToggleUI(visible); if (visible && !isMobileLayout()) { ensureTVPanelReady().catch((error) => { console.error("初始化电视直播面板失败:", error); }); ensureNewsPanelReady().catch((error) => { console.error("初始化态势新闻内容失败:", error); }); } } if (persist) { persistEarthSettings(); } } function resetDesktopHudPanelsForMobile() { document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR).forEach((panel) => { if (!(panel instanceof HTMLElement)) return; resetPanelInlineLayout(panel); }); } function applyCurrentViewportPanelVisibility({ persist = false } = {}) { const panelVisibility = getViewportPanelVisibility(earthSettingsState, layoutMode); HUD_PANEL_IDS.forEach((panelId) => { const visible = panelVisibility?.[panelId]; if (typeof visible === "boolean") { setHudPanelVisibility(panelId, visible, { persist }); } }); syncAllHudPanelToggles(); } function syncSettingsToggle(panelId, visible) { const inputs = document.querySelectorAll( `[data-settings-panel="${panelId}"]`, ); inputs.forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = visible; } }); } function syncAllHudPanelToggles() { HUD_PANEL_IDS.forEach((panelId) => { const panel = document.getElementById(panelId); syncSettingsToggle(panelId, !panel?.classList.contains("hud-panel-hidden")); }); } function syncDayNightToggle(enabled) { document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => { if (input instanceof HTMLInputElement) { input.checked = enabled; } }); } function applyDayNightEnabled(enabled, { persist = true } = {}) { dayNightEnabled = enabled; setDayNightEnabled(enabled); setCelestialDayNightEnabled(enabled); syncDayNightToggle(enabled); if (persist) persistEarthSettings(); } export function setDayNightEnabledExternal(enabled, { persist = true } = {}) { applyDayNightEnabled(enabled, { persist }); } export function getDayNightEnabled() { return dayNightEnabled; } export function setTerrainLayerInteractable(enabled) { const button = getLayerButton("terrain"); setLayerButtonState(button, { disabled: !enabled, tooltip: enabled ? null : "高清材质关闭时不可用", }); syncMobileLayerCards(); } export function setDayNightInteractable(enabled) { document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => { input.disabled = !enabled; const label = input.closest("label"); if (label) label.classList.toggle("is-disabled", !enabled); }); } function getBoundaryPrecisionEls() { return { 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]")), }; } async function fetchBoundaryPrecisionJson(path, options = {}) { const response = await fetch(path, { cache: "no-store", ...options, headers: { "content-type": "application/json", ...(options.headers || {}), }, }); const contentType = response.headers.get("content-type") || ""; if (!response.ok) { let detail = `HTTP ${response.status}`; if (contentType.includes("application/json")) { const payload = await response.json().catch(() => null); detail = payload?.detail?.message || payload?.detail || detail; } throw new Error(detail); } if (!contentType.includes("application/json")) { throw new Error("后端没有返回 JSON 状态"); } return response.json(); } function renderBoundaryPrecisionStatus(payload = {}) { const els = getBoundaryPrecisionEls(); const job = payload.job || payload.current_job || {}; const highReady = Boolean(payload.high_precision_ready || job?.result?.high_precision_ready); const enabled = getHighPrecisionBoundariesEnabled(); const running = job.status === "queued" || job.status === "running"; const failed = job.status === "failed" && boundaryBuildAttemptedThisSession; const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); const failureMessage = job.code === "source_not_configured" || job.code === "missing_sources" ? `高清国界更新源未配置完整:${job.message || job.code}` : `高清国界下载失败:${job.message || job.code || "请检查更新源"}`; els.statuses.forEach((status) => { status.textContent = "国界精度"; }); els.details.forEach((detail) => { detail.textContent = running ? (job.message || "正在准备高清国界") : failed ? failureMessage : highReady ? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。") : "当前使用低精国界;切到高精会下载并构建。"; }); 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}%`; }); 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; 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(); } async function refreshBoundaryPrecisionStatus() { const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/status"); renderBoundaryPrecisionStatus(payload); return payload; } function stopBoundaryBuildPolling() { if (boundaryBuildPollTimer) { window.clearInterval(boundaryBuildPollTimer); boundaryBuildPollTimer = null; } } function startBoundaryBuildPolling() { stopBoundaryBuildPolling(); boundaryBuildPollTimer = window.setInterval(async () => { try { const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build/status"); renderBoundaryPrecisionStatus(payload); const status = payload.job?.status; if (status === "succeeded" || status === "failed") { stopBoundaryBuildPolling(); await refreshBoundaryPrecisionStatus(); if (status === "succeeded" && boundaryBuildAttemptedThisSession) { setHighPrecisionBoundariesEnabled(true); await reloadCountryBoundaries({ suppressStatus: true }); await refreshBoundaryPrecisionStatus().catch(() => {}); showStatusMessage("高精国界已下载并应用", "info"); } } } catch (error) { stopBoundaryBuildPolling(); showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning"); } }, 1000); } async function startBoundaryPrecisionBuild() { renderBoundaryPrecisionStatus({ job: { status: "queued", progress: 0, message: "正在启动高精国界构建" }, }); boundaryBuildAttemptedThisSession = true; await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" }); showStatusMessage("高精国界构建已启动", "info"); startBoundaryBuildPolling(); } async function setupBoundaryPrecisionControls() { const els = getBoundaryPrecisionEls(); if (els.buildButtons.length === 0 && els.disableButtons.length === 0) return; try { const payload = await refreshBoundaryPrecisionStatus(); const jobStatus = payload.current_job?.status; if (jobStatus === "queued" || jobStatus === "running") { startBoundaryBuildPolling(); } } catch (error) { renderBoundaryPrecisionStatus({}); showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning"); } 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 || {}; const highReady = Boolean(statusPayload.high_precision_ready || job?.result?.high_precision_ready); if (highReady) { if (getHighPrecisionBoundariesEnabled()) return; setHighPrecisionBoundariesEnabled(true); await reloadCountryBoundaries({ suppressStatus: true }); showStatusMessage("已切换到高精国界", "info"); await refreshBoundaryPrecisionStatus().catch(() => {}); return; } await startBoundaryPrecisionBuild(); } catch (error) { await refreshBoundaryPrecisionStatus().catch(() => {}); showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning"); } }); }); els.rebuildButtons.forEach((rebuildButton) => { if (!(rebuildButton instanceof HTMLButtonElement)) return; bindListener(rebuildButton, "click", async () => { try { await startBoundaryPrecisionBuild(); } catch (error) { await refreshBoundaryPrecisionStatus().catch(() => {}); showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning"); } }); }); els.disableButtons.forEach((disableButton) => { if (!(disableButton instanceof HTMLButtonElement)) return; bindListener(disableButton, "click", async () => { try { if (!getHighPrecisionBoundariesEnabled()) return; setHighPrecisionBoundariesEnabled(false); await reloadCountryBoundaries({ suppressStatus: true }); showStatusMessage("已切换到低精国界", "info"); await refreshBoundaryPrecisionStatus().catch(() => {}); } catch (error) { 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 = `