540 lines
16 KiB
JavaScript
540 lines
16 KiB
JavaScript
// 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;
|
|
}
|