// controls.js - Zoom, rotate and toggle controls import * as THREE from "three"; import { CONFIG, CRUISE_MODULES, DEFAULT_SATELLITE_DISPLAY_STYLE, DEFAULT_CRUISE_MODULES, EARTH_CONFIG, ROTATION_MODE, SATELLITE_DISPLAY_STYLES, } from "./constants.js"; import { setEarthStatValue, showGestureStatusMessage, showStatusMessage, updateZoomDisplay, } from "./ui.js"; import { toggleTerrain, setDayNightEnabled, toggleGridLines, getShowGridLines, } from "./earth.js"; import { setCelestialDayNightEnabled } from "./celestial.js"; import { ensureTerrainReady, isTerrainReady, getTerrainOpacity, setTerrainOpacity, } from "./terrain.js"; import { reloadData, clearLockedObject, clearLockedObjectAndInfo, setCablesEnabled, setCountryBoundariesEnabled, setHighResTextureEnabled, getHighResTextureEnabled, setAtmosphereCloudsEnabled, getAtmosphereCloudsEnabled, setSatellitesEnabled, getSatellitesEnabled, setVesselsEnabled, getVesselsEnabled, } from "./main.js"; import { toggleTrails, getShowTrails, getSatelliteCount, getSatelliteDisplayStyle, setSatelliteDisplayStyle as applySatelliteDisplayStyle, } from "./satellites.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; import { getShowCountryBoundaries } from "./country-boundaries.js"; import { toggleComputeCenters, getShowComputeCenters, getComputeCenterCount, } from "./compute-centers.js"; import { getShowVessels, getVesselCount, } from "./vessels.js"; import { ensureTVPanelReady, isTVPanelVisible, 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"; 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 activeCamera = null; let settingsApplyPromise = Promise.resolve(); 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 = 5; const GRID_LINES_DEFAULT_VERSION = 3; const SATELLITE_DISPLAY_DEFAULT_VERSION = 4; const MEDIA_PANEL_DEFAULT_VERSION = 5; const DEFAULT_EARTH_ZOOM_STEP = 0.01; const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90; let settingsModalTimer = null; let settingsSheetAnimation = null; let terrainToggleToken = 0; let terrainPrefetchStarted = false; let terrainPrefetchScheduled = false; let focusViewAnimationToken = 0; let earthSettingsDefaults = null; let lastZoomStatusUpdateTime = 0; let earthSettingsState = 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; const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES)); const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set( Object.values(SATELLITE_DISPLAY_STYLES), ); 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" && isTVPanelVisible()) { setTVPanelVisible(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; } if (definition.startupMode === "preload") { return true; } return Boolean(definition?.getVisible?.()); } 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(), layerVisibility: Object.fromEntries( getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]), ), terrainOpacity: getTerrainOpacity(), dayNightEnabled, defaultEarthZoom, }; } 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, terrainOpacity: settings.shared.terrainOpacity, dayNightEnabled: settings.shared.dayNightEnabled, defaultEarthZoom: settings.shared.defaultEarthZoom, 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 ? ROTATION_MODE.CRUISE : defaults.shared.rotationMode; const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules) ? sharedSettings.cruiseModules : defaults.shared.cruiseModules; const nextCruiseModules = Array.from( new Set( requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)), ), ); 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, ); return { version: EARTH_SETTINGS_VERSION, shared: { rotationMode: nextRotationMode, cruiseModules: nextCruiseModules.length > 0 ? nextCruiseModules : [...DEFAULT_CRUISE_MODULES], satelliteDisplayStyle: nextSatelliteDisplayStyle, layerVisibility: normalizedLayerVisibility, terrainOpacity: Number.isFinite(nextTerrainOpacity) ? nextTerrainOpacity : defaults.shared.terrainOpacity, dayNightEnabled: nextDayNightEnabled, defaultEarthZoom: nextDefaultEarthZoom, }, views: { desktop: { panelVisibility: normalizedDesktopPanelVisibility, }, mobile: { panelVisibility: normalizedMobilePanelVisibility, }, }, }; } 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 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 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"); }); } export function getCruiseModules() { const configuredModules = earthSettingsState?.shared?.cruiseModules; return normalizeCruiseModules(configuredModules); } export function isCruiseModuleEnabled(moduleId) { return getCruiseModules().includes(moduleId); } export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) { const normalizedModules = normalizeCruiseModules(nextModules); const previousModules = getCruiseModules(); const changed = normalizedModules.length !== previousModules.length || normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId); if (!changed) { syncCruiseModuleControls(); return normalizedModules; } earthSettingsState = cloneEarthSettings( earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()), ); earthSettingsState.shared.cruiseModules = [...normalizedModules]; syncCruiseModuleControls(); dispatchCruiseModulesChange(); if (persist) { persistEarthSettings(); } if (!suppressStatus) { const labels = normalizedModules.map((moduleId) => moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP", ); showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info"); } return normalizedModules; } 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; } earthSettingsState = cloneEarthSettings( earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()), ); 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; } 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) { 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, }); if (typeof settings.shared.dayNightEnabled === "boolean") { applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false }); } setDefaultEarthZoom(settings.shared.defaultEarthZoom, { persist: false, applyToCurrentView: true, }); await applyLayerVisibilitySettings(settings.shared.layerVisibility, { persist: false, silent: true, }); } 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; } } function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { setHighResTextureEnabled(enabled, { suppressStatus: silent }); setLayerButtonState(button, { active: enabled, loading: false, tooltip: enabled ? "隐藏高清材质" : "显示高清材质", }); syncMobileLayerCards(); if (persist) persistEarthSettings(); return enabled; } function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { 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 setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { toggleTrails(enabled); const disabledState = getLayerDisabledState("trails"); setLayerButtonState(button, { active: enabled, disabled: disabledState.disabled, tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"), }); 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 ? "隐藏轨迹" : "显示轨迹"), }); 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 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", 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: "trails", buttonId: "toggle-trails", icon: "timeline", label: "轨迹", meta: "Trails", keywords: "轨迹 trails", defaultActive: true, displayOrder: 20, startupPriority: null, startupMode: "visible", startupLabel: "轨迹", startupMessage: "", getVisible: () => getShowTrails(), setVisible: (visible, options = {}) => setTrailsLayerEnabled(getLayerButton("trails"), 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 (terrainPrefetchStarted || isTerrainReady()) return; terrainPrefetchStarted = true; ensureTerrainReady().catch((error) => { terrainPrefetchStarted = false; console.warn("地形预热加载失败:", error); }); } function scheduleTerrainPrefetch() { if (terrainPrefetchScheduled || terrainPrefetchStarted || isTerrainReady()) { return; } terrainPrefetchScheduled = true; const runPrefetch = () => { terrainPrefetchScheduled = false; prewarmTerrainIfNeeded(); }; if (typeof window !== "undefined" && "requestIdleCallback" in window) { window.requestIdleCallback(runPrefetch, { timeout: 2200 }); return; } window.setTimeout(runPrefetch, 1400); } 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 input = document.querySelector( `[data-settings-panel="${panelId}"]`, ); 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 setupSettingsControls() { const settingsTrigger = document.getElementById("settings-trigger"); const settingsClose = document.getElementById("settings-close"); const settingsBackdrop = document.getElementById("settings-backdrop"); const settingsModal = document.getElementById("settings-modal"); const settingsReset = document.getElementById("settings-reset"); bindListener(settingsTrigger, "click", () => { openSettingsModal(); }); bindListener(settingsClose, "click", () => { closeSettingsModal(); }); bindListener(settingsBackdrop, "click", () => { closeSettingsModal(); }); bindListener(settingsReset, "click", () => { resetEarthSettings(); }); bindListener(settingsModal, "click", (event) => { const sheet = event.target.closest(".earth-settings-sheet"); if (!sheet) { closeSettingsModal(); } }); const toggleInputs = document.querySelectorAll("[data-settings-panel]"); toggleInputs.forEach((input) => { bindListener(input, "change", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLInputElement)) return; const panelId = target.dataset.settingsPanel; if (!panelId) return; setHudPanelVisibility(panelId, target.checked); }); }); const terrainOpacitySliders = document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]"); const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]"); const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]"); const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]"); const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]"); const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]"); const syncTerrainOpacityUi = (nextOpacity) => { const safeOpacity = Math.round(nextOpacity * 100); terrainOpacitySliders.forEach((slider) => { if (slider instanceof HTMLInputElement) { slider.value = nextOpacity.toFixed(2); } }); terrainOpacityValues.forEach((value) => { if (value instanceof HTMLElement) { value.textContent = `${safeOpacity}%`; } }); }; syncTerrainOpacityUi(getTerrainOpacity()); syncDefaultEarthZoomUi(defaultEarthZoom); terrainOpacitySliders.forEach((terrainOpacitySlider) => { if (!(terrainOpacitySlider instanceof HTMLInputElement)) return; bindListener(terrainOpacitySlider, "input", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLInputElement)) return; const nextOpacity = Number.parseFloat(target.value); const appliedOpacity = setTerrainOpacity( Number.isFinite(nextOpacity) ? nextOpacity : getTerrainOpacity(), ); syncTerrainOpacityUi(appliedOpacity); persistEarthSettings(); }); }); defaultEarthSizeSliders.forEach((defaultEarthSizeSlider) => { if (!(defaultEarthSizeSlider instanceof HTMLInputElement)) return; bindListener(defaultEarthSizeSlider, "input", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLInputElement)) return; const nextZoom = Number.parseFloat(target.value); setDefaultEarthZoom( Number.isFinite(nextZoom) ? nextZoom : defaultEarthZoom, { persist: true, applyToCurrentView: true }, ); }); }); rotationModeButtons.forEach((button) => { bindListener(button, "click", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLButtonElement)) return; const nextMode = target.dataset.rotationMode; if (!nextMode) return; setRotationMode(nextMode); }); }); cruiseModuleButtons.forEach((button) => { bindListener(button, "click", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLButtonElement)) return; const moduleId = target.dataset.cruiseModuleToggle; if (!moduleId) return; const currentModules = new Set(getCruiseModules()); if (currentModules.has(moduleId)) { currentModules.delete(moduleId); } else { currentModules.add(moduleId); } setCruiseModules(Array.from(currentModules)); }); }); satelliteDisplayStyleButtons.forEach((button) => { bindListener(button, "click", (event) => { const target = event.currentTarget; if (!(target instanceof HTMLButtonElement)) return; const nextStyle = target.dataset.satelliteDisplayStyle; if (!nextStyle) return; setSatelliteDisplayStyle(nextStyle); }); }); document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => { if (!(dayNightToggle instanceof HTMLInputElement)) return; bindListener(dayNightToggle, "change", () => { applyDayNightEnabled(dayNightToggle.checked); }); }); const mobileSettingsReset = document.getElementById("mobile-settings-reset"); bindListener(mobileSettingsReset, "click", () => { resetEarthSettings(); }); captureEarthSettingsDefaults(); settingsApplyPromise = applyEarthSettings(loadEarthSettings()); syncAllHudPanelToggles(); syncRotationModeButtons(); syncCruiseModuleControls(); syncSatelliteDisplayStyleControls(); syncDayNightToggle(dayNightEnabled); } function setupHudPanelControls() { const closeButtons = document.querySelectorAll("[data-close-panel]"); closeButtons.forEach((button) => { bindListener(button, "click", (event) => { event.stopPropagation(); const target = event.currentTarget; if (!(target instanceof HTMLElement)) return; const panelId = target.dataset.closePanel; if (!panelId) return; if (isMobileLayout() && panelId === "layer-toggles") { setMobileDrawerOpen(panelId, false); return; } setHudPanelVisibility(panelId, false); }); }); } function capturePanelAnchor(app, panel, desiredLeft, desiredTop) { // 拖拽期间:按用户给出的绝对位置重置 anchor,轴模式回到 left/top。 // clamp 真的触发时由 syncPanelAnchorFromClamp 改写成 right/bottom 模式。 panel.dataset.anchorXSide = "left"; panel.dataset.anchorX = String(desiredLeft); panel.dataset.anchorYSide = "top"; panel.dataset.anchorY = String(desiredTop); } function getHudScaleValue() { const rawScale = getComputedStyle(document.documentElement) .getPropertyValue("--hud-scale") .trim(); const parsedScale = Number.parseFloat(rawScale); return Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1; } function getPreferredHudEdgeGap() { return HUD_EDGE_GAP_PX * getHudScaleValue(); } function syncDraggedPanelSize(panel) { const dragWidthBase = Number.parseFloat(panel.dataset.dragWidthBase ?? ""); if (!Number.isFinite(dragWidthBase)) return; const nextWidth = dragWidthBase * getHudScaleValue(); panel.style.width = `${nextWidth}px`; } function syncPanelAnchorFromClamp( app, panel, desiredLeft, desiredTop, clampedLeft, clampedTop, ) { const appRect = app.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); // 只在 clamp 真的改了坐标时切换贴边方向: // clamp 把 left 往小推 → 右边/下方的边碰到 panel 了 → 切到 right/bottom 模式。 // 这里保存的是“恢复时应回到的默认边距”,不是 shrink 期间瞬时的 0 间距。 // clamp 把 left 往大推 → 左边/上方的边(含 brand L 区)碰到 panel 了 → 记到 left/top 模式。 const preferredGap = getPreferredHudEdgeGap(); if (clampedLeft < desiredLeft) { panel.dataset.anchorXSide = "right"; panel.dataset.anchorX = String(preferredGap); } else if (clampedLeft > desiredLeft) { panel.dataset.anchorXSide = "left"; panel.dataset.anchorX = String(clampedLeft <= 0 ? preferredGap : clampedLeft); } if (clampedTop < desiredTop) { panel.dataset.anchorYSide = "bottom"; panel.dataset.anchorY = String(preferredGap); } else if (clampedTop > desiredTop) { panel.dataset.anchorYSide = "top"; panel.dataset.anchorY = String(clampedTop <= 0 ? preferredGap : clampedTop); } } function resolveAnchorDesiredPosition(app, panel) { syncDraggedPanelSize(panel); const appRect = app.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); const anchorX = parseFloat(panel.dataset.anchorX ?? ""); const anchorY = parseFloat(panel.dataset.anchorY ?? ""); const fallbackLeft = parseFloat(panel.style.left) || 0; const fallbackTop = parseFloat(panel.style.top) || 0; const desiredLeft = Number.isFinite(anchorX) ? panel.dataset.anchorXSide === "right" ? appRect.width - panelRect.width - anchorX : anchorX : fallbackLeft; const desiredTop = Number.isFinite(anchorY) ? panel.dataset.anchorYSide === "bottom" ? appRect.height - panelRect.height - anchorY : anchorY : fallbackTop; return { desiredLeft, desiredTop }; } function clampDraggedPanelPosition(app, panel, desiredLeft, desiredTop) { const appRect = app.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); const brandPanel = document.getElementById("brand-panel"); const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null; const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0; const brandRight = brandRect ? brandRect.right - appRect.left : 0; const maxLeft = Math.max(0, appRect.width - panelRect.width); const maxTop = Math.max(0, appRect.height - panelRect.height); let nextLeft = Math.min(Math.max(desiredLeft, 0), maxLeft); let nextTop = Math.min(Math.max(desiredTop, 0), maxTop); // Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。 // 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。 // 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。 if (brandRect && nextLeft < brandRight && nextTop < brandBottom) { const leftAdjust = brandRight - nextLeft; const topAdjust = brandBottom - nextTop; if (leftAdjust <= topAdjust) { nextLeft = brandRight; } else { nextTop = brandBottom; } } return { left: nextLeft, top: nextTop }; } function setupDraggableHudPanels() { const app = document.getElementById("container"); const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR); if (!app || draggablePanels.length === 0) return; draggablePanels.forEach((panel) => { const handle = panel.dataset.dragSelf === "true" ? panel : panel.querySelector(".hud-panel-drag-handle"); if (!handle) return; let isDragging = false; let activePointerId = null; let startPointerX = 0; let startPointerY = 0; let startLeft = 0; let startTop = 0; const stopDragging = (event) => { if ( event && activePointerId !== null && "pointerId" in event && event.pointerId !== activePointerId ) { return; } isDragging = false; activePointerId = null; panel.classList.remove("is-dragging"); document.body.style.userSelect = ""; }; const onMove = (event) => { if (!isDragging) return; if (activePointerId !== null && event.pointerId !== activePointerId) return; if (isMobileLayout()) return; event.preventDefault(); const desiredLeft = startLeft + (event.clientX - startPointerX); const desiredTop = startTop + (event.clientY - startPointerY); capturePanelAnchor(app, panel, desiredLeft, desiredTop); const { left, top } = clampDraggedPanelPosition( app, panel, desiredLeft, desiredTop, ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; panel.dataset.dragged = "true"; syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top); }; bindListener(handle, "pointerdown", (event) => { if (isMobileLayout()) return; if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return; event.preventDefault(); isDragging = true; activePointerId = event.pointerId; startPointerX = event.clientX; startPointerY = event.clientY; const appRect = app.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); // If panel is inside a flow container (not a direct child of app), reparent // it so absolute positioning is relative to the app container. if (panel.parentElement !== app) { panel.dataset.originalParentId = panel.parentElement?.id || ""; panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || ""; const capturedWidth = panelRect.width; panel.dataset.dragWidthBase = String( capturedWidth / Math.max(getHudScaleValue(), 0.001), ); panel.style.position = "absolute"; panel.style.width = `${capturedWidth}px`; panel.style.margin = "0"; app.appendChild(panel); } startLeft = panelRect.left - appRect.left; startTop = panelRect.top - appRect.top; panel.style.left = `${startLeft}px`; panel.style.top = `${startTop}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; panel.dataset.dragged = "true"; capturePanelAnchor(app, panel, startLeft, startTop); panel.classList.add("is-dragging"); document.body.style.userSelect = "none"; handle.setPointerCapture?.(event.pointerId); }); bindListener(window, "pointermove", onMove, { passive: false }); bindListener(window, "pointerup", stopDragging); bindListener(window, "pointercancel", stopDragging); bindListener(handle, "lostpointercapture", stopDragging); }); const reclampDraggedPanels = () => { draggablePanels.forEach((panel) => { if (panel.dataset.dragged !== "true") return; syncDraggedPanelSize(panel); const { desiredLeft, desiredTop } = resolveAnchorDesiredPosition( app, panel, ); const { left, top } = clampDraggedPanelPosition( app, panel, desiredLeft, desiredTop, ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top); }); }; bindListener(window, "resize", () => { window.requestAnimationFrame(reclampDraggedPanels); }); } function clearForcedFloatingClose() { getFloatingGroups().forEach((group) => { group.classList.remove("force-closed"); }); } function clearForcedFloatingCloseIfPointerOutside() { getFloatingGroups().forEach((group) => { if (!group.matches(":hover")) { group.classList.remove("force-closed"); } }); } function setFloatingMenuOpen(group, shouldOpen) { closeFloatingMenus(); clearForcedFloatingClose(); group?.classList.toggle("open", shouldOpen); } function clearSelectionIfHiding(shouldHide) { if (shouldHide) { clearLockedObject(); } } function bindFloatingMenu(trigger, group) { bindListener(trigger, "click", (event) => { event.stopPropagation(); const shouldOpen = !group?.classList.contains("open"); setFloatingMenuOpen(group, shouldOpen); }); bindListener(group, "click", (event) => { event.stopPropagation(); }); } function updateTVToggleUI(visible) { const btn = document.getElementById("toggle-tv"); if (!btn) return; btn.classList.toggle("active", visible); setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播"); } function bindListener(element, eventName, handler, options) { if (!element) return; element.addEventListener(eventName, handler, options); listeners.push(() => element.removeEventListener(eventName, handler, options), ); } function resetCleanup() { cleanupFns.forEach((cleanup) => cleanup()); cleanupFns = []; listeners.forEach((cleanup) => cleanup()); listeners = []; } export async function setupControls(camera, renderer, scene, earth) { resetCleanup(); activeCamera = camera; earthObj = earth; applyResponsiveLayout(); setupZoomControls(camera); setupWheelZoom(camera, renderer); setupRotateControls(camera, earth); setupTerrainControls(); await settingsApplyPromise; syncTrailsAvailability(); setupLiquidGlassInteractions(); setupToolbarHubCluster(); setupKeyboardControls(); bindListener(window, "resize", () => { applyResponsiveLayout(); }); bindListener(window, "earth:search-open-change", (event) => { if (event instanceof CustomEvent && event.detail?.open) { closeTransientMobileOverlays({ except: "search" }); } }); bindListener(window, "earth:tv-visibility-change", (event) => { if (event instanceof CustomEvent && event.detail?.visible) { closeTransientMobileOverlays({ except: "media" }); } }); // No longer auto-navigates to details tab on mobile — popup handles the display. // Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup. } function setupZoomControls(camera) { let zoomInterval = null; let holdTimeout = null; let startTime = 0; const HOLD_THRESHOLD = 150; const LONG_PRESS_TICK = 50; const CLICK_STEP = 10; const MIN_PERCENT = CONFIG.minZoom * 100; const MAX_PERCENT = CONFIG.maxZoom * 100; function doZoomStep(direction) { let currentPercent = Math.round(zoomLevel * 100); let newPercent = direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP; if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT; if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT; zoomLevel = newPercent / 100; applyZoom(camera); showZoomStatusCapsule({ force: true }); } function doContinuousZoom(direction) { let currentPercent = Math.round(zoomLevel * 100); let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1; if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT; if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT; zoomLevel = newPercent / 100; applyZoom(camera); showZoomStatusCapsule(); } function startContinuousZoom(direction) { doContinuousZoom(direction); zoomInterval = window.setInterval(() => { doContinuousZoom(direction); }, LONG_PRESS_TICK); } function stopZoom() { if (zoomInterval) { clearInterval(zoomInterval); zoomInterval = null; } if (holdTimeout) { clearTimeout(holdTimeout); holdTimeout = null; } } function handleMouseDown(direction) { startTime = Date.now(); stopZoom(); holdTimeout = window.setTimeout(() => { startContinuousZoom(direction); }, HOLD_THRESHOLD); } function handleMouseUp(direction) { const heldTime = Date.now() - startTime; stopZoom(); if (heldTime < HOLD_THRESHOLD) { doZoomStep(direction); } } cleanupFns.push(stopZoom); const zoomIn = document.getElementById("zoom-in"); const zoomOut = document.getElementById("zoom-out"); const zoomValue = document.getElementById("zoom-value"); bindListener(zoomIn, "mousedown", () => handleMouseDown(1)); bindListener(zoomIn, "mouseup", () => handleMouseUp(1)); bindListener(zoomIn, "mouseleave", stopZoom); bindListener(zoomIn, "touchstart", (e) => { e.preventDefault(); handleMouseDown(1); }); bindListener(zoomIn, "touchend", () => handleMouseUp(1)); bindListener(zoomOut, "mousedown", () => handleMouseDown(-1)); bindListener(zoomOut, "mouseup", () => handleMouseUp(-1)); bindListener(zoomOut, "mouseleave", stopZoom); bindListener(zoomOut, "touchstart", (e) => { e.preventDefault(); handleMouseDown(-1); }); bindListener(zoomOut, "touchend", () => handleMouseUp(-1)); bindListener(zoomValue, "click", () => { const startZoomVal = zoomLevel; const targetZoom = getDefaultEarthZoomLevel(); const startDistance = CONFIG.defaultCameraZ / startZoomVal; const targetDistance = CONFIG.defaultCameraZ / targetZoom; animateValue( 0, 1, 600, (progress) => { const ease = 1 - Math.pow(1 - progress, 3); zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease; camera.position.z = CONFIG.defaultCameraZ / zoomLevel; const distance = startDistance + (targetDistance - startDistance) * ease; updateZoomDisplay(zoomLevel, distance.toFixed(0)); }, () => { zoomLevel = targetZoom; showStatusMessage(getZoomResetStatusMessage(targetZoom), "info"); }, ); }); } function setupWheelZoom(camera, renderer) { bindListener( renderer?.domElement, "wheel", (e) => { e.preventDefault(); if (e.deltaY < 0) { zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom); } else { zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom); } applyZoom(camera); showZoomStatusCapsule({ force: true }); }, { passive: false }, ); } function applyZoom(camera) { camera.position.z = CONFIG.defaultCameraZ / zoomLevel; const distance = camera.position.z.toFixed(0); updateZoomDisplay(zoomLevel, distance); } function animateValue(start, end, duration, onUpdate, onComplete) { const animationToken = ++focusViewAnimationToken; const startTime = performance.now(); function update(currentTime) { if (animationToken !== focusViewAnimationToken) { return; } const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easeProgress = 1 - Math.pow(1 - progress, 3); const current = start + (end - start) * easeProgress; onUpdate(current); if (progress < 1) { requestAnimationFrame(update); } else if (onComplete && animationToken === focusViewAnimationToken) { onComplete(); } } requestAnimationFrame(update); } export function resetView(camera) { if (!earthObj) return; const defaultZoom = getDefaultEarthZoomLevel(); if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (pos) => focusEarthView(camera, { lat: pos.coords.latitude, lon: pos.coords.longitude, rotLon: pos.coords.longitude - 270, zoom: defaultZoom, duration: 800, suppressStatus: false, }), () => focusEarthView(camera, { lat: EARTH_CONFIG.chinaLat, lon: EARTH_CONFIG.chinaLon, rotLon: EARTH_CONFIG.chinaRotLon, zoom: defaultZoom, duration: 800, suppressStatus: false, }), { timeout: 5000, enableHighAccuracy: false }, ); } else { focusEarthView(camera, { lat: EARTH_CONFIG.chinaLat, lon: EARTH_CONFIG.chinaLon, rotLon: EARTH_CONFIG.chinaRotLon, zoom: defaultZoom, duration: 800, suppressStatus: false, }); } clearLockedObject(); } function setupRotateControls(camera) { const rotateBtn = document.getElementById("rotate-toggle"); const resetViewBtn = document.getElementById("reset-view"); bindListener(rotateBtn, "click", () => { const isRotating = toggleAutoRotate(); const label = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转"; showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info"); }); updateRotateUI(); bindListener(resetViewBtn, "click", () => { resetView(camera); }); } function filterLayerRows(query, emptyStateEl, clearBtn) { const rows = document.querySelectorAll("#layer-panel-list .layer-row"); let visibleCount = 0; rows.forEach((row) => { const name = (row.dataset.layerName || "").toLowerCase(); const matches = !query || name.includes(query); row.hidden = !matches; if (matches) visibleCount++; }); if (emptyStateEl) emptyStateEl.hidden = visibleCount > 0; if (clearBtn) clearBtn.hidden = !query; } function setupLayerPanel() { const panel = document.getElementById("layer-toggles"); const collapseBtn = document.getElementById("layer-panel-collapse"); const searchInput = document.getElementById("layer-search-input"); const searchClear = document.getElementById("layer-search-clear"); const emptyState = document.getElementById("layer-panel-empty"); if (!panel) return; const layerPanel = createHUDPanel({ panel, header: ".layer-panel-header", body: "#layer-panel-body", collapseBtn, collapsedClass: "layer-panel--collapsed", preferredDirection: "down", expandLabel: "展开图层列表", collapseLabel: "折叠图层列表", }); bindListener(collapseBtn, "click", (e) => { e.stopPropagation(); layerPanel.setCollapsed(!layerPanel.isCollapsed()); }); if (searchInput) { bindListener(searchInput, "input", () => { const query = searchInput.value.trim().toLowerCase(); filterLayerRows(query, emptyState, searchClear); }); if (searchClear) { bindListener(searchClear, "click", () => { searchInput.value = ""; filterLayerRows("", emptyState, searchClear); searchInput.focus(); }); } } layerPanelInitialized = true; Array.from(layerRegistry.values()).forEach((definition) => { syncLayerRowDefinition(definition); const row = document.querySelector(`.layer-row[data-layer-id="${definition.id}"]`); if (row) { bindLayerButton(row, definition); } }); } function createLayerRow({ buttonId, icon, label, meta, defaultActive, statusTarget }) { const row = document.createElement("div"); row.className = "layer-row"; row.dataset.layerName = `${label} ${meta || ""}`.trim().toLowerCase(); row.innerHTML = ` ${icon}
${label} ${meta ? `${meta}` : ""}
`; return row; } function bindLayerButton(row, definition) { const button = row.querySelector("button"); if (!(button instanceof HTMLButtonElement)) return; if (button.dataset.layerBound === "true") return; bindListener(button, "click", async function () { if ( this.disabled || this.classList.contains("is-loading") || this.classList.contains("is-disabled") ) { return; } await definition.setVisible(!definition.getVisible()); }); button.dataset.layerBound = "true"; } export function registerLayer({ id, icon, label, meta = "", keywords = "", defaultActive = false, persist = true, displayOrder = null, startupPriority = null, startupMode = "visible", startupLabel = "", startupMessage = "", buttonId = `toggle-${id}`, statusTarget = "", getVisible = null, setVisible = null, onToggle = null, }) { const definition = registerLayerDefinition( { id, buttonId, icon, label, meta, keywords, defaultActive, persist, displayOrder, startupPriority, startupMode, startupLabel, startupMessage, statusTarget, getVisible: typeof getVisible === "function" ? getVisible : () => getLayerButton(id)?.classList.contains("active") ?? defaultActive, setVisible: typeof setVisible === "function" ? setVisible : async (visible) => { if (typeof onToggle === "function") { await onToggle(visible); } }, }, { appendIfMissing: true }, ); return definition; } export function getStartupLoadLayers() { if (layerRegistry.size === 0) { initializeLayerRegistry(); } return getSortedLayerDefinitions({ includeUnprioritized: false }) .filter(shouldIncludeLayerInStartupLoad) .map((definition) => ({ ...definition })); } function setupTerrainControls() { initializeLayerRegistry(); const container = document.getElementById("container"); const layerBtn = document.getElementById("layer-action"); const searchBtn = document.getElementById("search-action"); const terrainBtn = getLayerButton("terrain"); const layoutBtn = document.getElementById("layout-toggle"); const reloadBtn = document.getElementById("reload-data"); const zoomGroup = document.getElementById("zoom-control-group"); const zoomTrigger = document.getElementById("zoom-trigger"); setupSettingsControls(); setupHudPanelControls(); setupDraggableHudPanels(); setupLayerPanel(); setupMobileDrawerShell(); renderMobileLayerCards(); bindListener(layerBtn, "click", (event) => { event.preventDefault(); event.stopPropagation(); if (isMobileLayout()) { const nextOpen = !(mobileDrawerOpen && mobileDrawerCard === "layers"); setMobileDrawerState({ open: nextOpen, card: "layers" }); return; } const panel = document.getElementById("layer-toggles"); const currentlyVisible = !panel?.classList.contains("hud-panel-hidden"); setHudPanelVisibility("layer-toggles", !currentlyVisible); }); bindListener(searchBtn, "click", () => { if (isMobileLayout()) { setMobileDrawerState({ open: true, card: "search" }); return; } closeTransientMobileOverlays({ except: "search" }); openSearchPanel(); }); bindListener(terrainBtn, "pointerenter", () => { prewarmTerrainIfNeeded(); }); bindListener(terrainBtn, "focus", () => { prewarmTerrainIfNeeded(); }); scheduleTerrainPrefetch(); bindListener(reloadBtn, "click", async () => { await reloadData(); }); bindFloatingMenu(zoomTrigger, zoomGroup); bindListener(document, "click", (event) => { const openGroups = [zoomGroup].filter((group) => group?.classList.contains("open"), ); if ( isMobileLayout() && mobileDrawerOpen && event.target instanceof Element && !event.target.closest("#mobile-drawer-shell") ) { setMobileDrawerState({ open: false }); } if (openGroups.length === 0) return; const clickedInsideOpenGroup = openGroups.some((group) => group.contains(event.target), ); if (!clickedInsideOpenGroup) { closeFloatingMenus(); } }); bindListener(document, "mousemove", () => { clearForcedFloatingCloseIfPointerOutside(); }); bindListener(layoutBtn, "click", () => { const expanded = toggleLayoutExpanded(container); showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info"); }); const mediaVisible = !isMobileLayout() && !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden"); updateTVToggleUI(mediaVisible); if (mediaVisible) { ensureTVPanelReady().catch((error) => { console.error("初始化电视直播面板失败:", error); }); ensureNewsPanelReady().catch((error) => { console.error("初始化态势新闻内容失败:", error); }); } updateNewsToggleUI(mediaVisible); applyResponsiveLayout(); updateLayoutUI(container); } function setupKeyboardControls() { bindListener(document, "keydown", (event) => { if (event.key !== "Escape") return; if (isSearchPanelOpen()) { closeSearchPanel(); return; } if (isSettingsModalOpen()) { closeSettingsModal(); return; } if (isMobileLayout() && mobileDrawerOpen) { setMobileDrawerState({ open: false }); return; } if (isFloatingMenuVisible()) { closeFloatingMenus(); return; } if (toolbarHubController?.isOpen?.()) { toolbarHubController.close(); return; } clearLockedObjectAndInfo(); }); } function setupLiquidGlassInteractions() { const surfaces = document.querySelectorAll(".liquid-glass-surface, .hud-panel"); const resetSurface = (surface) => { surface.style.setProperty("--elastic-x", "0px"); surface.style.setProperty("--elastic-y", "0px"); surface.style.setProperty("--tilt-x", "0deg"); surface.style.setProperty("--tilt-y", "0deg"); surface.style.setProperty("--panel-tilt-x", "0deg"); surface.style.setProperty("--panel-tilt-y", "0deg"); surface.style.setProperty("--dock-scale", "1"); surface.style.setProperty("--dock-lift", "0px"); surface.style.setProperty("--dock-shift-x", "0px"); surface.style.setProperty("--glow-x", "50%"); surface.style.setProperty("--glow-y", "22%"); surface.style.setProperty("--glow-opacity", "0.24"); surface.style.setProperty("--panel-glow-x", "18%"); surface.style.setProperty("--panel-glow-y", "0%"); surface.style.setProperty("--panel-glow-opacity", "0.1"); surface.classList.remove("dock-focused"); surface.classList.remove("dock-active"); surface.classList.remove("is-pressed"); }; surfaces.forEach((surface) => { resetSurface(surface); const isToolbarSurface = Boolean(surface.closest(".earth-toolbar-items")); const isPanelSurface = surface.classList.contains("hud-panel"); bindListener(surface, "pointermove", (event) => { const rect = surface.getBoundingClientRect(); const px = (event.clientX - rect.left) / rect.width; const py = (event.clientY - rect.top) / rect.height; if (isPanelSurface) { const panelTiltX = (0.5 - py) * 5; const panelTiltY = (px - 0.5) * 6; surface.style.setProperty("--panel-glow-x", `${(px * 100).toFixed(1)}%`); surface.style.setProperty("--panel-glow-y", `${(py * 100).toFixed(1)}%`); surface.style.setProperty("--panel-glow-opacity", "0.16"); surface.style.setProperty("--panel-tilt-x", `${panelTiltX.toFixed(2)}deg`); surface.style.setProperty("--panel-tilt-y", `${panelTiltY.toFixed(2)}deg`); } else if (!isToolbarSurface) { const offsetX = (px - 0.5) * 6; const offsetY = (py - 0.5) * 6; const tiltX = (0.5 - py) * 8; const tiltY = (px - 0.5) * 10; surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`); surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`); surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`); surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`); } surface.style.setProperty("--glow-x", `${(px * 100).toFixed(1)}%`); surface.style.setProperty("--glow-y", `${(py * 100).toFixed(1)}%`); surface.style.setProperty("--glow-opacity", "0.34"); }); bindListener(surface, "pointerenter", () => { if (isPanelSurface) { surface.style.setProperty("--panel-glow-opacity", "0.14"); } else { surface.style.setProperty("--glow-opacity", "0.28"); } }); bindListener(surface, "pointerleave", () => { resetSurface(surface); }); bindListener(surface, "pointerdown", () => { surface.classList.add("is-pressed"); }); bindListener(surface, "pointerup", () => { surface.classList.remove("is-pressed"); }); bindListener(surface, "pointercancel", () => { resetSurface(surface); }); }); } function setupToolbarHubCluster() { const cluster = document.getElementById("toolbar-cluster"); const hub = document.getElementById("toolbar-hub"); const toolbar = document.getElementById("control-toolbar"); if ( !(cluster instanceof HTMLElement) || !(hub instanceof HTMLButtonElement) || !(toolbar instanceof HTMLElement) ) { return; } let collapseTimer = null; let expandedToolbarBounds = null; let refreshBoundsFrameId = 0; let hubPinnedOpen = false; const layoutToolbarOrbs = () => { const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter( (orb) => getComputedStyle(orb).display !== "none", ); if (orbs.length === 0) return; const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX; const orbCount = orbs.length; const viewportScale = Math.min( window.innerWidth / 1920, window.innerHeight / 1080, ); let toolbarScale = THREE.MathUtils.clamp( Math.min(toolbarWidth / TOOLBAR_BASE_WIDTH_PX, viewportScale), TOOLBAR_MIN_SCALE, 1, ); let orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale; let desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale; let span = TOOLBAR_ARCH_SPAN_PX * toolbarScale; let rise = TOOLBAR_ARCH_RISE_PX * toolbarScale; const minSpanForSpacing = orbCount > 1 ? (orbCount - 1) * (orbSize + desiredGap) : orbSize; const maxSpanByWidth = toolbarWidth - orbSize - TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale; if (minSpanForSpacing > maxSpanByWidth) { toolbarScale = THREE.MathUtils.clamp( maxSpanByWidth / minSpanForSpacing, TOOLBAR_MIN_SCALE, toolbarScale, ); orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale; desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale; span = TOOLBAR_ARCH_SPAN_PX * toolbarScale; rise = TOOLBAR_ARCH_RISE_PX * toolbarScale; } span = THREE.MathUtils.clamp( span, minSpanForSpacing, maxSpanByWidth, ); rise = Math.min(rise, span * 0.32); const hubSize = TOOLBAR_HUB_SIZE_PX * toolbarScale; const maxVerticalReach = rise + orbSize * 0.5; const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale; toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3)); toolbar.style.setProperty("--toolbar-orb-size", `${Math.round(orbSize)}px`); toolbar.style.setProperty("--toolbar-hub-size", `${Math.round(hubSize)}px`); toolbar.style.height = `${Math.ceil(toolbarHeight)}px`; toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`); toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`); toolbar.style.setProperty("--toolbar-inner-arc-width", `${Math.ceil(span * 0.72)}px`); toolbar.style.setProperty("--toolbar-inner-arc-height", `${Math.ceil((hubSize * 0.8) + (desiredGap * 0.5))}px`); orbs.forEach((orb, index) => { const t = orbCount === 1 ? 0.5 : index / (orbCount - 1); const x = (t - 0.5) * span; const normalized = (x / (span / 2 || 1)); const y = -(1 - normalized * normalized) * rise; orb.style.setProperty("--orb-x", `${x.toFixed(2)}px`); orb.style.setProperty("--orb-y", `${y.toFixed(2)}px`); }); if (cluster.classList.contains("is-expanded")) { scheduleExpandedToolbarBoundsRefresh(); } }; const setExpanded = (expanded) => { cluster.classList.toggle("is-expanded", expanded); cluster.classList.toggle("is-collapsed", !expanded); if (!expanded) { expandedToolbarBounds = null; if (refreshBoundsFrameId) { cancelAnimationFrame(refreshBoundsFrameId); refreshBoundsFrameId = 0; } return; } scheduleExpandedToolbarBoundsRefresh(); }; const closePinnedToolbar = () => { hubPinnedOpen = false; cancelCollapse(); setExpanded(false); }; const scheduleCollapse = () => { if (hubPinnedOpen) return; if (collapseTimer) clearTimeout(collapseTimer); collapseTimer = window.setTimeout(() => { setExpanded(false); collapseTimer = null; }, 200); }; const cancelCollapse = () => { if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; } }; cleanupFns.push(() => { if (collapseTimer) clearTimeout(collapseTimer); if (refreshBoundsFrameId) { cancelAnimationFrame(refreshBoundsFrameId); refreshBoundsFrameId = 0; } if (toolbarHubController?.cluster === cluster) { toolbarHubController = null; } }); // Start collapsed — hub acts as the hover target to reveal the arc layoutToolbarOrbs(); setExpanded(false); bindListener(window, "resize", layoutToolbarOrbs); bindListener(hub, "mouseenter", () => { cancelCollapse(); setExpanded(true); }); bindListener(hub, "focus", () => { cancelCollapse(); setExpanded(true); }); bindListener(hub, "click", (event) => { event.preventDefault(); event.stopPropagation(); cancelCollapse(); if (hubPinnedOpen) { closePinnedToolbar(); } else { hubPinnedOpen = true; setExpanded(true); } }); const HOVER_PADDING_PX = 12; const collectExpandedToolbarBounds = () => { const rects = []; const pushRect = (element) => { if (!(element instanceof HTMLElement)) return; const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; rects.push(rect); }; pushRect(hub); cluster.querySelectorAll(".earth-toolbar-orb > *").forEach((node) => { if (!(node instanceof HTMLElement)) return; pushRect(node); const popover = node.parentElement?.querySelector(".earth-stack-toolbar"); if ( popover instanceof HTMLElement && (node.parentElement?.matches(":hover") || node.parentElement?.matches(":focus-within") || node.parentElement?.classList.contains("open")) ) { pushRect(popover); } }); if (rects.length === 0) return null; const bounds = rects.reduce( (acc, rect) => ({ left: Math.min(acc.left, rect.left), top: Math.min(acc.top, rect.top), right: Math.max(acc.right, rect.right), bottom: Math.max(acc.bottom, rect.bottom), }), { left: Number.POSITIVE_INFINITY, top: Number.POSITIVE_INFINITY, right: Number.NEGATIVE_INFINITY, bottom: Number.NEGATIVE_INFINITY, }, ); return { left: bounds.left - HOVER_PADDING_PX, top: bounds.top - HOVER_PADDING_PX, right: bounds.right + HOVER_PADDING_PX, bottom: bounds.bottom + HOVER_PADDING_PX, }; }; const scheduleExpandedToolbarBoundsRefresh = () => { if (refreshBoundsFrameId) return; refreshBoundsFrameId = window.requestAnimationFrame(() => { expandedToolbarBounds = collectExpandedToolbarBounds(); refreshBoundsFrameId = 0; }); }; bindListener(document, "mousemove", (event) => { if (hubPinnedOpen) return; if (!cluster.classList.contains("is-expanded")) return; if ( event.target instanceof Element && event.target.closest("#toolbar-cluster, .earth-stack-toolbar") ) { scheduleExpandedToolbarBoundsRefresh(); } const activeBounds = expandedToolbarBounds; if (!activeBounds) { scheduleCollapse(); return; } const isInside = event.clientX >= activeBounds.left && event.clientX <= activeBounds.right && event.clientY >= activeBounds.top && event.clientY <= activeBounds.bottom; if (isInside) { cancelCollapse(); } else { scheduleCollapse(); } }); bindListener(cluster, "mouseenter", () => { if (cluster.classList.contains("is-expanded")) { scheduleExpandedToolbarBoundsRefresh(); } }); bindListener(cluster, "focusin", () => { if (cluster.classList.contains("is-expanded")) { scheduleExpandedToolbarBoundsRefresh(); } }); cluster.querySelectorAll(".earth-toolbar-orb > button").forEach((button) => { if (!(button instanceof HTMLButtonElement) || button === hub) return; bindListener(button, "click", () => { if (hubPinnedOpen) { hubPinnedOpen = false; setExpanded(false); } }); }); bindListener(document, "pointerdown", (event) => { if (!hubPinnedOpen) return; if (!(event.target instanceof Element)) return; if (event.target.closest("#toolbar-cluster")) return; closePinnedToolbar(); }); toolbarHubController = { cluster, isOpen: () => hubPinnedOpen || cluster.classList.contains("is-expanded"), close: closePinnedToolbar, }; } export function teardownControls() { resetCleanup(); activeCamera = null; } export function getAutoRotate() { return autoRotate; } function getRotationModeLabel(mode = rotationMode) { return mode === ROTATION_MODE.CRUISE ? "巡航模式" : "旋转模式"; } function syncRotationModeButtons() { const buttons = document.querySelectorAll("[data-rotation-mode]"); buttons.forEach((button) => { if (!(button instanceof HTMLButtonElement)) return; const isActive = button.dataset.rotationMode === rotationMode; button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", isActive ? "true" : "false"); }); } function updateRotateUI() { const btn = document.getElementById("rotate-toggle"); if (btn) { btn.classList.toggle("active", autoRotate); btn.classList.toggle("is-stopped", !autoRotate); const tooltip = btn.querySelector(".earth-toolbar-tooltip"); const activeLabel = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转"; if (tooltip) { tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`; } btn.title = `${getRotationModeLabel()} · ${activeLabel}`; } syncRotationModeButtons(); } export function setAutoRotate(value) { autoRotate = value; updateRotateUI(); if (rotationMode === ROTATION_MODE.CRUISE) { dispatchRotationModeChange(); } } export function toggleAutoRotate() { autoRotate = !autoRotate; updateRotateUI(); clearLockedObject(); if (rotationMode === ROTATION_MODE.CRUISE) { dispatchRotationModeChange(); } return autoRotate; } export function getRotationMode() { return rotationMode; } export function setRotationMode(nextMode, { persist = true, suppressStatus = false } = {}) { const normalizedMode = nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE; const changed = normalizedMode !== rotationMode; if (changed && normalizedMode === ROTATION_MODE.CRUISE) { autoRotate = true; } rotationMode = normalizedMode; updateRotateUI(); dispatchRotationModeChange(); if (persist) { persistEarthSettings(); } if (changed && !suppressStatus) { showStatusMessage( normalizedMode === ROTATION_MODE.CRUISE ? "已切换到巡航模式" : "已切换到旋转模式", "info", ); } } export function focusEarthView(camera, options = {}) { if (!earthObj || !camera) return Promise.resolve(); const { lat = EARTH_CONFIG.chinaLat, lon = EARTH_CONFIG.chinaLon, rotLon = lon - 270, zoom = getDefaultEarthZoomLevel(), duration = 800, suppressStatus = true, } = options; return new Promise((resolve) => { const nextRotation = getViewRotation(lat, rotLon); const startRotX = earthObj.rotation.x; const startRotY = earthObj.rotation.y; const startZoom = zoomLevel; animateValue( 0, 1, duration, (progress) => { const ease = 1 - Math.pow(1 - progress, 3); earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease; earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease; zoomLevel = startZoom + (zoom - startZoom) * ease; camera.position.z = CONFIG.defaultCameraZ / zoomLevel; updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0)); }, () => { zoomLevel = zoom; if (!suppressStatus) { showStatusMessage("视角已重置", "info"); } resolve(); }, ); }); } export function getZoomLevel() { return zoomLevel; } export function getDefaultEarthZoomLevel() { return defaultEarthZoom; } export function getShowTerrain() { return showTerrain; } function updateLayoutUI(container) { if (container) { container.classList.toggle("layout-expanded", layoutExpanded); } const btn = document.getElementById("layout-toggle"); if (btn) { btn.classList.toggle("active", layoutExpanded); const tooltip = btn.querySelector(".earth-toolbar-tooltip"); const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局"; btn.title = nextLabel; if (tooltip) tooltip.textContent = nextLabel; } } function resetPanelInlineLayout(panel) { const originalParentId = panel.dataset.originalParentId; if (originalParentId) { const originalParent = document.getElementById(originalParentId); if (originalParent) { const nextId = panel.dataset.originalNextSiblingId; const nextSibling = nextId ? document.getElementById(nextId) : null; if (nextSibling) { originalParent.insertBefore(panel, nextSibling); } else { originalParent.appendChild(panel); } } delete panel.dataset.originalParentId; delete panel.dataset.originalNextSiblingId; } panel.style.left = ""; panel.style.top = ""; panel.style.right = ""; panel.style.bottom = ""; panel.style.transform = ""; panel.style.position = ""; panel.style.width = ""; panel.style.margin = ""; delete panel.dataset.dragged; delete panel.dataset.anchorX; delete panel.dataset.anchorY; delete panel.dataset.anchorXSide; delete panel.dataset.anchorYSide; delete panel.dataset.dragWidthBase; } function isPanelVisible(panel) { return !panel.classList.contains("hud-panel-hidden"); } function animatePanelLayoutTransition(container, expand) { const panels = Array.from( container.querySelectorAll(DRAGGABLE_PANEL_SELECTOR), ); if (panels.length === 0) { layoutExpanded = expand; updateLayoutUI(container); return expand; } const visiblePanels = panels.filter(isPanelVisible); const firstRects = new Map( visiblePanels.map((panel) => [panel, panel.getBoundingClientRect()]), ); panels.forEach((panel) => panel.classList.add("is-layout-animating")); layoutExpanded = expand; panels.forEach(resetPanelInlineLayout); updateLayoutUI(container); visiblePanels.forEach((panel) => { const firstRect = firstRects.get(panel); if (!firstRect) return; const lastRect = panel.getBoundingClientRect(); const deltaX = firstRect.left - lastRect.left; const deltaY = firstRect.top - lastRect.top; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) { return; } panel.animate( [ { translate: `${deltaX}px ${deltaY}px`, }, { translate: "0 0", }, ], { duration: PANEL_LAYOUT_ANIMATION_MS, easing: "cubic-bezier(0.22, 1, 0.36, 1)", }, ); }); window.setTimeout(() => { panels.forEach((panel) => panel.classList.remove("is-layout-animating")); }, PANEL_LAYOUT_ANIMATION_MS); return expand; } function toggleLayoutExpanded(container) { return animatePanelLayoutTransition(container, !layoutExpanded); }