// 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 } from "./main.js"; import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, } from "./satellites.js"; import { toggleCables, getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; export let autoRotate = true; export let zoomLevel = 1.0; export let showTerrain = false; export let isDragging = false; export let layoutExpanded = false; let earthObj = null; let listeners = []; let cleanupFns = []; 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(); } 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 setupTerrainControls() { const container = document.getElementById("container"); const searchBtn = document.getElementById("search-action"); const infoGroup = document.getElementById("info-control-group"); const infoTrigger = document.getElementById("info-trigger"); 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"); if (trailsBtn) { trailsBtn.classList.add("active"); const tooltip = trailsBtn.querySelector(".tooltip"); if (tooltip) tooltip.textContent = "隐藏轨迹"; } bindListener(searchBtn, "click", () => { showStatusMessage("搜索功能待开发", "info"); }); bindListener(terrainBtn, "click", function () { showTerrain = !showTerrain; toggleTerrain(showTerrain); this.classList.toggle("active", showTerrain); const tooltip = this.querySelector(".tooltip"); if (tooltip) tooltip.textContent = showTerrain ? "隐藏地形" : "显示地形"; const terrainStatus = document.getElementById("terrain-status"); if (terrainStatus) terrainStatus.textContent = showTerrain ? "开启" : "关闭"; showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info"); }); bindListener(satellitesBtn, "click", function () { const showSats = !getShowSatellites(); if (!showSats) { clearLockedObject(); } toggleSatellites(showSats); this.classList.toggle("active", showSats); const tooltip = this.querySelector(".tooltip"); if (tooltip) tooltip.textContent = showSats ? "隐藏卫星" : "显示卫星"; const satelliteCountEl = document.getElementById("satellite-count"); if (satelliteCountEl) satelliteCountEl.textContent = getSatelliteCount() + " 颗"; showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info"); }); bindListener(bgpBtn, "click", function () { const showNextBGP = !getShowBGP(); if (!showNextBGP) { clearLockedObject(); } toggleBGP(showNextBGP); this.classList.toggle("active", showNextBGP); const tooltip = this.querySelector(".tooltip"); if (tooltip) tooltip.textContent = 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); this.classList.toggle("active", nextShowTrails); const tooltip = this.querySelector(".tooltip"); if (tooltip) tooltip.textContent = nextShowTrails ? "隐藏轨迹" : "显示轨迹"; showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info"); }); bindListener(cablesBtn, "click", function () { const showNextCables = !getShowCables(); if (!showNextCables) { clearLockedObject(); } toggleCables(showNextCables); this.classList.toggle("active", showNextCables); const tooltip = this.querySelector(".tooltip"); if (tooltip) tooltip.textContent = showNextCables ? "隐藏线缆" : "显示线缆"; showStatusMessage(showNextCables ? "线缆已显示" : "线缆已隐藏", "info"); }); bindListener(reloadBtn, "click", async () => { await reloadData(); }); bindListener(zoomTrigger, "click", (event) => { event.stopPropagation(); infoGroup?.classList.remove("open"); zoomGroup?.classList.toggle("open"); }); bindListener(zoomGroup, "click", (event) => { event.stopPropagation(); }); bindListener(infoTrigger, "click", (event) => { event.stopPropagation(); zoomGroup?.classList.remove("open"); infoGroup?.classList.toggle("open"); }); bindListener(infoGroup, "click", (event) => { event.stopPropagation(); }); bindListener(document, "click", (event) => { if (zoomGroup?.classList.contains("open")) { if (!zoomGroup.contains(event.target)) { zoomGroup.classList.remove("open"); } } if (infoGroup?.classList.contains("open")) { if (!infoGroup.contains(event.target)) { infoGroup.classList.remove("open"); } } }); bindListener(layoutBtn, "click", () => { const expanded = toggleLayoutExpanded(container); showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info"); }); updateLayoutUI(container); } 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(".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(".tooltip"); const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局"; btn.title = nextLabel; if (tooltip) tooltip.textContent = nextLabel; } } function toggleLayoutExpanded(container) { layoutExpanded = !layoutExpanded; updateLayoutUI(container); return layoutExpanded; }