Files
planet/frontend/public/earth/js/controls.js
linkong acbbfdf9e2
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.69.0
2026-06-03 17:27:00 +08:00

6177 lines
202 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_QUEUE_MODES,
CRUISE_MODULES,
DEFAULT_CRUISE_QUEUE_MODE,
DEFAULT_CRUISE_REGION_ORDER,
DEFAULT_SURFACE_HOVER_INFO_MODE,
DEFAULT_SATELLITE_DISPLAY_STYLE,
DEFAULT_CRUISE_MODULES,
EARTH_CONFIG,
ROTATION_MODE,
SATELLITE_DISPLAY_STYLES,
SURFACE_HOVER_INFO_MODES,
TOOLBAR_GLASS_CONFIG,
} 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,
reloadCountryBoundaries,
clearLockedObject,
clearLockedObjectAndInfo,
dismissCruisePresentation,
setCablesEnabled,
setCountryBoundariesEnabled,
setHighResTextureEnabled,
getHighResTextureEnabled,
setAtmosphereCloudsEnabled,
getAtmosphereCloudsEnabled,
setSatellitesEnabled,
getSatellitesEnabled,
setVesselsEnabled,
getVesselsEnabled,
} from "./main.js";
import {
toggleTrails,
getShowTrails,
getSatelliteCount,
getSatelliteDisplayStyle,
getSatelliteIdleBreathingEnabled,
getSatelliteRealAltitudeEnabled,
setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled,
setSatelliteRealAltitudeEnabled as applySatelliteRealAltitudeEnabled,
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 {
getHighPrecisionBoundariesEnabled,
getShowCountryBoundaries,
setHighPrecisionBoundariesEnabled,
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 autoRotationSpeed = CONFIG.rotationSpeed;
let motionDebugEnabled = false;
let motionProvider = DEFAULT_MOTION_PROVIDER;
let motionDebugSkeletonOnly = false;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
let boundaryBuildPollTimer = null;
let boundaryBuildAttemptedThisSession = false;
let earthObj = null;
let listeners = [];
let cleanupFns = [];
const HUD_PANEL_IDS = [
"legend",
"earth-stats",
"media-panel",
"layer-toggles",
];
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
const PANEL_LAYOUT_ANIMATION_MS = 420;
const TOOLBAR_BASE_WIDTH_PX = 620;
const TOOLBAR_MIN_SCALE = 0.68;
const TOOLBAR_ORB_SIZE_PX = 46;
const TOOLBAR_HUB_SIZE_PX = 58;
const TOOLBAR_ORB_GAP_PX = 12;
const TOOLBAR_ARCH_SPAN_PX = 232;
const TOOLBAR_ARCH_RISE_PX = 40;
const TOOLBAR_SIDE_PADDING_PX = 12;
const TOOLBAR_BOTTOM_CLEARANCE_PX = 34;
const TOOLBAR_EXTRA_HEIGHT_PX = 34;
const 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 = 16;
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 SURFACE_HOVER_INFO_DEFAULT_VERSION = 11;
const KEYBOARD_SHORTCUTS_DEFAULT_VERSION = 13;
const CRUISE_QUEUE_DEFAULT_VERSION = 14;
const AUTO_ROTATION_SPEED_DEFAULT_VERSION = 15;
const NEWS_CATEGORY_FILTERS_DEFAULT_VERSION = 16;
const DEFAULT_NEWS_CATEGORY_FILTERS = {
politics: true,
business: true,
ecommerce: true,
finance: true,
sports: true,
technology: true,
military: true,
disaster: true,
energy: true,
society: true,
culture: true,
other: true,
};
const AUTO_ROTATION_SPEED_MIN = 0.0001;
const AUTO_ROTATION_SPEED_MAX = 0.0015;
const AUTO_ROTATION_SPEED_STEP = 0.00005;
const AUTO_ROTATION_SPEED_BASE = CONFIG.rotationSpeed;
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
const KEYBOARD_ROTATION_ACCELERATION = 3.2;
const KEYBOARD_ROTATION_MAX_SPEED = 2.8;
const KEYBOARD_ROTATION_FRICTION = 5.4;
const KEYBOARD_ROTATION_STOP_SPEED = 0.012;
const KEYBOARD_ZOOM_STEP = 0.1;
const WHEEL_ZOOM_STEP = 0.1;
const WHEEL_ZOOM_DURATION_MS = 180;
const WHEEL_TRACKPAD_PIXEL_THRESHOLD = 48;
const WHEEL_TRACKPAD_DEADZONE = 0.35;
const WHEEL_TRACKPAD_RESIDUAL_WINDOW_MS = 140;
const WHEEL_TRACKPAD_RESIDUAL_RATIO = 0.65;
const WHEEL_TRACKPAD_SENSITIVITY = 0.0024;
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;
let keyboardShortcuts = {};
let capturingShortcutActionId = null;
let cruiseRegionDraggedItem = null;
let keyboardRotationFrameId = null;
let keyboardRotationLastFrameAt = 0;
let keyboardRotationOriginalAutoRotate = null;
let keyboardRotationPressSerial = 0;
const activeKeyboardRotationActions = new Map();
const keyboardRotationVelocity = { x: 0, y: 0 };
const keyboardRotationWorldAxisX = new THREE.Vector3(1, 0, 0);
const keyboardRotationWorldAxisY = new THREE.Vector3(0, 1, 0);
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
const ALLOWED_SURFACE_HOVER_INFO_MODES = new Set(
Object.values(SURFACE_HOVER_INFO_MODES),
);
const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.BGP]: "BGP",
[CRUISE_MODULES.NEWS]: "新闻",
[CRUISE_MODULES.COMPUTE_CENTERS]: "算力中心",
[CRUISE_MODULES.VESSELS]: "船只",
[CRUISE_MODULES.CABLES]: "海缆",
[CRUISE_MODULES.SATELLITES]: "卫星",
};
const ALLOWED_CRUISE_QUEUE_MODES = new Set(Object.values(CRUISE_QUEUE_MODES));
const ALLOWED_CRUISE_REGIONS = new Set(DEFAULT_CRUISE_REGION_ORDER);
const CRUISE_REGION_LABELS = {
americas: "美洲",
europe: "欧洲",
"middle-east-africa": "中东与非洲",
"asia-pacific": "亚太",
global: "全球",
};
const KEYBOARD_SHORTCUT_DEFINITIONS = [
{ id: "rotateUp", label: "向上旋转", category: "视角控制", defaultBinding: "W", aliases: ["ArrowUp"] },
{ id: "rotateLeft", label: "向左旋转", category: "视角控制", defaultBinding: "A", aliases: ["ArrowLeft"] },
{ id: "rotateDown", label: "向下旋转", category: "视角控制", defaultBinding: "S", aliases: ["ArrowDown"] },
{ id: "rotateRight", label: "向右旋转", category: "视角控制", defaultBinding: "D", aliases: ["ArrowRight"] },
{ id: "zoomIn", label: "放大", category: "视角控制", defaultBinding: "Plus", aliases: ["="] },
{ id: "zoomOut", label: "缩小", category: "视角控制", defaultBinding: "-", aliases: ["_"] },
{ id: "closeFocus", label: "关闭当前焦点菜单", category: "工具", defaultBinding: "Escape" },
{ id: "openSearch", label: "打开搜索", category: "工具", defaultBinding: "F" },
{ id: "resetView", label: "重置视角", category: "工具", defaultBinding: "R" },
{ id: "toggleLayerPanel", label: "打开/关闭图层面板", category: "工具", defaultBinding: "L" },
{ id: "toggleLayoutExpanded", label: "最大化布局", category: "工具", defaultBinding: "M" },
{ id: "toggleMediaPanel", label: "打开/关闭新闻直播", category: "工具", defaultBinding: "Ctrl+M" },
{ id: "toggleAutoRotate", label: "暂停/恢复运行", category: "工具", defaultBinding: "Space" },
{ id: "cruiseNextCard", label: "下一张巡航卡片", category: "工具", defaultBinding: "Enter" },
{ id: "toggleLayer:cables", label: "切换海缆", category: "图层", defaultBinding: "Ctrl+1" },
{ id: "toggleLayer:satellites", label: "切换卫星", category: "图层", defaultBinding: "Ctrl+2" },
{ id: "toggleLayer:computeCenters", label: "切换算力中心", category: "图层", defaultBinding: "Ctrl+3" },
{ id: "toggleLayer:vessels", label: "切换船只", category: "图层", defaultBinding: "Ctrl+4" },
{ id: "toggleLayer:bgp", label: "切换 BGP观测", category: "图层", defaultBinding: "Ctrl+5" },
{ id: "toggleLayer:terrain", label: "切换地形", category: "图层", defaultBinding: "Ctrl+6" },
{ id: "toggleLayer:earthHighResTexture", label: "切换高清材质", category: "图层", defaultBinding: "Ctrl+7" },
{ id: "toggleLayer:atmosphereClouds", label: "切换大气云图", category: "图层", defaultBinding: "Ctrl+8" },
{ id: "toggleLayer:countryBoundaries", label: "切换国界线", category: "图层", defaultBinding: "Ctrl+9" },
{ id: "toggleLayer:gridLines", label: "切换经纬线", category: "图层", defaultBinding: "Ctrl+0" },
];
const KEYBOARD_SHORTCUT_DEFINITION_BY_ID = new Map(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition]),
);
function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live";
}
function normalizeSurfaceHoverInfoMode(mode) {
return ALLOWED_SURFACE_HOVER_INFO_MODES.has(mode)
? mode
: DEFAULT_SURFACE_HOVER_INFO_MODE;
}
function getDefaultKeyboardShortcuts() {
return Object.fromEntries(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => [
definition.id,
{
binding: definition.defaultBinding,
enabled: true,
},
]),
);
}
function normalizeShortcutBinding(binding) {
if (typeof binding !== "string") return "";
if (binding.trim() === "+") return "+";
const parts = binding
.split("+")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) return "";
const key = parts[parts.length - 1];
const modifiers = new Set(
parts.slice(0, -1).map((part) => {
const normalized = part.toLowerCase();
if (normalized === "control") return "Ctrl";
if (normalized === "cmd" || normalized === "command") return "Meta";
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}),
);
const orderedModifiers = ["Ctrl", "Alt", "Shift", "Meta"].filter((modifier) =>
modifiers.has(modifier),
);
return [...orderedModifiers, normalizeShortcutKeyName(key)].join("+");
}
function normalizeShortcutKeyName(key) {
if (!key) return "";
if (key === " ") return "Space";
if (key === "+") return "Plus";
if (key === "_") return "-";
if (key.length === 1) return key.toUpperCase();
const lowered = key.toLowerCase();
if (lowered === "esc") return "Escape";
if (lowered === "spacebar") return "Space";
if (lowered.startsWith("arrow")) {
return `Arrow${lowered.slice(5, 6).toUpperCase()}${lowered.slice(6)}`;
}
return key.charAt(0).toUpperCase() + key.slice(1);
}
function normalizeKeyboardShortcuts(rawShortcuts = {}) {
const defaults = getDefaultKeyboardShortcuts();
const source =
rawShortcuts && typeof rawShortcuts === "object"
? rawShortcuts
: {};
return Object.fromEntries(
KEYBOARD_SHORTCUT_DEFINITIONS.map((definition) => {
const rawShortcut = source[definition.id] || {};
const binding = normalizeShortcutBinding(rawShortcut.binding);
return [
definition.id,
{
binding: binding || defaults[definition.id].binding,
enabled:
typeof rawShortcut.enabled === "boolean"
? rawShortcut.enabled
: defaults[definition.id].enabled,
},
];
}),
);
}
function getShortcutDisplayLabel(binding) {
const normalized = normalizeShortcutBinding(binding);
if (!normalized) return "未设置";
return normalized
.replaceAll("ArrowUp", "↑")
.replaceAll("ArrowDown", "↓")
.replaceAll("ArrowLeft", "←")
.replaceAll("ArrowRight", "→")
.replaceAll("Space", "空格")
.replaceAll("Enter", "回车")
.replaceAll("Escape", "Esc")
.replaceAll("Plus", "+")
.replaceAll("Ctrl", "Ctrl")
.replaceAll("Meta", "⌘");
}
function getShortcutAliasesLabel(definition) {
const aliases = Array.isArray(definition?.aliases) ? definition.aliases : [];
if (aliases.length === 0) return "";
return `备用:${aliases.map(getShortcutDisplayLabel).join(" / ")}`;
}
function getShortcutForAction(actionId) {
return keyboardShortcuts[actionId] || getDefaultKeyboardShortcuts()[actionId] || {
binding: "",
enabled: false,
};
}
function getAutoRotateShortcutStatusMessage(isActive) {
if (rotationMode === ROTATION_MODE.CRUISE) {
return isActive ? "巡航已恢复" : "巡航已暂停";
}
if (rotationMode === ROTATION_MODE.MOTION) {
return isActive ? "动捕已恢复" : "动捕已暂停";
}
return isActive ? "旋转已恢复" : "旋转已暂停";
}
function getShortcutOwnerByBinding(binding, { excludeActionId = null } = {}) {
const normalized = normalizeShortcutBinding(binding);
if (!normalized) return null;
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
if (definition.id === excludeActionId) continue;
const shortcut = getShortcutForAction(definition.id);
if (!shortcut.enabled) continue;
const bindings = [
shortcut.binding,
...(Array.isArray(definition.aliases) ? definition.aliases : []),
].map(normalizeShortcutBinding);
if (bindings.includes(normalized)) {
return definition;
}
}
return null;
}
function getShortcutChordFromEvent(event) {
if (!(event instanceof KeyboardEvent)) return "";
let key = event.key;
if (!key || key === "Unidentified" || key === "Dead") return "";
if (key === " ") key = "Space";
const ignoreShift = key === "+" || key === "_";
if (key.length === 1) key = key.toUpperCase();
const modifiers = [];
if (event.ctrlKey) modifiers.push("Ctrl");
if (event.altKey) modifiers.push("Alt");
if (event.shiftKey && key.length !== 1 && !ignoreShift) modifiers.push("Shift");
if (event.metaKey) modifiers.push("Meta");
return [...modifiers, normalizeShortcutKeyName(key)].join("+");
}
function isEditableShortcutTarget(target) {
if (!(target instanceof Element)) return false;
if (target.closest("[data-shortcut-capture]")) return false;
return Boolean(
target.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"),
);
}
function isShortcutSuppressedBySettingsUi(target) {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("#settings-modal, .earth-mobile-page--settings"));
}
function getShortcutDefinitionForChord(chord) {
const normalizedChord = normalizeShortcutBinding(chord);
if (!normalizedChord) return null;
for (const definition of KEYBOARD_SHORTCUT_DEFINITIONS) {
const shortcut = getShortcutForAction(definition.id);
if (!shortcut.enabled) continue;
const bindings = [
shortcut.binding,
...(Array.isArray(definition.aliases) ? definition.aliases : []),
].map(normalizeShortcutBinding);
if (bindings.includes(normalizedChord)) {
return definition;
}
}
return null;
}
function closeCurrentFocusOverlay() {
if (capturingShortcutActionId) {
capturingShortcutActionId = null;
syncShortcutCaptureUi();
return true;
}
if (isSearchPanelOpen()) {
closeSearchPanel();
return true;
}
if (isSettingsModalOpen()) {
closeSettingsModal();
return true;
}
if (isMobileLayout() && mobileDrawerOpen) {
setMobileDrawerState({ open: false });
return true;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return true;
}
if (toolbarHubController?.isOpen?.()) {
toolbarHubController.close();
return true;
}
if (dismissCruisePresentation()) {
return true;
}
clearLockedObjectAndInfo();
return true;
}
function isKeyboardRotationAction(actionId) {
return (
actionId === "rotateUp" ||
actionId === "rotateDown" ||
actionId === "rotateLeft" ||
actionId === "rotateRight"
);
}
function getLatestActiveKeyboardRotationAction(actionIds) {
let latestActionId = null;
let latestSerial = -1;
actionIds.forEach((actionId) => {
const serial = activeKeyboardRotationActions.get(actionId);
if (!Number.isFinite(serial) || serial <= latestSerial) return;
latestActionId = actionId;
latestSerial = serial;
});
return latestActionId;
}
function getKeyboardRotationDirection() {
const direction = { x: 0, y: 0 };
const verticalAction = getLatestActiveKeyboardRotationAction([
"rotateUp",
"rotateDown",
]);
const horizontalAction = getLatestActiveKeyboardRotationAction([
"rotateLeft",
"rotateRight",
]);
if (verticalAction === "rotateUp") direction.x -= 1;
if (verticalAction === "rotateDown") direction.x += 1;
if (horizontalAction === "rotateLeft") direction.y -= 1;
if (horizontalAction === "rotateRight") direction.y += 1;
const magnitude = Math.hypot(direction.x, direction.y);
if (magnitude > 1) {
direction.x /= magnitude;
direction.y /= magnitude;
}
return direction;
}
function applyKeyboardSphereRotation(deltaX, deltaY) {
if (!earthObj) return;
if (deltaX !== 0) {
earthObj.rotateOnWorldAxis(keyboardRotationWorldAxisX, deltaX);
}
if (deltaY !== 0) {
earthObj.rotateOnWorldAxis(keyboardRotationWorldAxisY, deltaY);
}
}
function clampKeyboardRotationVelocity() {
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
if (speed <= KEYBOARD_ROTATION_MAX_SPEED) return;
const scale = KEYBOARD_ROTATION_MAX_SPEED / speed;
keyboardRotationVelocity.x *= scale;
keyboardRotationVelocity.y *= scale;
}
function applyKeyboardRotationFrame(timestamp) {
keyboardRotationFrameId = null;
if (!earthObj) {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
return;
}
const deltaSeconds = keyboardRotationLastFrameAt
? Math.min((timestamp - keyboardRotationLastFrameAt) / 1000, 0.05)
: 0.016;
keyboardRotationLastFrameAt = timestamp;
const direction = getKeyboardRotationDirection();
const hasInput = direction.x !== 0 || direction.y !== 0;
if (hasInput) {
keyboardRotationVelocity.x += direction.x * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
keyboardRotationVelocity.y += direction.y * KEYBOARD_ROTATION_ACCELERATION * deltaSeconds;
clampKeyboardRotationVelocity();
} else {
const decay = Math.exp(-KEYBOARD_ROTATION_FRICTION * deltaSeconds);
keyboardRotationVelocity.x *= decay;
keyboardRotationVelocity.y *= decay;
}
const speed = Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y);
if (!hasInput && speed < KEYBOARD_ROTATION_STOP_SPEED) {
keyboardRotationVelocity.x = 0;
keyboardRotationVelocity.y = 0;
restoreKeyboardRotationAutoRotate();
keyboardRotationLastFrameAt = 0;
return;
}
applyKeyboardSphereRotation(
keyboardRotationVelocity.x * deltaSeconds,
keyboardRotationVelocity.y * deltaSeconds,
);
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
}
function ensureKeyboardRotationFrame() {
if (keyboardRotationFrameId !== null) return;
keyboardRotationFrameId = window.requestAnimationFrame(applyKeyboardRotationFrame);
}
function restoreKeyboardRotationAutoRotate() {
if (keyboardRotationOriginalAutoRotate === true && !autoRotate) {
setAutoRotate(true);
}
keyboardRotationOriginalAutoRotate = null;
}
function startKeyboardRotationControl(actionId, { repeat = false } = {}) {
if (!earthObj || !isKeyboardRotationAction(actionId)) return;
if (keyboardRotationOriginalAutoRotate === null) {
keyboardRotationOriginalAutoRotate = autoRotate;
}
if (autoRotate) {
setAutoRotate(false);
}
if (!repeat || !activeKeyboardRotationActions.has(actionId)) {
keyboardRotationPressSerial += 1;
activeKeyboardRotationActions.set(actionId, keyboardRotationPressSerial);
}
clearLockedObject();
ensureKeyboardRotationFrame();
}
function stopKeyboardRotationControl({ actionId = null, restoreAutoRotate = false, clearVelocity = false } = {}) {
if (actionId) {
activeKeyboardRotationActions.delete(actionId);
} else {
activeKeyboardRotationActions.clear();
}
if (clearVelocity) {
keyboardRotationVelocity.x = 0;
keyboardRotationVelocity.y = 0;
}
if (restoreAutoRotate && activeKeyboardRotationActions.size === 0) {
restoreKeyboardRotationAutoRotate();
}
if (clearVelocity && keyboardRotationFrameId !== null) {
window.cancelAnimationFrame(keyboardRotationFrameId);
keyboardRotationFrameId = null;
keyboardRotationLastFrameAt = 0;
} else if (activeKeyboardRotationActions.size === 0 && Math.hypot(keyboardRotationVelocity.x, keyboardRotationVelocity.y) > 0) {
ensureKeyboardRotationFrame();
}
}
function applyKeyboardZoom(direction) {
setZoomLevel(getZoomLevelFromCamera(activeCamera) + direction * KEYBOARD_ZOOM_STEP, activeCamera);
showZoomStatusCapsule({ force: true });
}
function openSearchFromShortcut() {
if (isMobileLayout()) {
setMobileDrawerState({ open: true, card: "search" });
return;
}
closeTransientMobileOverlays({ except: "search" });
openSearchPanel();
}
function toggleLayerPanelFromShortcut() {
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);
}
function toggleMediaPanelFromShortcut() {
if (isMobileLayout()) {
const nextOpen = !(mobileDrawerOpen && (mobileDrawerCard === "tv" || mobileDrawerCard === "news"));
setMobileDrawerState({ open: nextOpen, card: "tv" });
return;
}
const nextVisible = !isTVPanelVisible();
setTVPanelVisible(nextVisible);
}
function toggleLayoutExpandedFromShortcut() {
const container = document.getElementById("container");
if (!(container instanceof HTMLElement)) return;
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
}
async function toggleLayerFromShortcut(layerId) {
const definition = getLayerDefinition(layerId);
if (!definition) return;
const button = getLayerButton(layerId);
if (button?.disabled || button?.classList.contains("is-disabled")) {
showStatusMessage(`${definition.label}当前不可用`, "warning");
return;
}
await definition.setVisible(!definition.getVisible());
showStatusMessage(`${definition.label}${definition.getVisible() ? "已显示" : "已隐藏"}`, "info");
}
function executeKeyboardShortcut(actionId, event = null) {
if (actionId === "closeFocus") {
closeCurrentFocusOverlay();
return;
}
if (isKeyboardRotationAction(actionId)) {
startKeyboardRotationControl(actionId, {
repeat: Boolean(event?.repeat),
});
return;
}
if (actionId === "zoomIn") {
applyKeyboardZoom(1);
return;
}
if (actionId === "zoomOut") {
applyKeyboardZoom(-1);
return;
}
if (actionId === "openSearch") {
openSearchFromShortcut();
return;
}
if (actionId === "resetView") {
resetView(activeCamera);
return;
}
if (actionId === "toggleLayerPanel") {
toggleLayerPanelFromShortcut();
return;
}
if (actionId === "toggleLayoutExpanded") {
toggleLayoutExpandedFromShortcut();
return;
}
if (actionId === "toggleMediaPanel") {
toggleMediaPanelFromShortcut();
return;
}
if (actionId === "toggleAutoRotate") {
const isRotating = toggleAutoRotate();
showStatusMessage(getAutoRotateShortcutStatusMessage(isRotating), "info");
return;
}
if (actionId === "cruiseNextCard") {
window.dispatchEvent(new CustomEvent("earth:cruise-next-card"));
return;
}
if (actionId.startsWith("toggleLayer:")) {
void toggleLayerFromShortcut(actionId.slice("toggleLayer:".length));
}
}
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 getZoomLevelFromCamera(camera = activeCamera) {
const cameraZ = Number(camera?.position?.z);
if (!Number.isFinite(cameraZ) || cameraZ <= 0) {
return clampEarthZoomLevel(zoomLevel);
}
return clampEarthZoomLevel(CONFIG.defaultCameraZ / cameraZ);
}
function syncZoomLevelFromCamera(camera = activeCamera) {
zoomLevel = getZoomLevelFromCamera(camera);
return zoomLevel;
}
function formatZoomPercent(zoom) {
return `${Math.round(zoom * 100)}%`;
}
function getZoomResetTooltipText(zoom) {
return `重置缩放到${formatZoomPercent(zoom)}`;
}
function getZoomResetStatusMessage(zoom) {
return `缩放已重置到${formatZoomPercent(zoom)}`;
}
function normalizeAutoRotationSpeed(value) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) return CONFIG.rotationSpeed;
return Math.min(AUTO_ROTATION_SPEED_MAX, Math.max(AUTO_ROTATION_SPEED_MIN, parsed));
}
function formatAutoRotationSpeed(speed) {
const multiplier = normalizeAutoRotationSpeed(speed) / AUTO_ROTATION_SPEED_BASE;
return `${multiplier.toFixed(1)}x`;
}
function canUseLocalStorage() {
try {
return typeof window !== "undefined" && !!window.localStorage;
} catch {
return false;
}
}
function normalizeNewsCategoryFilters(filters) {
const normalized = { ...DEFAULT_NEWS_CATEGORY_FILTERS };
if (!filters || typeof filters !== "object") return normalized;
Object.keys(DEFAULT_NEWS_CATEGORY_FILTERS).forEach((category) => {
if (typeof filters[category] === "boolean") {
normalized[category] = filters[category];
}
});
return normalized;
}
function isNewsCategoryFilterEnabled(filters, category) {
const key = String(category || "").trim();
if (!(key in DEFAULT_NEWS_CATEGORY_FILTERS)) return true;
return normalizeNewsCategoryFilters(filters)[key] !== 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(),
cruiseQueueMode: getCruiseQueueMode(),
cruiseRegionOrder: getCruiseRegionOrder(),
satelliteDisplayStyle: getSatelliteDisplayStyle(),
trailsEnabled: getShowTrails(),
layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
),
terrainOpacity: getTerrainOpacity(),
dayNightEnabled,
defaultEarthZoom,
autoRotationSpeed,
motionDebugEnabled,
motionProvider,
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
newsCategoryFilters: normalizeNewsCategoryFilters(
earthSettingsState?.shared?.newsCategoryFilters,
),
keyboardShortcuts: normalizeKeyboardShortcuts(keyboardShortcuts),
};
}
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)],
cruiseQueueMode: normalizeCruiseQueueMode(settings.shared.cruiseQueueMode),
cruiseRegionOrder: normalizeCruiseRegionOrder(settings.shared.cruiseRegionOrder),
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,
autoRotationSpeed: normalizeAutoRotationSpeed(settings.shared.autoRotationSpeed),
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,
satelliteRealAltitudeEnabled:
settings.shared.satelliteRealAltitudeEnabled !== false,
interactableCompactDotsEnabled:
settings.shared.interactableCompactDotsEnabled !== false,
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
settings.shared.surfaceHoverInfoMode,
),
newsCategoryFilters: normalizeNewsCategoryFilters(settings.shared.newsCategoryFilters),
keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts),
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 nextAutoRotationSpeed =
(rawSettings?.version || 0) >= AUTO_ROTATION_SPEED_DEFAULT_VERSION
? normalizeAutoRotationSpeed(sharedSettings?.autoRotationSpeed)
: defaults.shared.autoRotationSpeed;
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 nextSatelliteRealAltitudeEnabled =
typeof sharedSettings?.satelliteRealAltitudeEnabled === "boolean"
? sharedSettings.satelliteRealAltitudeEnabled
: defaults.shared.satelliteRealAltitudeEnabled;
const nextInteractableCompactDotsEnabled =
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
typeof sharedSettings?.interactableCompactDotsEnabled === "boolean"
? sharedSettings.interactableCompactDotsEnabled
: defaults.shared.interactableCompactDotsEnabled;
const nextSurfaceHoverInfoMode =
(rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION
? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode)
: defaults.shared.surfaceHoverInfoMode;
const nextKeyboardShortcuts =
(rawSettings?.version || 0) >= KEYBOARD_SHORTCUTS_DEFAULT_VERSION
? normalizeKeyboardShortcuts(sharedSettings?.keyboardShortcuts)
: defaults.shared.keyboardShortcuts;
const legacyNewsCategoryFilters = sharedSettings?.["display" + "Types"]?.news;
const nextNewsCategoryFilters =
(rawSettings?.version || 0) >= NEWS_CATEGORY_FILTERS_DEFAULT_VERSION
? normalizeNewsCategoryFilters(
sharedSettings?.newsCategoryFilters || legacyNewsCategoryFilters,
)
: normalizeNewsCategoryFilters(defaults.shared.newsCategoryFilters);
const nextCruiseQueueMode =
(rawSettings?.version || 0) >= CRUISE_QUEUE_DEFAULT_VERSION
? normalizeCruiseQueueMode(sharedSettings?.cruiseQueueMode)
: defaults.shared.cruiseQueueMode;
const nextCruiseRegionOrder =
(rawSettings?.version || 0) >= CRUISE_QUEUE_DEFAULT_VERSION
? normalizeCruiseRegionOrder(sharedSettings?.cruiseRegionOrder)
: defaults.shared.cruiseRegionOrder;
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],
cruiseQueueMode: nextCruiseQueueMode,
cruiseRegionOrder: nextCruiseRegionOrder,
satelliteDisplayStyle: nextSatelliteDisplayStyle,
trailsEnabled: nextTrailsEnabled,
layerVisibility: normalizedLayerVisibility,
terrainOpacity: Number.isFinite(nextTerrainOpacity)
? nextTerrainOpacity
: defaults.shared.terrainOpacity,
dayNightEnabled: nextDayNightEnabled,
defaultEarthZoom: nextDefaultEarthZoom,
autoRotationSpeed: nextAutoRotationSpeed,
motionDebugEnabled: nextMotionDebugEnabled,
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
newsCategoryFilters: nextNewsCategoryFilters,
keyboardShortcuts: nextKeyboardShortcuts,
},
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");
});
syncSegmentedControlSliders();
syncRuntimeModeSections();
}
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 dispatchCruiseQueueSettingsChange() {
window.dispatchEvent(
new CustomEvent("earth:cruise-queue-settings-change", {
detail: {
mode: getCruiseQueueMode(),
regionOrder: getCruiseRegionOrder(),
},
}),
);
}
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 normalizeCruiseQueueMode(mode) {
return ALLOWED_CRUISE_QUEUE_MODES.has(mode) ? mode : DEFAULT_CRUISE_QUEUE_MODE;
}
function normalizeCruiseRegionOrder(order) {
const sourceOrder = Array.isArray(order) ? order : DEFAULT_CRUISE_REGION_ORDER;
const nextOrder = [];
sourceOrder.forEach((region) => {
if (ALLOWED_CRUISE_REGIONS.has(region) && !nextOrder.includes(region)) {
nextOrder.push(region);
}
});
DEFAULT_CRUISE_REGION_ORDER.forEach((region) => {
if (!nextOrder.includes(region)) nextOrder.push(region);
});
return nextOrder;
}
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 syncCruiseQueueModeControls() {
const activeMode = getCruiseQueueMode();
document.querySelectorAll("[data-cruise-queue-mode]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = normalizeCruiseQueueMode(button.dataset.cruiseQueueMode) === activeMode;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
document.querySelectorAll("[data-cruise-region-order-shell]").forEach((shell) => {
if (shell instanceof HTMLElement) {
shell.hidden = activeMode !== CRUISE_QUEUE_MODES.REGION;
}
});
syncSegmentedControlSliders();
syncRuntimeModeSections();
}
function renderCruiseRegionOrderControls() {
const regionOrder = getCruiseRegionOrder();
document.querySelectorAll("[data-cruise-region-order-list]").forEach((list) => {
if (!(list instanceof HTMLElement)) return;
list.innerHTML = "";
regionOrder.forEach((region) => {
const row = document.createElement("button");
row.type = "button";
row.className = "earth-cruise-region-order-item";
row.draggable = true;
row.dataset.cruiseRegionOrderItem = region;
row.innerHTML = `
<span class="material-symbols-rounded" aria-hidden="true" draggable="false">drag_indicator</span>
<span draggable="false">${CRUISE_REGION_LABELS[region] || region}</span>
`;
list.appendChild(row);
});
});
}
function syncSegmentedControlSliders() {
document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => {
if (!(segmented instanceof HTMLElement)) return;
const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill"));
const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active")));
segmented.style.setProperty("--item-count", String(Math.max(1, buttons.length)));
segmented.style.setProperty("--active-index", String(activeIndex));
});
}
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");
});
syncSegmentedControlSliders();
}
function syncSatelliteIdleBreathingToggle() {
const enabled = getSatelliteIdleBreathingEnabled();
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
});
}
function syncSatelliteRealAltitudeToggle() {
const enabled = getSatelliteRealAltitudeEnabled();
document.querySelectorAll("[data-satellite-real-altitude-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;
}
});
}
function syncNewsCategoryFilterControls() {
const filters = normalizeNewsCategoryFilters(
earthSettingsState?.shared?.newsCategoryFilters,
);
document.querySelectorAll("[data-news-category-toggle]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const category = button.dataset.newsCategoryToggle || "";
const active = isNewsCategoryFilterEnabled(filters, category);
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function dispatchNewsCategoryFiltersChange(
filters = earthSettingsState?.shared?.newsCategoryFilters,
) {
window.dispatchEvent(
new CustomEvent("earth:news-category-filters-change", {
detail: {
categories: normalizeNewsCategoryFilters(filters),
},
}),
);
}
function applyNewsCategoryFilters(filters = earthSettingsState?.shared?.newsCategoryFilters) {
const normalized = normalizeNewsCategoryFilters(filters);
syncNewsCategoryFilterControls();
dispatchNewsCategoryFiltersChange(normalized);
}
export function getEarthNewsCategoryFilters() {
return normalizeNewsCategoryFilters(earthSettingsState?.shared?.newsCategoryFilters);
}
export function isEarthNewsCategoryEnabled(category) {
return isNewsCategoryFilterEnabled(
earthSettingsState?.shared?.newsCategoryFilters,
category,
);
}
export function setEarthNewsCategoryEnabled(
category,
enabled,
{ persist = true, suppressStatus = false } = {},
) {
const key = String(category || "").trim();
if (!(key in DEFAULT_NEWS_CATEGORY_FILTERS)) return false;
ensureMutableEarthSettingsState();
const nextFilters = normalizeNewsCategoryFilters(earthSettingsState.shared.newsCategoryFilters);
nextFilters[key] = Boolean(enabled);
earthSettingsState.shared.newsCategoryFilters = nextFilters;
applyNewsCategoryFilters(nextFilters);
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
showStatusMessage(Boolean(enabled) ? "新闻类型已显示" : "新闻类型已隐藏", "info");
}
return true;
}
function syncSurfaceHoverInfoModeControls() {
const activeMode = getSurfaceHoverInfoMode();
document.querySelectorAll("[data-surface-hover-info-mode]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const mode = normalizeSurfaceHoverInfoMode(button.dataset.surfaceHoverInfoMode);
const active = mode === activeMode;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
syncSegmentedControlSliders();
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
}
export function getCruiseQueueMode() {
return normalizeCruiseQueueMode(earthSettingsState?.shared?.cruiseQueueMode);
}
export function getCruiseRegionOrder() {
return normalizeCruiseRegionOrder(earthSettingsState?.shared?.cruiseRegionOrder);
}
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 setCruiseQueueMode(
nextMode,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedMode = normalizeCruiseQueueMode(nextMode);
const previousMode = getCruiseQueueMode();
if (normalizedMode === previousMode) {
syncCruiseQueueModeControls();
return normalizedMode;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.cruiseQueueMode = normalizedMode;
syncCruiseQueueModeControls();
renderCruiseRegionOrderControls();
dispatchCruiseQueueSettingsChange();
if (persist) persistEarthSettings();
if (!suppressStatus) {
const label =
normalizedMode === CRUISE_QUEUE_MODES.REGION
? "按大区"
: normalizedMode === CRUISE_QUEUE_MODES.RANDOM
? "随机"
: "默认";
showStatusMessage(`巡航队列已切换为:${label}`, "info");
}
return normalizedMode;
}
export function setCruiseRegionOrder(
nextOrder,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedOrder = normalizeCruiseRegionOrder(nextOrder);
const previousOrder = getCruiseRegionOrder();
const changed = normalizedOrder.some((region, index) => previousOrder[index] !== region);
if (!changed) {
renderCruiseRegionOrderControls();
return normalizedOrder;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.cruiseRegionOrder = normalizedOrder;
renderCruiseRegionOrderControls();
dispatchCruiseQueueSettingsChange();
if (persist) persistEarthSettings();
if (!suppressStatus) {
showStatusMessage("巡航大区顺序已更新", "info");
}
return normalizedOrder;
}
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 setSatelliteRealAltitudeEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const enabled = applySatelliteRealAltitudeEnabled(nextEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteRealAltitudeEnabled = enabled;
syncSatelliteRealAltitudeToggle();
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;
}
export function getSurfaceHoverInfoMode() {
return normalizeSurfaceHoverInfoMode(earthSettingsState?.shared?.surfaceHoverInfoMode);
}
export function setSurfaceHoverInfoMode(
nextMode,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedMode = normalizeSurfaceHoverInfoMode(nextMode);
const previousMode = getSurfaceHoverInfoMode();
if (normalizedMode === previousMode) {
syncSurfaceHoverInfoModeControls();
return normalizedMode;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.surfaceHoverInfoMode = normalizedMode;
syncSurfaceHoverInfoModeControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const label =
normalizedMode === SURFACE_HOVER_INFO_MODES.COUNTRY
? "国家"
: normalizedMode === SURFACE_HOVER_INFO_MODES.POSITION
? "位置"
: "完整";
showStatusMessage(`悬停提示已切换为:${label}`, "info");
}
return normalizedMode;
}
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;
}
}
function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = true } = {}) {
defaultEarthZoom = clampEarthZoomLevel(nextZoom);
syncDefaultEarthZoomUi(defaultEarthZoom);
if (applyToCurrentView && activeCamera) {
setZoomLevel(defaultEarthZoom, 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 });
setCruiseQueueMode(settings.shared.cruiseQueueMode, { persist: false, suppressStatus: true });
setCruiseRegionOrder(settings.shared.cruiseRegionOrder, { 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,
});
setSatelliteRealAltitudeEnabled(settings.shared.satelliteRealAltitudeEnabled, {
persist: false,
suppressStatus: true,
});
setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, {
persist: false,
suppressStatus: true,
});
setSurfaceHoverInfoMode(settings.shared.surfaceHoverInfoMode, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
}
setDefaultEarthZoom(settings.shared.defaultEarthZoom, {
persist: false,
applyToCurrentView: true,
});
setAutoRotationSpeed(settings.shared.autoRotationSpeed, {
persist: false,
suppressStatus: 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);
keyboardShortcuts = normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts);
renderShortcutSettings();
if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
applyImmediateLayerVisibilityHints(layerVisibility);
deferredLayerVisibilitySettings = layerVisibility;
applyNewsCategoryFilters(settings.shared.newsCategoryFilters);
return;
}
deferredLayerVisibilitySettings = null;
await applyLayerVisibilitySettings(settings.shared.layerVisibility, {
persist: false,
silent: true,
});
applyNewsCategoryFilters(settings.shared.newsCategoryFilters);
}
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,
});
applyNewsCategoryFilters(earthSettingsState?.shared?.newsCategoryFilters);
}
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),
z: 0,
};
}
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;
targetEarthObj.rotation.z = nextRotation.z;
setZoomLevel(zoom, camera);
}
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, zoom = null } = {}) {
const now = Date.now();
if (!force && now - lastZoomStatusUpdateTime < ZOOM_STATUS_UPDATE_INTERVAL_MS) {
return;
}
lastZoomStatusUpdateTime = now;
const currentZoom = Number.isFinite(Number(zoom))
? clampEarthZoomLevel(zoom)
: syncZoomLevelFromCamera(activeCamera);
showGestureStatusMessage(`缩放 ${Math.round(currentZoom * 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 inputs = document.querySelectorAll(
`[data-settings-panel="${panelId}"]`,
);
inputs.forEach((input) => {
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 getBoundaryPrecisionEls() {
return {
statuses: Array.from(document.querySelectorAll("[data-boundary-precision-status]")),
details: Array.from(document.querySelectorAll("[data-boundary-precision-detail]")),
progressWraps: Array.from(document.querySelectorAll("[data-boundary-precision-progress-wrap]")),
progressBars: Array.from(document.querySelectorAll("[data-boundary-precision-progress-bar]")),
progressValues: Array.from(document.querySelectorAll("[data-boundary-precision-progress-value]")),
buildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-build]")),
rebuildButtons: Array.from(document.querySelectorAll("[data-boundary-precision-rebuild]")),
disableButtons: Array.from(document.querySelectorAll("[data-boundary-precision-disable]")),
};
}
async function fetchBoundaryPrecisionJson(path, options = {}) {
const response = await fetch(path, {
cache: "no-store",
...options,
headers: {
"content-type": "application/json",
...(options.headers || {}),
},
});
const contentType = response.headers.get("content-type") || "";
if (!response.ok) {
let detail = `HTTP ${response.status}`;
if (contentType.includes("application/json")) {
const payload = await response.json().catch(() => null);
detail = payload?.detail?.message || payload?.detail || detail;
}
throw new Error(detail);
}
if (!contentType.includes("application/json")) {
throw new Error("后端没有返回 JSON 状态");
}
return response.json();
}
function renderBoundaryPrecisionStatus(payload = {}) {
const els = getBoundaryPrecisionEls();
const job = payload.job || payload.current_job || {};
const highReady = Boolean(payload.high_precision_ready || job?.result?.high_precision_ready);
const enabled = getHighPrecisionBoundariesEnabled();
const running = job.status === "queued" || job.status === "running";
const failed = job.status === "failed" && boundaryBuildAttemptedThisSession;
const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
const failureMessage = job.code === "source_not_configured" || job.code === "missing_sources"
? `高清国界更新源未配置完整:${job.message || job.code}`
: `高清国界下载失败:${job.message || job.code || "请检查更新源"}`;
els.statuses.forEach((status) => {
status.textContent = "国界精度";
});
els.details.forEach((detail) => {
detail.textContent = running
? (job.message || "正在准备高清国界")
: failed
? failureMessage
: highReady
? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。")
: "当前使用低精国界;切到高精会下载并构建。";
});
els.progressWraps.forEach((progressWrap) => {
progressWrap.hidden = !running;
});
els.progressBars.forEach((progressBar) => {
progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
});
els.progressValues.forEach((progressValue) => {
progressValue.textContent = job.status === "failed"
? `失败:${job.message || job.code || "构建失败"}`
: `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
});
els.buildButtons.forEach((buildButton) => {
if (!(buildButton instanceof HTMLButtonElement)) return;
buildButton.disabled = running;
buildButton.textContent = "高精";
buildButton.classList.toggle("is-active", enabled || running);
buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
});
els.rebuildButtons.forEach((rebuildButton) => {
if (!(rebuildButton instanceof HTMLButtonElement)) return;
const shouldShowRebuild = highReady && enabled && !running;
rebuildButton.hidden = !shouldShowRebuild;
rebuildButton.disabled = !shouldShowRebuild;
});
els.disableButtons.forEach((disableButton) => {
if (!(disableButton instanceof HTMLButtonElement)) return;
disableButton.disabled = running;
disableButton.classList.toggle("is-active", !enabled && !running);
disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
});
syncSegmentedControlSliders();
}
async function refreshBoundaryPrecisionStatus() {
const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/status");
renderBoundaryPrecisionStatus(payload);
return payload;
}
function stopBoundaryBuildPolling() {
if (boundaryBuildPollTimer) {
window.clearInterval(boundaryBuildPollTimer);
boundaryBuildPollTimer = null;
}
}
function startBoundaryBuildPolling() {
stopBoundaryBuildPolling();
boundaryBuildPollTimer = window.setInterval(async () => {
try {
const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build/status");
renderBoundaryPrecisionStatus(payload);
const status = payload.job?.status;
if (status === "succeeded" || status === "failed") {
stopBoundaryBuildPolling();
await refreshBoundaryPrecisionStatus();
if (status === "succeeded" && boundaryBuildAttemptedThisSession) {
setHighPrecisionBoundariesEnabled(true);
await reloadCountryBoundaries({ suppressStatus: true });
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage("高精国界已下载并应用", "info");
}
}
} catch (error) {
stopBoundaryBuildPolling();
showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning");
}
}, 1000);
}
async function startBoundaryPrecisionBuild() {
renderBoundaryPrecisionStatus({
job: { status: "queued", progress: 0, message: "正在启动高精国界构建" },
});
boundaryBuildAttemptedThisSession = true;
await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" });
showStatusMessage("高精国界构建已启动", "info");
startBoundaryBuildPolling();
}
async function setupBoundaryPrecisionControls() {
const els = getBoundaryPrecisionEls();
if (els.buildButtons.length === 0 && els.disableButtons.length === 0) return;
try {
const payload = await refreshBoundaryPrecisionStatus();
const jobStatus = payload.current_job?.status;
if (jobStatus === "queued" || jobStatus === "running") {
startBoundaryBuildPolling();
}
} catch (error) {
renderBoundaryPrecisionStatus({});
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
}
els.buildButtons.forEach((buildButton) => {
if (!(buildButton instanceof HTMLButtonElement)) return;
bindListener(buildButton, "click", async () => {
try {
const statusPayload = await refreshBoundaryPrecisionStatus();
const job = statusPayload.job || statusPayload.current_job || {};
const highReady = Boolean(statusPayload.high_precision_ready || job?.result?.high_precision_ready);
if (highReady) {
if (getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(true);
await reloadCountryBoundaries({ suppressStatus: true });
showStatusMessage("已切换到高精国界", "info");
await refreshBoundaryPrecisionStatus().catch(() => {});
return;
}
await startBoundaryPrecisionBuild();
} catch (error) {
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
}
});
});
els.rebuildButtons.forEach((rebuildButton) => {
if (!(rebuildButton instanceof HTMLButtonElement)) return;
bindListener(rebuildButton, "click", async () => {
try {
await startBoundaryPrecisionBuild();
} catch (error) {
await refreshBoundaryPrecisionStatus().catch(() => {});
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
}
});
});
els.disableButtons.forEach((disableButton) => {
if (!(disableButton instanceof HTMLButtonElement)) return;
bindListener(disableButton, "click", async () => {
try {
if (!getHighPrecisionBoundariesEnabled()) return;
setHighPrecisionBoundariesEnabled(false);
await reloadCountryBoundaries({ suppressStatus: true });
showStatusMessage("已切换到低精国界", "info");
await refreshBoundaryPrecisionStatus().catch(() => {});
} catch (error) {
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
}
});
});
}
function setSettingsTab(root, tabId) {
if (!(root instanceof HTMLElement)) return;
const nextTab = tabId || "runtime";
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = button.dataset.settingsTab === nextTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
root.querySelectorAll("[data-settings-tab-panel]").forEach((panel) => {
if (!(panel instanceof HTMLElement)) return;
panel.hidden = panel.dataset.settingsTabPanel !== nextTab;
});
}
function setupSettingsTabs() {
const roots = document.querySelectorAll(".earth-settings-sheet, .earth-mobile-page--settings");
roots.forEach((root) => {
if (!(root instanceof HTMLElement)) return;
setSettingsTab(root, "runtime");
root.querySelectorAll("[data-settings-tab]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
bindListener(button, "click", () => {
setSettingsTab(root, button.dataset.settingsTab || "runtime");
});
});
});
}
function getRuntimeSettingsContainer(runtimePanel) {
if (!(runtimePanel instanceof HTMLElement)) return null;
return runtimePanel.querySelector(".earth-settings-list") || runtimePanel;
}
function getMotionSettingsItems(motionPanel) {
if (!(motionPanel instanceof HTMLElement)) return [];
const desktopList = motionPanel.querySelector(".earth-settings-list");
if (desktopList instanceof HTMLElement) {
return Array.from(desktopList.children).filter((child) => child instanceof HTMLElement);
}
return Array.from(motionPanel.children).filter(
(child) =>
child instanceof HTMLElement &&
!child.classList.contains("earth-mobile-settings-title") &&
!child.classList.contains("earth-settings-section-title"),
);
}
function markRuntimeModeSection(selector, mode) {
document.querySelectorAll(selector).forEach((control) => {
const card = control.closest(".earth-settings-item, .earth-mobile-settings-card");
if (card instanceof HTMLElement) {
card.dataset.runtimeModeSection = mode;
}
});
}
function isRuntimeModeSectionVisible(section, mode = rotationMode) {
const sectionModes = String(section?.dataset?.runtimeModeSection || "")
.split(/\s+/)
.filter(Boolean);
return sectionModes.includes(mode);
}
function syncRuntimeModeSections() {
document.querySelectorAll("[data-runtime-mode-section]").forEach((section) => {
if (!(section instanceof HTMLElement)) return;
section.hidden = !isRuntimeModeSectionVisible(section);
});
}
function syncAutoRotationSpeedUi(speed = autoRotationSpeed) {
const normalizedSpeed = normalizeAutoRotationSpeed(speed);
document.querySelectorAll("[data-auto-rotation-speed-slider]").forEach((slider) => {
if (slider instanceof HTMLInputElement) {
slider.min = String(AUTO_ROTATION_SPEED_MIN);
slider.max = String(AUTO_ROTATION_SPEED_MAX);
slider.step = String(AUTO_ROTATION_SPEED_STEP);
slider.value = normalizedSpeed.toFixed(5);
}
});
document.querySelectorAll("[data-auto-rotation-speed-value]").forEach((value) => {
if (value instanceof HTMLElement) {
value.textContent = formatAutoRotationSpeed(normalizedSpeed);
}
});
}
function integrateMotionSettingsIntoRuntime() {
const roots = document.querySelectorAll(".earth-settings-sheet, .earth-mobile-page--settings");
roots.forEach((root) => {
if (!(root instanceof HTMLElement)) return;
root.querySelectorAll('[data-settings-tab="motion"]').forEach((tab) => tab.remove());
const runtimePanel = root.querySelector('[data-settings-tab-panel="runtime"]');
const runtimeContainer = getRuntimeSettingsContainer(runtimePanel);
const motionPanel = root.querySelector('[data-settings-tab-panel="motion"]');
if (!(runtimeContainer instanceof HTMLElement) || !(motionPanel instanceof HTMLElement)) return;
getMotionSettingsItems(motionPanel).forEach((item) => {
if (!(item instanceof HTMLElement)) return;
item.dataset.runtimeModeSection = ROTATION_MODE.MOTION;
runtimeContainer.appendChild(item);
});
motionPanel.remove();
});
markRuntimeModeSection("[data-cruise-module-toggle]", `${ROTATION_MODE.CRUISE} ${ROTATION_MODE.MOTION}`);
markRuntimeModeSection("[data-cruise-queue-mode]", `${ROTATION_MODE.CRUISE} ${ROTATION_MODE.MOTION}`);
markRuntimeModeSection("[data-auto-rotation-speed-slider]", ROTATION_MODE.ROTATE);
markRuntimeModeSection("[data-motion-debug-toggle]", ROTATION_MODE.MOTION);
markRuntimeModeSection("[data-motion-provider]", ROTATION_MODE.MOTION);
syncRuntimeModeSections();
}
function renderShortcutSettings() {
const lists = document.querySelectorAll("[data-shortcut-list]");
if (lists.length === 0) return;
const groupedDefinitions = KEYBOARD_SHORTCUT_DEFINITIONS.reduce((groups, definition) => {
if (!groups.has(definition.category)) {
groups.set(definition.category, []);
}
groups.get(definition.category).push(definition);
return groups;
}, new Map());
lists.forEach((list) => {
if (!(list instanceof HTMLElement)) return;
list.innerHTML = "";
groupedDefinitions.forEach((definitions, category) => {
const heading = document.createElement("div");
heading.className = "earth-shortcut-category";
heading.textContent = category;
list.appendChild(heading);
definitions.forEach((definition) => {
const shortcut = getShortcutForAction(definition.id);
const row = document.createElement("div");
row.className = "earth-shortcut-row";
row.dataset.shortcutAction = definition.id;
row.innerHTML = `
<div class="earth-shortcut-copy">
<span class="earth-shortcut-label">${definition.label}</span>
<span class="earth-shortcut-alias">${getShortcutAliasesLabel(definition) || " "}</span>
</div>
<button class="earth-shortcut-key" type="button" data-shortcut-capture="${definition.id}">${getShortcutDisplayLabel(shortcut.binding)}</button>
<label class="earth-shortcut-enable" title="启用快捷键">
<input type="checkbox" data-shortcut-enabled="${definition.id}" ${shortcut.enabled ? "checked" : ""}>
<span class="earth-settings-switch-track"></span>
</label>
<button class="earth-shortcut-reset" type="button" data-shortcut-reset="${definition.id}" aria-label="重置${definition.label}" title="重置">
<span class="material-symbols-rounded">restart_alt</span>
</button>
`;
row.classList.toggle("is-disabled", !shortcut.enabled);
list.appendChild(row);
});
});
});
syncShortcutCaptureUi();
}
function syncShortcutCaptureUi() {
document.querySelectorAll("[data-shortcut-capture]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const actionId = button.dataset.shortcutCapture;
const isCapturing = Boolean(actionId && actionId === capturingShortcutActionId);
button.classList.toggle("is-capturing", isCapturing);
if (isCapturing) {
button.textContent = "按键...";
} else if (actionId) {
button.textContent = getShortcutDisplayLabel(getShortcutForAction(actionId).binding);
}
});
}
function setShortcutBinding(actionId, binding, { persist = true } = {}) {
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
const normalizedBinding = normalizeShortcutBinding(binding);
if (!normalizedBinding) return false;
const owner = getShortcutOwnerByBinding(normalizedBinding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
return false;
}
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
...nextShortcuts[actionId],
binding: normalizedBinding,
enabled: true,
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
return true;
}
function resetShortcutBinding(actionId, { persist = true } = {}) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(actionId);
if (!definition) return;
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
binding: definition.defaultBinding,
enabled: true,
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
}
function setShortcutEnabled(actionId, enabled, { persist = true } = {}) {
if (!KEYBOARD_SHORTCUT_DEFINITION_BY_ID.has(actionId)) return false;
const currentShortcut = getShortcutForAction(actionId);
if (enabled) {
const owner = getShortcutOwnerByBinding(currentShortcut.binding, { excludeActionId: actionId });
if (owner) {
showStatusMessage(`快捷键已被「${owner.label}」使用`, "warning");
renderShortcutSettings();
return false;
}
}
const nextShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
nextShortcuts[actionId] = {
...nextShortcuts[actionId],
enabled: Boolean(enabled),
};
keyboardShortcuts = nextShortcuts;
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
renderShortcutSettings();
if (persist) persistEarthSettings();
return true;
}
function resetAllShortcutBindings() {
keyboardShortcuts = getDefaultKeyboardShortcuts();
if (earthSettingsState?.shared) {
earthSettingsState.shared.keyboardShortcuts = normalizeKeyboardShortcuts(keyboardShortcuts);
}
capturingShortcutActionId = null;
renderShortcutSettings();
persistEarthSettings();
showStatusMessage("快捷键已恢复默认", "info");
}
function moveCruiseRegionInOrder(region, targetRegion) {
if (!region || !targetRegion || region === targetRegion) return;
const currentOrder = getCruiseRegionOrder().filter((item) => item !== region);
const targetIndex = currentOrder.indexOf(targetRegion);
if (targetIndex < 0) return;
currentOrder.splice(targetIndex, 0, region);
setCruiseRegionOrder(currentOrder);
}
function getCruiseRegionOrderRows(list) {
if (!(list instanceof HTMLElement)) return [];
return Array.from(list.querySelectorAll("[data-cruise-region-order-item]")).filter(
(row) => row instanceof HTMLElement,
);
}
function getCruiseRegionOrderFromList(list) {
return getCruiseRegionOrderRows(list)
.map((row) => row.dataset.cruiseRegionOrderItem || "")
.filter((region) => ALLOWED_CRUISE_REGIONS.has(region));
}
function animateCruiseRegionOrderMutation(list, mutate) {
const rows = getCruiseRegionOrderRows(list);
const firstRects = new Map(rows.map((row) => [row, row.getBoundingClientRect()]));
mutate();
rows.forEach((row) => {
if (!row.isConnected) return;
const firstRect = firstRects.get(row);
const lastRect = row.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;
row.style.transition = "none";
row.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
});
list.getBoundingClientRect();
window.requestAnimationFrame(() => {
rows.forEach((row) => {
if (!row.isConnected) return;
row.style.transition = "";
row.style.transform = "";
});
});
}
function previewCruiseRegionOrderDrag(list, target, event) {
if (!cruiseRegionDraggedItem || !(target instanceof HTMLElement)) return;
const draggedRow = getCruiseRegionOrderRows(list).find(
(row) => row.dataset.cruiseRegionOrderItem === cruiseRegionDraggedItem,
);
if (!draggedRow || draggedRow === target) return;
const targetRect = target.getBoundingClientRect();
const sameVisualRow = event.clientY >= targetRect.top && event.clientY <= targetRect.bottom;
const shouldInsertAfter = sameVisualRow
? event.clientX > targetRect.left + targetRect.width / 2
: event.clientY > targetRect.top + targetRect.height / 2;
if (shouldInsertAfter && target.nextElementSibling === draggedRow) return;
if (!shouldInsertAfter && target.previousElementSibling === draggedRow) return;
animateCruiseRegionOrderMutation(list, () => {
if (shouldInsertAfter) {
target.after(draggedRow);
} else {
target.before(draggedRow);
}
});
}
function commitCruiseRegionOrderDrag(list) {
const order = getCruiseRegionOrderFromList(list);
if (order.length !== DEFAULT_CRUISE_REGION_ORDER.length) {
renderCruiseRegionOrderControls();
return;
}
setCruiseRegionOrder(order);
}
function setupShortcutSettingsControls() {
document.querySelectorAll("[data-shortcut-list]").forEach((list) => {
bindListener(list, "click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const captureButton = target?.closest("[data-shortcut-capture]");
const resetButton = target?.closest("[data-shortcut-reset]");
if (captureButton instanceof HTMLButtonElement) {
event.preventDefault();
event.stopPropagation();
capturingShortcutActionId = captureButton.dataset.shortcutCapture || null;
syncShortcutCaptureUi();
} else if (resetButton instanceof HTMLButtonElement) {
event.preventDefault();
event.stopPropagation();
const actionId = resetButton.dataset.shortcutReset;
if (actionId) resetShortcutBinding(actionId);
}
});
bindListener(list, "change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
const actionId = target.dataset.shortcutEnabled;
if (!actionId) return;
setShortcutEnabled(actionId, target.checked);
});
});
document.querySelectorAll("[data-shortcut-reset-all]").forEach((button) => {
bindListener(button, "click", (event) => {
event.preventDefault();
event.stopPropagation();
resetAllShortcutBindings();
});
});
renderShortcutSettings();
}
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();
});
integrateMotionSettingsIntoRuntime();
setupSettingsTabs();
setupShortcutSettingsControls();
renderCruiseRegionOrderControls();
syncCruiseQueueModeControls();
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 cruiseQueueModeButtons = document.querySelectorAll("[data-cruise-queue-mode]");
const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
const surfaceHoverInfoModeButtons = document.querySelectorAll("[data-surface-hover-info-mode]");
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);
syncAutoRotationSpeedUi(autoRotationSpeed);
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 },
);
});
});
document.querySelectorAll("[data-auto-rotation-speed-slider]").forEach((speedSlider) => {
if (!(speedSlider instanceof HTMLInputElement)) return;
bindListener(speedSlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
setAutoRotationSpeed(target.value, { persist: true, suppressStatus: true });
});
});
document.querySelectorAll("[data-auto-rotation-speed-reset]").forEach((resetButton) => {
if (!(resetButton instanceof HTMLButtonElement)) return;
bindListener(resetButton, "click", () => {
setAutoRotationSpeed(CONFIG.rotationSpeed, { persist: true, suppressStatus: 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));
});
});
cruiseQueueModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
setCruiseQueueMode(target.dataset.cruiseQueueMode);
});
});
document.querySelectorAll("[data-cruise-region-order-list]").forEach((list) => {
if (!(list instanceof HTMLElement)) return;
bindListener(list, "dragstart", (event) => {
const target = event.target instanceof Element
? event.target.closest("[data-cruise-region-order-item]")
: null;
if (!(target instanceof HTMLElement)) return;
event.stopPropagation();
document.getSelection()?.removeAllRanges();
cruiseRegionDraggedItem = target.dataset.cruiseRegionOrderItem || "";
list.classList.add("is-dragging");
event.dataTransfer?.setData("text/plain", cruiseRegionDraggedItem);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
event.dataTransfer?.setDragImage?.(target, 16, 16);
target.classList.add("is-dragging");
});
bindListener(list, "dragend", (event) => {
event.stopPropagation();
const target = event.target instanceof Element
? event.target.closest("[data-cruise-region-order-item]")
: null;
target?.classList.remove("is-dragging");
list.classList.remove("is-dragging");
cruiseRegionDraggedItem = null;
renderCruiseRegionOrderControls();
});
bindListener(list, "dragover", (event) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
const target = event.target instanceof Element
? event.target.closest("[data-cruise-region-order-item]")
: null;
if (target instanceof HTMLElement) {
previewCruiseRegionOrderDrag(list, target, event);
}
});
bindListener(list, "drop", (event) => {
event.preventDefault();
event.stopPropagation();
commitCruiseRegionOrderDrag(list);
list.classList.remove("is-dragging");
cruiseRegionDraggedItem = null;
});
});
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);
});
});
surfaceHoverInfoModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
setSurfaceHoverInfoMode(target.dataset.surfaceHoverInfoMode);
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setSatelliteIdleBreathingEnabled(toggle.checked);
});
});
document.querySelectorAll("[data-satellite-real-altitude-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
setSatelliteRealAltitudeEnabled(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("[data-news-category-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLButtonElement)) return;
bindListener(toggle, "click", () => {
const active = toggle.classList.contains("is-active");
setEarthNewsCategoryEnabled(toggle.dataset.newsCategoryToggle, !active);
});
});
bindListener(window, "earth:set-news-category-enabled", (event) => {
const detail = event.detail || {};
setEarthNewsCategoryEnabled(detail.category, Boolean(detail.enabled));
});
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();
syncSatelliteIdleBreathingToggle();
syncSatelliteRealAltitudeToggle();
syncInteractableCompactDotsToggle();
syncNewsCategoryFilterControls();
syncSurfaceHoverInfoModeControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
void setupBoundaryPrecisionControls();
}
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, .tv-panel-player, .tv-panel-edge, .earth-news-hud-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(getZoomLevelFromCamera(camera) * 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;
setZoomLevel(newPercent / 100, camera);
showZoomStatusCapsule({ force: true });
}
function doContinuousZoom(direction) {
let currentPercent = Math.round(getZoomLevelFromCamera(camera) * 100);
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
setZoomLevel(newPercent / 100, 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 = getZoomLevelFromCamera(camera);
const targetZoom = getDefaultEarthZoomLevel();
animateValue(
0,
1,
600,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
const nextZoom = startZoomVal + (targetZoom - startZoomVal) * ease;
setZoomLevel(nextZoom, camera);
},
() => {
setZoomLevel(targetZoom, camera);
showStatusMessage(getZoomResetStatusMessage(targetZoom), "info");
},
);
});
}
function setupWheelZoom(camera, renderer) {
let wheelZoomFrameId = null;
let wheelZoomTarget = getZoomLevelFromCamera(camera);
let wheelZoomStart = wheelZoomTarget;
let wheelZoomStartAt = 0;
let lastTrackpadDirection = 0;
let suppressedTrackpadDirection = 0;
let suppressedTrackpadUntil = 0;
let suppressedTrackpadMagnitude = 0;
function getWheelPixelDelta(event) {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
return event.deltaY * 16;
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * window.innerHeight;
}
return event.deltaY;
}
function isTrackpadWheel(event, pixelDelta) {
return event.deltaMode === WheelEvent.DOM_DELTA_PIXEL &&
Math.abs(pixelDelta) < WHEEL_TRACKPAD_PIXEL_THRESHOLD;
}
function shouldSuppressTrackpadResidual(pixelDelta) {
const direction = Math.sign(pixelDelta);
const magnitude = Math.abs(pixelDelta);
const now = performance.now();
return direction !== 0 &&
direction === suppressedTrackpadDirection &&
now < suppressedTrackpadUntil &&
magnitude < suppressedTrackpadMagnitude * WHEEL_TRACKPAD_RESIDUAL_RATIO;
}
function recordTrackpadWheel(pixelDelta) {
const direction = Math.sign(pixelDelta);
const magnitude = Math.abs(pixelDelta);
if (direction === 0) return;
const now = performance.now();
if (lastTrackpadDirection !== 0 && direction !== lastTrackpadDirection) {
suppressedTrackpadDirection = lastTrackpadDirection;
suppressedTrackpadUntil = now + WHEEL_TRACKPAD_RESIDUAL_WINDOW_MS;
suppressedTrackpadMagnitude = magnitude;
}
lastTrackpadDirection = direction;
}
function stopWheelZoomAnimation() {
if (wheelZoomFrameId !== null) {
window.cancelAnimationFrame(wheelZoomFrameId);
wheelZoomFrameId = null;
}
wheelZoomStartAt = 0;
}
function animateWheelZoom(timestamp) {
if (!wheelZoomStartAt) {
wheelZoomStartAt = timestamp;
}
const progress = Math.min(
(timestamp - wheelZoomStartAt) / WHEEL_ZOOM_DURATION_MS,
1,
);
const ease = 1 - Math.pow(1 - progress, 3);
setZoomLevel(
wheelZoomStart + (wheelZoomTarget - wheelZoomStart) * ease,
camera,
);
if (progress < 1) {
wheelZoomFrameId = window.requestAnimationFrame(animateWheelZoom);
return;
}
setZoomLevel(wheelZoomTarget, camera);
wheelZoomFrameId = null;
wheelZoomStartAt = 0;
}
function startWheelZoomAnimation() {
wheelZoomStart = getZoomLevelFromCamera(camera);
wheelZoomStartAt = 0;
if (wheelZoomFrameId === null) {
wheelZoomFrameId = window.requestAnimationFrame(animateWheelZoom);
}
}
function applyMouseWheelZoom(direction) {
suppressedTrackpadDirection = 0;
suppressedTrackpadUntil = 0;
const baseZoom = wheelZoomFrameId === null
? getZoomLevelFromCamera(camera)
: wheelZoomTarget;
wheelZoomTarget = clampEarthZoomLevel(
baseZoom + direction * WHEEL_ZOOM_STEP,
);
stopWheelZoomAnimation();
startWheelZoomAnimation();
showZoomStatusCapsule({ force: true, zoom: wheelZoomTarget });
}
function applyTrackpadWheelZoom(pixelDelta) {
if (Math.abs(pixelDelta) < WHEEL_TRACKPAD_DEADZONE) return;
if (shouldSuppressTrackpadResidual(pixelDelta)) return;
stopWheelZoomAnimation();
const currentZoom = getZoomLevelFromCamera(camera);
const nextZoom = currentZoom * Math.exp(-pixelDelta * WHEEL_TRACKPAD_SENSITIVITY);
wheelZoomTarget = setZoomLevel(nextZoom, camera);
wheelZoomStart = wheelZoomTarget;
recordTrackpadWheel(pixelDelta);
showZoomStatusCapsule({ force: true, zoom: wheelZoomTarget });
}
cleanupFns.push(stopWheelZoomAnimation);
bindListener(
renderer?.domElement,
"wheel",
(e) => {
e.preventDefault();
const pixelDelta = getWheelPixelDelta(e);
if (isTrackpadWheel(e, pixelDelta)) {
applyTrackpadWheelZoom(pixelDelta);
return;
}
applyMouseWheelZoom(pixelDelta < 0 ? 1 : -1);
},
{ passive: false },
);
}
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 (capturingShortcutActionId) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
capturingShortcutActionId = null;
syncShortcutCaptureUi();
return;
}
const nextBinding = getShortcutChordFromEvent(event);
if (!nextBinding) return;
if (setShortcutBinding(capturingShortcutActionId, nextBinding)) {
const definition = KEYBOARD_SHORTCUT_DEFINITION_BY_ID.get(capturingShortcutActionId);
showStatusMessage(`${definition?.label || "快捷键"}已设置为 ${getShortcutDisplayLabel(nextBinding)}`, "info");
capturingShortcutActionId = null;
syncShortcutCaptureUi();
}
return;
}
const chord = getShortcutChordFromEvent(event);
const definition = getShortcutDefinitionForChord(chord);
if (!definition) return;
if (isEditableShortcutTarget(event.target) && definition.id !== "closeFocus") return;
if (
definition.id !== "closeFocus" &&
(isShortcutSuppressedBySettingsUi(event.target) ||
isSettingsModalOpen() ||
(isMobileLayout() && mobileDrawerOpen && mobileDrawerCard === "settings"))
) {
return;
}
event.preventDefault();
event.stopPropagation();
executeKeyboardShortcut(definition.id, event);
});
bindListener(document, "keyup", (event) => {
const chord = getShortcutChordFromEvent(event);
const definition = getShortcutDefinitionForChord(chord);
if (!definition || !isKeyboardRotationAction(definition.id)) return;
stopKeyboardRotationControl({ actionId: definition.id });
});
bindListener(window, "blur", () => {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
});
cleanupFns.push(() => {
stopKeyboardRotationControl({ restoreAutoRotate: true, clearVelocity: true });
});
}
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.setProperty("--toolbar-light-rgb", TOOLBAR_GLASS_CONFIG.lightRgb.join(", "));
toolbar.style.setProperty("--toolbar-light-intensity", `${TOOLBAR_GLASS_CONFIG.lightIntensity}`);
toolbar.style.setProperty("--toolbar-light-size-ratio", `${TOOLBAR_GLASS_CONFIG.lightSizeRatio}`);
toolbar.style.setProperty("--toolbar-glass-opacity", `${TOOLBAR_GLASS_CONFIG.glassOpacity}`);
toolbar.style.setProperty("--toolbar-active-light-scale", `${TOOLBAR_GLASS_CONFIG.activeLightScale}`);
toolbar.style.setProperty("--toolbar-glass-blur", `${TOOLBAR_GLASS_CONFIG.glassBlurPx * toolbarScale}px`);
toolbar.style.setProperty("--toolbar-light-blur", `${TOOLBAR_GLASS_CONFIG.lightBlurPx * toolbarScale}px`);
toolbar.style.setProperty("--toolbar-outer-glow-blur", `${TOOLBAR_GLASS_CONFIG.outerGlowBlurPx * toolbarScale}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");
});
syncSegmentedControlSliders();
syncRuntimeModeSections();
}
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 getAutoRotationSpeed() {
return autoRotationSpeed;
}
export function setAutoRotationSpeed(value, { persist = true, suppressStatus = false } = {}) {
const normalizedSpeed = normalizeAutoRotationSpeed(value);
const changed = normalizedSpeed !== autoRotationSpeed;
autoRotationSpeed = normalizedSpeed;
syncAutoRotationSpeedUi(normalizedSpeed);
if (persist) {
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(`旋转转速已设为 ${formatAutoRotationSpeed(normalizedSpeed)}`, "info");
}
return normalizedSpeed;
}
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 startRotZ = earthObj.rotation.z;
const startZoom = getZoomLevelFromCamera(camera);
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;
earthObj.rotation.z = startRotZ + (nextRotation.z - startRotZ) * rotateEase;
if (progress < rotateStartProgress) {
const zoomProgress = progress / rotateStartProgress;
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
setZoomLevel(startZoom + (defaultZoom - startZoom) * zoomEase, camera);
} else if (progress <= rotateEndProgress) {
setZoomLevel(defaultZoom, camera);
} else {
const zoomProgress = (progress - rotateEndProgress) / (1 - rotateEndProgress);
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
setZoomLevel(defaultZoom + (startZoom - defaultZoom) * zoomEase, camera);
}
} else {
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
earthObj.rotation.z = startRotZ + (nextRotation.z - startRotZ) * ease;
setZoomLevel(startZoom + (zoom - startZoom) * ease, camera);
}
},
() => {
setZoomLevel(shouldRestoreZoomViaDefault ? startZoom : zoom, camera);
earthObj.rotation.x = nextRotation.x;
earthObj.rotation.y = nextRotation.y;
earthObj.rotation.z = nextRotation.z;
if (!suppressStatus) {
showStatusMessage("视角已重置", "info");
}
resolve();
},
);
});
}
export function getZoomLevel() {
return syncZoomLevelFromCamera(activeCamera);
}
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);
}