// controls.js - Zoom, rotate and toggle controls import * as THREE from "three"; import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js"; import { updateZoomDisplay, showStatusMessage } from "./ui.js"; import { toggleTerrain } from "./earth.js"; import { ensureTerrainReady, isTerrainReady, getTerrainOpacity, setTerrainOpacity, } from "./terrain.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"; 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 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; let settingsModalTimer = null; let settingsSheetAnimation = null; let terrainToggleToken = 0; let terrainPrefetchStarted = false; let terrainPrefetchScheduled = false; let focusViewAnimationToken = 0; 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 = 1.0, } = 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)); } } 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() { 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(); cancelSettingsSheetAnimation(); 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() { const modal = document.getElementById("settings-modal"); const trigger = document.getElementById("settings-trigger"); const sheet = modal?.querySelector(".earth-settings-sheet"); if (!modal) return; cancelSettingsSheetAnimation(); 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) { const panel = document.getElementById(panelId); if (!panel) return; panel.classList.toggle("hud-panel-hidden", !visible); syncSettingsToggle(panelId, visible); if (panelId === "media-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); }); }); const terrainOpacitySlider = document.getElementById("terrain-opacity-slider"); const terrainOpacityValue = document.getElementById("terrain-opacity-value"); const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]"); const syncTerrainOpacityUi = (nextOpacity) => { const safeOpacity = Math.round(nextOpacity * 100); if (terrainOpacitySlider instanceof HTMLInputElement) { terrainOpacitySlider.value = nextOpacity.toFixed(2); } if (terrainOpacityValue) { terrainOpacityValue.textContent = `${safeOpacity}%`; } }; syncTerrainOpacityUi(getTerrainOpacity()); if (terrainOpacitySlider instanceof HTMLInputElement) { 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); }); } 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); }); }); syncAllHudPanelToggles(); syncRotationModeButtons(); } 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 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 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 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 (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; 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.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(handle, "pointermove", onMove); bindListener(handle, "pointerup", stopDragging); bindListener(handle, "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 function setupControls(camera, renderer, scene, earth) { resetCleanup(); earthObj = earth; setupZoomControls(camera); setupWheelZoom(camera, renderer); setupRotateControls(camera, earth); setupTerrainControls(); setupLiquidGlassInteractions(); setupToolbarHubCluster(); 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 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; if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (pos) => focusEarthView(camera, { lat: pos.coords.latitude, lon: pos.coords.longitude, rotLon: pos.coords.longitude - 270, zoom: 1.0, duration: 800, suppressStatus: false, }), () => focusEarthView(camera, { lat: EARTH_CONFIG.chinaLat, lon: EARTH_CONFIG.chinaLon, rotLon: EARTH_CONFIG.chinaRotLon, zoom: 1.0, duration: 800, suppressStatus: false, }), { timeout: 5000, enableHighAccuracy: false }, ); } else { focusEarthView(camera, { lat: EARTH_CONFIG.chinaLat, lon: EARTH_CONFIG.chinaLon, rotLon: EARTH_CONFIG.chinaRotLon, zoom: 1.0, 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(); }); } } } 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 = `