Files
planet/frontend/public/earth/js/controls.js
2026-04-22 03:52:09 +08:00

2297 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// controls.js - Zoom, rotate and toggle controls
import * as THREE from "three";
import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain, setDayNightEnabled } from "./earth.js";
import { setCelestialDayNightEnabled } from "./celestial.js";
import {
ensureTerrainReady,
isTerrainReady,
getTerrainOpacity,
setTerrainOpacity,
} from "./terrain.js";
import {
reloadData,
clearLockedObject,
clearLockedObjectAndInfo,
setCablesEnabled,
setSatellitesEnabled,
getSatellitesEnabled,
} from "./main.js";
import {
toggleTrails,
getShowTrails,
getSatelliteCount,
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { ensureTVPanelReady } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
updateNewsToggleUI,
} from "./news.js";
import {
setButtonTooltip,
setLayerButtonState,
updateLayerButtonState,
} from "./layer-button-state.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 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.v1";
let settingsModalTimer = null;
let settingsSheetAnimation = null;
let terrainToggleToken = 0;
let terrainPrefetchStarted = false;
let terrainPrefetchScheduled = false;
let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let layerRegistry = new Map();
let layerPanelInitialized = false;
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 shouldIncludeLayerInStartupLoad(definition) {
if (!Number.isFinite(definition?.startupPriority)) {
return false;
}
if (definition.startupMode === "preload") {
return true;
}
return Boolean(definition?.getVisible?.());
}
function canUseLocalStorage() {
try {
return typeof window !== "undefined" && !!window.localStorage;
} catch {
return false;
}
}
function getCurrentSettingsSnapshot() {
const panelVisibility = Object.fromEntries(
HUD_PANEL_IDS.map((panelId) => {
const panel = document.getElementById(panelId);
const visible = !panel?.classList.contains("hud-panel-hidden");
return [panelId, visible];
}),
);
return {
rotationMode,
panelVisibility,
layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
),
terrainOpacity: getTerrainOpacity(),
dayNightEnabled,
};
}
function captureEarthSettingsDefaults() {
if (!earthSettingsDefaults) {
earthSettingsDefaults = getCurrentSettingsSnapshot();
}
return earthSettingsDefaults;
}
function cloneEarthSettings(settings) {
return {
rotationMode: settings.rotationMode,
terrainOpacity: settings.terrainOpacity,
dayNightEnabled: settings.dayNightEnabled,
panelVisibility: { ...(settings.panelVisibility || {}) },
layerVisibility: { ...(settings.layerVisibility || {}) },
};
}
function normalizeEarthSettings(rawSettings, defaults) {
const normalizedPanelVisibility = { ...defaults.panelVisibility };
const normalizedLayerVisibility = { ...defaults.layerVisibility };
const inputPanelVisibility =
rawSettings && typeof rawSettings.panelVisibility === "object"
? rawSettings.panelVisibility
: {};
const inputLayerVisibility =
rawSettings && typeof rawSettings.layerVisibility === "object"
? rawSettings.layerVisibility
: {};
Object.entries(inputPanelVisibility).forEach(([panelId, visible]) => {
if (panelId in normalizedPanelVisibility) {
normalizedPanelVisibility[panelId] = Boolean(visible);
}
});
Object.entries(inputLayerVisibility).forEach(([layerId, visible]) => {
if (layerId in normalizedLayerVisibility) {
normalizedLayerVisibility[layerId] = Boolean(visible);
}
});
const nextRotationMode =
rawSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
: defaults.rotationMode;
const nextTerrainOpacity = Number.parseFloat(rawSettings?.terrainOpacity);
const nextDayNightEnabled = typeof rawSettings?.dayNightEnabled === "boolean"
? rawSettings.dayNightEnabled
: defaults.dayNightEnabled;
return {
rotationMode: nextRotationMode,
panelVisibility: normalizedPanelVisibility,
layerVisibility: normalizedLayerVisibility,
terrainOpacity: Number.isFinite(nextTerrainOpacity)
? nextTerrainOpacity
: defaults.terrainOpacity,
dayNightEnabled: nextDayNightEnabled,
};
}
function getPersistedLayers() {
return getSortedLayerDefinitions().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);
if (!rawValue) return defaults;
const parsedValue = JSON.parse(rawValue);
return normalizeEarthSettings(parsedValue, defaults);
} catch (error) {
console.warn("读取 Earth 设置失败,已回退默认值:", error);
return defaults;
}
}
function persistEarthSettings() {
if (!canUseLocalStorage()) return;
try {
window.localStorage.setItem(
EARTH_SETTINGS_STORAGE_KEY,
JSON.stringify(getCurrentSettingsSnapshot()),
);
} catch (error) {
console.warn("保存 Earth 设置失败:", error);
}
}
async function applyEarthSettings(settings) {
if (!settings) return;
HUD_PANEL_IDS.forEach((panelId) => {
const visible = settings.panelVisibility?.[panelId];
if (typeof visible === "boolean") {
setHudPanelVisibility(panelId, visible, { persist: false });
}
});
const appliedOpacity = setTerrainOpacity(settings.terrainOpacity);
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySlider.value = appliedOpacity.toFixed(2);
}
if (terrainOpacityValue) {
terrainOpacityValue.textContent = `${Math.round(appliedOpacity * 100)}%`;
}
setRotationMode(settings.rotationMode, { persist: false, suppressStatus: true });
if (typeof settings.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.dayNightEnabled, { persist: false });
}
await applyLayerVisibilitySettings(settings.layerVisibility, {
persist: false,
silent: true,
});
}
function resetEarthSettings() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
if (canUseLocalStorage()) {
try {
window.localStorage.removeItem(EARTH_SETTINGS_STORAGE_KEY);
} catch (error) {
console.warn("移除 Earth 设置失败:", error);
}
}
void applyEarthSettings(defaults).then(() => {
showStatusMessage("Earth 设置已重置", "info");
});
}
async function setTerrainEnabled(button, enabled, { persist = true, silent = false } = {}) {
const toggleToken = ++terrainToggleToken;
if (!enabled) {
applyTerrainUiState(button, false);
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("地形已隐藏", "info");
}
return false;
}
try {
if (!isTerrainReady()) {
setLayerButtonState(button, {
loading: true,
tooltip: "地形加载中...",
statusText: "加载中",
});
if (!silent) {
showStatusMessage("正在加载真实地形数据...", "info");
}
await ensureTerrainReady();
}
if (toggleToken !== terrainToggleToken) return showTerrain;
applyTerrainUiState(button, true);
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形已显示", "success");
}
return true;
} catch (error) {
console.error("加载真实地形失败:", error);
applyTerrainUiState(button, false);
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage("真实地形暂时不可用", "error");
}
return false;
}
}
async function setSatellitesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "卫星加载中...",
});
}
await setSatellitesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
if (!enabled && !silent) {
showStatusMessage("卫星已隐藏", "info");
} else if (enabled) {
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${getSatelliteCount()}`;
}
}
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换卫星显示失败:", error);
setLayerButtonState(button, {
active: false,
loading: false,
tooltip: "显示卫星",
});
if (persist) persistEarthSettings();
return false;
}
}
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观测",
});
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
bgpCountEl.textContent = `${getBGPCount()}`;
}
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "BGP观测已显示" : "BGP观测已隐藏", "info");
}
return enabled;
}
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleTrails(enabled);
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
});
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "轨迹已显示" : "轨迹已隐藏", "info");
}
return enabled;
}
async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
await setCablesEnabled(enabled, { suppressStatus: silent, suppressLoadingUi: silent });
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换线缆显示失败:", error);
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 getBuiltinLayerDefinitions() {
return [
{
id: "terrain",
buttonId: "toggle-terrain",
icon: "landscape",
label: "地形",
meta: "Terrain",
keywords: "地形 terrain",
defaultActive: false,
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
icon: "satellite_alt",
label: "卫星",
meta: "Satellites",
keywords: "卫星 satellites",
defaultActive: false,
startupPriority: 30,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
getVisible: () => getSatellitesEnabled(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
},
{
id: "trails",
buttonId: "toggle-trails",
icon: "timeline",
label: "轨迹",
meta: "Trails",
keywords: "轨迹 trails",
defaultActive: true,
startupPriority: null,
startupMode: "visible",
startupLabel: "轨迹",
startupMessage: "",
getVisible: () => getShowTrails(),
setVisible: (visible, options = {}) =>
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
},
{
id: "cables",
buttonId: "toggle-cables",
icon: "cable",
label: "海缆",
meta: "Subsea Cables",
keywords: "海缆 subsea cables",
defaultActive: true,
startupPriority: 20,
startupMode: "visible",
startupLabel: "海缆",
startupMessage: {
prepare: "正在加载登陆点...",
load: "正在加载海缆...",
},
getVisible: () => getShowCables(),
setVisible: (visible, options = {}) =>
setCablesLayerEnabled(getLayerButton("cables"), visible, options),
},
{
id: "bgp",
buttonId: "toggle-bgp",
icon: "hub",
label: "BGP观测",
meta: "Routing Signals",
keywords: "bgp观测 routing signals",
defaultActive: true,
startupPriority: 40,
startupMode: "preload",
startupLabel: "BGP态势",
startupMessage: "正在加载BGP态势...",
getVisible: () => getShowBGP(),
setVisible: (visible, options = {}) =>
setBGPLayerEnabled(getLayerButton("bgp"), 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,
startupPriority: null,
startupMode: "visible",
startupLabel: "",
startupMessage: "",
...definition,
};
layerRegistry.set(normalizedDefinition.id, normalizedDefinition);
const row = syncLayerRowDefinition(normalizedDefinition, options);
if (row && layerPanelInitialized) {
bindLayerButton(row, normalizedDefinition);
}
return normalizedDefinition;
}
function initializeLayerRegistry() {
layerRegistry = new Map();
getBuiltinLayerDefinitions().forEach((definition) => {
registerLayerDefinition(definition);
});
}
function getViewRotation(targetLat, targetRotLon) {
const latRot = (targetLat * Math.PI) / 180;
return {
x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient,
y: -((targetRotLon * Math.PI) / 180),
};
}
function dispatchRotationModeChange() {
window.dispatchEvent(
new CustomEvent("earth:rotation-mode-change", {
detail: {
mode: rotationMode,
active: autoRotate,
},
}),
);
}
function applyTerrainUiState(button, enabled) {
showTerrain = enabled;
toggleTerrain(enabled);
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏地形" : "显示地形",
statusText: enabled ? "开启" : "关闭",
});
}
function prewarmTerrainIfNeeded() {
if (terrainPrefetchStarted || isTerrainReady()) return;
terrainPrefetchStarted = true;
ensureTerrainReady().catch((error) => {
terrainPrefetchStarted = false;
console.warn("地形预热加载失败:", error);
});
}
function scheduleTerrainPrefetch() {
if (terrainPrefetchScheduled || terrainPrefetchStarted || isTerrainReady()) {
return;
}
terrainPrefetchScheduled = true;
const runPrefetch = () => {
terrainPrefetchScheduled = false;
prewarmTerrainIfNeeded();
};
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(runPrefetch, { timeout: 2200 });
return;
}
window.setTimeout(runPrefetch, 1400);
}
export function applyImmediateView(targetEarthObj, camera, options = {}) {
if (!targetEarthObj) return;
const {
lat = EARTH_CONFIG.chinaLat,
rotLon = EARTH_CONFIG.chinaRotLon,
zoom = 1.0,
} = options;
const nextRotation = getViewRotation(lat, rotLon);
targetEarthObj.rotation.x = nextRotation.x;
targetEarthObj.rotation.y = nextRotation.y;
zoomLevel = zoom;
if (camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}
}
function cancelSettingsSheetAnimation() {
if (settingsSheetAnimation) {
settingsSheetAnimation.cancel();
settingsSheetAnimation = null;
}
}
function getSettingsSheetAnimationState(trigger, sheet) {
if (!(trigger instanceof HTMLElement) || !(sheet instanceof HTMLElement)) {
return null;
}
const triggerRect = trigger.getBoundingClientRect();
const sheetRect = sheet.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
const sheetCenterX = sheetRect.left + sheetRect.width / 2;
const sheetCenterY = sheetRect.top + sheetRect.height / 2;
return {
translateX: triggerCenterX - sheetCenterX,
translateY: triggerCenterY - sheetCenterY,
scaleX: Math.max(
SETTINGS_SHEET_MIN_SCALE,
Math.min(
SETTINGS_SHEET_MAX_SCALE_X,
triggerRect.width / Math.max(sheetRect.width, 1),
),
),
scaleY: Math.max(
SETTINGS_SHEET_MIN_SCALE,
Math.min(
SETTINGS_SHEET_MAX_SCALE_Y,
triggerRect.height / Math.max(sheetRect.height, 1),
),
),
radius: `${Math.max(triggerRect.width, triggerRect.height).toFixed(2)}px`,
};
}
function animateSettingsSheet(sheet, trigger, opening) {
const animationState = getSettingsSheetAnimationState(trigger, sheet);
if (!animationState || typeof sheet.animate !== "function") {
return;
}
cancelSettingsSheetAnimation();
const fromTransform = `translate(${animationState.translateX.toFixed(2)}px, ${animationState.translateY.toFixed(2)}px) scale(${animationState.scaleX.toFixed(4)}, ${animationState.scaleY.toFixed(4)})`;
const toTransform = "translate(0px, 0px) scale(1, 1)";
const keyframes = opening
? [
{
transform: fromTransform,
opacity: 0.22,
filter: "blur(10px)",
borderRadius: animationState.radius,
},
{
transform: "translate(0px, 0px) scale(1.015, 1.015)",
opacity: 1,
filter: "blur(0px)",
borderRadius: "0px",
offset: 0.76,
},
{
transform: toTransform,
opacity: 1,
filter: "blur(0px)",
borderRadius: "0px",
},
]
: [
{
transform: toTransform,
opacity: 1,
filter: "blur(0px)",
borderRadius: "0px",
},
{
transform: fromTransform,
opacity: 0.08,
filter: "blur(10px)",
borderRadius: animationState.radius,
},
];
settingsSheetAnimation = sheet.animate(keyframes, {
duration: opening
? SETTINGS_MODAL_OPEN_ANIMATION_MS
: SETTINGS_MODAL_CLOSE_ANIMATION_MS,
easing: opening
? "cubic-bezier(0.16, 1, 0.3, 1)"
: "cubic-bezier(0.4, 0, 0.2, 1)",
fill: "both",
});
settingsSheetAnimation.onfinish = () => {
sheet.style.transform = "";
sheet.style.opacity = "";
sheet.style.filter = "";
sheet.style.borderRadius = "";
settingsSheetAnimation = null;
};
settingsSheetAnimation.oncancel = () => {
settingsSheetAnimation = null;
};
}
function getFloatingGroups() {
return [
document.getElementById("zoom-control-group"),
].filter(Boolean);
}
function isFloatingMenuVisible() {
return getFloatingGroups().some((group) => {
return (
group.classList.contains("open") ||
group.matches(":hover") ||
group.matches(":focus-within")
);
});
}
function isSettingsModalOpen() {
return document
.getElementById("settings-modal")
?.classList.contains("is-open");
}
function closeFloatingMenus() {
getFloatingGroups().forEach((group) => {
group.classList.remove("open");
group.classList.add("force-closed");
});
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
function openSettingsModal() {
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
if (settingsModalTimer) {
clearTimeout(settingsModalTimer);
settingsModalTimer = null;
}
closeFloatingMenus();
cancelSettingsSheetAnimation();
modal.classList.remove("is-closing");
modal.classList.add("is-opening");
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => {
if (sheet instanceof HTMLElement) {
animateSettingsSheet(sheet, trigger, true);
}
window.setTimeout(() => {
modal.classList.remove("is-opening");
}, SETTINGS_MODAL_OPEN_ANIMATION_MS);
});
}
function closeSettingsModal() {
const modal = document.getElementById("settings-modal");
const trigger = document.getElementById("settings-trigger");
const sheet = modal?.querySelector(".earth-settings-sheet");
if (!modal) return;
cancelSettingsSheetAnimation();
modal.classList.remove("is-open");
modal.classList.add("is-closing");
if (sheet instanceof HTMLElement) {
animateSettingsSheet(sheet, trigger, false);
}
if (settingsModalTimer) {
clearTimeout(settingsModalTimer);
}
settingsModalTimer = window.setTimeout(() => {
modal.classList.remove("is-closing", "is-opening");
modal.setAttribute("aria-hidden", "true");
settingsModalTimer = null;
}, SETTINGS_MODAL_CLOSE_ANIMATION_MS);
}
function setHudPanelVisibility(panelId, visible, { persist = true } = {}) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle("hud-panel-hidden", !visible);
syncSettingsToggle(panelId, visible);
if (panelId === "media-panel") {
updateTVToggleUI(visible);
updateNewsToggleUI(visible);
if (visible) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
});
}
}
if (persist) {
persistEarthSettings();
}
}
function syncSettingsToggle(panelId, visible) {
const input = document.querySelector(
`[data-settings-panel="${panelId}"]`,
);
if (input instanceof HTMLInputElement) {
input.checked = visible;
}
}
function syncAllHudPanelToggles() {
HUD_PANEL_IDS.forEach((panelId) => {
const panel = document.getElementById(panelId);
syncSettingsToggle(panelId, !panel?.classList.contains("hud-panel-hidden"));
});
}
function syncDayNightToggle(enabled) {
const input = document.getElementById("toggle-daynight");
if (input instanceof HTMLInputElement) {
input.checked = enabled;
}
}
function applyDayNightEnabled(enabled, { persist = true } = {}) {
dayNightEnabled = enabled;
setDayNightEnabled(enabled);
setCelestialDayNightEnabled(enabled);
syncDayNightToggle(enabled);
if (persist) persistEarthSettings();
}
function setupSettingsControls() {
const settingsTrigger = document.getElementById("settings-trigger");
const settingsClose = document.getElementById("settings-close");
const settingsBackdrop = document.getElementById("settings-backdrop");
const settingsModal = document.getElementById("settings-modal");
const settingsReset = document.getElementById("settings-reset");
bindListener(settingsTrigger, "click", () => {
openSettingsModal();
});
bindListener(settingsClose, "click", () => {
closeSettingsModal();
});
bindListener(settingsBackdrop, "click", () => {
closeSettingsModal();
});
bindListener(settingsReset, "click", () => {
resetEarthSettings();
});
bindListener(settingsModal, "click", (event) => {
const sheet = event.target.closest(".earth-settings-sheet");
if (!sheet) {
closeSettingsModal();
}
});
const toggleInputs = document.querySelectorAll("[data-settings-panel]");
toggleInputs.forEach((input) => {
bindListener(input, "change", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
const panelId = target.dataset.settingsPanel;
if (!panelId) return;
setHudPanelVisibility(panelId, target.checked);
});
});
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
if (terrainOpacitySlider instanceof HTMLInputElement) {
terrainOpacitySlider.value = nextOpacity.toFixed(2);
}
if (terrainOpacityValue) {
terrainOpacityValue.textContent = `${safeOpacity}%`;
}
};
syncTerrainOpacityUi(getTerrainOpacity());
if (terrainOpacitySlider instanceof HTMLInputElement) {
bindListener(terrainOpacitySlider, "input", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) return;
const nextOpacity = Number.parseFloat(target.value);
const appliedOpacity = setTerrainOpacity(
Number.isFinite(nextOpacity) ? nextOpacity : getTerrainOpacity(),
);
syncTerrainOpacityUi(appliedOpacity);
persistEarthSettings();
});
}
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);
});
});
const dayNightToggle = document.getElementById("toggle-daynight");
if (dayNightToggle instanceof HTMLInputElement) {
bindListener(dayNightToggle, "change", () => {
applyDayNightEnabled(dayNightToggle.checked);
});
}
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
syncAllHudPanelToggles();
syncRotationModeButtons();
syncDayNightToggle(dayNightEnabled);
}
function setupHudPanelControls() {
const closeButtons = document.querySelectorAll("[data-close-panel]");
closeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
event.stopPropagation();
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
const panelId = target.dataset.closePanel;
if (!panelId) return;
setHudPanelVisibility(panelId, false);
});
});
}
function capturePanelAnchor(app, panel, desiredLeft, desiredTop) {
// 拖拽期间:按用户给出的绝对位置重置 anchor轴模式回到 left/top。
// clamp 真的触发时由 syncPanelAnchorFromClamp 改写成 right/bottom 模式。
panel.dataset.anchorXSide = "left";
panel.dataset.anchorX = String(desiredLeft);
panel.dataset.anchorYSide = "top";
panel.dataset.anchorY = String(desiredTop);
}
function getHudScaleValue() {
const rawScale = getComputedStyle(document.documentElement)
.getPropertyValue("--hud-scale")
.trim();
const parsedScale = Number.parseFloat(rawScale);
return Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1;
}
function getPreferredHudEdgeGap() {
return HUD_EDGE_GAP_PX * getHudScaleValue();
}
function syncDraggedPanelSize(panel) {
const dragWidthBase = Number.parseFloat(panel.dataset.dragWidthBase ?? "");
if (!Number.isFinite(dragWidthBase)) return;
const nextWidth = dragWidthBase * getHudScaleValue();
panel.style.width = `${nextWidth}px`;
}
function syncPanelAnchorFromClamp(
app,
panel,
desiredLeft,
desiredTop,
clampedLeft,
clampedTop,
) {
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
// 只在 clamp 真的改了坐标时切换贴边方向:
// clamp 把 left 往小推 → 右边/下方的边碰到 panel 了 → 切到 right/bottom 模式。
// 这里保存的是“恢复时应回到的默认边距”,不是 shrink 期间瞬时的 0 间距。
// clamp 把 left 往大推 → 左边/上方的边(含 brand L 区)碰到 panel 了 → 记到 left/top 模式。
const preferredGap = getPreferredHudEdgeGap();
if (clampedLeft < desiredLeft) {
panel.dataset.anchorXSide = "right";
panel.dataset.anchorX = String(preferredGap);
} else if (clampedLeft > desiredLeft) {
panel.dataset.anchorXSide = "left";
panel.dataset.anchorX = String(clampedLeft <= 0 ? preferredGap : clampedLeft);
}
if (clampedTop < desiredTop) {
panel.dataset.anchorYSide = "bottom";
panel.dataset.anchorY = String(preferredGap);
} else if (clampedTop > desiredTop) {
panel.dataset.anchorYSide = "top";
panel.dataset.anchorY = String(clampedTop <= 0 ? preferredGap : clampedTop);
}
}
function resolveAnchorDesiredPosition(app, panel) {
syncDraggedPanelSize(panel);
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const anchorX = parseFloat(panel.dataset.anchorX ?? "");
const anchorY = parseFloat(panel.dataset.anchorY ?? "");
const fallbackLeft = parseFloat(panel.style.left) || 0;
const fallbackTop = parseFloat(panel.style.top) || 0;
const desiredLeft = Number.isFinite(anchorX)
? panel.dataset.anchorXSide === "right"
? appRect.width - panelRect.width - anchorX
: anchorX
: fallbackLeft;
const desiredTop = Number.isFinite(anchorY)
? panel.dataset.anchorYSide === "bottom"
? appRect.height - panelRect.height - anchorY
: anchorY
: fallbackTop;
return { desiredLeft, desiredTop };
}
function clampDraggedPanelPosition(app, panel, desiredLeft, desiredTop) {
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const brandPanel = document.getElementById("brand-panel");
const brandRect = brandPanel ? brandPanel.getBoundingClientRect() : null;
const brandBottom = brandRect ? brandRect.bottom - appRect.top : 0;
const brandRight = brandRect ? brandRect.right - appRect.left : 0;
const maxLeft = Math.max(0, appRect.width - panelRect.width);
const maxTop = Math.max(0, appRect.height - panelRect.height);
let nextLeft = Math.min(Math.max(desiredLeft, 0), maxLeft);
let nextTop = Math.min(Math.max(desiredTop, 0), maxTop);
// Brand 面板形成 L 形禁区panel 不能进入 brand 左上角矩形区域。
// 当两个轴同时越界时,比较两侧超出量——哪侧需要的调整量更小就卡哪侧。
// 从右侧滑入 → leftAdjust 小 → 卡右边;从下方滑入 → topAdjust 小 → 卡底边。
if (brandRect && nextLeft < brandRight && nextTop < brandBottom) {
const leftAdjust = brandRight - nextLeft;
const topAdjust = brandBottom - nextTop;
if (leftAdjust <= topAdjust) {
nextLeft = brandRight;
} else {
nextTop = brandBottom;
}
}
return { left: nextLeft, top: nextTop };
}
function setupDraggableHudPanels() {
const app = document.getElementById("container");
const draggablePanels = document.querySelectorAll(DRAGGABLE_PANEL_SELECTOR);
if (!app || draggablePanels.length === 0) return;
draggablePanels.forEach((panel) => {
const handle = panel.dataset.dragSelf === "true"
? panel
: panel.querySelector(".hud-panel-drag-handle");
if (!handle) return;
let isDragging = false;
let startPointerX = 0;
let startPointerY = 0;
let startLeft = 0;
let startTop = 0;
const stopDragging = () => {
isDragging = false;
panel.classList.remove("is-dragging");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!isDragging) return;
const desiredLeft = startLeft + (event.clientX - startPointerX);
const desiredTop = startTop + (event.clientY - startPointerY);
capturePanelAnchor(app, panel, desiredLeft, desiredTop);
const { left, top } = clampDraggedPanelPosition(
app,
panel,
desiredLeft,
desiredTop,
);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
};
bindListener(handle, "pointerdown", (event) => {
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
isDragging = true;
startPointerX = event.clientX;
startPointerY = event.clientY;
const appRect = app.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
// If panel is inside a flow container (not a direct child of app), reparent
// it so absolute positioning is relative to the app container.
if (panel.parentElement !== app) {
panel.dataset.originalParentId = panel.parentElement?.id || "";
panel.dataset.originalNextSiblingId = panel.nextElementSibling?.id || "";
const capturedWidth = panelRect.width;
panel.dataset.dragWidthBase = String(
capturedWidth / Math.max(getHudScaleValue(), 0.001),
);
panel.style.position = "absolute";
panel.style.width = `${capturedWidth}px`;
panel.style.margin = "0";
app.appendChild(panel);
}
startLeft = panelRect.left - appRect.left;
startTop = panelRect.top - appRect.top;
panel.style.left = `${startLeft}px`;
panel.style.top = `${startTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
capturePanelAnchor(app, panel, startLeft, startTop);
panel.classList.add("is-dragging");
document.body.style.userSelect = "none";
handle.setPointerCapture?.(event.pointerId);
});
bindListener(handle, "pointermove", onMove);
bindListener(handle, "pointerup", stopDragging);
bindListener(handle, "pointercancel", stopDragging);
bindListener(handle, "lostpointercapture", stopDragging);
});
const reclampDraggedPanels = () => {
draggablePanels.forEach((panel) => {
if (panel.dataset.dragged !== "true") return;
syncDraggedPanelSize(panel);
const { desiredLeft, desiredTop } = resolveAnchorDesiredPosition(
app,
panel,
);
const { left, top } = clampDraggedPanelPosition(
app,
panel,
desiredLeft,
desiredTop,
);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
syncPanelAnchorFromClamp(app, panel, desiredLeft, desiredTop, left, top);
});
};
bindListener(window, "resize", () => {
window.requestAnimationFrame(reclampDraggedPanels);
});
}
function clearForcedFloatingClose() {
getFloatingGroups().forEach((group) => {
group.classList.remove("force-closed");
});
}
function clearForcedFloatingCloseIfPointerOutside() {
getFloatingGroups().forEach((group) => {
if (!group.matches(":hover")) {
group.classList.remove("force-closed");
}
});
}
function setFloatingMenuOpen(group, shouldOpen) {
closeFloatingMenus();
clearForcedFloatingClose();
group?.classList.toggle("open", shouldOpen);
}
function clearSelectionIfHiding(shouldHide) {
if (shouldHide) {
clearLockedObject();
}
}
function bindFloatingMenu(trigger, group) {
bindListener(trigger, "click", (event) => {
event.stopPropagation();
const shouldOpen = !group?.classList.contains("open");
setFloatingMenuOpen(group, shouldOpen);
});
bindListener(group, "click", (event) => {
event.stopPropagation();
});
}
function updateTVToggleUI(visible) {
const btn = document.getElementById("toggle-tv");
if (!btn) return;
btn.classList.toggle("active", visible);
setButtonTooltip(btn, visible ? "关闭新闻直播" : "打开新闻直播");
}
function bindListener(element, eventName, handler, options) {
if (!element) return;
element.addEventListener(eventName, handler, options);
listeners.push(() =>
element.removeEventListener(eventName, handler, options),
);
}
function resetCleanup() {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns = [];
listeners.forEach((cleanup) => cleanup());
listeners = [];
}
export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
earthObj = earth;
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
}
function setupZoomControls(camera) {
let zoomInterval = null;
let holdTimeout = null;
let startTime = 0;
const HOLD_THRESHOLD = 150;
const LONG_PRESS_TICK = 50;
const CLICK_STEP = 10;
const MIN_PERCENT = CONFIG.minZoom * 100;
const MAX_PERCENT = CONFIG.maxZoom * 100;
function doZoomStep(direction) {
let currentPercent = Math.round(zoomLevel * 100);
let newPercent =
direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
zoomLevel = newPercent / 100;
applyZoom(camera);
}
function doContinuousZoom(direction) {
let currentPercent = Math.round(zoomLevel * 100);
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
zoomLevel = newPercent / 100;
applyZoom(camera);
}
function startContinuousZoom(direction) {
doContinuousZoom(direction);
zoomInterval = window.setInterval(() => {
doContinuousZoom(direction);
}, LONG_PRESS_TICK);
}
function stopZoom() {
if (zoomInterval) {
clearInterval(zoomInterval);
zoomInterval = null;
}
if (holdTimeout) {
clearTimeout(holdTimeout);
holdTimeout = null;
}
}
function handleMouseDown(direction) {
startTime = Date.now();
stopZoom();
holdTimeout = window.setTimeout(() => {
startContinuousZoom(direction);
}, HOLD_THRESHOLD);
}
function handleMouseUp(direction) {
const heldTime = Date.now() - startTime;
stopZoom();
if (heldTime < HOLD_THRESHOLD) {
doZoomStep(direction);
}
}
cleanupFns.push(stopZoom);
const zoomIn = document.getElementById("zoom-in");
const zoomOut = document.getElementById("zoom-out");
const zoomValue = document.getElementById("zoom-value");
bindListener(zoomIn, "mousedown", () => handleMouseDown(1));
bindListener(zoomIn, "mouseup", () => handleMouseUp(1));
bindListener(zoomIn, "mouseleave", stopZoom);
bindListener(zoomIn, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(1);
});
bindListener(zoomIn, "touchend", () => handleMouseUp(1));
bindListener(zoomOut, "mousedown", () => handleMouseDown(-1));
bindListener(zoomOut, "mouseup", () => handleMouseUp(-1));
bindListener(zoomOut, "mouseleave", stopZoom);
bindListener(zoomOut, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(-1);
});
bindListener(zoomOut, "touchend", () => handleMouseUp(-1));
bindListener(zoomValue, "click", () => {
const startZoomVal = zoomLevel;
const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
animateValue(
0,
1,
600,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
const distance =
startDistance + (targetDistance - startDistance) * ease;
updateZoomDisplay(zoomLevel, distance.toFixed(0));
},
() => {
zoomLevel = 1.0;
showStatusMessage("缩放已重置到100%", "info");
},
);
});
}
function setupWheelZoom(camera, renderer) {
bindListener(
renderer?.domElement,
"wheel",
(e) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
} else {
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
},
{ passive: false },
);
}
function applyZoom(camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
const distance = camera.position.z.toFixed(0);
updateZoomDisplay(zoomLevel, distance);
}
function animateValue(start, end, duration, onUpdate, onComplete) {
const animationToken = ++focusViewAnimationToken;
const startTime = performance.now();
function update(currentTime) {
if (animationToken !== focusViewAnimationToken) {
return;
}
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const current = start + (end - start) * easeProgress;
onUpdate(current);
if (progress < 1) {
requestAnimationFrame(update);
} else if (onComplete && animationToken === focusViewAnimationToken) {
onComplete();
}
}
requestAnimationFrame(update);
}
export function resetView(camera) {
if (!earthObj) return;
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) =>
focusEarthView(camera, {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
rotLon: pos.coords.longitude - 270,
zoom: 1.0,
duration: 800,
suppressStatus: false,
}),
() =>
focusEarthView(camera, {
lat: EARTH_CONFIG.chinaLat,
lon: EARTH_CONFIG.chinaLon,
rotLon: EARTH_CONFIG.chinaRotLon,
zoom: 1.0,
duration: 800,
suppressStatus: false,
}),
{ timeout: 5000, enableHighAccuracy: false },
);
} else {
focusEarthView(camera, {
lat: EARTH_CONFIG.chinaLat,
lon: EARTH_CONFIG.chinaLon,
rotLon: EARTH_CONFIG.chinaRotLon,
zoom: 1.0,
duration: 800,
suppressStatus: false,
});
}
clearLockedObject();
}
function setupRotateControls(camera) {
const rotateBtn = document.getElementById("rotate-toggle");
const resetViewBtn = document.getElementById("reset-view");
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
const label = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
});
updateRotateUI();
bindListener(resetViewBtn, "click", () => {
resetView(camera);
});
}
function filterLayerRows(query, emptyStateEl, clearBtn) {
const rows = document.querySelectorAll("#layer-panel-list .layer-row");
let visibleCount = 0;
rows.forEach((row) => {
const name = (row.dataset.layerName || "").toLowerCase();
const matches = !query || name.includes(query);
row.hidden = !matches;
if (matches) visibleCount++;
});
if (emptyStateEl) emptyStateEl.hidden = visibleCount > 0;
if (clearBtn) clearBtn.hidden = !query;
}
function setupLayerPanel() {
const panel = document.getElementById("layer-toggles");
const collapseBtn = document.getElementById("layer-panel-collapse");
const searchInput = document.getElementById("layer-search-input");
const searchClear = document.getElementById("layer-search-clear");
const emptyState = document.getElementById("layer-panel-empty");
if (!panel) return;
const layerPanel = createHUDPanel({
panel,
header: ".layer-panel-header",
body: "#layer-panel-body",
collapseBtn,
collapsedClass: "layer-panel--collapsed",
preferredDirection: "down",
expandLabel: "展开图层列表",
collapseLabel: "折叠图层列表",
});
bindListener(collapseBtn, "click", (e) => {
e.stopPropagation();
layerPanel.setCollapsed(!layerPanel.isCollapsed());
});
if (searchInput) {
bindListener(searchInput, "input", () => {
const query = searchInput.value.trim().toLowerCase();
filterLayerRows(query, emptyState, searchClear);
});
if (searchClear) {
bindListener(searchClear, "click", () => {
searchInput.value = "";
filterLayerRows("", emptyState, searchClear);
searchInput.focus();
});
}
}
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.classList.contains("is-loading")) {
return;
}
await definition.setVisible(!definition.getVisible());
});
button.dataset.layerBound = "true";
}
export function registerLayer({
id,
icon,
label,
meta = "",
keywords = "",
defaultActive = false,
persist = true,
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,
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 }));
}
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
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();
bindListener(searchBtn, "click", () => {
showStatusMessage("搜索功能待开发", "info");
});
bindListener(terrainBtn, "pointerenter", () => {
prewarmTerrainIfNeeded();
});
bindListener(terrainBtn, "focus", () => {
prewarmTerrainIfNeeded();
});
scheduleTerrainPrefetch();
bindListener(reloadBtn, "click", async () => {
await reloadData();
});
bindFloatingMenu(zoomTrigger, zoomGroup);
bindListener(document, "click", (event) => {
const openGroups = [zoomGroup].filter((group) =>
group?.classList.contains("open"),
);
if (openGroups.length === 0) return;
const clickedInsideOpenGroup = openGroups.some((group) =>
group.contains(event.target),
);
if (!clickedInsideOpenGroup) {
closeFloatingMenus();
}
});
bindListener(document, "mousemove", () => {
clearForcedFloatingCloseIfPointerOutside();
});
bindListener(layoutBtn, "click", () => {
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
});
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
updateTVToggleUI(mediaVisible);
if (mediaVisible) {
ensureTVPanelReady().catch((error) => {
console.error("初始化电视直播面板失败:", error);
});
}
updateNewsToggleUI(mediaVisible);
ensureNewsPanelReady().catch((error) => {
console.error("初始化态势新闻内容失败:", error);
});
updateLayoutUI(container);
}
function setupKeyboardControls() {
bindListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
if (isSettingsModalOpen()) {
closeSettingsModal();
return;
}
if (isFloatingMenuVisible()) {
closeFloatingMenus();
return;
}
clearLockedObjectAndInfo();
});
}
function setupLiquidGlassInteractions() {
const surfaces = document.querySelectorAll(".liquid-glass-surface, .hud-panel");
const resetSurface = (surface) => {
surface.style.setProperty("--elastic-x", "0px");
surface.style.setProperty("--elastic-y", "0px");
surface.style.setProperty("--tilt-x", "0deg");
surface.style.setProperty("--tilt-y", "0deg");
surface.style.setProperty("--panel-tilt-x", "0deg");
surface.style.setProperty("--panel-tilt-y", "0deg");
surface.style.setProperty("--dock-scale", "1");
surface.style.setProperty("--dock-lift", "0px");
surface.style.setProperty("--dock-shift-x", "0px");
surface.style.setProperty("--glow-x", "50%");
surface.style.setProperty("--glow-y", "22%");
surface.style.setProperty("--glow-opacity", "0.24");
surface.style.setProperty("--panel-glow-x", "18%");
surface.style.setProperty("--panel-glow-y", "0%");
surface.style.setProperty("--panel-glow-opacity", "0.1");
surface.classList.remove("dock-focused");
surface.classList.remove("dock-active");
surface.classList.remove("is-pressed");
};
surfaces.forEach((surface) => {
resetSurface(surface);
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar-items"));
const isPanelSurface = surface.classList.contains("hud-panel");
bindListener(surface, "pointermove", (event) => {
const rect = surface.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width;
const py = (event.clientY - rect.top) / rect.height;
if (isPanelSurface) {
const panelTiltX = (0.5 - py) * 5;
const panelTiltY = (px - 0.5) * 6;
surface.style.setProperty("--panel-glow-x", `${(px * 100).toFixed(1)}%`);
surface.style.setProperty("--panel-glow-y", `${(py * 100).toFixed(1)}%`);
surface.style.setProperty("--panel-glow-opacity", "0.16");
surface.style.setProperty("--panel-tilt-x", `${panelTiltX.toFixed(2)}deg`);
surface.style.setProperty("--panel-tilt-y", `${panelTiltY.toFixed(2)}deg`);
} else if (!isToolbarSurface) {
const offsetX = (px - 0.5) * 6;
const offsetY = (py - 0.5) * 6;
const tiltX = (0.5 - py) * 8;
const tiltY = (px - 0.5) * 10;
surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`);
surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`);
surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`);
surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`);
}
surface.style.setProperty("--glow-x", `${(px * 100).toFixed(1)}%`);
surface.style.setProperty("--glow-y", `${(py * 100).toFixed(1)}%`);
surface.style.setProperty("--glow-opacity", "0.34");
});
bindListener(surface, "pointerenter", () => {
if (isPanelSurface) {
surface.style.setProperty("--panel-glow-opacity", "0.14");
} else {
surface.style.setProperty("--glow-opacity", "0.28");
}
});
bindListener(surface, "pointerleave", () => {
resetSurface(surface);
});
bindListener(surface, "pointerdown", () => {
surface.classList.add("is-pressed");
});
bindListener(surface, "pointerup", () => {
surface.classList.remove("is-pressed");
});
bindListener(surface, "pointercancel", () => {
resetSurface(surface);
});
});
}
function setupToolbarHubCluster() {
const cluster = document.getElementById("toolbar-cluster");
const hub = document.getElementById("toolbar-hub");
const toolbar = document.getElementById("control-toolbar");
if (
!(cluster instanceof HTMLElement) ||
!(hub instanceof HTMLButtonElement) ||
!(toolbar instanceof HTMLElement)
) {
return;
}
let collapseTimer = null;
const layoutToolbarOrbs = () => {
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"));
if (orbs.length === 0) return;
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
const orbCount = orbs.length;
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.height = `${Math.ceil(toolbarHeight)}px`;
toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`);
toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`);
toolbar.style.setProperty("--toolbar-inner-arc-width", `${Math.ceil(span * 0.72)}px`);
toolbar.style.setProperty("--toolbar-inner-arc-height", `${Math.ceil((hubSize * 0.8) + (desiredGap * 0.5))}px`);
orbs.forEach((orb, index) => {
const t = orbCount === 1 ? 0.5 : index / (orbCount - 1);
const x = (t - 0.5) * span;
const normalized = (x / (span / 2 || 1));
const y = -(1 - normalized * normalized) * rise;
orb.style.setProperty("--orb-x", `${x.toFixed(2)}px`);
orb.style.setProperty("--orb-y", `${y.toFixed(2)}px`);
});
};
const setExpanded = (expanded) => {
cluster.classList.toggle("is-expanded", expanded);
cluster.classList.toggle("is-collapsed", !expanded);
};
const scheduleCollapse = () => {
if (collapseTimer) clearTimeout(collapseTimer);
collapseTimer = window.setTimeout(() => {
setExpanded(false);
collapseTimer = null;
}, 200);
};
const cancelCollapse = () => {
if (collapseTimer) {
clearTimeout(collapseTimer);
collapseTimer = null;
}
};
cleanupFns.push(() => {
if (collapseTimer) clearTimeout(collapseTimer);
});
// Start collapsed — hub acts as the hover target to reveal the arc
layoutToolbarOrbs();
setExpanded(false);
bindListener(window, "resize", layoutToolbarOrbs);
bindListener(hub, "mouseenter", () => {
cancelCollapse();
setExpanded(true);
});
bindListener(hub, "focus", () => {
cancelCollapse();
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,
};
};
bindListener(document, "mousemove", (event) => {
if (!cluster.classList.contains("is-expanded")) return;
const activeBounds = collectExpandedToolbarBounds();
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();
}
});
}
export function teardownControls() {
resetCleanup();
}
export function getAutoRotate() {
return autoRotate;
}
function getRotationModeLabel(mode = rotationMode) {
return mode === ROTATION_MODE.CRUISE ? "巡航模式" : "旋转模式";
}
function syncRotationModeButtons() {
const buttons = document.querySelectorAll("[data-rotation-mode]");
buttons.forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const isActive = button.dataset.rotationMode === rotationMode;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
function updateRotateUI() {
const btn = document.getElementById("rotate-toggle");
if (btn) {
btn.classList.toggle("active", autoRotate);
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
const activeLabel =
rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
if (tooltip) {
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
}
btn.title = `${getRotationModeLabel()} · ${activeLabel}`;
}
syncRotationModeButtons();
}
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
if (rotationMode === ROTATION_MODE.CRUISE) {
dispatchRotationModeChange();
}
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
if (rotationMode === ROTATION_MODE.CRUISE) {
dispatchRotationModeChange();
}
return autoRotate;
}
export function getRotationMode() {
return rotationMode;
}
export function setRotationMode(nextMode, { persist = true, suppressStatus = false } = {}) {
const normalizedMode =
nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE;
const changed = normalizedMode !== rotationMode;
if (changed && normalizedMode === ROTATION_MODE.CRUISE) {
autoRotate = true;
}
rotationMode = normalizedMode;
updateRotateUI();
dispatchRotationModeChange();
if (persist) {
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(
normalizedMode === ROTATION_MODE.CRUISE ? "已切换到巡航模式" : "已切换到旋转模式",
"info",
);
}
}
export function focusEarthView(camera, options = {}) {
if (!earthObj || !camera) return Promise.resolve();
const {
lat = EARTH_CONFIG.chinaLat,
lon = EARTH_CONFIG.chinaLon,
rotLon = lon - 270,
zoom = 1.0,
duration = 800,
suppressStatus = true,
} = options;
return new Promise((resolve) => {
const nextRotation = getViewRotation(lat, rotLon);
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
animateValue(
0,
1,
duration,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
zoomLevel = startZoom + (zoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
},
() => {
zoomLevel = zoom;
if (!suppressStatus) {
showStatusMessage("视角已重置", "info");
}
resolve();
},
);
});
}
export function getZoomLevel() {
return zoomLevel;
}
export function getShowTerrain() {
return showTerrain;
}
function updateLayoutUI(container) {
if (container) {
container.classList.toggle("layout-expanded", layoutExpanded);
}
const btn = document.getElementById("layout-toggle");
if (btn) {
btn.classList.toggle("active", layoutExpanded);
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
btn.title = nextLabel;
if (tooltip) tooltip.textContent = nextLabel;
}
}
function resetPanelInlineLayout(panel) {
const originalParentId = panel.dataset.originalParentId;
if (originalParentId) {
const originalParent = document.getElementById(originalParentId);
if (originalParent) {
const nextId = panel.dataset.originalNextSiblingId;
const nextSibling = nextId ? document.getElementById(nextId) : null;
if (nextSibling) {
originalParent.insertBefore(panel, nextSibling);
} else {
originalParent.appendChild(panel);
}
}
delete panel.dataset.originalParentId;
delete panel.dataset.originalNextSiblingId;
}
panel.style.left = "";
panel.style.top = "";
panel.style.right = "";
panel.style.bottom = "";
panel.style.transform = "";
panel.style.position = "";
panel.style.width = "";
panel.style.margin = "";
delete panel.dataset.dragged;
delete panel.dataset.anchorX;
delete panel.dataset.anchorY;
delete panel.dataset.anchorXSide;
delete panel.dataset.anchorYSide;
delete panel.dataset.dragWidthBase;
}
function isPanelVisible(panel) {
return !panel.classList.contains("hud-panel-hidden");
}
function animatePanelLayoutTransition(container, expand) {
const panels = Array.from(
container.querySelectorAll(DRAGGABLE_PANEL_SELECTOR),
);
if (panels.length === 0) {
layoutExpanded = expand;
updateLayoutUI(container);
return expand;
}
const visiblePanels = panels.filter(isPanelVisible);
const firstRects = new Map(
visiblePanels.map((panel) => [panel, panel.getBoundingClientRect()]),
);
panels.forEach((panel) => panel.classList.add("is-layout-animating"));
layoutExpanded = expand;
panels.forEach(resetPanelInlineLayout);
updateLayoutUI(container);
visiblePanels.forEach((panel) => {
const firstRect = firstRects.get(panel);
if (!firstRect) return;
const lastRect = panel.getBoundingClientRect();
const deltaX = firstRect.left - lastRect.left;
const deltaY = firstRect.top - lastRect.top;
if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) {
return;
}
panel.animate(
[
{
translate: `${deltaX}px ${deltaY}px`,
},
{
translate: "0 0",
},
],
{
duration: PANEL_LAYOUT_ANIMATION_MS,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
},
);
});
window.setTimeout(() => {
panels.forEach((panel) => panel.classList.remove("is-layout-animating"));
}, PANEL_LAYOUT_ANIMATION_MS);
return expand;
}
function toggleLayoutExpanded(container) {
return animatePanelLayoutTransition(container, !layoutExpanded);
}