Files
planet/frontend/public/earth/js/controls.js
2026-04-21 18:35:40 +08:00

1651 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
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 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);
updateLayerButtonState(button, enabled);
setButtonTooltip(button, enabled ? "隐藏地形" : "显示地形");
const terrainStatus = document.getElementById("terrain-status");
if (terrainStatus) terrainStatus.textContent = enabled ? "开启" : "关闭";
}
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 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();
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 = `
<span class="material-symbols-rounded layer-row-icon">${icon}</span>
<div class="layer-row-copy">
<span class="layer-row-label">${label}</span>
${meta ? `<span class="layer-row-meta">${meta}</span>` : ""}
</div>
<button id="${id}" class="layer-row-toggle${defaultActive ? " active" : ""}" type="button"
role="switch" aria-checked="${defaultActive ? "true" : "false"}" title="切换${label}显示">
<span class="layer-row-toggle-track"></span>
</button>
`;
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", async function () {
const nextShowTerrain = !showTerrain;
const toggleToken = ++terrainToggleToken;
if (!nextShowTerrain) {
applyTerrainUiState(this, false);
showStatusMessage("地形已隐藏", "info");
return;
}
try {
if (!isTerrainReady()) {
showStatusMessage("正在加载真实地形数据...", "info");
await ensureTerrainReady();
}
if (toggleToken !== terrainToggleToken) return;
applyTerrainUiState(this, true);
showStatusMessage("真实地形已显示", "success");
} catch (error) {
console.error("加载真实地形失败:", error);
applyTerrainUiState(this, false);
showStatusMessage("真实地形暂时不可用", "error");
}
});
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);
if (!showNextBGP && rotationMode === ROTATION_MODE.CRUISE && autoRotate) {
setAutoRotate(false);
}
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 mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
updateTVToggleUI(mediaVisible);
if (mediaVisible) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
});
}
updateNewsToggleUI(mediaVisible);
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, .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)) {
return;
}
let collapseTimer = null;
const layoutToolbarOrbs = () => {
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
const orbCount = orbs.length;
let toolbarScale = THREE.MathUtils.clamp(
toolbarWidth / TOOLBAR_BASE_WIDTH_PX,
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.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`);
});
};
const setExpanded = (expanded) => {
cluster.classList.toggle("is-expanded", expanded);
cluster.classList.toggle("is-collapsed", !expanded);
};
const scheduleCollapse = () => {
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);
});
// 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);
});
// Keep expanded while cursor stays anywhere within the toolbar area
bindListener(toolbar, "mouseenter", () => {
cancelCollapse();
});
bindListener(toolbar, "mouseleave", () => {
scheduleCollapse();
});
}
export function teardownControls() {
resetCleanup();
}
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();
dispatchRotationModeChange();
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
dispatchRotationModeChange();
return autoRotate;
}
export function getRotationMode() {
return rotationMode;
}
export function setRotationMode(nextMode) {
const normalizedMode =
nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE;
const changed = normalizedMode !== rotationMode;
rotationMode = normalizedMode;
updateRotateUI();
dispatchRotationModeChange();
if (changed) {
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 = 1.0,
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 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);
}