6329 lines
208 KiB
JavaScript
6329 lines
208 KiB
JavaScript
// 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 { earthMessage, translateText } from "./i18n.js";
|
||
import {
|
||
DEFAULT_MOTION_PROVIDER,
|
||
MOTION_GESTURES,
|
||
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 motionEnabledGestures = [];
|
||
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 = 17;
|
||
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 MOTION_GESTURES_DEFAULT_VERSION = 17;
|
||
const MOTION_GESTURE_DEFINITIONS = [
|
||
{ id: "rotate_left", label: "左旋" },
|
||
{ id: "rotate_right", label: "右旋" },
|
||
{ id: "rotate_up", label: "上旋" },
|
||
{ id: "rotate_down", label: "下旋" },
|
||
{ id: "zoom_in", label: "放大" },
|
||
{ id: "zoom_out", label: "缩小" },
|
||
{ id: "focus_prev", label: "上个焦点" },
|
||
{ id: "focus_next", label: "下个焦点" },
|
||
{ id: "layer_prev", label: "上一图层" },
|
||
{ id: "layer_next", label: "下一图层" },
|
||
{ id: "confirm", label: "确认" },
|
||
];
|
||
const DEFAULT_MOTION_ENABLED_GESTURES = MOTION_GESTURE_DEFINITIONS.map((item) => item.id);
|
||
motionEnabledGestures = [...DEFAULT_MOTION_ENABLED_GESTURES];
|
||
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 earthMessage("status.runtimePaused", { label: "巡航", active: isActive });
|
||
}
|
||
if (rotationMode === ROTATION_MODE.MOTION) {
|
||
return earthMessage("status.runtimePaused", { label: "动捕", active: isActive });
|
||
}
|
||
return earthMessage("status.runtimePaused", { label: "旋转", active: 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(earthMessage("status.layoutExpanded", { 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(earthMessage("status.layerUnavailable", { layer: definition.label }), "warning");
|
||
return;
|
||
}
|
||
await definition.setVisible(!definition.getVisible());
|
||
showStatusMessage(
|
||
earthMessage("status.layerVisibility", {
|
||
layer: definition.label,
|
||
visible: 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 earthMessage("status.zoomReset", { zoom: 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,
|
||
motionEnabledGestures: getMotionEnabledGestures(),
|
||
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),
|
||
motionEnabledGestures: normalizeMotionEnabledGestures(settings.shared.motionEnabledGestures),
|
||
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 nextMotionEnabledGestures =
|
||
(rawSettings?.version || 0) >= MOTION_GESTURES_DEFAULT_VERSION
|
||
? normalizeMotionEnabledGestures(sharedSettings?.motionEnabledGestures)
|
||
: normalizeMotionEnabledGestures(defaults.shared.motionEnabledGestures);
|
||
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,
|
||
motionEnabledGestures: nextMotionEnabledGestures,
|
||
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 syncMotionGestureControls() {
|
||
const enabledGestures = new Set(getMotionEnabledGestures());
|
||
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((button) => {
|
||
if (!(button instanceof HTMLButtonElement)) return;
|
||
const gesture = button.dataset.motionGestureToggle || "";
|
||
const active = enabledGestures.has(gesture);
|
||
button.classList.toggle("is-active", active);
|
||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
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,
|
||
enabledGestures: getMotionEnabledGestures(),
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
|
||
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 normalizeMotionEnabledGestures(nextGestures) {
|
||
const sourceGestures = Array.isArray(nextGestures)
|
||
? nextGestures
|
||
: DEFAULT_MOTION_ENABLED_GESTURES;
|
||
const normalizedGestures = Array.from(
|
||
new Set(sourceGestures.filter((gesture) => MOTION_GESTURES.has(gesture))),
|
||
);
|
||
return normalizedGestures.length > 0
|
||
? normalizedGestures
|
||
: [...DEFAULT_MOTION_ENABLED_GESTURES];
|
||
}
|
||
|
||
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(earthMessage("status.layerVisibility", { layer: "新闻类型", visible: 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(
|
||
earthMessage("status.modulesChanged", {
|
||
label: "巡航模块",
|
||
value: labels.map((label) => translateText(label)).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(earthMessage("status.valueChanged", { label: "巡航队列", value: 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(earthMessage("status.updated", { label: "巡航大区顺序" }), "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(earthMessage("status.valueChanged", { label: "卫星显示风格", value: 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(earthMessage("status.booleanSetting", { label: "卫星呼吸闪烁", 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
|
||
? earthMessage("status.booleanSetting", { label: "卫星真实高度", enabled })
|
||
: earthMessage("status.valueChanged", { label: "卫星", value: "旧版同层高度" }),
|
||
"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(earthMessage("status.booleanSetting", { label: "低缩放彩色圆点", 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(earthMessage("status.valueChanged", { label: "悬停提示", value: 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,
|
||
});
|
||
setMotionEnabledGestures(settings.shared.motionEnabledGestures, {
|
||
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 getMotionEnabledGestures() {
|
||
return normalizeMotionEnabledGestures(motionEnabledGestures);
|
||
}
|
||
|
||
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) {
|
||
showStatusMessage(
|
||
earthMessage("status.motionDebugMode", {
|
||
enabled: motionDebugEnabled,
|
||
pending: rotationMode !== ROTATION_MODE.MOTION,
|
||
}),
|
||
"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(earthMessage("status.motionProvider", { provider: motionProvider }), "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(earthMessage("status.motionDebugView", { skeletonOnly: motionDebugSkeletonOnly }), "info");
|
||
}
|
||
return motionDebugSkeletonOnly;
|
||
}
|
||
|
||
export function setMotionEnabledGestures(
|
||
nextGestures,
|
||
{ persist = true, suppressStatus = false } = {},
|
||
) {
|
||
const normalized = normalizeMotionEnabledGestures(nextGestures);
|
||
const previous = getMotionEnabledGestures();
|
||
const changed =
|
||
normalized.length !== previous.length ||
|
||
normalized.some((gesture, index) => previous[index] !== gesture);
|
||
|
||
motionEnabledGestures = normalized;
|
||
syncMotionGestureControls();
|
||
|
||
ensureMutableEarthSettingsState();
|
||
earthSettingsState.shared.motionEnabledGestures = [...motionEnabledGestures];
|
||
|
||
if (changed) {
|
||
dispatchMotionSettingsChange();
|
||
}
|
||
if (persist) {
|
||
persistEarthSettings();
|
||
}
|
||
if (!suppressStatus && changed) {
|
||
showStatusMessage(earthMessage("status.motionGesturesUpdated"), "info");
|
||
}
|
||
return getMotionEnabledGestures();
|
||
}
|
||
|
||
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(earthMessage("status.settingsReset"), "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(earthMessage("status.layerVisibility", { layer: "真实地形", visible: false }), "info");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
if (!isTerrainReady()) {
|
||
setLayerButtonState(button, {
|
||
loading: true,
|
||
tooltip: "地形加载中...",
|
||
statusText: "加载中",
|
||
});
|
||
if (!silent) {
|
||
showStatusMessage(earthMessage("loading.realTerrainData"), "info");
|
||
}
|
||
await ensureTerrainReady();
|
||
}
|
||
if (toggleToken !== terrainToggleToken) return showTerrain;
|
||
|
||
applyTerrainUiState(button, true);
|
||
syncMobileLayerCards();
|
||
if (persist) persistEarthSettings();
|
||
if (!silent) {
|
||
showStatusMessage(earthMessage("status.layerVisibility", { layer: "真实地形", visible: true }), "success");
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
console.error("加载真实地形失败:", error);
|
||
applyTerrainUiState(button, false);
|
||
syncMobileLayerCards();
|
||
if (persist) persistEarthSettings();
|
||
if (!silent) {
|
||
showStatusMessage(earthMessage("status.terrainUnavailable"), "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(earthMessage("status.layerVisibility", { layer: "卫星", visible: false }), "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(earthMessage("status.layerVisibility", { layer: "经纬线", visible: 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(earthMessage("status.layerVisibility", { layer: "BGP观测", visible: enabled }), "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(earthMessage("status.layerVisibility", { layer: "算力中心", visible: 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(earthMessage("status.layerVisibility", { layer: "轨迹", visible: 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: earthMessage("startup.landOceanBase"),
|
||
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: earthMessage("startup.hdTexture"),
|
||
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: earthMessage("startup.landingPoints"),
|
||
load: earthMessage("startup.cables"),
|
||
},
|
||
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: earthMessage("startup.computeCenters"),
|
||
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: earthMessage("startup.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: earthMessage("startup.vessels"),
|
||
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: earthMessage("startup.satellites"),
|
||
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: earthMessage("startup.terrain"),
|
||
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(
|
||
earthMessage("status.zoomPercent", { percent: 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;
|
||
syncSettingsSwitchVisual(input, visible);
|
||
}
|
||
});
|
||
}
|
||
|
||
function syncSettingsSwitchVisual(input, visible = input?.checked === true) {
|
||
if (!(input instanceof HTMLInputElement)) return;
|
||
const switchShell = input.closest(".earth-settings-switch, .earth-mobile-settings-switch");
|
||
switchShell?.classList.toggle("is-checked", Boolean(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(earthMessage("status.boundaryDownloaded"), "info");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
stopBoundaryBuildPolling();
|
||
showStatusMessage(
|
||
earthMessage("status.failure", { label: "高清国界进度读取失败", error: 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(earthMessage("status.boundaryBuildStarted"), "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(
|
||
earthMessage("status.failure", { label: "高清国界状态读取失败", error: 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(earthMessage("status.boundaryPrecision", { high: true }), "info");
|
||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||
return;
|
||
}
|
||
await startBoundaryPrecisionBuild();
|
||
} catch (error) {
|
||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||
showStatusMessage(
|
||
earthMessage("status.failure", { label: "高精国界切换失败", error: 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(
|
||
earthMessage("status.failure", { label: "高精国界重建启动失败", error: 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(earthMessage("status.boundaryPrecision", { high: false }), "info");
|
||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||
} catch (error) {
|
||
showStatusMessage(
|
||
earthMessage("status.failure", { label: "低精国界切换失败", error: 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);
|
||
markRuntimeModeSection("[data-motion-gesture-toggle]", 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(earthMessage("status.shortcutConflict", { owner: 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(earthMessage("status.shortcutConflict", { owner: 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(earthMessage("status.shortcutsReset"), "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) => {
|
||
if (input instanceof HTMLInputElement) {
|
||
syncSettingsSwitchVisual(input);
|
||
}
|
||
bindListener(input, "change", (event) => {
|
||
const target = event.currentTarget;
|
||
if (!(target instanceof HTMLInputElement)) return;
|
||
const panelId = target.dataset.settingsPanel;
|
||
if (!panelId) return;
|
||
syncSettingsSwitchVisual(target, target.checked);
|
||
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);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("[data-motion-gesture-toggle]").forEach((motionGestureButton) => {
|
||
if (!(motionGestureButton instanceof HTMLButtonElement)) return;
|
||
bindListener(motionGestureButton, "click", () => {
|
||
const gesture = motionGestureButton.dataset.motionGestureToggle;
|
||
if (!gesture) return;
|
||
const nextGestures = new Set(getMotionEnabledGestures());
|
||
if (nextGestures.has(gesture)) {
|
||
nextGestures.delete(gesture);
|
||
} else {
|
||
nextGestures.add(gesture);
|
||
}
|
||
setMotionEnabledGestures(Array.from(nextGestures));
|
||
});
|
||
});
|
||
|
||
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);
|
||
syncMotionGestureControls();
|
||
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(
|
||
earthMessage("status.runtimePaused", { label, active: isRotating }),
|
||
"info",
|
||
);
|
||
});
|
||
|
||
updateRotateUI();
|
||
|
||
bindListener(resetViewBtn, "click", () => {
|
||
resetView(camera);
|
||
});
|
||
}
|
||
|
||
function filterLayerRows(query, emptyStateEl, clearBtn) {
|
||
const rows = document.querySelectorAll("#layer-panel-list .layer-row");
|
||
let visibleCount = 0;
|
||
rows.forEach((row) => {
|
||
const name = (row.dataset.layerName || "").toLowerCase();
|
||
const matches = !query || name.includes(query);
|
||
row.hidden = !matches;
|
||
if (matches) visibleCount++;
|
||
});
|
||
if (emptyStateEl) emptyStateEl.hidden = visibleCount > 0;
|
||
if (clearBtn) clearBtn.hidden = !query;
|
||
}
|
||
|
||
function setupLayerPanel() {
|
||
const panel = document.getElementById("layer-toggles");
|
||
const collapseBtn = document.getElementById("layer-panel-collapse");
|
||
const searchInput = document.getElementById("layer-search-input");
|
||
const searchClear = document.getElementById("layer-search-clear");
|
||
const emptyState = document.getElementById("layer-panel-empty");
|
||
if (!panel) return;
|
||
|
||
const layerPanel = createHUDPanel({
|
||
panel,
|
||
header: ".layer-panel-header",
|
||
body: "#layer-panel-body",
|
||
collapseBtn,
|
||
collapsedClass: "layer-panel--collapsed",
|
||
preferredDirection: "down",
|
||
expandLabel: "展开图层列表",
|
||
collapseLabel: "折叠图层列表",
|
||
});
|
||
|
||
bindListener(collapseBtn, "click", (e) => {
|
||
e.stopPropagation();
|
||
layerPanel.setCollapsed(!layerPanel.isCollapsed());
|
||
});
|
||
|
||
if (searchInput) {
|
||
bindListener(searchInput, "input", () => {
|
||
const query = searchInput.value.trim().toLowerCase();
|
||
filterLayerRows(query, emptyState, searchClear);
|
||
});
|
||
|
||
if (searchClear) {
|
||
bindListener(searchClear, "click", () => {
|
||
searchInput.value = "";
|
||
filterLayerRows("", emptyState, searchClear);
|
||
searchInput.focus();
|
||
});
|
||
}
|
||
}
|
||
|
||
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(earthMessage("status.layoutExpanded", { 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(
|
||
earthMessage("status.shortcutSet", {
|
||
label: definition?.label || "快捷键",
|
||
binding: 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("--mouse-x", "0.5");
|
||
surface.style.setProperty("--mouse-y", "0.5");
|
||
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"));
|
||
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;
|
||
surface.style.setProperty("--mouse-x", `${px.toFixed(3)}`);
|
||
surface.style.setProperty("--mouse-y", `${py.toFixed(3)}`);
|
||
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 = translateText(autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`);
|
||
}
|
||
btn.title = translateText(`${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(earthMessage("status.rotateSpeed", { speed: 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(earthMessage("status.switchedTo", { label: 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(earthMessage("status.viewReset"), "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);
|
||
}
|