1429 lines
43 KiB
JavaScript
1429 lines
43 KiB
JavaScript
// controls.js - Zoom, rotate and toggle controls
|
||
|
||
import * as THREE from "three";
|
||
import { CONFIG, EARTH_CONFIG } 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;
|
||
|
||
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 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;
|
||
|
||
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 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 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);
|
||
});
|
||
}
|
||
|
||
syncAllHudPanelToggles();
|
||
}
|
||
|
||
function setupHudPanelControls() {
|
||
const closeButtons = document.querySelectorAll("[data-close-panel]");
|
||
closeButtons.forEach((button) => {
|
||
bindListener(button, "click", (event) => {
|
||
event.stopPropagation();
|
||
const target = event.currentTarget;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
const panelId = target.dataset.closePanel;
|
||
if (!panelId) return;
|
||
setHudPanelVisibility(panelId, false);
|
||
});
|
||
});
|
||
}
|
||
|
||
function setupDraggableHudPanels() {
|
||
const app = document.getElementById("container");
|
||
const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR);
|
||
if (!app || draggablePanels.length === 0) return;
|
||
|
||
draggablePanels.forEach((panel) => {
|
||
const handle = panel.dataset.dragSelf === "true"
|
||
? panel
|
||
: panel.querySelector(".hud-panel-drag-handle");
|
||
if (!handle) return;
|
||
|
||
let isDragging = false;
|
||
let startPointerX = 0;
|
||
let startPointerY = 0;
|
||
let startLeft = 0;
|
||
let startTop = 0;
|
||
|
||
const stopDragging = () => {
|
||
isDragging = false;
|
||
panel.classList.remove("is-dragging");
|
||
document.body.style.userSelect = "";
|
||
};
|
||
|
||
const onMove = (event) => {
|
||
if (!isDragging) return;
|
||
const appRect = app.getBoundingClientRect();
|
||
const panelRect = panel.getBoundingClientRect();
|
||
const brandPanel = document.getElementById("brand-panel");
|
||
const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
|
||
const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
|
||
const brandRight = brandRect ? brandRect.right - appRect.left : 0;
|
||
|
||
let nextLeft = Math.min(
|
||
Math.max(startLeft + (event.clientX - startPointerX), 0),
|
||
appRect.width - panelRect.width,
|
||
);
|
||
let nextTop = Math.min(
|
||
Math.max(startTop + (event.clientY - startPointerY), 0),
|
||
appRect.height - panelRect.height,
|
||
);
|
||
// Brand 面板形成 L 形禁区:panel 不能进入 brand 左上角矩形区域。
|
||
// 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
|
||
// 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
|
||
if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
|
||
const leftAdjust = brandRight - nextLeft;
|
||
const topAdjust = brandBottom - nextTop;
|
||
if (leftAdjust <= topAdjust) {
|
||
nextLeft = brandRight;
|
||
} else {
|
||
nextTop = brandBottom;
|
||
}
|
||
}
|
||
|
||
panel.style.left = `${nextLeft}px`;
|
||
panel.style.top = `${nextTop}px`;
|
||
panel.style.right = "auto";
|
||
panel.style.bottom = "auto";
|
||
panel.style.transform = "none";
|
||
panel.dataset.dragged = "true";
|
||
};
|
||
|
||
bindListener(handle, "pointerdown", (event) => {
|
||
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .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.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";
|
||
panel.classList.add("is-dragging");
|
||
document.body.style.userSelect = "none";
|
||
handle.setPointerCapture?.(event.pointerId);
|
||
});
|
||
|
||
bindListener(handle, "pointermove", onMove);
|
||
bindListener(handle, "pointerup", stopDragging);
|
||
bindListener(handle, "pointercancel", stopDragging);
|
||
bindListener(handle, "lostpointercapture", stopDragging);
|
||
});
|
||
}
|
||
|
||
function clearForcedFloatingClose() {
|
||
getFloatingGroups().forEach((group) => {
|
||
group.classList.remove("force-closed");
|
||
});
|
||
}
|
||
|
||
function clearForcedFloatingCloseIfPointerOutside() {
|
||
getFloatingGroups().forEach((group) => {
|
||
if (!group.matches(":hover")) {
|
||
group.classList.remove("force-closed");
|
||
}
|
||
});
|
||
}
|
||
|
||
function setFloatingMenuOpen(group, shouldOpen) {
|
||
closeFloatingMenus();
|
||
clearForcedFloatingClose();
|
||
group?.classList.toggle("open", shouldOpen);
|
||
}
|
||
|
||
function setButtonTooltip(button, text) {
|
||
const tooltip = button?.querySelector(".earth-toolbar-tooltip");
|
||
if (tooltip) {
|
||
tooltip.textContent = text;
|
||
}
|
||
}
|
||
|
||
export function updateLayerButtonState(button, isActive) {
|
||
if (!button) return;
|
||
button.classList.toggle("active", isActive);
|
||
button.setAttribute("aria-checked", isActive ? "true" : "false");
|
||
// Legacy badge text (kept for compatibility)
|
||
const state = button.querySelector(".earth-layer-btn__state");
|
||
if (state) {
|
||
state.textContent = isActive ? "ON" : "OFF";
|
||
}
|
||
}
|
||
|
||
function clearSelectionIfHiding(shouldHide) {
|
||
if (shouldHide) {
|
||
clearLockedObject();
|
||
}
|
||
}
|
||
|
||
function bindFloatingMenu(trigger, group) {
|
||
bindListener(trigger, "click", (event) => {
|
||
event.stopPropagation();
|
||
const shouldOpen = !group?.classList.contains("open");
|
||
setFloatingMenuOpen(group, shouldOpen);
|
||
});
|
||
|
||
bindListener(group, "click", (event) => {
|
||
event.stopPropagation();
|
||
});
|
||
}
|
||
|
||
function updateTVToggleUI(visible) {
|
||
const btn = document.getElementById("toggle-tv");
|
||
if (!btn) return;
|
||
btn.classList.toggle("active", visible);
|
||
setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播");
|
||
}
|
||
|
||
function bindListener(element, eventName, handler, options) {
|
||
if (!element) return;
|
||
element.addEventListener(eventName, handler, options);
|
||
listeners.push(() =>
|
||
element.removeEventListener(eventName, handler, options),
|
||
);
|
||
}
|
||
|
||
function resetCleanup() {
|
||
cleanupFns.forEach((cleanup) => cleanup());
|
||
cleanupFns = [];
|
||
listeners.forEach((cleanup) => cleanup());
|
||
listeners = [];
|
||
}
|
||
|
||
export function setupControls(camera, renderer, scene, earth) {
|
||
resetCleanup();
|
||
earthObj = earth;
|
||
setupZoomControls(camera);
|
||
setupWheelZoom(camera, renderer);
|
||
setupRotateControls(camera, earth);
|
||
setupTerrainControls();
|
||
setupLiquidGlassInteractions();
|
||
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 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 targetRotation = getViewRotation(targetLat, targetRotLon);
|
||
|
||
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 + (targetRotation.x - startRotX) * ease;
|
||
earthObj.rotation.y =
|
||
startRotY + (targetRotation.y - startRotY) * ease;
|
||
|
||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||
},
|
||
() => {
|
||
zoomLevel = 1.0;
|
||
showStatusMessage("视角已重置", "info");
|
||
},
|
||
);
|
||
}
|
||
|
||
if (navigator.geolocation) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) =>
|
||
animateToView(
|
||
pos.coords.latitude,
|
||
pos.coords.longitude,
|
||
-pos.coords.longitude,
|
||
),
|
||
() =>
|
||
animateToView(
|
||
EARTH_CONFIG.chinaLat,
|
||
EARTH_CONFIG.chinaLon,
|
||
EARTH_CONFIG.chinaRotLon,
|
||
),
|
||
{ timeout: 5000, enableHighAccuracy: false },
|
||
);
|
||
} else {
|
||
animateToView(
|
||
EARTH_CONFIG.chinaLat,
|
||
EARTH_CONFIG.chinaLon,
|
||
EARTH_CONFIG.chinaRotLon,
|
||
);
|
||
}
|
||
|
||
clearLockedObject();
|
||
}
|
||
|
||
function setupRotateControls(camera) {
|
||
const rotateBtn = document.getElementById("rotate-toggle");
|
||
const resetViewBtn = document.getElementById("reset-view");
|
||
|
||
bindListener(rotateBtn, "click", () => {
|
||
const isRotating = toggleAutoRotate();
|
||
showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
|
||
});
|
||
|
||
updateRotateUI();
|
||
|
||
bindListener(resetViewBtn, "click", () => {
|
||
resetView(camera);
|
||
});
|
||
}
|
||
|
||
function filterLayerRows(query, emptyStateEl, clearBtn) {
|
||
const rows = document.querySelectorAll("#layer-panel-list .layer-row");
|
||
let visibleCount = 0;
|
||
rows.forEach((row) => {
|
||
const name = (row.dataset.layerName || "").toLowerCase();
|
||
const matches = !query || name.includes(query);
|
||
row.hidden = !matches;
|
||
if (matches) visibleCount++;
|
||
});
|
||
if (emptyStateEl) emptyStateEl.hidden = visibleCount > 0;
|
||
if (clearBtn) clearBtn.hidden = !query;
|
||
}
|
||
|
||
function setupLayerPanel() {
|
||
const panel = document.getElementById("layer-toggles");
|
||
const collapseBtn = document.getElementById("layer-panel-collapse");
|
||
const searchInput = document.getElementById("layer-search-input");
|
||
const searchClear = document.getElementById("layer-search-clear");
|
||
const emptyState = document.getElementById("layer-panel-empty");
|
||
if (!panel) return;
|
||
|
||
const layerPanel = createHUDPanel({
|
||
panel,
|
||
header: ".layer-panel-header",
|
||
body: "#layer-panel-body",
|
||
collapseBtn,
|
||
collapsedClass: "layer-panel--collapsed",
|
||
preferredDirection: "down",
|
||
expandLabel: "展开图层列表",
|
||
collapseLabel: "折叠图层列表",
|
||
});
|
||
|
||
bindListener(collapseBtn, "click", (e) => {
|
||
e.stopPropagation();
|
||
layerPanel.setCollapsed(!layerPanel.isCollapsed());
|
||
});
|
||
|
||
if (searchInput) {
|
||
bindListener(searchInput, "input", () => {
|
||
const query = searchInput.value.trim().toLowerCase();
|
||
filterLayerRows(query, emptyState, searchClear);
|
||
});
|
||
|
||
if (searchClear) {
|
||
bindListener(searchClear, "click", () => {
|
||
searchInput.value = "";
|
||
filterLayerRows("", emptyState, searchClear);
|
||
searchInput.focus();
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function createLayerRow({ id, icon, label, meta, defaultActive }) {
|
||
const row = document.createElement("div");
|
||
row.className = "layer-row";
|
||
row.dataset.layerName = `${label} ${meta || ""}`.trim().toLowerCase();
|
||
row.innerHTML = `
|
||
<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);
|
||
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 updateRotateUI() {
|
||
const btn = document.getElementById("rotate-toggle");
|
||
if (btn) {
|
||
btn.classList.toggle("active", autoRotate);
|
||
btn.classList.toggle("is-stopped", !autoRotate);
|
||
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
|
||
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
|
||
}
|
||
}
|
||
|
||
export function setAutoRotate(value) {
|
||
autoRotate = value;
|
||
updateRotateUI();
|
||
}
|
||
|
||
export function toggleAutoRotate() {
|
||
autoRotate = !autoRotate;
|
||
updateRotateUI();
|
||
clearLockedObject();
|
||
return autoRotate;
|
||
}
|
||
|
||
export function getZoomLevel() {
|
||
return zoomLevel;
|
||
}
|
||
|
||
export function getShowTerrain() {
|
||
return showTerrain;
|
||
}
|
||
|
||
function updateLayoutUI(container) {
|
||
if (container) {
|
||
container.classList.toggle("layout-expanded", layoutExpanded);
|
||
}
|
||
|
||
const btn = document.getElementById("layout-toggle");
|
||
if (btn) {
|
||
btn.classList.toggle("active", layoutExpanded);
|
||
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
|
||
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
|
||
btn.title = nextLabel;
|
||
if (tooltip) tooltip.textContent = nextLabel;
|
||
}
|
||
}
|
||
|
||
function resetPanelInlineLayout(panel) {
|
||
const originalParentId = panel.dataset.originalParentId;
|
||
if (originalParentId) {
|
||
const originalParent = document.getElementById(originalParentId);
|
||
if (originalParent) {
|
||
const nextId = panel.dataset.originalNextSiblingId;
|
||
const nextSibling = nextId ? document.getElementById(nextId) : null;
|
||
if (nextSibling) {
|
||
originalParent.insertBefore(panel, nextSibling);
|
||
} else {
|
||
originalParent.appendChild(panel);
|
||
}
|
||
}
|
||
delete panel.dataset.originalParentId;
|
||
delete panel.dataset.originalNextSiblingId;
|
||
}
|
||
panel.style.left = "";
|
||
panel.style.top = "";
|
||
panel.style.right = "";
|
||
panel.style.bottom = "";
|
||
panel.style.transform = "";
|
||
panel.style.position = "";
|
||
panel.style.width = "";
|
||
panel.style.margin = "";
|
||
delete panel.dataset.dragged;
|
||
}
|
||
|
||
function isPanelVisible(panel) {
|
||
return !panel.classList.contains("hud-panel-hidden");
|
||
}
|
||
|
||
function animatePanelLayoutTransition(container, expand) {
|
||
const panels = Array.from(
|
||
container.querySelectorAll(DRAGGABLE_PANEL_SELECTOR),
|
||
);
|
||
if (panels.length === 0) {
|
||
layoutExpanded = expand;
|
||
updateLayoutUI(container);
|
||
return expand;
|
||
}
|
||
|
||
const visiblePanels = panels.filter(isPanelVisible);
|
||
const firstRects = new Map(
|
||
visiblePanels.map((panel) => [panel, panel.getBoundingClientRect()]),
|
||
);
|
||
|
||
panels.forEach((panel) => panel.classList.add("is-layout-animating"));
|
||
layoutExpanded = expand;
|
||
panels.forEach(resetPanelInlineLayout);
|
||
updateLayoutUI(container);
|
||
|
||
visiblePanels.forEach((panel) => {
|
||
const firstRect = firstRects.get(panel);
|
||
if (!firstRect) return;
|
||
|
||
const lastRect = panel.getBoundingClientRect();
|
||
const deltaX = firstRect.left - lastRect.left;
|
||
const deltaY = firstRect.top - lastRect.top;
|
||
|
||
if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) {
|
||
return;
|
||
}
|
||
|
||
panel.animate(
|
||
[
|
||
{
|
||
translate: `${deltaX}px ${deltaY}px`,
|
||
},
|
||
{
|
||
translate: "0 0",
|
||
},
|
||
],
|
||
{
|
||
duration: PANEL_LAYOUT_ANIMATION_MS,
|
||
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||
},
|
||
);
|
||
});
|
||
|
||
window.setTimeout(() => {
|
||
panels.forEach((panel) => panel.classList.remove("is-layout-animating"));
|
||
}, PANEL_LAYOUT_ANIMATION_MS);
|
||
|
||
return expand;
|
||
}
|
||
|
||
function toggleLayoutExpanded(container) {
|
||
return animatePanelLayoutTransition(container, !layoutExpanded);
|
||
}
|