Files
planet/frontend/public/earth/js/controls.js
linkong 3b4347c87d
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.54.0
2026-05-13 14:26:47 +08:00

4342 lines
135 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,
CRUISE_MODULES,
DEFAULT_SATELLITE_DISPLAY_STYLE,
DEFAULT_CRUISE_MODULES,
EARTH_CONFIG,
ROTATION_MODE,
SATELLITE_DISPLAY_STYLES,
} from "./constants.js";
import {
setEarthStatValue,
showGestureStatusMessage,
showStatusMessage,
updateZoomDisplay,
} from "./ui.js";
import {
toggleTerrain,
setDayNightEnabled,
toggleClouds,
toggleGridLines,
getShowGridLines,
} from "./earth.js";
import { setCelestialDayNightEnabled } from "./celestial.js";
import {
ensureTerrainReady,
isTerrainReady,
getTerrainOpacity,
setTerrainOpacity,
} from "./terrain.js";
import {
reloadData,
clearLockedObject,
clearLockedObjectAndInfo,
setCablesEnabled,
setCountryBoundariesEnabled,
setHighResTextureEnabled,
getHighResTextureEnabled,
setAtmosphereCloudsEnabled,
getAtmosphereCloudsEnabled,
setSatellitesEnabled,
getSatellitesEnabled,
setVesselsEnabled,
getVesselsEnabled,
} from "./main.js";
import {
toggleTrails,
getShowTrails,
getSatelliteCount,
getSatelliteDisplayStyle,
getSatelliteIdleBreathingEnabled,
setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled,
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
} from "./satellites.js";
import {
getInteractableCompactDotsEnabled,
setInteractableCompactDotsEnabled as applyInteractableCompactDotsEnabled,
} from "./interactable.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
import {
toggleComputeCenters,
getShowComputeCenters,
getComputeCenterCount,
} from "./compute-centers.js";
import {
getShowVessels,
getVesselCount,
} from "./vessels.js";
import {
ensureTVPanelReady,
getActiveTVTab,
isTVPanelVisible,
setActiveTVTab,
setTVPanelVisible,
} from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
import {
closeSearchPanel,
focusSearchInput,
isSearchPanelOpen,
openSearchPanel,
refreshSearchResults,
} from "./search.js";
import {
setButtonTooltip,
setLayerButtonState,
updateLayerButtonState,
} from "./layer-button-state.js";
import {
DEFAULT_MOTION_PROVIDER,
normalizeMotionProvider,
} from "./motion-protocol.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 dayNightEnabled = true;
let defaultEarthZoom = CONFIG.defaultViewZoom;
let motionDebugEnabled = false;
let motionProvider = DEFAULT_MOTION_PROVIDER;
let motionDebugSkeletonOnly = false;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
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;
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
const EARTH_SETTINGS_VERSION = 10;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
const MOTION_DEBUG_DEFAULT_VERSION = 6;
const MOTION_PROVIDER_DEFAULT_VERSION = 7;
const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
let settingsModalTimer = null;
let settingsSheetAnimation = null;
let terrainToggleToken = 0;
let terrainPrefetchStarted = false;
let terrainPrefetchTimer = null;
let terrainPrefetchIdleHandle = null;
let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let lastZoomStatusUpdateTime = 0;
let earthSettingsState = null;
let deferredLayerVisibilitySettings = null;
let layerRegistry = new Map();
let layerPanelInitialized = false;
let layoutMode = "desktop";
let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
let toolbarHubController = null;
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.BGP]: "BGP",
[CRUISE_MODULES.NEWS]: "新闻",
[CRUISE_MODULES.COMPUTE_CENTERS]: "算力中心",
[CRUISE_MODULES.VESSELS]: "船只",
[CRUISE_MODULES.CABLES]: "海缆",
[CRUISE_MODULES.SATELLITES]: "卫星",
};
function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live";
}
function detectLayoutMode() {
const width = window.innerWidth;
const height = window.innerHeight;
if (width <= 820) {
return "mobile";
}
if (width <= 1080 || height <= 760) {
return "compact";
}
return "desktop";
}
function getSettingsViewportScope(mode = layoutMode) {
return mode === "mobile" ? "mobile" : "desktop";
}
export function getLayoutMode() {
return layoutMode;
}
export function isMobileLayout() {
return layoutMode === "mobile";
}
function isCompactLayout() {
return layoutMode === "compact";
}
function syncMobileDrawerState() {
const shell = document.getElementById("mobile-drawer-shell");
const overlay = document.getElementById("mobile-drawer-overlay");
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
const tabs = document.querySelectorAll("[data-drawer-card]");
const slots = document.querySelectorAll("[data-drawer-slot]");
const isMobile = isMobileLayout();
document.body.classList.toggle(
"earth-mobile-drawer-open",
isMobile && mobileDrawerOpen,
);
if (shell instanceof HTMLElement) {
shell.setAttribute("aria-hidden", (!isMobile).toString());
}
if (overlay instanceof HTMLElement) {
overlay.hidden = !isMobile;
}
tabs.forEach((tab) => {
if (!(tab instanceof HTMLButtonElement)) return;
const isActive = isMobile && tab.dataset.drawerCard === mobileDrawerCard;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", String(isActive));
});
slots.forEach((slot) => {
if (!(slot instanceof HTMLElement)) return;
slot.classList.toggle(
"is-active",
isMobile && slot.dataset.drawerSlot === mobileDrawerCard,
);
});
const layerPanel = document.getElementById("layer-toggles");
if (layerPanel instanceof HTMLElement) {
layerPanel.classList.toggle("is-mobile-open", false);
}
if (sheet instanceof HTMLElement) {
if (isMobile && !mobileDrawerOpen) {
if (!mobileDrawerHintTimer) {
mobileDrawerHintTimer = setInterval(() => {
if (mobileDrawerOpen) return;
sheet.classList.remove("is-hinting");
void sheet.offsetWidth;
sheet.classList.add("is-hinting");
}, 5000);
}
} else {
clearInterval(mobileDrawerHintTimer);
mobileDrawerHintTimer = null;
sheet.classList.remove("is-hinting");
}
}
}
function setMobileDrawerOpen(panelId, open) {
if (!isMobileLayout()) return;
if (panelId === "layer-toggles") {
mobileDrawerCard = "layers";
}
mobileDrawerOpen = open;
activeMobileDrawerId = open ? panelId : null;
syncMobileDrawerState();
}
function closeTransientMobileOverlays({ except = null } = {}) {
if (isMobileLayout()) {
if (!except) {
mobileDrawerOpen = false;
syncMobileDrawerState();
}
return;
}
if (except !== "search" && isSearchPanelOpen()) {
closeSearchPanel();
}
if (except !== "settings" && isSettingsModalOpen()) {
closeSettingsModal();
}
if (except !== "layer-toggles" && activeMobileDrawerId === "layer-toggles") {
setMobileDrawerOpen("layer-toggles", false);
}
if (
except !== "media"
&& except !== "search"
&& except !== "settings"
&& isTVPanelVisible()
) {
setTVPanelVisible(false, { persist: false });
}
}
function applyResponsiveLayout() {
const previousLayoutMode = layoutMode;
layoutMode = detectLayoutMode();
const isMobile = isMobileLayout();
const isCompact = isCompactLayout();
const container = document.getElementById("container");
document.documentElement.classList.toggle("layout-mode-mobile", isMobile);
document.documentElement.classList.toggle("layout-mode-compact", isCompact);
document.documentElement.dataset.earthLayoutMode = layoutMode;
document.body.classList.toggle("layout-mode-mobile", isMobile);
document.body.classList.toggle("layout-mode-compact", isCompact);
container?.classList.toggle("layout-mode-mobile", isMobile);
container?.classList.toggle("layout-mode-compact", isCompact);
if (!isMobile) {
activeMobileDrawerId = null;
mobileDrawerOpen = false;
}
if (previousLayoutMode !== layoutMode) {
if (isMobile) {
resetDesktopHudPanelsForMobile();
}
if (earthSettingsState) {
applyCurrentViewportPanelVisibility({ persist: false });
}
}
syncMobileDrawerState();
}
function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard } = {}) {
mobileDrawerOpen = Boolean(open);
mobileDrawerCard = card || "layers";
activeMobileDrawerId = mobileDrawerOpen && mobileDrawerCard === "layers"
? "layer-toggles"
: null;
syncMobileDrawerState();
if (!isMobileLayout()) {
return;
}
if (mobileDrawerOpen) {
closeFloatingMenus();
closeSearchPanel();
if (isSettingsModalOpen()) {
closeSettingsModal();
}
}
if (!mobileDrawerOpen) {
return;
}
if (mobileDrawerCard === "search") {
window.setTimeout(() => {
focusSearchInput({ select: true });
refreshSearchResults().catch((error) => {
console.warn("刷新抽屉搜索失败:", error);
});
}, 16);
} else if (mobileDrawerCard === "tv") {
ensureTVPanelReady().catch((error) => {
console.error("初始化媒体抽屉失败:", error);
});
} else if (mobileDrawerCard === "news") {
ensureNewsPanelReady().catch((error) => {
console.error("初始化新闻抽屉失败:", error);
});
}
}
function getMobileLayerButtons(layerId) {
return Array.from(
document.querySelectorAll(`[data-mobile-layer-button="${layerId}"]`),
).filter((button) => button instanceof HTMLButtonElement);
}
function getLayerDisabledState(layerId) {
if (layerId === "trails" && !getSatellitesEnabled()) {
return {
disabled: true,
statusText: "不可用",
tooltip: "卫星关闭时不可用",
};
}
if (layerId === "terrain" && !getHighResTextureEnabled()) {
return {
disabled: true,
statusText: "不可用",
tooltip: "高清材质关闭时不可用",
};
}
return {
disabled: false,
statusText: null,
tooltip: null,
};
}
function syncMobileLayerCards() {
const summary = document.getElementById("mobile-layer-summary");
const definitions = getDisplayLayerDefinitions();
let activeCount = 0;
definitions.forEach((definition) => {
const visible = Boolean(definition.getVisible?.());
if (visible) {
activeCount += 1;
}
getMobileLayerButtons(definition.id).forEach((button) => {
const disabledState = getLayerDisabledState(definition.id);
button.classList.toggle("is-active", visible);
button.classList.toggle("is-disabled", disabledState.disabled);
button.disabled = disabledState.disabled;
button.setAttribute("aria-checked", visible ? "true" : "false");
if (disabledState.tooltip) {
button.title = disabledState.tooltip;
} else {
button.removeAttribute("title");
}
const status = button.querySelector("[data-mobile-layer-status]");
if (status) {
status.textContent = disabledState.statusText || (visible ? "开启" : "关闭");
}
});
});
if (summary) {
summary.textContent = `已启用 ${activeCount} 个图层`;
}
}
function renderMobileLayerCards() {
const list = document.getElementById("mobile-layer-list");
if (!(list instanceof HTMLElement)) return;
const definitions = getDisplayLayerDefinitions();
list.innerHTML = definitions
.map((definition) => `
<button
class="earth-mobile-layer-card"
type="button"
data-mobile-layer-button="${definition.id}"
role="switch"
aria-checked="${definition.getVisible?.() ? "true" : "false"}"
>
<span class="earth-mobile-layer-card-icon material-symbols-rounded">${definition.icon}</span>
<span class="earth-mobile-layer-card-copy">
<span class="earth-mobile-layer-card-title">${definition.label}</span>
<span class="earth-mobile-layer-card-subtitle">${definition.meta || ""}</span>
</span>
<span class="earth-mobile-layer-card-status" data-mobile-layer-status>${definition.getVisible?.() ? "开启" : "关闭"}</span>
</button>
`)
.join("");
list.querySelectorAll("[data-mobile-layer-button]").forEach((button) => {
bindListener(button, "click", async (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
if (target.disabled || target.classList.contains("is-disabled")) return;
const layerId = target.dataset.mobileLayerButton;
const definition = layerId ? getLayerDefinition(layerId) : null;
if (!definition) return;
await definition.setVisible(!definition.getVisible());
syncMobileLayerCards();
});
});
syncMobileLayerCards();
}
function setupMobileDrawerShell() {
const overlay = document.getElementById("mobile-drawer-overlay");
const shell = document.getElementById("mobile-drawer-shell");
const handle = document.getElementById("mobile-drawer-handle");
const tabs = document.querySelectorAll("[data-drawer-card]");
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
bindListener(overlay, "click", () => {
setMobileDrawerState({ open: false });
});
tabs.forEach((tab) => {
bindListener(tab, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const card = target.dataset.drawerCard || "layers";
setMobileDrawerState({ open: true, card });
});
});
if (handle instanceof HTMLElement && sheet instanceof HTMLElement) {
const DRAWER_HANDLE_PX = 36;
const SWIPE_OPEN_VELOCITY = 0.3; // px/ms upward → open regardless of position
const SWIPE_IDLE_VELOCITY = 0.05; // px/ms threshold below which position decides
const SWIPE_CLOSE_VELOCITY = 0.5; // px/ms downward on content → close
let startY = 0;
let startTranslate = 0;
let dragging = false;
let activePointerId = null;
let lastMoveY = 0;
let lastMoveTime = 0;
let velocityY = 0;
sheet.addEventListener("animationend", () => {
sheet.classList.remove("is-hinting");
});
const getClosedOffset = () => {
const safeBottom = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"),
) || 0;
return Math.max(sheet.offsetHeight - DRAWER_HANDLE_PX - safeBottom, 0);
};
const applyTranslate = (value) => {
sheet.style.transition = "none";
sheet.style.transform = `translateY(${Math.max(0, Math.min(getClosedOffset(), value))}px)`;
};
const stopDragging = (event) => {
if (!dragging) return;
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
const currentTransform = sheet.style.transform;
const match = currentTransform.match(/translateY\(([-\d.]+)px\)/);
const finalOffset = match ? Number.parseFloat(match[1]) : startTranslate;
const closedOffset = getClosedOffset();
const velocity = velocityY;
dragging = false;
activePointerId = null;
velocityY = 0;
lastMoveY = 0;
lastMoveTime = 0;
sheet.style.transition = "";
sheet.style.transform = "";
const shouldOpen =
velocity < -SWIPE_OPEN_VELOCITY
|| (velocity <= SWIPE_IDLE_VELOCITY && finalOffset < closedOffset * 0.5);
if (shouldOpen) {
setMobileDrawerState({ open: true, card: mobileDrawerCard || "layers" });
} else {
setMobileDrawerState({ open: false });
}
};
bindListener(handle, "click", () => {
if (dragging) return;
setMobileDrawerState({ open: !mobileDrawerOpen, card: mobileDrawerCard || "layers" });
});
bindListener(handle, "pointerdown", (event) => {
if (!isMobileLayout()) return;
sheet.classList.remove("is-hinting");
dragging = true;
activePointerId = event.pointerId;
startY = event.clientY;
lastMoveY = event.clientY;
lastMoveTime = performance.now();
velocityY = 0;
startTranslate = mobileDrawerOpen ? 0 : getClosedOffset();
applyTranslate(startTranslate);
handle.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
bindListener(window, "pointermove", (event) => {
if (!dragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
const now = performance.now();
const dt = now - lastMoveTime;
if (dt > 0) {
velocityY = (event.clientY - lastMoveY) / dt;
}
lastMoveY = event.clientY;
lastMoveTime = now;
const deltaY = event.clientY - startY;
applyTranslate(startTranslate + deltaY);
event.preventDefault();
}, { passive: false });
bindListener(window, "pointerup", stopDragging);
bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
}
bindListener(window, "earth:open-details-tab", () => {
if (isMobileLayout()) setMobileDrawerState({ open: true, card: "details" });
});
const content = shell?.querySelector(".earth-mobile-drawer-content");
if (content instanceof HTMLElement) {
let contentStartY = 0;
let contentLastY = 0;
let contentLastTime = 0;
let contentVelocityY = 0;
let contentTracking = false;
bindListener(content, "pointerdown", (event) => {
if (!isMobileLayout() || !mobileDrawerOpen) return;
contentStartY = event.clientY;
contentLastY = event.clientY;
contentLastTime = performance.now();
contentVelocityY = 0;
contentTracking = true;
});
bindListener(content, "pointermove", (event) => {
if (!contentTracking) return;
const now = performance.now();
const dt = now - contentLastTime;
if (dt > 0) {
contentVelocityY = (event.clientY - contentLastY) / dt;
}
contentLastY = event.clientY;
contentLastTime = now;
});
const endContentTrack = () => {
if (!contentTracking) return;
contentTracking = false;
const activeSlot = content.querySelector(".earth-mobile-drawer-slot.is-active");
const atTop = !activeSlot || activeSlot.scrollTop <= 2;
if (atTop && contentVelocityY > SWIPE_CLOSE_VELOCITY) {
setMobileDrawerState({ open: false });
}
contentVelocityY = 0;
};
bindListener(content, "pointerup", endContentTrack);
bindListener(content, "pointercancel", endContentTrack);
}
}
function compareLayerDefinitionsByStartupPriority(left, right) {
const leftPriority = Number.isFinite(left?.startupPriority)
? left.startupPriority
: Number.POSITIVE_INFINITY;
const rightPriority = Number.isFinite(right?.startupPriority)
? right.startupPriority
: Number.POSITIVE_INFINITY;
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return String(left?.id || "").localeCompare(String(right?.id || ""));
}
function getSortedLayerDefinitions({ includeUnprioritized = true } = {}) {
return Array.from(layerRegistry.values())
.filter((definition) =>
includeUnprioritized
? true
: Number.isFinite(definition?.startupPriority),
)
.sort(compareLayerDefinitionsByStartupPriority);
}
function getDisplayLayerDefinitions() {
return Array.from(layerRegistry.values()).sort((left, right) => {
const leftOrder = Number.isFinite(left?.displayOrder)
? left.displayOrder
: Number.POSITIVE_INFINITY;
const rightOrder = Number.isFinite(right?.displayOrder)
? right.displayOrder
: Number.POSITIVE_INFINITY;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left?.id || "").localeCompare(String(right?.id || ""));
});
}
function shouldIncludeLayerInStartupLoad(definition) {
if (!Number.isFinite(definition?.startupPriority)) {
return false;
}
const persistedVisible = getPersistedLayerVisibilityOverride(definition.id);
if (typeof persistedVisible === "boolean") {
if (definition.startupMode === "preload" && definition.startupAlwaysLoad) {
return true;
}
return persistedVisible;
}
if (definition.startupMode === "preload") {
return true;
}
return Boolean(definition?.getVisible?.());
}
function getPersistedLayerVisibilityOverride(layerId) {
if (!layerId) return null;
const layerVisibility =
deferredLayerVisibilitySettings || earthSettingsState?.shared?.layerVisibility;
const persistedVisible = layerVisibility?.[layerId];
return typeof persistedVisible === "boolean" ? persistedVisible : null;
}
function clampEarthZoomLevel(nextZoom) {
const parsedZoom = Number.parseFloat(nextZoom);
if (!Number.isFinite(parsedZoom)) {
return CONFIG.defaultViewZoom;
}
return Math.min(CONFIG.maxZoom, Math.max(CONFIG.minZoom, parsedZoom));
}
function formatZoomPercent(zoom) {
return `${Math.round(zoom * 100)}%`;
}
function getZoomResetTooltipText(zoom) {
return `重置缩放到${formatZoomPercent(zoom)}`;
}
function getZoomResetStatusMessage(zoom) {
return `缩放已重置到${formatZoomPercent(zoom)}`;
}
function canUseLocalStorage() {
try {
return typeof window !== "undefined" && !!window.localStorage;
} catch {
return false;
}
}
function getCurrentPanelVisibilitySnapshot() {
return Object.fromEntries(
HUD_PANEL_IDS.map((panelId) => {
const panel = document.getElementById(panelId);
const visible = !panel?.classList.contains("hud-panel-hidden");
return [panelId, visible];
}),
);
}
function getCurrentSharedSettingsSnapshot() {
return {
rotationMode,
cruiseModules: getCruiseModules(),
satelliteDisplayStyle: getSatelliteDisplayStyle(),
trailsEnabled: getShowTrails(),
layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
),
terrainOpacity: getTerrainOpacity(),
dayNightEnabled,
defaultEarthZoom,
motionDebugEnabled,
motionProvider,
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
};
}
function getDefaultLayerVisibilitySnapshot() {
return Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.defaultActive)]),
);
}
function captureEarthSettingsDefaults() {
if (!earthSettingsDefaults) {
const panelVisibility = getCurrentPanelVisibilitySnapshot();
const shared = getCurrentSharedSettingsSnapshot();
earthSettingsDefaults = {
version: EARTH_SETTINGS_VERSION,
shared: {
...shared,
layerVisibility: getDefaultLayerVisibilitySnapshot(),
},
views: {
desktop: {
panelVisibility: { ...panelVisibility },
},
mobile: {
panelVisibility: { ...panelVisibility },
},
},
};
}
return earthSettingsDefaults;
}
function cloneEarthSettings(settings) {
return {
version: EARTH_SETTINGS_VERSION,
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
satelliteDisplayStyle:
settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE,
trailsEnabled: settings.shared.trailsEnabled !== false,
terrainOpacity: settings.shared.terrainOpacity,
dayNightEnabled: settings.shared.dayNightEnabled,
defaultEarthZoom: settings.shared.defaultEarthZoom,
motionDebugEnabled: settings.shared.motionDebugEnabled,
motionProvider: normalizeMotionProvider(
settings.shared.motionProvider,
DEFAULT_MOTION_PROVIDER,
),
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
satelliteIdleBreathingEnabled:
settings.shared.satelliteIdleBreathingEnabled !== false,
interactableCompactDotsEnabled:
settings.shared.interactableCompactDotsEnabled !== false,
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
desktop: {
panelVisibility: {
...(settings.views?.desktop?.panelVisibility || {}),
},
},
mobile: {
panelVisibility: {
...(settings.views?.mobile?.panelVisibility || {}),
},
},
},
};
}
function normalizeEarthSettings(rawSettings, defaults) {
const normalizedDesktopPanelVisibility = {
...defaults.views.desktop.panelVisibility,
};
const normalizedMobilePanelVisibility = {
...defaults.views.mobile.panelVisibility,
};
const normalizedLayerVisibility = {
...defaults.shared.layerVisibility,
};
const sharedSettings =
rawSettings && typeof rawSettings.shared === "object"
? rawSettings.shared
: rawSettings;
const inputDesktopPanelVisibility =
rawSettings?.views?.desktop && typeof rawSettings.views.desktop.panelVisibility === "object"
? rawSettings.views.desktop.panelVisibility
: rawSettings && typeof rawSettings.panelVisibility === "object"
? rawSettings.panelVisibility
: {};
const inputMobilePanelVisibility =
rawSettings?.views?.mobile && typeof rawSettings.views.mobile.panelVisibility === "object"
? rawSettings.views.mobile.panelVisibility
: {};
const inputLayerVisibility =
sharedSettings && typeof sharedSettings.layerVisibility === "object"
? sharedSettings.layerVisibility
: {};
Object.entries(inputDesktopPanelVisibility).forEach(([panelId, visible]) => {
if (panelId in normalizedDesktopPanelVisibility) {
normalizedDesktopPanelVisibility[panelId] = Boolean(visible);
}
});
Object.entries(inputMobilePanelVisibility).forEach(([panelId, visible]) => {
if (panelId in normalizedMobilePanelVisibility) {
normalizedMobilePanelVisibility[panelId] = Boolean(visible);
}
});
Object.entries(inputLayerVisibility).forEach(([layerId, visible]) => {
if (layerId in normalizedLayerVisibility) {
normalizedLayerVisibility[layerId] = Boolean(visible);
}
});
if ((rawSettings?.version || 0) < GRID_LINES_DEFAULT_VERSION && inputLayerVisibility.gridLines === true) {
normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines;
}
if ((rawSettings?.version || 0) < MEDIA_PANEL_DEFAULT_VERSION) {
normalizedDesktopPanelVisibility["media-panel"] = true;
}
const nextRotationMode =
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE ||
sharedSettings?.rotationMode === ROTATION_MODE.MOTION
? sharedSettings.rotationMode
: defaults.shared.rotationMode;
const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules)
? sharedSettings.cruiseModules
: defaults.shared.cruiseModules;
const nextCruiseModules = Array.from(
new Set(
requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)),
),
);
let nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
sharedSettings?.satelliteDisplayStyle,
)
? sharedSettings.satelliteDisplayStyle
: defaults.shared.satelliteDisplayStyle;
if (
(rawSettings?.version || 0) < SATELLITE_DISPLAY_DEFAULT_VERSION &&
nextSatelliteDisplayStyle === SATELLITE_DISPLAY_STYLES.SELF_GLOW
) {
nextSatelliteDisplayStyle = defaults.shared.satelliteDisplayStyle;
}
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? sharedSettings.dayNightEnabled
: defaults.shared.dayNightEnabled;
const nextDefaultEarthZoom = clampEarthZoomLevel(
sharedSettings?.defaultEarthZoom ?? defaults.shared.defaultEarthZoom,
);
const nextMotionDebugEnabled =
(rawSettings?.version || 0) >= MOTION_DEBUG_DEFAULT_VERSION &&
typeof sharedSettings?.motionDebugEnabled === "boolean"
? sharedSettings.motionDebugEnabled
: defaults.shared.motionDebugEnabled;
const nextMotionProvider =
(rawSettings?.version || 0) >= MOTION_PROVIDER_DEFAULT_VERSION
? normalizeMotionProvider(sharedSettings?.motionProvider, defaults.shared.motionProvider)
: defaults.shared.motionProvider;
const nextMotionDebugSkeletonOnly =
(rawSettings?.version || 0) >= MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION &&
typeof sharedSettings?.motionDebugSkeletonOnly === "boolean"
? sharedSettings.motionDebugSkeletonOnly
: defaults.shared.motionDebugSkeletonOnly;
const nextMediaPanelActiveTab =
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
const nextSatelliteIdleBreathingEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean"
? sharedSettings.satelliteIdleBreathingEnabled
: defaults.shared.satelliteIdleBreathingEnabled;
const nextInteractableCompactDotsEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.interactableCompactDotsEnabled === "boolean"
? sharedSettings.interactableCompactDotsEnabled
: defaults.shared.interactableCompactDotsEnabled;
const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean"
? sharedSettings.trailsEnabled
: typeof inputLayerVisibility.trails === "boolean"
? inputLayerVisibility.trails
: defaults.shared.trailsEnabled;
return {
version: EARTH_SETTINGS_VERSION,
shared: {
rotationMode: nextRotationMode,
cruiseModules: nextCruiseModules.length > 0
? nextCruiseModules
: [...DEFAULT_CRUISE_MODULES],
satelliteDisplayStyle: nextSatelliteDisplayStyle,
trailsEnabled: nextTrailsEnabled,
layerVisibility: normalizedLayerVisibility,
terrainOpacity: Number.isFinite(nextTerrainOpacity)
? nextTerrainOpacity
: defaults.shared.terrainOpacity,
dayNightEnabled: nextDayNightEnabled,
defaultEarthZoom: nextDefaultEarthZoom,
motionDebugEnabled: nextMotionDebugEnabled,
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
},
views: {
desktop: {
panelVisibility: normalizedDesktopPanelVisibility,
},
mobile: {
panelVisibility: normalizedMobilePanelVisibility,
},
},
};
}
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
const interactable = rotationMode === ROTATION_MODE.MOTION;
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
input.disabled = !interactable;
const label = input.closest("label");
label?.classList.toggle("is-disabled", !interactable);
if (label instanceof HTMLElement) {
if (interactable) {
label.removeAttribute("title");
} else {
label.title = "切换到动捕模式后可开启调试面板";
}
}
}
});
}
function syncMotionProviderControls(nextProvider = motionProvider) {
document.querySelectorAll("[data-motion-provider]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = normalizeMotionProvider(button.dataset.motionProvider) === nextProvider;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) {
document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
}
});
}
function dispatchMotionSettingsChange() {
const effectiveDebugEnabled =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
window.dispatchEvent(
new CustomEvent("earth:motion-debug-mode-change", {
detail: {
enabled: effectiveDebugEnabled,
preferredEnabled: motionDebugEnabled,
provider: motionProvider,
skeletonOnly: motionDebugSkeletonOnly,
},
}),
);
}
function getPersistedLayers() {
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
}
function getLayerDefinition(layerId) {
return layerRegistry.get(layerId) || null;
}
function getLayerButton(layerId) {
const definition = getLayerDefinition(layerId);
if (!definition?.buttonId) return null;
const button = document.getElementById(definition.buttonId);
return button instanceof HTMLButtonElement ? button : null;
}
function loadEarthSettings() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
if (!canUseLocalStorage()) return defaults;
try {
const rawValue = window.localStorage.getItem(EARTH_SETTINGS_STORAGE_KEY);
const legacyRawValue = window.localStorage.getItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
const sourceValue = rawValue || legacyRawValue;
if (!sourceValue) return defaults;
const parsedValue = JSON.parse(sourceValue);
return normalizeEarthSettings(parsedValue, defaults);
} catch (error) {
console.warn("读取 Earth 设置失败,已回退默认值:", error);
return defaults;
}
}
function getViewportPanelVisibility(settings, mode = layoutMode) {
const scope = getSettingsViewportScope(mode);
return settings?.views?.[scope]?.panelVisibility || {};
}
function syncEarthSettingsStateFromRuntime() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
const nextSettings = earthSettingsState
? cloneEarthSettings(earthSettingsState)
: defaults;
const scope = getSettingsViewportScope();
nextSettings.shared = getCurrentSharedSettingsSnapshot();
// panelVisibility is maintained in earthSettingsState via setHudPanelVisibility.
// Do not re-snapshot from DOM here: transient hides (e.g. closeTransientMobileOverlays)
// change the DOM without going through setHudPanelVisibility and would corrupt the
// user's persisted preference.
earthSettingsState = nextSettings;
return nextSettings;
}
function ensureMutableEarthSettingsState() {
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
return earthSettingsState;
}
function persistEarthSettings() {
if (!canUseLocalStorage()) return;
try {
const nextSettings = syncEarthSettingsStateFromRuntime();
window.localStorage.setItem(
EARTH_SETTINGS_STORAGE_KEY,
JSON.stringify(nextSettings),
);
window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
} catch (error) {
console.warn("保存 Earth 设置失败:", error);
}
}
function dispatchCruiseModulesChange() {
window.dispatchEvent(
new CustomEvent("earth:cruise-modules-change", {
detail: {
modules: getCruiseModules(),
},
}),
);
}
function normalizeCruiseModules(nextModules) {
const sourceModules = Array.isArray(nextModules) ? nextModules : DEFAULT_CRUISE_MODULES;
const normalizedModules = Array.from(
new Set(sourceModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId))),
);
return normalizedModules.length > 0
? normalizedModules
: [...DEFAULT_CRUISE_MODULES];
}
function syncCruiseModuleControls() {
const enabledModules = new Set(getCruiseModules());
document.querySelectorAll("[data-cruise-module-toggle]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const moduleId = button.dataset.cruiseModuleToggle || "";
const active = enabledModules.has(moduleId);
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function syncSatelliteDisplayStyleControls() {
const activeStyle = getSatelliteDisplayStyle();
document.querySelectorAll("[data-satellite-display-style]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const styleId = button.dataset.satelliteDisplayStyle || "";
const active = styleId === activeStyle;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function syncSatelliteIdleBreathingToggle() {
const enabled = getSatelliteIdleBreathingEnabled();
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function syncInteractableCompactDotsToggle() {
const enabled = getInteractableCompactDotsEnabled();
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
}
export function isCruiseModuleEnabled(moduleId) {
return getCruiseModules().includes(moduleId);
}
export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) {
const normalizedModules = normalizeCruiseModules(nextModules);
const previousModules = getCruiseModules();
const changed =
normalizedModules.length !== previousModules.length ||
normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId);
if (!changed) {
syncCruiseModuleControls();
return normalizedModules;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.cruiseModules = [...normalizedModules];
syncCruiseModuleControls();
dispatchCruiseModulesChange();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const labels = normalizedModules.map((moduleId) => CRUISE_MODULE_LABELS[moduleId] || moduleId);
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
}
return normalizedModules;
}
export function setSatelliteDisplayStyle(
nextStyle,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(nextStyle)
? nextStyle
: DEFAULT_SATELLITE_DISPLAY_STYLE;
const previousStyle = getSatelliteDisplayStyle();
if (normalizedStyle === previousStyle) {
syncSatelliteDisplayStyleControls();
return normalizedStyle;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle;
applySatelliteDisplayStyle(normalizedStyle);
syncSatelliteDisplayStyleControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const nextLabel =
normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT
? "真实地表覆盖"
: "自身发光";
showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info");
}
return normalizedStyle;
}
export function setSatelliteIdleBreathingEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applySatelliteIdleBreathingEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteIdleBreathingEnabled = enabled;
syncSatelliteIdleBreathingToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
}
return enabled;
}
export function setInteractableCompactDotsEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applyInteractableCompactDotsEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.interactableCompactDotsEnabled = enabled;
syncInteractableCompactDotsToggle();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
}
return enabled;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
const zoomValue = document.getElementById("zoom-value");
const tooltipText = getZoomResetTooltipText(nextZoom);
sliders.forEach((slider) => {
if (!(slider instanceof HTMLInputElement)) return;
slider.min = CONFIG.minZoom.toString();
slider.max = CONFIG.maxZoom.toString();
slider.step = DEFAULT_EARTH_ZOOM_STEP.toString();
slider.value = nextZoom.toFixed(2);
});
values.forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = formatZoomPercent(nextZoom);
}
});
if (zoomValue instanceof HTMLElement) {
zoomValue.title = tooltipText;
const tooltip = zoomValue.querySelector(".tooltip");
if (tooltip) {
tooltip.textContent = tooltipText;
}
}
}
function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = true } = {}) {
defaultEarthZoom = clampEarthZoomLevel(nextZoom);
syncDefaultEarthZoomUi(defaultEarthZoom);
if (applyToCurrentView && activeCamera) {
zoomLevel = defaultEarthZoom;
applyZoom(activeCamera);
}
if (persist) {
persistEarthSettings();
}
return defaultEarthZoom;
}
async function applyEarthSettings(settings, { applyLayers = true } = {}) {
if (!settings) return;
earthSettingsState = cloneEarthSettings(settings);
applyCurrentViewportPanelVisibility({ persist: false });
const appliedOpacity = setTerrainOpacity(settings.shared.terrainOpacity);
document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]").forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.value = appliedOpacity.toFixed(2);
}
});
document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]").forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = `${Math.round(appliedOpacity * 100)}%`;
}
});
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true });
setSatelliteDisplayStyle(settings.shared.satelliteDisplayStyle, {
persist: false,
suppressStatus: true,
});
setTrailsDisplayEnabled(settings.shared.trailsEnabled, {
persist: false,
silent: true,
});
setSatelliteIdleBreathingEnabled(settings.shared.satelliteIdleBreathingEnabled, {
persist: false,
suppressStatus: true,
});
setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
}
setDefaultEarthZoom(settings.shared.defaultEarthZoom, {
persist: false,
applyToCurrentView: true,
});
setMotionDebugEnabled(settings.shared.motionDebugEnabled, {
persist: false,
suppressStatus: true,
});
setMotionProvider(settings.shared.motionProvider, {
persist: false,
suppressStatus: true,
});
setMotionDebugSkeletonOnly(settings.shared.motionDebugSkeletonOnly, {
persist: false,
suppressStatus: true,
});
setActiveTVTab(settings.shared.mediaPanelActiveTab);
if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
applyImmediateLayerVisibilityHints(layerVisibility);
deferredLayerVisibilitySettings = layerVisibility;
return;
}
deferredLayerVisibilitySettings = null;
await applyLayerVisibilitySettings(settings.shared.layerVisibility, {
persist: false,
silent: true,
});
}
export function getMotionDebugEnabled() {
return motionDebugEnabled;
}
export function getMotionProvider() {
return motionProvider;
}
export function getMotionDebugSkeletonOnly() {
return motionDebugSkeletonOnly;
}
export function setMotionDebugEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = Boolean(nextEnabled);
const previousEffective =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
const changed = motionDebugEnabled !== normalized;
motionDebugEnabled = normalized;
syncMotionDebugToggle(motionDebugEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
const nextEffective =
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
if (changed || previousEffective !== nextEffective) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
const message = motionDebugEnabled
? rotationMode === ROTATION_MODE.MOTION
? "动捕调试模式已开启"
: "动捕调试模式将在下次进入动捕时开启"
: "动捕调试模式已关闭";
showStatusMessage(message, "info");
}
return motionDebugEnabled;
}
export function setMotionProvider(
nextProvider,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = normalizeMotionProvider(nextProvider, DEFAULT_MOTION_PROVIDER);
const changed = motionProvider !== normalized;
motionProvider = normalized;
syncMotionProviderControls(motionProvider);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionProvider = motionProvider;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionProvider === "motion_agent"
? "动捕输入源已切换为 Motion Agent"
: "动捕输入源已切换为浏览器摄像头",
"info",
);
}
return motionProvider;
}
export function setMotionDebugSkeletonOnly(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = Boolean(nextEnabled);
const changed = motionDebugSkeletonOnly !== normalized;
motionDebugSkeletonOnly = normalized;
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugSkeletonOnly = motionDebugSkeletonOnly;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面",
"info",
);
}
return motionDebugSkeletonOnly;
}
export async function applyDeferredLayerVisibilitySettings(options = {}) {
const layerVisibility = deferredLayerVisibilitySettings;
deferredLayerVisibilitySettings = null;
if (!layerVisibility) return;
await applyLayerVisibilitySettings(layerVisibility, {
persist: false,
silent: true,
...options,
});
}
function resetEarthSettings() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
earthSettingsState = cloneEarthSettings(defaults);
if (canUseLocalStorage()) {
try {
window.localStorage.removeItem(EARTH_SETTINGS_STORAGE_KEY);
window.localStorage.removeItem(LEGACY_EARTH_SETTINGS_STORAGE_KEY);
} catch (error) {
console.warn("移除 Earth 设置失败:", error);
}
}
void applyEarthSettings(defaults).then(() => {
showStatusMessage("Earth 设置已重置", "info");
});
}
async function setTerrainEnabled(button, enabled, { persist = true, silent = false } = {}) {
const toggleToken = ++terrainToggleToken;
if (!enabled) {
applyTerrainUiState(button, false);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("地形已隐藏", "info");
}
return false;
}
try {
if (!isTerrainReady()) {
setLayerButtonState(button, {
loading: true,
tooltip: "地形加载中...",
statusText: "加载中",
});
if (!silent) {
showStatusMessage("正在加载真实地形数据...", "info");
}
await ensureTerrainReady();
}
if (toggleToken !== terrainToggleToken) return showTerrain;
applyTerrainUiState(button, true);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形已显示", "success");
}
return true;
} catch (error) {
console.error("加载真实地形失败:", error);
applyTerrainUiState(button, false);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形暂时不可用", "error");
}
return false;
}
}
async function setSatellitesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "卫星加载中...",
});
}
await setSatellitesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
if (!enabled && !silent) {
showStatusMessage("卫星已隐藏", "info");
} else if (enabled) {
setEarthStatValue("satellite-count", `${getSatelliteCount()}`);
}
syncTrailsAvailability();
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换卫星显示失败:", error);
setLayerButtonState(button, {
active: false,
loading: false,
tooltip: "显示卫星",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
}
function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleGridLines(enabled);
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏经纬线" : "显示经纬线",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
}
return enabled;
}
async function setCountryBoundariesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
try {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "国界加载中...",
});
}
await setCountryBoundariesEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏国界" : "显示国界",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换国界显示失败:", error);
setLayerButtonState(button, {
active: false,
loading: false,
tooltip: "显示国界",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
}
async function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "高清材质加载中...",
});
}
await setHighResTextureEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏高清材质" : "显示高清材质",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
}
async function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "大气云图加载中...",
});
}
await setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏大气云图" : "显示大气云图",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
}
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
toggleBGP(enabled);
if (!enabled && rotationMode === ROTATION_MODE.CRUISE && autoRotate) {
setAutoRotate(false);
}
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏BGP观测" : "显示BGP观测",
});
setEarthStatValue("bgp-anomaly-count", `${getBGPCount()}`);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
}
return enabled;
}
function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
toggleComputeCenters(enabled);
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏算力中心" : "显示算力中心",
});
setEarthStatValue("compute-center-count", `${getComputeCenterCount()}`);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "算力中心已显示" : "算力中心已隐藏", "info");
}
return enabled;
}
async function setVesselsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "船只加载中...",
});
}
await setVesselsEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏船只" : "显示船只",
});
setEarthStatValue("vessel-count", `${getVesselCount()}`);
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换船只显示失败:", error);
setLayerButtonState(button, {
active: false,
loading: false,
tooltip: "显示船只",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
}
function syncTrailsDisplayControls() {
const trailsEnabled = getShowTrails();
const disabledState = getLayerDisabledState("trails");
document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
toggle.checked = trailsEnabled;
toggle.disabled = disabledState.disabled;
const label = toggle.closest("label");
label?.classList.toggle("is-disabled", disabledState.disabled);
if (label instanceof HTMLElement) {
if (disabledState.disabled && disabledState.tooltip) {
label.title = disabledState.tooltip;
} else {
label.removeAttribute("title");
}
}
});
}
function setTrailsDisplayEnabled(enabled, { persist = true, silent = false } = {}) {
toggleTrails(enabled);
const disabledState = getLayerDisabledState("trails");
setLayerButtonState(getLayerButton("trails"), {
active: enabled,
disabled: disabledState.disabled,
tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"),
});
syncTrailsDisplayControls();
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
}
return enabled;
}
function syncTrailsAvailability() {
const trailsEnabled = getShowTrails();
const disabledState = getLayerDisabledState("trails");
setLayerButtonState(getLayerButton("trails"), {
active: trailsEnabled,
disabled: disabledState.disabled,
tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"),
});
syncTrailsDisplayControls();
syncMobileLayerCards();
}
async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
await setCablesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换线缆显示失败:", error);
syncMobileLayerCards();
if (persist) persistEarthSettings();
return getShowCables();
}
}
async function applyLayerVisibilitySettings(layerVisibility = {}, options = {}) {
for (const layer of getPersistedLayers()) {
const nextVisible = layerVisibility?.[layer.id];
if (typeof nextVisible !== "boolean") continue;
await layer.setVisible(nextVisible, options);
}
}
function applyImmediateLayerVisibilityHints(layerVisibility = {}) {
if (typeof layerVisibility.gridLines === "boolean") {
setGridLinesLayerEnabled(getLayerButton("gridLines"), layerVisibility.gridLines, {
persist: false,
silent: true,
});
}
if (typeof layerVisibility.countryBoundaries === "boolean") {
toggleCountryBoundaries(layerVisibility.countryBoundaries, {
showLandFill: true,
});
setLayerButtonState(getLayerButton("countryBoundaries"), {
active: layerVisibility.countryBoundaries,
loading: false,
tooltip: layerVisibility.countryBoundaries ? "隐藏国界线" : "显示国界线",
});
}
if (layerVisibility.earthHighResTexture === false) {
void setHighResTextureEnabled(false, { suppressStatus: true });
setLayerButtonState(getLayerButton("earthHighResTexture"), {
active: false,
loading: false,
tooltip: "显示高清材质",
});
}
if (typeof layerVisibility.atmosphereClouds === "boolean") {
toggleClouds(layerVisibility.atmosphereClouds);
setLayerButtonState(getLayerButton("atmosphereClouds"), {
active: layerVisibility.atmosphereClouds,
loading: false,
tooltip: layerVisibility.atmosphereClouds ? "隐藏大气云图" : "显示大气云图",
});
}
}
function getBuiltinLayerDefinitions() {
return [
{
id: "gridLines",
buttonId: "toggle-grid-lines",
icon: "grid_4x4",
label: "经纬线",
meta: "Graticule",
keywords: "经纬线 graticule 经纬 latitude longitude",
defaultActive: false,
displayOrder: 100,
startupPriority: 10,
startupMode: "visible",
startupLabel: "经纬线",
startupMessage: "",
getVisible: () => getShowGridLines(),
setVisible: (visible, options = {}) =>
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
},
{
id: "countryBoundaries",
buttonId: "toggle-country-boundaries",
icon: "public",
label: "国界线",
meta: "Country Borders",
keywords: "国界 国家 borders countries boundary",
defaultActive: true,
displayOrder: 90,
startupPriority: 20,
startupMode: "preload",
startupAlwaysLoad: true,
startupLabel: "海陆基座",
startupMessage: "正在加载海陆基座...",
getVisible: () => getShowCountryBoundaries(),
setVisible: (visible, options = {}) =>
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
},
{
id: "earthHighResTexture",
buttonId: "toggle-earth-high-res-texture",
icon: "globe",
label: "高清材质",
meta: "High-Res Texture",
keywords: "高清 材质 纹理 texture hd 地表 earth",
defaultActive: true,
displayOrder: 70,
startupPriority: 30,
startupMode: "visible",
startupLabel: "高清材质",
startupMessage: "正在启用高清材质...",
getVisible: () => getHighResTextureEnabled(),
setVisible: (visible, options = {}) =>
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
},
{
id: "atmosphereClouds",
buttonId: "toggle-atmosphere-clouds",
icon: "cloud",
label: "大气云图",
meta: "Cloud Layer",
keywords: "大气 云图 云层 clouds atmosphere",
defaultActive: true,
displayOrder: 80,
startupPriority: 40,
startupMode: "visible",
startupLabel: "大气云图",
startupMessage: "",
getVisible: () => getAtmosphereCloudsEnabled(),
setVisible: (visible, options = {}) =>
setAtmosphereCloudsLayerEnabled(getLayerButton("atmosphereClouds"), visible, options),
},
{
id: "cables",
buttonId: "toggle-cables",
icon: "cable",
label: "海缆",
meta: "Subsea Cables",
keywords: "海缆 subsea cables",
defaultActive: true,
displayOrder: 10,
startupPriority: 50,
startupMode: "visible",
startupLabel: "海缆",
startupMessage: {
prepare: "正在加载登陆点...",
load: "正在加载海缆...",
},
getVisible: () => getShowCables(),
setVisible: (visible, options = {}) =>
setCablesLayerEnabled(getLayerButton("cables"), visible, options),
},
{
id: "computeCenters",
buttonId: "toggle-compute-centers",
icon: "memory",
label: "算力中心",
meta: "Compute Centers",
keywords: "算力中心 compute centers gpu 超算",
defaultActive: true,
displayOrder: 40,
startupPriority: 60,
startupMode: "preload",
startupLabel: "算力中心",
startupMessage: "正在加载算力中心...",
getVisible: () => getShowComputeCenters(),
setVisible: (visible, options = {}) =>
setComputeCentersLayerEnabled(getLayerButton("computeCenters"), visible, options),
},
{
id: "bgp",
buttonId: "toggle-bgp",
icon: "hub",
label: "BGP观测",
meta: "Routing Signals",
keywords: "bgp观测 routing signals",
defaultActive: true,
displayOrder: 50,
startupPriority: 70,
startupMode: "preload",
startupLabel: "BGP态势",
startupMessage: "正在加载BGP态势...",
getVisible: () => getShowBGP(),
setVisible: (visible, options = {}) =>
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
},
{
id: "vessels",
buttonId: "toggle-vessels",
icon: "directions_boat",
label: "船只",
meta: "AIS Vessels",
keywords: "船只 船舶 ais vessels ships maritime",
defaultActive: false,
displayOrder: 45,
startupPriority: 65,
startupMode: "visible",
startupLabel: "船只",
startupMessage: "正在加载船只...",
getVisible: () => getVesselsEnabled(),
setVisible: (visible, options = {}) =>
setVesselsLayerEnabled(getLayerButton("vessels"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
icon: "satellite_alt",
label: "卫星",
meta: "Satellites",
keywords: "卫星 satellites",
defaultActive: false,
displayOrder: 30,
startupPriority: 80,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
getVisible: () => getSatellitesEnabled(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
},
{
id: "terrain",
buttonId: "toggle-terrain",
icon: "landscape",
label: "地形",
meta: "Terrain",
keywords: "地形 terrain",
defaultActive: false,
displayOrder: 60,
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
];
}
function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) {
const list = document.getElementById("layer-panel-list");
let row = definition.buttonId
? document.getElementById(definition.buttonId)?.closest(".layer-row")
: null;
if (!row && appendIfMissing && list) {
row = createLayerRow(definition);
list.appendChild(row);
}
if (!row) return null;
row.dataset.layerId = definition.id;
row.dataset.layerName = (definition.keywords || `${definition.label} ${definition.meta || ""}`)
.trim()
.toLowerCase();
const icon = row.querySelector(".layer-row-icon");
const label = row.querySelector(".layer-row-label");
const meta = row.querySelector(".layer-row-meta");
const button = row.querySelector("button");
if (icon) icon.textContent = definition.icon;
if (label) label.textContent = definition.label;
if (definition.meta) {
if (meta) {
meta.textContent = definition.meta;
} else {
const copy = row.querySelector(".layer-row-copy");
if (copy) {
const metaEl = document.createElement("span");
metaEl.className = "layer-row-meta";
metaEl.textContent = definition.meta;
copy.appendChild(metaEl);
}
}
} else if (meta) {
meta.remove();
}
if (button instanceof HTMLButtonElement) {
button.id = definition.buttonId;
button.title = `切换${definition.label}显示`;
if (definition.statusTarget) {
button.dataset.statusTarget = definition.statusTarget;
} else {
delete button.dataset.statusTarget;
}
}
return row;
}
function registerLayerDefinition(definition, options = {}) {
const normalizedDefinition = {
persist: true,
displayOrder: null,
startupPriority: null,
startupMode: "visible",
startupLabel: "",
startupMessage: "",
...definition,
};
layerRegistry.set(normalizedDefinition.id, normalizedDefinition);
const row = syncLayerRowDefinition(normalizedDefinition, options);
if (row && layerPanelInitialized) {
bindLayerButton(row, normalizedDefinition);
}
renderMobileLayerCards();
return normalizedDefinition;
}
function initializeLayerRegistry() {
layerRegistry = new Map();
getBuiltinLayerDefinitions().forEach((definition) => {
registerLayerDefinition(definition);
});
}
function getViewRotation(targetLat, targetRotLon) {
const latRot = (targetLat * Math.PI) / 180;
return {
x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient,
y: -((targetRotLon * Math.PI) / 180),
};
}
function dispatchRotationModeChange() {
window.dispatchEvent(
new CustomEvent("earth:rotation-mode-change", {
detail: {
mode: rotationMode,
active: autoRotate,
},
}),
);
}
function applyTerrainUiState(button, enabled) {
showTerrain = enabled;
toggleTerrain(enabled);
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏地形" : "显示地形",
statusText: enabled ? "开启" : "关闭",
});
}
function prewarmTerrainIfNeeded() {
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) return;
terrainPrefetchStarted = true;
ensureTerrainReady().catch((error) => {
terrainPrefetchStarted = false;
console.warn("地形预热加载失败:", error);
});
}
function clearScheduledTerrainPrefetch() {
if (terrainPrefetchTimer !== null) {
window.clearTimeout(terrainPrefetchTimer);
terrainPrefetchTimer = null;
}
if (
terrainPrefetchIdleHandle !== null &&
typeof window !== "undefined" &&
"cancelIdleCallback" in window
) {
window.cancelIdleCallback(terrainPrefetchIdleHandle);
}
terrainPrefetchIdleHandle = null;
}
export function scheduleTerrainPrefetch({ delayMs = 4500, idleTimeoutMs = 6000 } = {}) {
clearScheduledTerrainPrefetch();
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) {
return;
}
const runPrefetch = () => {
terrainPrefetchIdleHandle = null;
prewarmTerrainIfNeeded();
};
terrainPrefetchTimer = window.setTimeout(() => {
terrainPrefetchTimer = null;
if (!getHighResTextureEnabled() || terrainPrefetchStarted || isTerrainReady()) {
return;
}
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
terrainPrefetchIdleHandle = window.requestIdleCallback(runPrefetch, {
timeout: idleTimeoutMs,
});
return;
}
runPrefetch();
}, delayMs);
}
export function applyImmediateView(targetEarthObj, camera, options = {}) {
if (!targetEarthObj) return;
const {
lat = EARTH_CONFIG.chinaLat,
rotLon = EARTH_CONFIG.chinaRotLon,
zoom = getDefaultEarthZoomLevel(),
} = 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));
}
}
export function setZoomLevel(nextZoom, camera = activeCamera) {
zoomLevel = clampEarthZoomLevel(nextZoom);
if (camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}
return zoomLevel;
}
export function showZoomStatusCapsule({ force = false } = {}) {
const now = Date.now();
if (!force && now - lastZoomStatusUpdateTime < ZOOM_STATUS_UPDATE_INTERVAL_MS) {
return;
}
lastZoomStatusUpdateTime = now;
showGestureStatusMessage(`缩放 ${Math.round(zoomLevel * 100)}%`, "info");
}
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() {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "settings" });
return;
}
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();
closeTransientMobileOverlays({ except: "settings" });
cancelSettingsSheetAnimation();
document.body.classList.add("earth-settings-open");
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() {
if (isMobileLayout()) {
return;
}
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
cancelSettingsSheetAnimation();
document.body.classList.remove("earth-settings-open");
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, { persist = true } = {}) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
const scope = getSettingsViewportScope();
if (earthSettingsState?.views?.[scope]?.panelVisibility) {
earthSettingsState.views[scope].panelVisibility[panelId] = visible;
}
if (!visible && activeMobileDrawerId === panelId) {
activeMobileDrawerId = null;
syncMobileDrawerState();
}
syncSettingsToggle(panelId, visible);
if (panelId === "media-panel") {
updateTVToggleUI(visible);
updateNewsToggleUI(visible);
if (visible && !isMobileLayout()) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
});
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
}
}
if (persist) {
persistEarthSettings();
}
}
function resetDesktopHudPanelsForMobile() {
document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR).forEach((panel) => {
if (!(panel instanceof HTMLElement)) return;
resetPanelInlineLayout(panel);
});
}
function applyCurrentViewportPanelVisibility({ persist = false } = {}) {
const panelVisibility = getViewportPanelVisibility(earthSettingsState, layoutMode);
HUD_PANEL_IDS.forEach((panelId) => {
const visible = panelVisibility?.[panelId];
if (typeof visible === "boolean") {
setHudPanelVisibility(panelId, visible, { persist });
}
});
syncAllHudPanelToggles();
}
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 syncDayNightToggle(enabled) {
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function applyDayNightEnabled(enabled, { persist = true } = {}) {
dayNightEnabled = enabled;
setDayNightEnabled(enabled);
setCelestialDayNightEnabled(enabled);
syncDayNightToggle(enabled);
if (persist) persistEarthSettings();
}
export function setDayNightEnabledExternal(enabled, { persist = true } = {}) {
applyDayNightEnabled(enabled, { persist });
}
export function getDayNightEnabled() {
return dayNightEnabled;
}
export function setTerrainLayerInteractable(enabled) {
const button = getLayerButton("terrain");
setLayerButtonState(button, {
disabled: !enabled,
tooltip: enabled ? null : "高清材质关闭时不可用",
});
syncMobileLayerCards();
}
export function setDayNightInteractable(enabled) {
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
input.disabled = !enabled;
const label = input.closest("label");
if (label) label.classList.toggle("is-disabled", !enabled);
});
}
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");
const settingsReset = document.getElementById("settings-reset");
bindListener(settingsTrigger, "click", () => {
openSettingsModal();
});
bindListener(settingsClose, "click", () => {
closeSettingsModal();
});
bindListener(settingsBackdrop, "click", () => {
closeSettingsModal();
});
bindListener(settingsReset, "click", () => {
resetEarthSettings();
});
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 terrainOpacitySliders = document.querySelectorAll("#terrain-opacity-slider, [data-terrain-opacity-slider]");
const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]");
const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.value = nextOpacity.toFixed(2);
}
});
terrainOpacityValues.forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = `${safeOpacity}%`;
}
});
};
syncTerrainOpacityUi(getTerrainOpacity());
syncDefaultEarthZoomUi(defaultEarthZoom);
terrainOpacitySliders.forEach((terrainOpacitySlider) => {
if (!(terrainOpacitySlider instanceof HTMLInputElement)) return;
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);
persistEarthSettings();
});
});
defaultEarthSizeSliders.forEach((defaultEarthSizeSlider) => {
if (!(defaultEarthSizeSlider instanceof HTMLInputElement)) return;
bindListener(defaultEarthSizeSlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
const nextZoom = Number.parseFloat(target.value);
setDefaultEarthZoom(
Number.isFinite(nextZoom) ? nextZoom : defaultEarthZoom,
{ persist: true, applyToCurrentView: true },
);
});
});
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);
});
});
cruiseModuleButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const moduleId = target.dataset.cruiseModuleToggle;
if (!moduleId) return;
const currentModules = new Set(getCruiseModules());
if (currentModules.has(moduleId)) {
currentModules.delete(moduleId);
} else {
currentModules.add(moduleId);
}
setCruiseModules(Array.from(currentModules));
});
});
satelliteDisplayStyleButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const nextStyle = target.dataset.satelliteDisplayStyle;
if (!nextStyle) return;
setSatelliteDisplayStyle(nextStyle);
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setSatelliteIdleBreathingEnabled(toggle.checked);
});
});
document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setTrailsDisplayEnabled(toggle.checked);
});
});
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setInteractableCompactDotsEnabled(toggle.checked);
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
applyDayNightEnabled(dayNightToggle.checked);
});
});
document.querySelectorAll("[data-motion-debug-toggle]").forEach((motionDebugToggle) => {
if (!(motionDebugToggle instanceof HTMLInputElement)) return;
bindListener(motionDebugToggle, "change", () => {
setMotionDebugEnabled(motionDebugToggle.checked);
});
});
document.querySelectorAll("[data-motion-provider]").forEach((motionProviderButton) => {
if (!(motionProviderButton instanceof HTMLButtonElement)) return;
bindListener(motionProviderButton, "click", () => {
setMotionProvider(motionProviderButton.dataset.motionProvider);
});
});
document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((motionSkeletonOnlyToggle) => {
if (!(motionSkeletonOnlyToggle instanceof HTMLInputElement)) return;
bindListener(motionSkeletonOnlyToggle, "change", () => {
setMotionDebugSkeletonOnly(motionSkeletonOnlyToggle.checked);
});
});
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
bindListener(mobileSettingsReset, "click", () => {
resetEarthSettings();
});
captureEarthSettingsDefaults();
settingsApplyPromise = applyEarthSettings(loadEarthSettings(), { applyLayers: false });
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
syncSatelliteDisplayStyleControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
}
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;
if (isMobileLayout() && panelId === "layer-toggles") {
setMobileDrawerOpen(panelId, false);
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 activePointerId = null;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = (event) => {
if (
event &&
activePointerId !== null &&
"pointerId" in event &&
event.pointerId !== activePointerId
) {
return;
}
isDragging = false;
activePointerId = null;
panel.classList.remove("is-dragging");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!isDragging) return;
if (activePointerId !== null && event.pointerId !== activePointerId) return;
if (isMobileLayout()) return;
event.preventDefault();
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 (isMobileLayout()) return;
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;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;
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(window, "pointermove", onMove, { passive: false });
bindListener(window, "pointerup", stopDragging);
bindListener(window, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
});
const reclampDraggedPanels = () => {
draggablePanels.forEach((panel) => {
if (panel.dataset.dragged !== "true") return;
syncDraggedPanelSize(panel);
const { desiredLeft, desiredTop } = resolveAnchorDesiredPosition(
app,
panel,
);
const { left, top } = clampDraggedPanelPosition(
app,
panel,
desiredLeft,
desiredTop,
);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
});
};
bindListener(window, "resize", () => {
window.requestAnimationFrame(reclampDraggedPanels);
});
}
function clearForcedFloatingClose() {
getFloatingGroups().forEach((group) => {
group.classList.remove("force-closed");
});
}
function clearForcedFloatingCloseIfPointerOutside() {
getFloatingGroups().forEach((group) => {
if (!group.matches(":hover")) {
group.classList.remove("force-closed");
}
});
}
function setFloatingMenuOpen(group, shouldOpen) {
closeFloatingMenus();
clearForcedFloatingClose();
group?.classList.toggle("open", shouldOpen);
}
function clearSelectionIfHiding(shouldHide) {
if (shouldHide) {
clearLockedObject();
}
}
function bindFloatingMenu(trigger, group) {
bindListener(trigger, "click", (event) => {
event.stopPropagation();
const shouldOpen = !group?.classList.contains("open");
setFloatingMenuOpen(group, shouldOpen);
});
bindListener(group, "click", (event) => {
event.stopPropagation();
});
}
function updateTVToggleUI(visible) {
const btn = document.getElementById("toggle-tv");
if (!btn) return;
btn.classList.toggle("active", visible);
setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播");
}
function bindListener(element, eventName, handler, options) {
if (!element) return;
element.addEventListener(eventName, handler, options);
listeners.push(() =>
element.removeEventListener(eventName, handler, options),
);
}
function resetCleanup() {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns = [];
listeners.forEach((cleanup) => cleanup());
listeners = [];
}
export async function setupControls(camera, renderer, scene, earth) {
resetCleanup();
activeCamera = camera;
earthObj = earth;
applyResponsiveLayout();
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
await settingsApplyPromise;
syncTrailsAvailability();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
bindListener(window, "resize", () => {
applyResponsiveLayout();
});
bindListener(window, "earth:search-open-change", (event) => {
if (event instanceof CustomEvent && event.detail?.open) {
closeTransientMobileOverlays({ except: "search" });
}
});
bindListener(window, "earth:tv-visibility-change", (event) => {
if (
event instanceof CustomEvent &&
typeof event.detail?.visible === "boolean" &&
event.detail.persist !== false
) {
const scope = getSettingsViewportScope();
ensureMutableEarthSettingsState();
if (earthSettingsState.views?.[scope]?.panelVisibility) {
earthSettingsState.views[scope].panelVisibility["media-panel"] = event.detail.visible;
}
persistEarthSettings();
}
if (event instanceof CustomEvent && event.detail?.visible) {
closeTransientMobileOverlays({ except: "media" });
}
});
bindListener(window, "earth:tv-tab-change", (event) => {
if (!(event instanceof CustomEvent)) return;
ensureMutableEarthSettingsState();
earthSettingsState.shared.mediaPanelActiveTab = normalizeMediaPanelActiveTab(event.detail?.tab);
persistEarthSettings();
});
// No longer auto-navigates to details tab on mobile — popup handles the display.
// Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup.
}
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);
showZoomStatusCapsule({ force: true });
}
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);
showZoomStatusCapsule();
}
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 = getDefaultEarthZoomLevel();
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 = targetZoom;
showStatusMessage(getZoomResetStatusMessage(targetZoom), "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);
showZoomStatusCapsule({ force: true });
},
{ 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;
const defaultZoom = getDefaultEarthZoomLevel();
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) =>
focusEarthView(camera, {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
rotLon: pos.coords.longitude - 270,
zoom: defaultZoom,
duration: 800,
suppressStatus: false,
}),
() =>
focusEarthView(camera, {
lat: EARTH_CONFIG.chinaLat,
lon: EARTH_CONFIG.chinaLon,
rotLon: EARTH_CONFIG.chinaRotLon,
zoom: defaultZoom,
duration: 800,
suppressStatus: false,
}),
{ timeout: 5000, enableHighAccuracy: false },
);
} else {
focusEarthView(camera, {
lat: EARTH_CONFIG.chinaLat,
lon: EARTH_CONFIG.chinaLon,
rotLon: EARTH_CONFIG.chinaRotLon,
zoom: defaultZoom,
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
? "巡航"
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
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();
});
}
}
layerPanelInitialized = true;
Array.from(layerRegistry.values()).forEach((definition) => {
syncLayerRowDefinition(definition);
const row = document.querySelector(`.layer-row[data-layer-id="${definition.id}"]`);
if (row) {
bindLayerButton(row, definition);
}
});
}
function createLayerRow({ buttonId, icon, label, meta, defaultActive, statusTarget }) {
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="${buttonId}" class="layer-row-toggle${defaultActive ? " active" : ""}" type="button"
role="switch" aria-checked="${defaultActive ? "true" : "false"}" title="切换${label}显示"${statusTarget ? ` data-status-target="${statusTarget}"` : ""}>
<span class="layer-row-toggle-track"></span>
</button>
`;
return row;
}
function bindLayerButton(row, definition) {
const button = row.querySelector("button");
if (!(button instanceof HTMLButtonElement)) return;
if (button.dataset.layerBound === "true") return;
bindListener(button, "click", async function () {
if (
this.disabled ||
this.classList.contains("is-loading") ||
this.classList.contains("is-disabled")
) {
return;
}
await definition.setVisible(!definition.getVisible());
});
button.dataset.layerBound = "true";
}
export function registerLayer({
id,
icon,
label,
meta = "",
keywords = "",
defaultActive = false,
persist = true,
displayOrder = null,
startupPriority = null,
startupMode = "visible",
startupLabel = "",
startupMessage = "",
buttonId = `toggle-${id}`,
statusTarget = "",
getVisible = null,
setVisible = null,
onToggle = null,
}) {
const definition = registerLayerDefinition(
{
id,
buttonId,
icon,
label,
meta,
keywords,
defaultActive,
persist,
displayOrder,
startupPriority,
startupMode,
startupLabel,
startupMessage,
statusTarget,
getVisible:
typeof getVisible === "function"
? getVisible
: () => getLayerButton(id)?.classList.contains("active") ?? defaultActive,
setVisible:
typeof setVisible === "function"
? setVisible
: async (visible) => {
if (typeof onToggle === "function") {
await onToggle(visible);
}
},
},
{ appendIfMissing: true },
);
return definition;
}
export function getStartupLoadLayers() {
if (layerRegistry.size === 0) {
initializeLayerRegistry();
}
return getSortedLayerDefinitions({ includeUnprioritized: false })
.filter(shouldIncludeLayerInStartupLoad)
.map((definition) => ({ ...definition }));
}
export function getVisibleMotionLayerDefinitions() {
if (layerRegistry.size === 0) {
initializeLayerRegistry();
}
const motionLayerIds = new Set(["cables", "computeCenters", "bgp", "vessels", "satellites"]);
return getDisplayLayerDefinitions()
.filter((definition) => motionLayerIds.has(definition.id))
.filter((definition) => Boolean(definition.getVisible?.()))
.map((definition) => ({
id: definition.id,
label: definition.label,
meta: definition.meta,
}));
}
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
const layerBtn = document.getElementById("layer-action");
const searchBtn = document.getElementById("search-action");
const terrainBtn = getLayerButton("terrain");
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();
setupMobileDrawerShell();
renderMobileLayerCards();
bindListener(layerBtn, "click", (event) => {
event.preventDefault();
event.stopPropagation();
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && mobileDrawerCard === "layers");
setMobileDrawerState({ open: nextOpen, card: "layers" });
return;
}
const panel = document.getElementById("layer-toggles");
const currentlyVisible = !panel?.classList.contains("hud-panel-hidden");
setHudPanelVisibility("layer-toggles", !currentlyVisible);
});
bindListener(searchBtn, "click", () => {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "search" });
return;
}
closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
});
bindListener(terrainBtn, "pointerenter", () => {
prewarmTerrainIfNeeded();
});
bindListener(terrainBtn, "focus", () => {
prewarmTerrainIfNeeded();
});
bindListener(reloadBtn, "click", async () => {
await reloadData();
});
bindFloatingMenu(zoomTrigger, zoomGroup);
bindListener(document, "click", (event) => {
const openGroups = [zoomGroup].filter((group) =>
group?.classList.contains("open"),
);
if (
isMobileLayout() &&
mobileDrawerOpen &&
event.target instanceof Element &&
!event.target.closest("#mobile-drawer-shell")
) {
setMobileDrawerState({ open: false });
}
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 =
!isMobileLayout() &&
!document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
updateTVToggleUI(mediaVisible);
if (mediaVisible) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
});
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
}
updateNewsToggleUI(mediaVisible);
applyResponsiveLayout();
updateLayoutUI(container);
}
function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
if (isSearchPanelOpen()) {
closeSearchPanel();
return;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
return;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
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) ||
!(toolbar instanceof HTMLElement)
) {
return;
}
let collapseTimer = null;
let expandedToolbarBounds = null;
let refreshBoundsFrameId = 0;
let hubPinnedOpen = false;
const layoutToolbarOrbs = () => {
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter(
(orb) => getComputedStyle(orb).display !== "none",
);
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
const orbCount = orbs.length;
const viewportScale = Math.min(
window.innerWidth / 1920,
window.innerHeight / 1080,
);
let toolbarScale = THREE.MathUtils.clamp(
Math.min(toolbarWidth / TOOLBAR_BASE_WIDTH_PX, viewportScale),
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.setProperty("--toolbar-orb-size", `${Math.round(orbSize)}px`);
toolbar.style.setProperty("--toolbar-hub-size", `${Math.round(hubSize)}px`);
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`);
});
if (cluster.classList.contains("is-expanded")) {
scheduleExpandedToolbarBoundsRefresh();
}
};
const setExpanded = (expanded) => {
cluster.classList.toggle("is-expanded", expanded);
cluster.classList.toggle("is-collapsed", !expanded);
if (!expanded) {
expandedToolbarBounds = null;
if (refreshBoundsFrameId) {
cancelAnimationFrame(refreshBoundsFrameId);
refreshBoundsFrameId = 0;
}
return;
}
scheduleExpandedToolbarBoundsRefresh();
};
const closePinnedToolbar = () => {
hubPinnedOpen = false;
cancelCollapse();
setExpanded(false);
};
const scheduleCollapse = () => {
if (hubPinnedOpen) return;
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);
if (refreshBoundsFrameId) {
cancelAnimationFrame(refreshBoundsFrameId);
refreshBoundsFrameId = 0;
}
if (toolbarHubController?.cluster === cluster) {
toolbarHubController = null;
}
});
// 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);
});
bindListener(hub, "focus", () => {
cancelCollapse();
setExpanded(true);
});
bindListener(hub, "click", (event) => {
event.preventDefault();
event.stopPropagation();
cancelCollapse();
if (hubPinnedOpen) {
closePinnedToolbar();
} else {
hubPinnedOpen = true;
setExpanded(true);
}
});
const HOVER_PADDING_PX = 12;
const collectExpandedToolbarBounds = () => {
const rects = [];
const pushRect = (element) => {
if (!(element instanceof HTMLElement)) return;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
rects.push(rect);
};
pushRect(hub);
cluster.querySelectorAll(".earth-toolbar-orb > *").forEach((node) => {
if (!(node instanceof HTMLElement)) return;
pushRect(node);
const popover = node.parentElement?.querySelector(".earth-stack-toolbar");
if (
popover instanceof HTMLElement &&
(node.parentElement?.matches(":hover") ||
node.parentElement?.matches(":focus-within") ||
node.parentElement?.classList.contains("open"))
) {
pushRect(popover);
}
});
if (rects.length === 0) return null;
const bounds = rects.reduce(
(acc, rect) => ({
left: Math.min(acc.left, rect.left),
top: Math.min(acc.top, rect.top),
right: Math.max(acc.right, rect.right),
bottom: Math.max(acc.bottom, rect.bottom),
}),
{
left: Number.POSITIVE_INFINITY,
top: Number.POSITIVE_INFINITY,
right: Number.NEGATIVE_INFINITY,
bottom: Number.NEGATIVE_INFINITY,
},
);
return {
left: bounds.left - HOVER_PADDING_PX,
top: bounds.top - HOVER_PADDING_PX,
right: bounds.right + HOVER_PADDING_PX,
bottom: bounds.bottom + HOVER_PADDING_PX,
};
};
const scheduleExpandedToolbarBoundsRefresh = () => {
if (refreshBoundsFrameId) return;
refreshBoundsFrameId = window.requestAnimationFrame(() => {
expandedToolbarBounds = collectExpandedToolbarBounds();
refreshBoundsFrameId = 0;
});
};
bindListener(document, "mousemove", (event) => {
if (hubPinnedOpen) return;
if (!cluster.classList.contains("is-expanded")) return;
if (
event.target instanceof Element &&
event.target.closest("#toolbar-cluster, .earth-stack-toolbar")
) {
scheduleExpandedToolbarBoundsRefresh();
}
const activeBounds = expandedToolbarBounds;
if (!activeBounds) {
scheduleCollapse();
return;
}
const isInside =
event.clientX >= activeBounds.left &&
event.clientX <= activeBounds.right &&
event.clientY >= activeBounds.top &&
event.clientY <= activeBounds.bottom;
if (isInside) {
cancelCollapse();
} else {
scheduleCollapse();
}
});
bindListener(cluster, "mouseenter", () => {
if (cluster.classList.contains("is-expanded")) {
scheduleExpandedToolbarBoundsRefresh();
}
});
bindListener(cluster, "focusin", () => {
if (cluster.classList.contains("is-expanded")) {
scheduleExpandedToolbarBoundsRefresh();
}
});
cluster.querySelectorAll(".earth-toolbar-orb > button").forEach((button) => {
if (!(button instanceof HTMLButtonElement) || button === hub) return;
bindListener(button, "click", () => {
if (hubPinnedOpen) {
hubPinnedOpen = false;
setExpanded(false);
}
});
});
bindListener(document, "pointerdown", (event) => {
if (!hubPinnedOpen) return;
if (!(event.target instanceof Element)) return;
if (event.target.closest("#toolbar-cluster")) return;
closePinnedToolbar();
});
toolbarHubController = {
cluster,
isOpen: () => hubPinnedOpen || cluster.classList.contains("is-expanded"),
close: closePinnedToolbar,
};
}
export function teardownControls() {
clearScheduledTerrainPrefetch();
resetCleanup();
activeCamera = null;
}
export function getAutoRotate() {
return autoRotate;
}
function getRotationModeLabel(mode = rotationMode) {
if (mode === ROTATION_MODE.CRUISE) return "巡航模式";
if (mode === ROTATION_MODE.MOTION) return "动捕模式";
return "旋转模式";
}
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
? "巡航"
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
if (tooltip) {
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
}
btn.title = `${getRotationModeLabel()} · ${activeLabel}`;
}
syncRotationModeButtons();
syncMotionDebugToggle(motionDebugEnabled);
}
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
if (rotationMode === ROTATION_MODE.CRUISE || rotationMode === ROTATION_MODE.MOTION) {
dispatchRotationModeChange();
}
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
if (rotationMode === ROTATION_MODE.CRUISE || rotationMode === ROTATION_MODE.MOTION) {
dispatchRotationModeChange();
}
return autoRotate;
}
export function getRotationMode() {
return rotationMode;
}
export function setRotationMode(nextMode, { persist = true, suppressStatus = false } = {}) {
const normalizedMode =
nextMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
: nextMode === ROTATION_MODE.MOTION
? ROTATION_MODE.MOTION
: ROTATION_MODE.ROTATE;
const changed = normalizedMode !== rotationMode;
if (changed && (normalizedMode === ROTATION_MODE.CRUISE || normalizedMode === ROTATION_MODE.MOTION)) {
autoRotate = true;
}
rotationMode = normalizedMode;
updateRotateUI();
dispatchRotationModeChange();
if (persist) {
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(`已切换到${getRotationModeLabel(normalizedMode)}`, "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 = getDefaultEarthZoomLevel(),
duration = 800,
suppressStatus = true,
zoomTransitionMode = "direct",
} = options;
return new Promise((resolve) => {
const nextRotation = getViewRotation(lat, rotLon);
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const defaultZoom = getDefaultEarthZoomLevel();
const shouldRestoreZoomViaDefault =
zoomTransitionMode === "restore-current-via-default" &&
Math.abs(startZoom - defaultZoom) > 0.005;
const rotateStartProgress = TARGET_SWITCH_ZOOM_IN_PHASE;
const rotateEndProgress =
TARGET_SWITCH_ZOOM_IN_PHASE + TARGET_SWITCH_ROTATE_PHASE;
animateValue(
0,
1,
duration,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
if (shouldRestoreZoomViaDefault) {
const rotateProgress = THREE.MathUtils.clamp(
(progress - rotateStartProgress) / TARGET_SWITCH_ROTATE_PHASE,
0,
1,
);
const rotateEase = 1 - Math.pow(1 - rotateProgress, 3);
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * rotateEase;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * rotateEase;
if (progress < rotateStartProgress) {
const zoomProgress = progress / rotateStartProgress;
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
zoomLevel = startZoom + (defaultZoom - startZoom) * zoomEase;
} else if (progress <= rotateEndProgress) {
zoomLevel = defaultZoom;
} else {
const zoomProgress = (progress - rotateEndProgress) / (1 - rotateEndProgress);
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
zoomLevel = defaultZoom + (startZoom - defaultZoom) * zoomEase;
}
} else {
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 = shouldRestoreZoomViaDefault ? startZoom : zoom;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
if (!suppressStatus) {
showStatusMessage("视角已重置", "info");
}
resolve();
},
);
});
}
export function getZoomLevel() {
return zoomLevel;
}
export function getDefaultEarthZoomLevel() {
return defaultEarthZoom;
}
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);
}