// controls.js - Zoom, rotate and toggle controls import { CONFIG, EARTH_CONFIG } from "./constants.js"; import { updateZoomDisplay, showStatusMessage } from "./ui.js"; import { toggleTerrain } from "./earth.js"; import { reloadData, clearLockedObject, clearLockedObjectAndInfo, setCablesEnabled, setSatellitesEnabled, } from "./main.js"; import { toggleTrails, getShowSatellites, getSatelliteCount, } from "./satellites.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; import { ensureTVPanelReady } from "./tv.js"; import { createHUDPanel } from "./hud-panels.js"; import { ensureNewsPanelReady, updateNewsToggleUI, } from "./news.js"; export let autoRotate = true; export let zoomLevel = 1.0; export let showTerrain = false; export let layoutExpanded = false; let earthObj = null; let listeners = []; let cleanupFns = []; const HUD_PANEL_IDS = [ "legend", "earth-stats", "tv-panel", "layer-toggles", ]; const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable"; const PANEL_LAYOUT_ANIMATION_MS = 420; 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() { const modal = document.getElementById("settings-modal"); if (!modal) return; closeFloatingMenus(); modal.classList.add("is-open"); modal.setAttribute("aria-hidden", "false"); } function closeSettingsModal() { const modal = document.getElementById("settings-modal"); if (!modal) return; modal.classList.remove("is-open"); modal.setAttribute("aria-hidden", "true"); } function setHudPanelVisibility(panelId, visible) { const panel = document.getElementById(panelId); if (!panel) return; panel.classList.toggle("hud-panel-hidden", !visible); syncSettingsToggle(panelId, visible); if (panelId === "tv-panel") { updateTVToggleUI(visible); updateNewsToggleUI(visible); if (visible) { ensureTVPanelReady().catch((error) => { console.error("初始化电视直播面板失败:", error); }); } } } 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 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"); bindListener(settingsTrigger, "click", () => { openSettingsModal(); }); bindListener(settingsClose, "click", () => { closeSettingsModal(); }); bindListener(settingsBackdrop, "click", () => { closeSettingsModal(); }); 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); }); }); syncAllHudPanelToggles(); } 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; setHudPanelVisibility(panelId, false); }); }); } 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 startPointerX = 0; let startPointerY = 0; let startLeft = 0; let startTop = 0; const stopDragging = () => { isDragging = false; panel.classList.remove("is-dragging"); document.body.style.userSelect = ""; }; const onMove = (event) => { if (!isDragging) return; 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; let nextLeft = Math.min( Math.max(startLeft + (event.clientX - startPointerX), 0), appRect.width - panelRect.width, ); let nextTop = Math.min( Math.max(startTop + (event.clientY - startPointerY), 0), appRect.height - panelRect.height, ); // 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; } } panel.style.left = `${nextLeft}px`; panel.style.top = `${nextTop}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.transform = "none"; panel.dataset.dragged = "true"; }; bindListener(handle, "pointerdown", (event) => { if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return; isDragging = true; 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.style.position = "absolute"; panel.style.width = `${capturedWidth}px`; 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"; panel.classList.add("is-dragging"); document.body.style.userSelect = "none"; handle.setPointerCapture?.(event.pointerId); }); bindListener(handle, "pointermove", onMove); bindListener(handle, "pointerup", stopDragging); bindListener(handle, "pointercancel", stopDragging); bindListener(handle, "lostpointercapture", stopDragging); }); } 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 setButtonTooltip(button, text) { const tooltip = button?.querySelector(".earth-toolbar-tooltip"); if (tooltip) { tooltip.textContent = text; } } export function updateLayerButtonState(button, isActive) { if (!button) return; button.classList.toggle("active", isActive); button.setAttribute("aria-checked", isActive ? "true" : "false"); // Legacy badge text (kept for compatibility) const state = button.querySelector(".earth-layer-btn__state"); if (state) { state.textContent = isActive ? "ON" : "OFF"; } } 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 function setupControls(camera, renderer, scene, earth) { resetCleanup(); earthObj = earth; setupZoomControls(camera); setupWheelZoom(camera, renderer); setupRotateControls(camera, earth); setupTerrainControls(); setupLiquidGlassInteractions(); setupKeyboardControls(); } 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); } 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); } 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 = 1.0; 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 = 1.0; showStatusMessage("缩放已重置到100%", "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); }, { 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 startTime = performance.now(); function update(currentTime) { 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) { onComplete(); } } requestAnimationFrame(update); } export function resetView(camera) { if (!earthObj) return; function animateToView(targetLat, targetLon, targetRotLon) { const latRot = (targetLat * Math.PI) / 180; const targetRotX = EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient; const targetRotY = -((targetRotLon * Math.PI) / 180); const startRotX = earthObj.rotation.x; const startRotY = earthObj.rotation.y; const startZoom = zoomLevel; const targetZoom = 1.0; animateValue( 0, 1, 800, (progress) => { const ease = 1 - Math.pow(1 - progress, 3); earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease; earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease; zoomLevel = startZoom + (targetZoom - startZoom) * ease; camera.position.z = CONFIG.defaultCameraZ / zoomLevel; updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0)); }, () => { zoomLevel = 1.0; showStatusMessage("视角已重置", "info"); }, ); } if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (pos) => animateToView( pos.coords.latitude, pos.coords.longitude, -pos.coords.longitude, ), () => animateToView( EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon, ), { timeout: 5000, enableHighAccuracy: false }, ); } else { animateToView( EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon, ); } clearLockedObject(); } function setupRotateControls(camera) { const rotateBtn = document.getElementById("rotate-toggle"); const resetViewBtn = document.getElementById("reset-view"); bindListener(rotateBtn, "click", () => { const isRotating = toggleAutoRotate(); showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "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(); }); } } } function createLayerRow({ id, icon, label, meta, defaultActive }) { const row = document.createElement("div"); row.className = "layer-row"; row.dataset.layerName = `${label} ${meta || ""}`.trim().toLowerCase(); row.innerHTML = ` ${icon}
${label} ${meta ? `${meta}` : ""}
`; return row; } export function registerLayer({ id, icon, label, meta = "", defaultActive = false, onToggle }) { const list = document.getElementById("layer-panel-list"); if (!list) return; if (document.getElementById(id)) return; // avoid duplicates const row = createLayerRow({ id, icon, label, meta, defaultActive }); list.appendChild(row); const btn = row.querySelector("button"); if (btn && typeof onToggle === "function") { btn.addEventListener("click", function () { const isActive = this.classList.toggle("active"); this.setAttribute("aria-checked", isActive ? "true" : "false"); onToggle(isActive); }); } } function setupTerrainControls() { const container = document.getElementById("container"); const searchBtn = document.getElementById("search-action"); const terrainBtn = document.getElementById("toggle-terrain"); const satellitesBtn = document.getElementById("toggle-satellites"); const bgpBtn = document.getElementById("toggle-bgp"); const trailsBtn = document.getElementById("toggle-trails"); const cablesBtn = document.getElementById("toggle-cables"); 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(); if (trailsBtn) { updateLayerButtonState(trailsBtn, true); setButtonTooltip(trailsBtn, "隐藏轨迹"); } bindListener(searchBtn, "click", () => { showStatusMessage("搜索功能待开发", "info"); }); bindListener(terrainBtn, "click", function () { showTerrain = !showTerrain; toggleTerrain(showTerrain); updateLayerButtonState(this, showTerrain); setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形"); const terrainStatus = document.getElementById("terrain-status"); if (terrainStatus) terrainStatus.textContent = showTerrain ? "开启" : "关闭"; showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info"); }); bindListener(satellitesBtn, "click", async function () { const showSats = !getShowSatellites(); clearSelectionIfHiding(!showSats); try { await setSatellitesEnabled(showSats); if (!showSats) { showStatusMessage("卫星已隐藏", "info"); } else { const satelliteCountEl = document.getElementById("satellite-count"); if (satelliteCountEl) { satelliteCountEl.textContent = `${getSatelliteCount()} 颗`; } } } catch (error) { console.error("切换卫星显示失败:", error); } }); bindListener(bgpBtn, "click", function () { const showNextBGP = !getShowBGP(); clearSelectionIfHiding(!showNextBGP); toggleBGP(showNextBGP); updateLayerButtonState(this, showNextBGP); setButtonTooltip(this, showNextBGP ? "隐藏BGP观测" : "显示BGP观测"); const bgpCountEl = document.getElementById("bgp-anomaly-count"); if (bgpCountEl) { bgpCountEl.textContent = `${getBGPCount()} 条`; } showStatusMessage(showNextBGP ? "BGP观测已显示" : "BGP观测已隐藏", "info"); }); bindListener(trailsBtn, "click", function () { const isActive = this.classList.contains("active"); const nextShowTrails = !isActive; toggleTrails(nextShowTrails); updateLayerButtonState(this, nextShowTrails); setButtonTooltip(this, nextShowTrails ? "隐藏轨迹" : "显示轨迹"); showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info"); }); bindListener(cablesBtn, "click", async function () { const showNextCables = !getShowCables(); clearSelectionIfHiding(!showNextCables); try { await setCablesEnabled(showNextCables); } catch (error) { console.error("切换线缆显示失败:", error); } }); bindListener(reloadBtn, "click", async () => { await reloadData(); }); bindFloatingMenu(zoomTrigger, zoomGroup); bindListener(document, "click", (event) => { const openGroups = [zoomGroup].filter((group) => group?.classList.contains("open"), ); 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 tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden"); updateTVToggleUI(tvVisible); if (tvVisible) { ensureTVPanelReady().catch((error) => { console.error("初始化电视直播面板失败:", error); }); } updateNewsToggleUI(!document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden")); ensureNewsPanelReady().catch((error) => { console.error("初始化态势新闻内容失败:", error); }); updateLayoutUI(container); } function setupKeyboardControls() { bindListener(document, "keydown", (event) => { if (event.key !== "Escape") return; if (isSettingsModalOpen()) { closeSettingsModal(); return; } if (isFloatingMenuVisible()) { closeFloatingMenus(); return; } clearLockedObjectAndInfo(); }); } function setupLiquidGlassInteractions() { const surfaces = document.querySelectorAll(".liquid-glass-surface"); 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("--glow-x", "50%"); surface.style.setProperty("--glow-y", "22%"); surface.style.setProperty("--glow-opacity", "0.24"); surface.classList.remove("is-pressed"); }; surfaces.forEach((surface) => { resetSurface(surface); bindListener(surface, "pointermove", (event) => { const rect = surface.getBoundingClientRect(); const px = (event.clientX - rect.left) / rect.width; const py = (event.clientY - rect.top) / rect.height; 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", () => { 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); }); }); } export function teardownControls() { resetCleanup(); } export function getAutoRotate() { return autoRotate; } 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"); if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转"; } } export function setAutoRotate(value) { autoRotate = value; updateRotateUI(); } export function toggleAutoRotate() { autoRotate = !autoRotate; updateRotateUI(); clearLockedObject(); return autoRotate; } export function getZoomLevel() { return zoomLevel; } 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 = ""; delete panel.dataset.dragged; } 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); }