Files
planet/frontend/public/earth/js/main.js
linkong 5bdb55f3f1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.0
2026-06-30 13:52:52 +08:00

6433 lines
201 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.
import * as THREE from "three";
import {
earthMessage,
getEarthLocale,
initEarthI18n,
localizeCountryName,
onEarthLocaleChange,
translateText,
} from "./i18n.js";
import {
CONFIG,
CRUISE_MODULES,
CRUISE_QUEUE_MODES,
HUD_CONFIG,
CABLE_CONFIG,
CABLE_STATE,
SATELLITE_CONFIG,
PATHS,
BGP_CONFIG,
CRUISE_CONFIG,
ROTATION_MODE,
SCENE_LIGHT_CONFIG,
SURFACE_HOVER_INFO_MODES,
} from "./constants.js";
import {
canAttemptEarthRealtime,
getEarthRealtimeCooldownMs,
getEarthRealtimeUrl,
recordEarthRealtimeFailure,
recordEarthRealtimeOpen,
} from "./realtime.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
queueStatusMessage,
updateCoordinatesDisplay,
updateEarthStats,
setEarthStatValue,
setLoading,
setLoadingMessage,
showTooltip,
hideTooltip,
showError,
hideError,
clearUiState,
} from "./ui.js";
import {
createEarth,
createClouds,
createTerrain,
getShowClouds,
createGridLines,
getEarth,
getEarthSurfacePickTarget,
loadCloudTexture,
loadEarthTexture,
clearEarthTexture,
setEarthSunDirection,
setEarthTextureVisible,
getEarthTextureVisible,
toggleClouds,
toggleTerrain,
} from "./earth.js";
import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js";
import {
initCelestialLayer,
updateCelestialLayer,
disposeCelestialLayer,
getCelestialDebugState,
getSunDirection,
setCelestialOrientation,
setCelestialFollow,
setCelestialDayNightEnabled,
} from "./celestial.js";
import {
clearCountryBoundaryData,
clearCountryBoundaryHover,
createCountryBoundaryLayer,
ensureCountryBoundariesReady,
getCountryBoundaryLegendItems,
getShowCountryBoundaries,
setLandFillEnabled,
setLandFillSuppressed,
setSurfaceTintEnabled,
toggleCountryBoundaries,
updateCountryBoundaryHover,
updateCountryBoundaryTiles,
} from "./country-boundaries.js";
import {
loadGeoJSONFromPath,
loadLandingPoints,
handleCableClick,
clearCableSelection,
getCableLines,
getCableLegendItems,
getCableState,
setCableState,
clearAllCableStates,
applyLandingPointVisualState,
resetLandingPointVisualState,
getCableSourceRecordCount,
getShowCables,
clearCableData,
getLandingPoints,
toggleCables,
} from "./cables.js";
import {
createSatellites,
loadSatellites,
updateSatellitePositions,
toggleSatellites,
getShowSatellites,
getSatelliteLegendItems,
getSatelliteData,
setSelectedSatelliteLegend,
clearSelectedSatelliteLegend,
getSatelliteCount,
getSatelliteAt,
selectSatellite,
getSatellitePoints,
getSatellitePresentationInfo,
setSatelliteRingState,
updateLockedRingPosition,
updateHoverRingPosition,
getSatellitePositions,
showPredictedOrbit,
hidePredictedOrbit,
highlightRelatedSatellites,
clearRelatedSatelliteHighlights,
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
updateSatelliteIdleBreathingVisual,
updateSatellitePointSize,
isSatelliteFrontFacing,
setSatelliteCamera,
setSatelliteSunDirection,
setLockedSatelliteIndex,
setHoveredSatelliteIndex,
resetSatelliteState,
clearSatelliteData,
} from "./satellites.js";
import {
loadBGPAnomalies,
getBGPAnomalyMarkers,
getBGPAnomalyPointerIntersections as getBGPEventIconPointerIntersections,
getBGPCollectorPointerIntersections as getBGPCollectorIconPointerIntersections,
getBGPCollectorMarkers,
getBGPLegendItems,
getBGPCount,
getBGPCollectorCount,
getBGPStatusSummary,
getShowBGP,
clearBGPSelection,
setBGPMarkerState,
updateBGPVisualState,
clearBGPData,
toggleBGP,
formatBGPAnomalyTypeLabel,
formatBGPASPath,
formatBGPCollectorStatus,
formatBGPConfidence,
formatBGPImpactedScope,
formatBGPLocation,
formatBGPObservedTime,
formatBGPObservedBy,
formatBGPRelatedCables,
formatBGPRouteChange,
formatBGPTopEventTypes,
formatBGPScope,
formatBGPCollectorCoverageHalo,
formatBGPSeverityLabel,
formatBGPStatusLabel,
showBGPEventOverlay,
showBGPCollectorCoverageOverlay,
} from "./bgp.js";
import {
clearComputeCenterData,
clearComputeCenterSelection,
clearComputeCenterLocationPreview,
formatComputeCenterCapacity,
formatComputeCenterLocationPrecision,
formatComputeCenterLocationConfidence,
formatComputeCenterLocationSource,
formatComputeCenterNeedsConfirmation,
formatComputeCenterTypeLabel,
formatComputeCenterUpdatedAt,
getComputeCenterCount,
getComputeCenterLegendItems,
getComputeCenterMarkers,
getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections,
getShowComputeCenters,
getUnresolvedComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
showComputeCenterLocationPreview,
spawnSavedComputeCenterLocation,
toggleComputeCenters,
updateComputeCenterVisualState,
} from "./compute-centers.js";
import {
clearVesselData,
clearVesselSelection,
getShowVessels,
getVesselCount,
getVesselLegendItems,
getVesselMarkers,
getVesselPointerIntersections as getVesselIconPointerIntersections,
getVesselRealtimeStats,
loadVessels,
setVesselMarkerState,
showVesselTrack,
startVesselRealtime,
stopVesselRealtime,
toggleVessels,
updateVesselVisualState,
} from "./vessels.js";
import {
applyEarthInteractableEvent,
clearEarthInteractableSelection,
clearEarthInteractables,
getEarthInteractablePointerIntersections as getEarthInteractableIconPointerIntersections,
loadEarthInteractables,
refreshEarthInteractables,
setEarthInteractableMarkerState,
updateEarthInteractableVisualState,
} from "./earth-interactables.js";
import {
setupControls,
getAutoRotate,
getCruiseModules,
getCruiseQueueMode,
getCruiseRegionOrder,
getAutoRotationSpeed,
getRotationMode,
getShowTerrain,
getStartupLoadLayers,
setAutoRotate,
applyImmediateView,
focusEarthView,
getZoomLevel,
getDefaultEarthZoomLevel,
setZoomLevel,
showZoomStatusCapsule,
teardownControls,
getDayNightEnabled,
getMotionDebugEnabled,
getMotionDebugSkeletonOnly,
getMotionEnabledGestures,
getMotionProvider,
getVisibleMotionLayerDefinitions,
setMotionDebugEnabled,
setDayNightEnabledExternal,
setTerrainLayerInteractable,
setDayNightInteractable,
applyDeferredLayerVisibilitySettings,
scheduleTerrainPrefetch,
getSurfaceHoverInfoMode,
} from "./controls.js";
import {
createLayerStartupTaskMap,
resolveStartupMessage,
} from "./layer-startup-tasks.js";
import {
setLayerButtonState,
} from "./layer-button-state.js";
import {
CalloutConnector,
} from "./callout-connector.js";
import { CruiseSequencer } from "./cruise-sequencer.js";
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
import { createNewsCruiseAdapter } from "./news-cruise-adapter.js";
import { createMotionCruiseAdapter } from "./motion-cruise-adapter.js";
import { PresentationController } from "./presentation-controller.js";
import {
initInfoCard,
showInfoCard,
hideInfoCard,
isInfoCardSticky,
shouldPreserveInfoCardOnCruiseInterrupt,
} from "./info-card.js";
import {
initLegend,
setLegendMode,
refreshLegend,
setLegendItems,
} from "./legend.js";
import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
import { initEarthAbout } from "./about.js";
import { initEarthOobe } from "./oobe.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, refreshEarthNews, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js";
import {
registerEarthClientErrorHandlers,
reportEarthClientLog,
} from "./client-logs.js";
import {
clearMobileCenterCountryHighlight,
updateMobileCenterCountryHighlight,
} from "./mobile-center-country-highlight.js";
import {
createMotionControlAdapter,
} from "./motion-control.js";
import {
MOTION_DEBUG_CLOSE_EVENT,
} from "./motion-events.js";
import {
initMotionDebugPanel,
setMotionDebugPanelSkeletonOnly,
setMotionDebugPanelVisible,
} from "./motion-debug-panel.js";
initEarthI18n();
window.addEventListener("earth:status", (event) => {
const message = event.detail?.message;
if (message) showStatusMessage(message, event.detail?.type || "info");
});
const EARTH_RADIUS_KM = 6371;
const EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 = 398600.4418;
const SECONDS_PER_DAY = 86400;
export let scene;
export let camera;
export let renderer;
const WEBGL_GPU_DIAGNOSTICS_EVENT = "earth:gpu-diagnostics";
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
let targetRotation = { x: 0, y: 0 };
let inertialVelocity = { x: 0, y: 0 };
let hoveredCable = null;
let hoveredBGP = null;
let hoveredComputeCenter = null;
let hoveredVessel = null;
let hoveredEarthInteractable = null;
let hoveredSatellite = null;
let hoveredSatelliteIndex = null;
let lockedSatellite = null;
let lockedSatelliteIndex = null;
let lockedObject = null;
let lockedObjectType = null;
let dragStartTime = 0;
let isLongDrag = false;
let lastSatClickTime = 0;
let lastSatClickIndex = 0;
let lastSatClickPos = { x: 0, y: 0 };
let lastBGPClickTime = 0;
let lastBGPClickCollector = null;
let lastBGPClickType = null;
let lastBGPClickPos = { x: 0, y: 0 };
let lastVesselHoverPickTime = 0;
let earthTexture = null;
let animationFrameId = null;
let initialized = false;
let destroyed = false;
let runtimeBrandConfig = null;
let isDataLoading = false;
let currentLoadToken = 0;
let cablesEnabled = true;
let satellitesEnabled = false;
let vesselsEnabled = false;
let cableToggleToken = 0;
let satelliteToggleToken = 0;
let satelliteHydrationToken = 0;
let sceneLights = null;
let cruisePollTimerId = null;
let calloutConnector = null;
let cruiseBGPAdapter = null;
let cruiseNewsAdapter = null;
let cruiseSequencer = null;
function getEarthBrandLanguage() {
return getEarthLocale() === "en-US" ? "en" : (HUD_CONFIG.brandLanguage || "zh");
}
function getLocalizedBrandConfig(config = null) {
if (config && typeof config === "object") {
return { ...config, variant: config.variant || getEarthBrandLanguage() };
}
return getEarthBrandLanguage();
}
function remountEarthBrand() {
mountBrand(document.getElementById("brand-root"), getLocalizedBrandConfig(runtimeBrandConfig));
}
let cruiseRandomQueueSignature = "";
let cruiseRandomQueueItems = [];
let earthUpdatesSocket = null;
let earthUpdatesReconnectTimer = null;
let earthDataReconcileTimer = null;
let earthDataReconcileInFlight = false;
let earthUpdateFlushTimer = null;
let earthUpdateFlushPromise = null;
const pendingEarthUpdateLayers = new Set();
const pendingEarthUpdateStrategies = new Map();
let pendingEarthUpdateIsDatabaseChange = false;
let presentationController = null;
let motionControlAdapter = null;
let motionCruiseAdapter = null;
let motionCruiseSequencer = null;
let motionSharedCruiseSequencer = null;
let motionFocusCandidates = [];
let motionFocusIndex = 0;
let motionFocusedCandidate = null;
let motionFocusLayerId = null;
let lastMotionFocusRefreshAt = 0;
const motionActionLocks = new Set();
let activeToolInfoCard = null;
let toolInfoCardToken = 0;
let earthStatsSummary = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
let pinchGesture = null;
let pointerDragDistance = 0;
let suppressNextClick = false;
let shouldRefreshSatellitesAfterVisibilityResume = false;
const EARTH_UPDATES_CHANNEL = "earth_updates";
const EARTH_UPDATES_RECONNECT_DELAY_MS = 5000;
const EARTH_DATA_RECONCILE_INTERVAL_MS = 60000;
const EARTH_UPDATE_CLIENT_DEBOUNCE_MS = 250;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
const interactionMouse = new THREE.Vector2();
const scratchCameraToEarth = new THREE.Vector3();
const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3();
const scratchSatelliteWorldPosition = new THREE.Vector3();
const scratchSatelliteScreenPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = [];
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
const CRUISE_TRANSITION_GAP_MS = 24;
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8;
const VESSEL_HOVER_PICK_INTERVAL_MS = 100;
const VESSEL_POINTER_RADIUS_PX = 22;
const INTERACTABLE_POINTER_RADIUS_PX = 24;
const MOTION_FOCUS_RADIUS_PX = 180;
const MOTION_FOCUS_REFRESH_MS = 350;
function isEnglishEarthLocale() {
return getEarthLocale() === "en-US";
}
function formatUnresolvedComputeTitle(count) {
return isEnglishEarthLocale()
? `${count} compute centers pending location`
: `${count} 个算力中心待定位`;
}
function formatUnresolvedComputeSuffix(count) {
if (count <= 0) return "";
return isEnglishEarthLocale() ? ` (${count} pending location)` : `${count} 个待定位)`;
}
const MOTION_MARKER_ANCHOR_SIZE_PX = 24;
const MOTION_SATELLITE_ANCHOR_SIZE_PX = 18;
const MOTION_CABLE_ANCHOR_SIZE_PX = 14;
const INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
const INTERACTABLE_CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
const INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX = 12;
const INTERACTABLE_CRUISE_PRESENTATION_HIDE_MS = 220;
const TARGET_SWITCH_DURATION_SCALE = 1.12;
const MOTION_ROTATION_DELTA = 0.095;
const MOTION_INERTIA_FACTOR = 0.65;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
"#info-panel",
"#info-panel *",
"#right-toolbar-group",
"#right-toolbar-group *",
"#legend",
"#legend *",
"#earth-stats",
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#desktop-news-ticker",
"#desktop-news-ticker *",
"#news-hud-panel",
"#news-hud-panel *",
"#motion-debug-panel",
"#motion-debug-panel *",
"#mobile-drawer-shell",
"#mobile-drawer-shell *",
];
function bindListener(target, eventName, handler, options) {
if (!target) return;
target.addEventListener(eventName, handler, options);
cleanupFns.push(() =>
target.removeEventListener(eventName, handler, options),
);
}
function getViewportAspect() {
return window.innerWidth / window.innerHeight;
}
function getWebGLGpuDiagnostics(activeRenderer) {
const gl = activeRenderer?.getContext?.();
if (!gl) return null;
const debugInfo = gl.getExtension?.("WEBGL_debug_renderer_info");
const attrs = gl.getContextAttributes?.() || {};
return {
requestedPowerPreference: "high-performance",
contextPowerPreference: attrs.powerPreference || null,
vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
antialias: Boolean(attrs.antialias),
alpha: Boolean(attrs.alpha),
};
}
function publishWebGLGpuDiagnostics(activeRenderer) {
const diagnostics = getWebGLGpuDiagnostics(activeRenderer);
if (!diagnostics) return;
window.__planetEarthGpu = diagnostics;
console.info("[智能星球] WebGL GPU diagnostics", diagnostics);
window.dispatchEvent(new CustomEvent(WEBGL_GPU_DIAGNOSTICS_EVENT, { detail: diagnostics }));
}
function syncRendererViewport() {
if (!camera || !renderer) return;
camera.aspect = getViewportAspect();
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function isEventOnHud(event) {
const target = event?.target;
if (!(target instanceof Element)) return false;
return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector));
}
function clearDocumentSelection() {
const selection = window.getSelection?.();
if (!selection || selection.rangeCount === 0) return;
selection.removeAllRanges();
}
function setGlobeDraggingUiState(active) {
document.body.classList.toggle(GLOBE_DRAGGING_CLASS, active);
document.documentElement.classList.toggle(GLOBE_DRAGGING_CLASS, active);
}
function hasActiveGlobeInertia() {
return (
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY
);
}
function getDragRotationFactor() {
const zoom = Math.max(getZoomLevel(), 0.01);
const scale = THREE.MathUtils.clamp(
Math.pow(zoom, -CONFIG.dragRotationZoomExponent),
CONFIG.dragRotationScaleMin,
CONFIG.dragRotationScaleMax,
);
return CONFIG.dragRotationFactorBase * scale;
}
function getTargetSwitchZoomOptions(requestedZoom) {
const currentZoom = getZoomLevel();
const defaultZoom = getDefaultEarthZoomLevel();
if (Math.abs(currentZoom - defaultZoom) <= 0.005) {
return { zoom: requestedZoom };
}
return {
zoom: currentZoom,
zoomTransitionMode: "restore-current-via-default",
};
}
function focusTargetSwitchView(options = {}) {
const requestedZoom = options.zoom ?? getDefaultEarthZoomLevel();
const zoomOptions = getTargetSwitchZoomOptions(requestedZoom);
const duration = zoomOptions.zoomTransitionMode === "restore-current-via-default"
? Math.round((options.duration ?? CRUISE_CONFIG.focusDurationMs) * TARGET_SWITCH_DURATION_SCALE)
: options.duration;
return focusEarthView(camera, {
...options,
...zoomOptions,
duration,
});
}
function getTouchDistance(firstPoint, secondPoint) {
return Math.hypot(
secondPoint.clientX - firstPoint.clientX,
secondPoint.clientY - firstPoint.clientY,
);
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
if (material.map) material.map.dispose();
if (material.alphaMap) material.alphaMap.dispose();
if (material.aoMap) material.aoMap.dispose();
if (material.bumpMap) material.bumpMap.dispose();
if (material.displacementMap) material.displacementMap.dispose();
if (material.emissiveMap) material.emissiveMap.dispose();
if (material.envMap) material.envMap.dispose();
if (material.lightMap) material.lightMap.dispose();
if (material.metalnessMap) material.metalnessMap.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
if (material.specularMap) material.specularMap.dispose();
material.dispose();
}
function disposeSceneObject(object) {
if (!object) return;
for (let i = object.children.length - 1; i >= 0; i -= 1) {
disposeSceneObject(object.children[i]);
}
if (object.geometry) {
object.geometry.dispose();
}
if (object.material) {
disposeMaterial(object.material);
}
if (object.parent) {
object.parent.remove(object);
}
}
function clearRuntimeSelection() {
hoveredCable = null;
hoveredBGP = null;
hoveredComputeCenter = null;
hoveredSatellite = null;
hoveredSatelliteIndex = null;
lockedObject = null;
lockedObjectType = null;
lockedSatellite = null;
lockedSatelliteIndex = null;
setLockedSatelliteIndex(null);
clearSelectedSatelliteLegend();
}
export function clearLockedObject() {
hidePredictedOrbit();
clearAllCableStates();
clearCableSelection();
clearBGPSelection();
clearComputeCenterSelection();
clearVesselSelection();
clearEarthInteractableSelection();
clearRelatedSatelliteHighlights();
setSatelliteRingState(null, "none", null);
clearRuntimeSelection();
setLegendItems("satellites", getSatelliteLegendItems());
}
export function clearLockedObjectAndInfo() {
clearLockedObject();
hideInfoCard();
hideTooltip();
}
function isSameCable(cable1, cable2) {
if (!cable1 || !cable2) return false;
const id1 = cable1.userData?.cableId;
const id2 = cable2.userData?.cableId;
if (id1 === undefined || id2 === undefined) return false;
return id1 === id2;
}
function isSameBGPMarker(marker1, marker2) {
if (!marker1 || !marker2) return false;
const type1 = marker1.userData?.type;
const type2 = marker2.userData?.type;
if (type1 !== type2) return false;
if (type1 === "bgp") {
return marker1.userData?.id === marker2.userData?.id;
}
if (type1 === "bgp_collector") {
return marker1.userData?.collector === marker2.userData?.collector;
}
return false;
}
function isSameComputeCenter(marker1, marker2) {
if (!marker1 || !marker2) return false;
if (marker1.userData?.type !== "compute_center" || marker2.userData?.type !== "compute_center") {
return false;
}
return marker1.userData?.id === marker2.userData?.id;
}
function getBGPCollectorMarkerByName(collector) {
return getBGPCollectorMarkers().find(
(marker) => marker.userData?.collector === collector,
);
}
function resetTransientBGPStates() {
getBGPCollectorMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setBGPMarkerState(marker, "normal");
}
});
getBGPAnomalyMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setBGPMarkerState(marker, "normal");
}
});
}
function resetTransientComputeCenterStates() {
getComputeCenterMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setComputeCenterMarkerState(marker, "normal");
}
});
}
function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
resetTransientVesselStates();
resetTransientEarthInteractableStates();
hoveredBGP = null;
hoveredComputeCenter = null;
hoveredVessel = null;
hoveredEarthInteractable = null;
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
}
hoveredCable = null;
if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== lockedSatelliteIndex) {
setSatelliteRingState(hoveredSatelliteIndex, "none", null);
}
hoveredSatellite = null;
hoveredSatelliteIndex = null;
setHoveredSatelliteIndex(null);
}
function getVesselPointerIntersections() {
const earth = getEarth();
return getVesselIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: VESSEL_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function getEarthInteractablePointerIntersections() {
const earth = getEarth();
return getEarthInteractableIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function getBGPEventPointerIntersections() {
const earth = getEarth();
return getBGPEventIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function getBGPCollectorPointerIntersections() {
const earth = getEarth();
return getBGPCollectorIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function shouldSkipVesselHoverPicking() {
return isDragging || hasActiveGlobeInertia();
}
function getVesselHoverIntersections() {
if (!getShowVessels()) {
return { checked: true, intersects: [] };
}
if (shouldSkipVesselHoverPicking()) {
return { checked: false, intersects: [] };
}
const now = performance.now();
if (now - lastVesselHoverPickTime < VESSEL_HOVER_PICK_INTERVAL_MS) {
return { checked: false, intersects: [] };
}
lastVesselHoverPickTime = now;
return {
checked: true,
intersects: getVesselPointerIntersections(),
};
}
function applyBGPHoverState(marker) {
resetTransientBGPStates();
if (!marker) {
hoveredBGP = null;
return;
}
hoveredBGP = marker;
if (marker !== lockedObject) {
setBGPMarkerState(marker, "hover");
}
const relatedCollector =
marker.userData?.type === "bgp_collector"
? marker
: getBGPCollectorMarkerByName(marker.userData?.collector);
if (relatedCollector && relatedCollector !== lockedObject && relatedCollector !== marker) {
setBGPMarkerState(relatedCollector, "linked");
}
}
function applyComputeCenterHoverState(marker) {
resetTransientComputeCenterStates();
if (!marker) {
hoveredComputeCenter = null;
return;
}
hoveredComputeCenter = marker;
if (marker !== lockedObject) {
setComputeCenterMarkerState(marker, "hover");
}
}
function resetTransientVesselStates() {
if (hoveredVessel && hoveredVessel !== lockedObject) {
setVesselMarkerState(hoveredVessel, "normal");
}
hoveredVessel = null;
}
function applyVesselHoverState(marker) {
if (isSameVessel(hoveredVessel, marker)) return;
resetTransientVesselStates();
if (!marker) {
hoveredVessel = null;
return;
}
hoveredVessel = marker;
if (marker !== lockedObject) {
setVesselMarkerState(marker, "hover");
}
}
function isSameVessel(marker1, marker2) {
return Boolean(marker1 && marker2 && marker1.userData?.mmsi === marker2.userData?.mmsi);
}
function isSameEarthInteractable(marker1, marker2) {
return Boolean(marker1 && marker2 && marker1.userData?.id === marker2.userData?.id);
}
function resetTransientEarthInteractableStates() {
if (hoveredEarthInteractable && hoveredEarthInteractable !== lockedObject) {
setEarthInteractableMarkerState(hoveredEarthInteractable, "normal");
}
hoveredEarthInteractable = null;
}
function applyEarthInteractableHoverState(marker) {
if (isSameEarthInteractable(hoveredEarthInteractable, marker)) return;
resetTransientEarthInteractableStates();
if (!marker) {
hoveredEarthInteractable = null;
return;
}
hoveredEarthInteractable = marker;
if (marker !== lockedObject) {
setEarthInteractableMarkerState(marker, "hover");
}
}
function getFirstObjectIntersection(intersections) {
return intersections.find((hit) => !hit?.cluster && hit?.object)?.object || null;
}
function getPrimaryClusterHit(...intersectionGroups) {
return intersectionGroups
.flat()
.filter((hit) => hit?.cluster && Number(hit.clusterCount) > 1)
.sort((a, b) => a.distancePxSq - b.distancePxSq)[0] || null;
}
function getClusterBriefHtml(hit) {
const count = Number(hit?.clusterCount || hit?.clusterMarkers?.length || 0);
const title = isEnglishEarthLocale() ? `${count} objects` : `${count} 个对象`;
const hint = isEnglishEarthLocale() ? "Zoom in to inspect individual markers" : "放大后可查看单个图标";
return `<strong>${title}</strong><br><span>${hint}</span>`;
}
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
const anomalyMarker = getFirstObjectIntersection(bgpAnomalyIntersects);
if (anomalyMarker) {
return anomalyMarker;
}
const collectorMarker = getFirstObjectIntersection(bgpCollectorIntersects);
if (collectorMarker) {
return collectorMarker;
}
return null;
}
function getPrimaryBGPClickTarget(
event,
bgpAnomalyIntersects,
bgpCollectorIntersects,
) {
const anomalyMarker = getFirstObjectIntersection(bgpAnomalyIntersects);
const collectorMarker = getFirstObjectIntersection(bgpCollectorIntersects);
if (!anomalyMarker && !collectorMarker) return null;
if (!anomalyMarker) return collectorMarker;
if (!collectorMarker) return anomalyMarker;
const clickCollector = anomalyMarker.userData?.collector || collectorMarker.userData?.collector;
const isRepeatedClick =
clickCollector &&
clickCollector === lastBGPClickCollector &&
Date.now() - lastBGPClickTime < 650 &&
Math.abs(event.clientX - lastBGPClickPos.x) < 28 &&
Math.abs(event.clientY - lastBGPClickPos.y) < 28;
if (isRepeatedClick) {
return lastBGPClickType === "bgp" ? collectorMarker : anomalyMarker;
}
return anomalyMarker;
}
function isMotionControlActive() {
return Boolean(
motionControlAdapter &&
getRotationMode() === ROTATION_MODE.MOTION &&
getAutoRotate(),
);
}
function isMotionPresentationActive() {
return (
presentationController?.isActive?.("motion") === true ||
motionSharedCruiseSequencer?.isPresentationPinned?.() === true
);
}
function getMotionCenterPointer() {
return { x: 0, y: 0 };
}
function getMotionCenterScreenPoint() {
return {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
};
}
function getMotionScreenPointFromWorld(worldPoint) {
if (!camera || !worldPoint) return getMotionCenterScreenPoint();
scratchSatelliteScreenPosition.copy(worldPoint).project(camera);
return {
x: (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * window.innerWidth,
y: (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * window.innerHeight,
};
}
function getMotionAnchorRectFromCenter(center, sizePx) {
if (!center) return null;
const size = Math.max(1, Number(sizePx) || 1);
return {
x: center.x - size * 0.5,
y: center.y - size * 0.5,
width: size,
height: size,
};
}
function getMarkerMotionScreenPoint(marker) {
if (!marker || !camera) return null;
scratchSatelliteWorldPosition.copy(marker.position);
if (marker.parent) {
marker.parent.localToWorld(scratchSatelliteWorldPosition);
} else {
const earth = getEarth();
earth?.updateMatrixWorld(true);
earth?.localToWorld(scratchSatelliteWorldPosition);
}
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
}
function getMotionCandidateScreenCoords(candidate) {
if (!candidate) return getMotionCenterScreenPoint();
if (
candidate.type === "bgp" ||
candidate.type === "bgp_collector" ||
candidate.type === "compute_center" ||
candidate.type === "vessel" ||
candidate.type === "earth_interactable"
) {
return getMarkerMotionScreenPoint(candidate.object) || candidate.screen || getMotionCenterScreenPoint();
}
if (candidate.type === "cable" && candidate.object?.userData?.localCenter) {
scratchCableCenter.copy(candidate.object.userData.localCenter);
candidate.object.localToWorld(scratchCableCenter);
return getMotionScreenPointFromWorld(scratchCableCenter);
}
if (candidate.type === "satellite") {
const satPoints = getSatellitePoints();
const satPositions = getSatellitePositions();
const position = satPositions?.[candidate.index]?.current;
if (satPoints?.visible && position) {
satPoints.updateMatrixWorld(true);
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
}
}
return candidate.screen || getMotionCenterScreenPoint();
}
function getMotionCandidateAnchor(candidate) {
if (!candidate) return getMotionAnchorRectFromCenter(getMotionCenterScreenPoint(), MOTION_MARKER_ANCHOR_SIZE_PX);
if (
candidate.type === "bgp" ||
candidate.type === "bgp_collector" ||
candidate.type === "compute_center" ||
candidate.type === "vessel" ||
candidate.type === "earth_interactable"
) {
return getMotionAnchorRectFromCenter(
getMarkerMotionScreenPoint(candidate.object) || candidate.screen,
MOTION_MARKER_ANCHOR_SIZE_PX,
);
}
if (candidate.type === "cable") {
return getMotionAnchorRectFromCenter(getMotionCandidateScreenCoords(candidate), MOTION_CABLE_ANCHOR_SIZE_PX);
}
if (candidate.type === "satellite") {
return getMotionAnchorRectFromCenter(getMotionCandidateScreenCoords(candidate), MOTION_SATELLITE_ANCHOR_SIZE_PX);
}
return getMotionAnchorRectFromCenter(candidate.screen || getMotionCenterScreenPoint(), MOTION_MARKER_ANCHOR_SIZE_PX);
}
function getMotionIconCandidates() {
const earth = getEarth();
if (!earth || !camera) return [];
const pointer = getMotionCenterPointer();
const sharedOptions = {
earth,
camera,
pointer,
radiusPx: MOTION_FOCUS_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
};
const candidates = [];
if (getShowBGP()) {
getBGPEventIconPointerIntersections(sharedOptions).forEach((hit) => {
if (hit.cluster || !hit.object) return;
candidates.push({ type: "bgp", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
getBGPCollectorIconPointerIntersections(sharedOptions).forEach((hit) => {
if (hit.cluster || !hit.object) return;
candidates.push({ type: "bgp_collector", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
}
if (getShowComputeCenters()) {
getComputeCenterIconPointerIntersections(sharedOptions).forEach((hit) => {
if (hit.cluster || !hit.object) return;
candidates.push({ type: "compute_center", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
}
if (getShowVessels()) {
getVesselIconPointerIntersections(sharedOptions).forEach((hit) => {
if (hit.cluster || !hit.object) return;
candidates.push({ type: "vessel", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
}
getEarthInteractableIconPointerIntersections(sharedOptions).forEach((hit) => {
if (hit.cluster || !hit.object) return;
candidates.push({ type: "earth_interactable", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
});
return candidates;
}
function getMotionSatelliteCandidates() {
if (!renderer || !camera || !getShowSatellites()) return [];
const satPoints = getSatellitePoints();
const satPositions = getSatellitePositions();
if (!satPoints?.visible || !Array.isArray(satPositions)) return [];
const rect = renderer.domElement.getBoundingClientRect();
const center = getMotionCenterScreenPoint();
const radiusSq = MOTION_FOCUS_RADIUS_PX * MOTION_FOCUS_RADIUS_PX;
const candidates = [];
satPoints.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
for (let index = 0; index < satelliteCount; index += 1) {
const position = satPositions[index]?.current;
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
if (scratchSatelliteScreenPosition.z < -1 || scratchSatelliteScreenPosition.z > 1) continue;
const screen = {
x: rect.left + (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * rect.width,
y: rect.top + (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * rect.height,
};
const dx = screen.x - center.x;
const dy = screen.y - center.y;
const distancePxSq = dx * dx + dy * dy;
if (distancePxSq <= radiusSq) {
candidates.push({ type: "satellite", index, object: getSatelliteAt(index), screen, distancePxSq });
}
}
return candidates;
}
function getMotionCableCandidates() {
if (!camera || !getShowCables()) return [];
const center = getMotionCenterScreenPoint();
const radiusSq = MOTION_FOCUS_RADIUS_PX * MOTION_FOCUS_RADIUS_PX;
return getFrontFacingCables(getCableLines())
.map((cable) => {
if (!cable.userData?.localCenter) return null;
scratchCableCenter.copy(cable.userData.localCenter);
cable.localToWorld(scratchCableCenter);
scratchSatelliteScreenPosition.copy(scratchCableCenter).project(camera);
if (scratchSatelliteScreenPosition.z < -1 || scratchSatelliteScreenPosition.z > 1) return null;
const screen = {
x: (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * window.innerWidth,
y: (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * window.innerHeight,
};
const dx = screen.x - center.x;
const dy = screen.y - center.y;
const distancePxSq = dx * dx + dy * dy;
if (distancePxSq > radiusSq) return null;
return { type: "cable", object: cable, screen, distancePxSq };
})
.filter(Boolean);
}
function getMotionFocusKey(candidate) {
if (!candidate) return "";
if (candidate.type === "satellite") return `satellite:${candidate.index}`;
if (candidate.type === "cable") return `cable:${candidate.object?.userData?.cableId || candidate.object?.userData?.name || ""}`;
return `${candidate.type}:${candidate.object?.userData?.id || candidate.object?.userData?.collector || candidate.object?.userData?.mmsi || candidate.object?.userData?.name || ""}`;
}
function collectMotionFocusCandidates() {
const layerId = getCurrentMotionFocusLayer()?.id || null;
return [
...getMotionIconCandidates(),
...getMotionSatelliteCandidates(),
...getMotionCableCandidates(),
]
.filter((candidate) => candidate.type === "earth_interactable" || !layerId || getMotionCandidateLayerId(candidate) === layerId)
.sort((left, right) => left.distancePxSq - right.distancePxSq);
}
function getMotionCandidateLayerId(candidate) {
if (!candidate) return null;
if (candidate.type === "cable") return "cables";
if (candidate.type === "satellite") return "satellites";
if (candidate.type === "bgp" || candidate.type === "bgp_collector") return "bgp";
if (candidate.type === "compute_center") return "computeCenters";
if (candidate.type === "vessel") return "vessels";
if (candidate.type === "earth_interactable") return "interactables";
return null;
}
function getCurrentMotionFocusLayer() {
const layers = getVisibleMotionLayerDefinitions();
if (layers.length === 0) {
motionFocusLayerId = null;
return null;
}
const currentLayer = layers.find((layer) => layer.id === motionFocusLayerId);
if (currentLayer) return currentLayer;
motionFocusLayerId = layers[0].id;
return layers[0];
}
function createMotionCruiseItem(candidate) {
if (!candidate) return null;
const id = `motion:${getMotionFocusKey(candidate)}`;
return {
id,
moduleId: "motion",
payload: {
...candidate,
id,
layerId: getMotionCandidateLayerId(candidate),
},
};
}
function getMotionCruiseItems() {
return motionFocusCandidates
.map(createMotionCruiseItem)
.filter(Boolean);
}
function resolveLatestMotionCruiseItem(item) {
const key = getMotionFocusKey(item?.payload);
getEarth()?.updateMatrixWorld(true);
refreshMotionFocusCandidates({ force: true });
const candidate =
motionFocusCandidates.find((nextCandidate) => getMotionFocusKey(nextCandidate) === key) ||
item?.payload ||
null;
return createMotionCruiseItem(candidate) || item;
}
function ensureMotionCruiseAdapter() {
if (motionCruiseAdapter) return motionCruiseAdapter;
motionCruiseAdapter = createMotionCruiseAdapter({
presentationController: ensurePresentationController(),
focusView: focusTargetSwitchView,
getItems: getMotionCruiseItems,
getItemId: (item) => item?.id || null,
resolveLatestItem: resolveLatestMotionCruiseItem,
getCandidateAnchor: getMotionCandidateAnchor,
getCandidateFocusCoords: getMotionCandidateFocusCoords,
showCandidateInfo: showMotionCandidateInfo,
hideInfo: hideInfoCard,
clearCandidateVisual: () => clearMotionFocusVisual(),
});
return motionCruiseAdapter;
}
function repositionMotionConnector() {
if (motionCruiseSequencer?.isPresentationPinned?.()) {
const item = motionCruiseSequencer.getCurrentItem?.();
if (item) {
ensureMotionCruiseAdapter().repositionConnector();
}
}
repositionMotionSharedCruiseConnector();
}
function repositionMotionSharedCruiseConnector() {
if (!motionSharedCruiseSequencer?.isPresentationPinned?.()) return;
const item = motionSharedCruiseSequencer.getCurrentItem?.();
if (!item) return;
getCruiseModuleForItem(item)?.repositionPresentation?.(item);
}
function ensureMotionCruiseSequencer() {
if (motionCruiseSequencer) return motionCruiseSequencer;
const adapter = ensureMotionCruiseAdapter();
motionCruiseSequencer = new CruiseSequencer({
presentationMode: "pinned",
isActive: isMotionControlActive,
getItems: () => adapter.getSortedItems(),
getItemId: (item) => item?.id || null,
clearCurrent: () => {
adapter.clearCurrentHighlight();
adapter.resetPresentation();
},
onStop: ({ preservePresentation }) => {
if (!preservePresentation) {
adapter.resetPresentation();
}
},
focusItem: (item, options = {}) => adapter.focusItem(item, options),
presentItem: (item, options = {}) => adapter.presentItem(item, options),
hideItem: (_item, options = {}) => adapter.hidePresentation(options),
});
return motionCruiseSequencer;
}
function ensureMotionSharedCruiseSequencer() {
if (motionSharedCruiseSequencer) return motionSharedCruiseSequencer;
motionSharedCruiseSequencer = new CruiseSequencer({
presentationMode: "pinned",
isActive: isMotionControlActive,
getItems: () => getCruiseQueueItemsSorted(),
getItemId: (item) => item?.id || null,
clearCurrent: () => {
clearCruiseHighlights();
clearLockedObject();
if (!shouldPreserveInfoCardOnCruiseInterrupt()) {
hideInfoCard();
}
setCruisePresentationVisible(false);
},
onStop: ({ preservePresentation }) => {
clearBGPSelection();
if (!preservePresentation && !lockedObject) {
hideInfoCard();
}
},
focusItem: async (item, { interrupt }) =>
getCruiseModuleForItem(item)?.focusQueueItem?.(item, { interrupt }),
presentItem: async (item, { context }) => {
setCruisePresentationVisible(true);
const presented = await getCruiseModuleForItem(item)?.presentQueueItem?.(item, {
context,
});
if (!presented) {
setCruisePresentationVisible(false);
}
return presented;
},
hideItem: async (item, { context }) => {
await getCruiseModuleForItem(item)?.hidePresentation?.({ context });
setCruisePresentationVisible(false);
},
});
return motionSharedCruiseSequencer;
}
async function applyMotionSharedCruiseFocus(direction = "next") {
await syncCruiseModuleKnownEventIds();
const items = getCruiseQueueItemsSorted();
if (!Array.isArray(items) || items.length === 0) return false;
const sequencer = ensureMotionSharedCruiseSequencer();
const currentId =
sequencer.getCurrentItemId?.() ||
cruiseSequencer?.getCurrentItemId?.() ||
"";
const currentIndex = items.findIndex((item) => item?.id === currentId);
const delta = direction === "prev" ? -1 : 1;
const nextIndex =
currentIndex >= 0
? (currentIndex + delta + items.length) % items.length
: direction === "prev"
? items.length - 1
: 0;
const targetItem = items[nextIndex] || null;
if (!targetItem) return false;
motionCruiseSequencer?.stop?.();
motionFocusedCandidate = null;
clearMotionFocusVisual();
const presented = await sequencer.presentSpecificItem(targetItem, { interrupt: true });
if (presented) {
showStatusMessage(earthMessage("status.motionCruiseTargetSwitched"), "info");
}
return Boolean(presented);
}
async function presentMotionCandidate(candidate, { interrupt = true } = {}) {
const item = createMotionCruiseItem(candidate);
if (!item) return false;
const presented = await ensureMotionCruiseSequencer().presentSpecificItem(item, { interrupt });
if (presented) {
const latestCandidate = resolveLatestMotionCruiseItem(item)?.payload || item.payload;
applyMotionFocusVisual(latestCandidate);
}
return presented;
}
function clearMotionFocusVisual() {
if (!motionFocusedCandidate) return;
const candidate = motionFocusedCandidate;
motionFocusedCandidate = null;
if (candidate.type === "bgp" || candidate.type === "bgp_collector") resetTransientBGPStates();
if (candidate.type === "compute_center") resetTransientComputeCenterStates();
if (candidate.type === "vessel") resetTransientVesselStates();
if (candidate.type === "earth_interactable") resetTransientEarthInteractableStates();
if (candidate.type === "satellite" && candidate.index !== lockedSatelliteIndex) {
setSatelliteRingState(candidate.index, "none", null);
setHoveredSatelliteIndex(null);
}
if (candidate.type === "cable" && !isSameCable(candidate.object, lockedObject)) {
setCableState(candidate.object.userData.cableId, CABLE_STATE.NORMAL);
}
}
function applyMotionFocusVisual(candidate) {
if (!candidate) {
clearMotionFocusVisual();
return;
}
if (getMotionFocusKey(candidate) === getMotionFocusKey(motionFocusedCandidate)) return;
clearMotionFocusVisual();
motionFocusedCandidate = candidate;
if (candidate.type === "bgp" || candidate.type === "bgp_collector") {
applyBGPHoverState(candidate.object);
} else if (candidate.type === "compute_center") {
applyComputeCenterHoverState(candidate.object);
} else if (candidate.type === "vessel") {
applyVesselHoverState(candidate.object);
} else if (candidate.type === "earth_interactable") {
applyEarthInteractableHoverState(candidate.object);
} else if (candidate.type === "satellite") {
hoveredSatelliteIndex = candidate.index;
hoveredSatellite = { properties: candidate.object };
setHoveredSatelliteIndex(candidate.index);
const satPositions = getSatellitePositions();
if (satPositions?.[candidate.index]) {
setSatelliteRingState(candidate.index, "hover", satPositions[candidate.index].current);
}
} else if (candidate.type === "cable") {
hoveredCable = candidate.object;
setCableState(candidate.object.userData.cableId, CABLE_STATE.HOVERED);
}
}
function refreshMotionFocusCandidates({ force = false } = {}) {
if (!isMotionControlActive()) {
motionFocusCandidates = [];
motionFocusIndex = 0;
clearMotionFocusVisual();
return;
}
const now = performance.now();
if (!force && now - lastMotionFocusRefreshAt < MOTION_FOCUS_REFRESH_MS) return;
lastMotionFocusRefreshAt = now;
const previousKey = getMotionFocusKey(motionFocusedCandidate);
motionFocusCandidates = collectMotionFocusCandidates();
if (motionFocusCandidates.length === 0) {
motionFocusIndex = 0;
clearMotionFocusVisual();
return;
}
const previousIndex = motionFocusCandidates.findIndex(
(candidate) => getMotionFocusKey(candidate) === previousKey,
);
motionFocusIndex = previousIndex >= 0
? previousIndex
: Math.min(motionFocusIndex, motionFocusCandidates.length - 1);
applyMotionFocusVisual(motionFocusCandidates[motionFocusIndex]);
}
function isMotionActionBlocked(action) {
if (action === "focus") {
return (
motionActionLocks.has("focus") ||
motionActionLocks.has("layer") ||
motionActionLocks.has("confirm")
);
}
if (action === "layer") {
return motionActionLocks.has("layer") || motionActionLocks.has("confirm");
}
if (action === "confirm") {
return (
motionActionLocks.has("focus") ||
motionActionLocks.has("layer") ||
motionActionLocks.has("confirm")
);
}
return motionActionLocks.has(action);
}
function beginMotionActionGate(action) {
if (isMotionActionBlocked(action)) return null;
motionActionLocks.add(action);
return () => {
motionActionLocks.delete(action);
};
}
export async function applyMotionFocus(direction = "next") {
const releaseGate = beginMotionActionGate("focus");
if (!releaseGate) return false;
try {
if (await applyMotionSharedCruiseFocus(direction)) {
return true;
}
refreshMotionFocusCandidates({ force: true });
if (motionFocusCandidates.length === 0) {
const layer = getCurrentMotionFocusLayer();
showStatusMessage(
earthMessage("status.motionNoTarget", { layer: layer?.label || "" }),
"info",
);
return false;
}
const delta = direction === "prev" ? -1 : 1;
motionFocusIndex = (motionFocusIndex + delta + motionFocusCandidates.length) % motionFocusCandidates.length;
const candidate = motionFocusCandidates[motionFocusIndex];
applyMotionFocusVisual(candidate);
await presentMotionCandidate(candidate);
showStatusMessage(
earthMessage("status.motionSwitchedTo", { label: getMotionCandidateLabel(candidate) }),
"info",
);
return true;
} finally {
releaseGate();
}
}
export async function applyMotionLayerSwitch(direction = "next") {
const releaseGate = beginMotionActionGate("layer");
if (!releaseGate) return false;
try {
const layers = getVisibleMotionLayerDefinitions();
if (layers.length === 0) {
showStatusMessage(earthMessage("status.motionNoVisibleLayer"), "info");
return false;
}
const currentIndex = layers.findIndex((layer) => layer.id === motionFocusLayerId);
const delta = direction === "prev" ? -1 : 1;
const nextIndex =
currentIndex >= 0
? (currentIndex + delta + layers.length) % layers.length
: 0;
const nextLayer = layers[nextIndex];
motionFocusLayerId = nextLayer.id;
motionFocusIndex = 0;
motionFocusCandidates = [];
clearMotionFocusVisual();
refreshMotionFocusCandidates({ force: true });
const candidate = motionFocusedCandidate || motionFocusCandidates[0] || null;
if (candidate) {
await presentMotionCandidate(candidate);
}
showStatusMessage(
earthMessage("status.motionSwitchedTo", { label: nextLayer.label, layer: true }),
"info",
);
return true;
} finally {
releaseGate();
}
}
function showMotionCandidateInfo(candidate, options = {}) {
if (candidate.type === "bgp") {
showBGPInfo(candidate.object, options);
} else if (candidate.type === "bgp_collector") {
showBGPCollectorInfo(candidate.object, options);
} else if (candidate.type === "compute_center") {
showComputeCenterInfo(candidate.object, options);
} else if (candidate.type === "vessel") {
showVesselInfo(candidate.object, options);
} else if (candidate.type === "earth_interactable") {
showEarthInteractableInfo(candidate.object, options);
} else if (candidate.type === "cable") {
showCableInfo(candidate.object, options);
} else if (candidate.type === "satellite") {
showSatelliteInfo(candidate.object?.properties || candidate.object, options);
}
}
function getMotionCandidateFocusCoords(candidate) {
if (!candidate) return null;
if (candidate.type === "cable") return getCableFocusCoords(candidate.object);
if (candidate.type === "satellite") return getSatelliteFocusCoords(candidate.index);
if (candidate.type === "bgp" || candidate.type === "bgp_collector") return getBGPFocusCoords(candidate.object);
if (candidate.type === "compute_center") return getComputeCenterFocusCoords(candidate.object);
if (candidate.type === "vessel") return getVesselFocusCoords(candidate.object);
if (candidate.type === "earth_interactable") return getEarthInteractableFocusCoords(candidate.object);
return null;
}
function getMotionCandidateLabel(candidate) {
if (!candidate) return "目标";
if (candidate.type === "satellite") return candidate.object?.properties?.name || "卫星";
return (
candidate.object?.userData?.name ||
candidate.object?.userData?.collector ||
candidate.object?.userData?.mmsi ||
candidate.object?.userData?.label ||
candidate.object?.userData?.id ||
"目标"
);
}
function getMotionCandidateFromCruiseItem(item) {
const payload = item?.payload || null;
if (!payload) return null;
if (
payload.type === "bgp" ||
payload.type === "bgp_collector" ||
payload.type === "compute_center" ||
payload.type === "vessel" ||
payload.type === "earth_interactable" ||
payload.type === "cable" ||
payload.type === "satellite"
) {
return payload;
}
const userDataType = payload?.userData?.type;
if (userDataType === "bgp" || userDataType === "bgp_collector" || userDataType === "earth_interactable") {
return { type: userDataType, object: payload };
}
return null;
}
function getCurrentMotionConfirmationCandidate() {
return (
motionFocusedCandidate ||
getMotionCandidateFromCruiseItem(motionCruiseSequencer?.getCurrentItem?.()) ||
getMotionCandidateFromCruiseItem(motionSharedCruiseSequencer?.getCurrentItem?.()) ||
motionFocusCandidates[motionFocusIndex] ||
null
);
}
function dismissMotionPresentationsForConfirmation() {
motionCruiseSequencer?.stop?.({ preservePresentation: false });
motionSharedCruiseSequencer?.stop?.({ preservePresentation: false });
presentationController?.dismiss?.("motion_confirm");
}
function confirmMotionCandidate(candidate, { showMotionStatus = true } = {}) {
const earth = getEarth();
if (!candidate || !earth) return false;
interruptCruisePresentation();
dismissMotionPresentationsForConfirmation();
clearLockedObject();
setAutoRotate(false);
const sourceCoords = candidate.screen || getMotionCandidateScreenCoords(candidate) || getMotionCenterScreenPoint();
if (candidate.type === "bgp") {
const marker = candidate.object;
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp";
lastBGPClickTime = Date.now();
lastBGPClickCollector = marker.userData?.collector || null;
lastBGPClickType = "bgp";
lastBGPClickPos = sourceCoords;
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
} else if (candidate.type === "bgp_collector") {
const marker = candidate.object;
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp_collector";
lastBGPClickTime = Date.now();
lastBGPClickCollector = marker.userData?.collector || null;
lastBGPClickType = "bgp_collector";
lastBGPClickPos = sourceCoords;
showBGPCollectorCoverageOverlay(marker, earth);
marker.userData.related_satellite_count = 0;
} else if (candidate.type === "compute_center") {
const marker = candidate.object;
setComputeCenterMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "compute_center";
} else if (candidate.type === "vessel") {
const marker = candidate.object;
setVesselMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "vessel";
showVesselTrack(marker, earth).catch((error) => {
console.warn("船只轨迹加载失败:", error);
});
} else if (candidate.type === "earth_interactable") {
const marker = candidate.object;
setEarthInteractableMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "earth_interactable";
} else if (candidate.type === "cable") {
const cable = candidate.object;
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
lockedObject = cable;
lockedObjectType = "cable";
handleCableClick(cable);
} else if (candidate.type === "satellite") {
const sat = selectSatellite(candidate.index);
if (!sat?.properties) return false;
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = candidate.index;
setLockedSatelliteIndex(candidate.index);
showPredictedOrbit(sat);
const satPositions = getSatellitePositions();
if (satPositions?.[candidate.index]) {
setSatelliteRingState(candidate.index, "locked", satPositions[candidate.index].current);
}
} else {
return false;
}
motionFocusedCandidate = candidate;
showMotionCandidateInfo(candidate, {
x: sourceCoords.x,
y: sourceCoords.y,
absolute: true,
anchorStable: true,
});
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
if (showMotionStatus) {
showStatusMessage(
earthMessage("status.motionConfirmed", { label: getMotionCandidateLabel(candidate) }),
"info",
);
}
return true;
}
function showCableInfo(cable, coords) {
setLegendMode("cables");
showInfoCard("cable", {
name: cable.userData.name,
owner: cable.userData.owner,
status: cable.userData.status,
length: cable.userData.length,
coords: cable.userData.coords,
rfs: cable.userData.rfs,
}, coords);
}
function getCableBriefHtml(cable) {
const name = cable.userData.name || "未知海缆";
const status = cable.userData.status || "";
return `<strong>${translateText(name)}</strong>${status ? `<br>${translateText(status)}` : ""}`;
}
function showSatelliteInfo(props, coords) {
const meanMotion = props?.mean_motion || 0;
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
const altitudeRange = calculateSatelliteAltitudeRangeKm(
props?.mean_motion,
props?.eccentricity,
);
const presentation = getSatellitePresentationInfo(props);
let footprintModel = "不适用";
if (presentation.footprintPolicy === "starlink_ground_footprint") {
footprintModel = "Starlink 单星地表覆盖";
} else if (presentation.footprintPolicy === "iridium_coverage_ring") {
footprintModel = "Iridium 外圈半透明覆盖";
}
setSelectedSatelliteLegend(props);
setLegendItems("satellites", getSatelliteLegendItems());
setLegendMode("satellites");
showInfoCard("satellite", {
name: props?.name || "-",
norad_id: props?.norad_cat_id,
constellation: presentation.constellationLabel,
footprint_capability: presentation.footprintCapabilityLabel,
current_display: presentation.presentationModeLabel,
footprint_model: footprintModel,
inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
period,
perigee: altitudeRange?.perigee ?? "-",
apogee: altitudeRange?.apogee ?? "-",
}, coords);
}
function calculateSatelliteAltitudeRangeKm(meanMotion, eccentricity = 0) {
const revolutionsPerDay = Number(meanMotion);
const ecc = Number(eccentricity);
if (
!Number.isFinite(revolutionsPerDay) ||
revolutionsPerDay <= 0 ||
!Number.isFinite(ecc) ||
ecc < 0 ||
ecc >= 1
) {
return null;
}
const meanMotionRadPerSecond =
(revolutionsPerDay * Math.PI * 2) / SECONDS_PER_DAY;
const semiMajorAxisKm = Math.cbrt(
EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 /
(meanMotionRadPerSecond * meanMotionRadPerSecond),
);
const perigeeKm = semiMajorAxisKm * (1 - ecc) - EARTH_RADIUS_KM;
const apogeeKm = semiMajorAxisKm * (1 + ecc) - EARTH_RADIUS_KM;
return {
perigee: Math.max(0, perigeeKm).toFixed(0),
apogee: Math.max(0, apogeeKm).toFixed(0),
};
}
function getSatelliteBriefHtml(props) {
const name = props?.name || "未知卫星";
const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : "";
return `<strong>${name}</strong>${id ? `<br>${id}` : ""}`;
}
function showComputeCenterInfo(marker, coords) {
const siteType = marker.userData?.site_type === "supercomputer"
? "supercomputer"
: "gpu_cluster";
setLegendMode("computeCenters");
const ud = marker.userData || {};
showInfoCard(siteType, {
id: ud.id,
source_id: ud.source_id,
name: ud.name || "-",
site_type_label: formatComputeCenterTypeLabel(siteType),
rank: ud.rank ?? "-",
capacity: formatComputeCenterCapacity(ud),
vendor: ud.vendor || "-",
operator: ud.operator || "-",
gpu_count: ud.gpu_count ?? "-",
gpu_type: ud.gpu_type || "-",
cores: ud.cores ?? "-",
power: ud.power ?? "-",
country: ud.country || "-",
city: ud.city || "-",
metadata: ud.metadata || {},
location_precision: ud.location_precision,
location_precision_label: formatComputeCenterLocationPrecision(ud),
location_source: ud.location_source,
location_source_label: formatComputeCenterLocationSource(ud),
location_status_label: formatComputeCenterNeedsConfirmation(ud),
needs_confirmation: ud.needs_confirmation === true,
estimated_reason: ud.estimated_reason || "-",
location_confidence: formatComputeCenterLocationConfidence(ud),
location_source_note: ud.location_source_note || "-",
matched_location_name: ud.matched_location_name || "-",
location_verified_at: ud.location_verified_at || "-",
source: ud.source || "-",
updated_at: formatComputeCenterUpdatedAt(ud.updated_at),
}, coords);
}
function formatVesselStatus(navStatus) {
if (navStatus === 1) return "锚泊";
if (navStatus === 5) return "停靠";
if (navStatus === 0) return "航行中";
return navStatus ?? "-";
}
function showVesselInfo(marker, coords) {
setLegendMode("vessels");
const vesselType = marker.userData?.vessel_type_display || marker.userData?.vessel_type_name || "-";
showInfoCard("vessel", {
name: marker.userData?.name || `MMSI ${marker.userData?.mmsi}`,
mmsi: marker.userData?.mmsi,
imo: marker.userData?.imo || "-",
flag: marker.userData?.flag || "-",
vessel_type: vesselType,
speed: marker.userData?.sog ?? "-",
course: marker.userData?.cog ?? marker.userData?.heading ?? "-",
status: formatVesselStatus(marker.userData?.nav_status),
length: marker.userData?.length ?? "-",
received_at: marker.userData?.received_at
? new Date(marker.userData.received_at).toLocaleString(getEarthLocale(), { hour12: false })
: "-",
}, coords);
}
function showEarthInteractableInfo(marker, coords) {
const ud = marker?.userData || {};
showInfoCard("earth_interactable", {
id: ud.id || "-",
label: ud.label || ud.name || "-",
kind: ud.kind || "-",
latitude: Number.isFinite(Number(ud.latitude)) ? Number(ud.latitude).toFixed(4) : "-",
longitude: Number.isFinite(Number(ud.longitude)) ? Number(ud.longitude).toFixed(4) : "-",
description: ud.description || ud.summary || "-",
source: ud.source || "-",
status: ud.status || "-",
updated_at: ud.updated_at || ud.updatedAt || "-",
}, coords);
}
function getEarthInteractableBriefHtml(marker) {
const ud = marker?.userData || {};
const name = ud.label || ud.name || "交互点";
const kind = ud.kind || "数据点";
return `<strong>${translateText(name)}</strong><br>${translateText(kind)}`;
}
function getVesselBriefHtml(marker) {
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
const speed = marker.userData?.sog ?? "-";
const vesselType = marker.userData?.vessel_type_display || marker.userData?.vessel_type_name || "Vessel";
return `<strong>${translateText(name)}</strong><br>${translateText(vesselType)} · ${speed} kn`;
}
function getComputeCenterBriefHtml(marker) {
const name = marker.userData?.name || "算力中心";
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
const location = [marker.userData?.city, localizeCountryName(marker.userData?.country)]
.filter(Boolean)
.join(", ");
const precision = marker.userData?.is_estimated ? ` · ${translateText("估算位置")}` : "";
return `<strong>${translateText(name)}</strong><br>${translateText(type)}${location ? ` · ${location}` : ""}${precision}`;
}
function getCountryBoundaryBriefHtml(country) {
const name = localizeCountryName(country) || translateText("未知国家");
const code = country?.isoA3 || country?.isoA2 || "-";
const continent = translateText(country?.continent || "-");
return `<strong>${name}</strong><br>ISO: ${code}<br>${translateText("大洲")}: ${continent}`;
}
function getSurfacePositionBriefHtml(coords) {
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
return `${translateText("纬度")}: ${coords.lat}°<br>${translateText("经度")}: ${coords.lon}°<br>${translateText("海拔")}: ${elevText}`;
}
function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
Array.isArray(marker.userData.impacted_regions) &&
marker.userData.impacted_regions.length > 0
? marker.userData.impacted_regions
: [
{
city: marker.userData.city,
country: marker.userData.country,
},
];
const observedBy =
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors);
const impactedScope = formatBGPImpactedScope(impactedRegions);
const relatedCables = formatBGPRelatedCables(marker.userData.related_cables);
const narrative =
marker.userData.summary && marker.userData.summary !== "-"
? marker.userData.summary
: buildBGPIncidentNarrative(marker, impactedRegions);
showInfoCard("bgp", {
anomaly_type: formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
),
severity: formatBGPSeverityLabel(
marker.userData.rawSeverity || marker.userData.severity,
),
status: formatBGPStatusLabel(marker.userData.status),
route_change:
marker.userData.route_change ||
formatBGPRouteChange(
marker.userData.origin_asn,
marker.userData.new_origin_asn,
),
prefix:
Array.isArray(marker.userData.prefixes) && marker.userData.prefixes.length > 1
? isEnglishEarthLocale()
? `${marker.userData.prefixes[0]} + ${marker.userData.prefixes.length - 1}`
: `${marker.userData.prefixes[0]}${marker.userData.prefixes.length}`
: marker.userData.prefix,
as_path_display:
Array.isArray(marker.userData.as_path) && marker.userData.as_path.length > 0
? formatBGPASPath(marker.userData.as_path)
: "-",
origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 0
? marker.userData.affected_asns.slice(0, 3).map((asn) => `AS${asn}`).join(", ")
: marker.userData.origin_asn,
new_origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 3
? isEnglishEarthLocale()
? `${marker.userData.affected_asns.length} ASNs`
: `${marker.userData.affected_asns.length}个ASN`
: marker.userData.new_origin_asn,
confidence: formatBGPConfidence(marker.userData.confidence),
collector: marker.userData.collector,
observed_by: observedBy,
impacted_scope: impactedScope,
related_cables: relatedCables,
related_satellites:
marker.userData.related_satellite_count > 0
? isEnglishEarthLocale()
? `${marker.userData.related_satellite_count} nearby event satellites`
: `${marker.userData.related_satellite_count}颗事件附近卫星`
: "-",
location:
marker.userData.location ||
formatBGPLocation(marker.userData.city, marker.userData.country),
created_at: formatBGPObservedTime(marker.userData.created_at_raw),
summary: narrative,
}, coords);
}
function getBGPBriefHtml(marker) {
const type = formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
);
const collector = marker.userData.collector || "";
return `<strong>${type}</strong>${collector ? `<br>${collector}` : ""}`;
}
function showBGPCollectorInfo(marker, coords) {
setLegendMode("bgp");
showInfoCard("bgp_collector", {
collector: marker.userData.collector,
location: formatBGPLocation(marker.userData.city, marker.userData.country),
anomaly_count: marker.userData.anomaly_count ?? 0,
observation_count: marker.userData.observation_count ?? 0,
recent_24h_observation_count: marker.userData.recent_24h_observation_count ?? 0,
recent_7d_observation_count: marker.userData.recent_7d_observation_count ?? 0,
prefix_count: marker.userData.prefix_count ?? 0,
origin_asn_count: marker.userData.origin_asn_count ?? 0,
top_event_types: formatBGPTopEventTypes(marker.userData.top_event_types),
coverage_halo: formatBGPCollectorCoverageHalo(marker.userData),
related_satellites: "-",
latest_event_type: marker.userData.latest_event_type || "-",
latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at),
baseline_scope: formatBGPScope(marker.userData.baseline_scope),
status: formatBGPCollectorStatus(marker.userData.status || "online"),
}, coords);
}
function getBGPCollectorBriefHtml(marker) {
const name = marker.userData.collector || "观测站";
const count = marker.userData.anomaly_count ?? 0;
const eventText = isEnglishEarthLocale() ? `${count} events` : `${count} 条事件`;
return `<strong>${translateText(name)}</strong><br>${eventText}`;
}
function getSearchCardCoords() {
return {
x: Math.round(window.innerWidth * SEARCH_CARD_X_RATIO),
y: Math.round(window.innerHeight * SEARCH_CARD_Y_RATIO),
absolute: true,
};
}
function normalizeSearchString(...parts) {
return parts
.flat()
.filter((part) => part !== undefined && part !== null && part !== false)
.map((part) => String(part).trim())
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function computeSearchScore(query, ...parts) {
const text = normalizeSearchString(...parts);
if (!text) return -1;
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return -1;
if (text === normalizedQuery) return 240;
if (text.startsWith(normalizedQuery)) return 180;
if (text.includes(normalizedQuery)) return 120;
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return -1;
let score = 0;
for (const token of tokens) {
if (text.startsWith(token)) {
score += 60;
continue;
}
if (text.includes(token)) {
score += 36;
continue;
}
return -1;
}
return score;
}
function getCableFocusCoords(cable) {
if (!cable?.userData?.localCenter) return null;
return vector3ToLatLon(cable.userData.localCenter);
}
function getLandingPointFocusCoords(point) {
if (!point?.position) return null;
return vector3ToLatLon(point.position);
}
function getSatelliteFocusCoords(index) {
const positions = getSatellitePositions();
const vector = positions?.[index]?.current;
if (!vector) return null;
return vector3ToLatLon(vector);
}
function getBGPFocusCoords(marker) {
const lat = marker?.userData?.displayLatitude ?? marker?.userData?.latitude;
const lon = marker?.userData?.displayLongitude ?? marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
function getComputeCenterFocusCoords(marker) {
const lat = marker?.userData?.latitude;
const lon = marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
function getVesselFocusCoords(marker) {
const lat = marker?.userData?.latitude;
const lon = marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
function getEarthInteractableFocusCoords(marker) {
const lat = Number(marker?.userData?.latitude);
const lon = Number(marker?.userData?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon };
}
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
if (!coords || !camera) return;
await focusEarthView(camera, {
lat: coords.lat,
lon: coords.lon,
zoom,
duration: 950,
suppressStatus: true,
});
}
function showLandingPointInfo(point, coords) {
const cableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
setLegendMode("cables");
showInfoCard(
"landing_point",
{
name: point?.userData?.name || "-",
country: point?.userData?.country || "-",
status: point?.userData?.status || "-",
cable_count: cableNames.length,
cables: cableNames.length > 0 ? cableNames.join(" / ") : "-",
},
coords,
);
}
async function focusSearchCable(cable) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getCableFocusCoords(cable);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.14));
}
const cableId = cable?.userData?.cableId;
if (cableId !== undefined) {
setCableState(cableId, CABLE_STATE.LOCKED);
}
lockedObject = cable;
lockedObjectType = "cable";
handleCableClick(cable);
showCableInfo(cable, getSearchCardCoords());
}
async function focusSearchLandingPoint(point) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getLandingPointFocusCoords(point);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.22));
}
const relatedCableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
clearAllCableStates();
getCableLines().forEach((cable) => {
if (relatedCableNames.includes(cable.userData?.name)) {
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
}
});
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
showLandingPointInfo(point, getSearchCardCoords());
showStatusMessage(
earthMessage("status.located", { target: "登陆点", name: point.userData?.name || "未知登陆点" }),
"info",
);
}
async function focusSearchSatellite(index) {
await setSatellitesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const sat = selectSatellite(index);
if (!sat?.properties) return;
const coords = getSatelliteFocusCoords(index);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.18));
}
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = index;
setLockedSatelliteIndex(index);
showPredictedOrbit(sat);
const satPositions = getSatellitePositions();
if (satPositions?.[index]) {
setSatelliteRingState(index, "locked", satPositions[index].current);
if (hoveredSatelliteIndex === index) {
setHoveredSatelliteIndex(index);
}
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(
earthMessage("status.located", {
target: "卫星",
name: sat.properties.name || sat.properties.norad_cat_id || "未知卫星",
}),
"info",
);
}
async function focusSearchBGPMarker(marker) {
if (!getShowBGP()) {
toggleBGP(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getBGPFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
}
const earth = getEarth();
if (marker?.userData?.type === "bgp") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp";
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
showBGPInfo(marker, getSearchCardCoords());
showStatusMessage(
earthMessage("status.located", {
target: "BGP 事件",
name: marker.userData?.collector || "未知观测站",
}),
"info",
);
return;
}
if (marker?.userData?.type === "bgp_collector") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp_collector";
showBGPCollectorCoverageOverlay(marker, earth);
showBGPCollectorInfo(marker, getSearchCardCoords());
showStatusMessage(
earthMessage("status.located", {
target: "观测站",
name: marker.userData?.collector || "未知观测站",
}),
"info",
);
}
}
async function focusSearchComputeCenter(marker) {
if (!getShowComputeCenters()) {
toggleComputeCenters(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getComputeCenterFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.16));
}
setComputeCenterMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "compute_center";
showComputeCenterInfo(marker, getSearchCardCoords());
showStatusMessage(
earthMessage("status.located", {
target: "算力中心",
name: marker.userData?.name || "未知节点",
}),
"info",
);
}
async function previewLocationCandidate(detail) {
const lat = Number(detail?.latitude);
const lon = Number(detail?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const earth = getEarth();
if (earth && detail?.entityType === "compute_center") {
showComputeCenterLocationPreview(earth, {
latitude: lat,
longitude: lon,
});
}
interruptCruisePresentation({ resetLoop: true });
setAutoRotate(false);
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
}
async function refreshComputeCentersAfterLocationSave() {
const earth = getEarth();
if (!scene || !earth) {
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过自动刷新");
return { skipped: true };
}
const result = await loadComputeCenters(scene, earth);
toggleComputeCenters(getShowComputeCenters());
updateComputeCenterHud(result);
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
return result;
}
async function spawnComputeCenterAfterLocationSave(detail = {}) {
clearComputeCenterLocationPreview();
const earth = getEarth();
if (!earth) {
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过即时生成");
return null;
}
const sourceId = detail.entityId || detail.sourceId || detail.result?.source_id;
const result = await spawnSavedComputeCenterLocation(earth, {
sourceId,
candidate: detail.candidate,
context: detail.context,
saveResult: detail.result,
});
if (!result) return null;
toggleComputeCenters(getShowComputeCenters());
updateComputeCenterHud(result);
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
updateStatsSummary();
syncComputeCenterUnresolvedCount(result.unresolvedCount);
if (result.marker) {
clearLockedObject();
setComputeCenterMarkerState(result.marker, "locked");
lockedObject = result.marker;
lockedObjectType = "compute_center";
}
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
return result;
}
async function focusSearchVessel(marker) {
await setVesselsEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getVesselFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
}
setVesselMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "vessel";
showVesselInfo(marker, getSearchCardCoords());
showVesselTrack(marker, getEarth()).catch((error) => {
console.warn("船只轨迹加载失败:", error);
});
showStatusMessage(
earthMessage("status.located", {
target: "船只",
name: marker.userData?.name || marker.userData?.mmsi || "未知船只",
}),
"info",
);
}
function resolveEarthSearchResults(query) {
const results = [];
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return results;
getCableLines().forEach((cable) => {
const score = computeSearchScore(
normalizedQuery,
cable.userData?.name,
cable.userData?.owner,
cable.userData?.status,
cable.userData?.length,
"海缆 电缆 cable",
);
if (score < 0) return;
results.push({
id: `cable:${cable.userData?.cableId || cable.uuid}`,
kind: "cable",
icon: "cable",
typeLabel: "海缆",
title: cable.userData?.name || "未知海缆",
subtitle: [cable.userData?.owner, cable.userData?.status].filter(Boolean).join(" · ") || "海底光缆系统",
score,
entity: cable,
});
});
getLandingPoints().forEach((point, index) => {
const score = computeSearchScore(
normalizedQuery,
point.userData?.name,
point.userData?.country,
point.userData?.status,
point.userData?.cableNames,
"登陆点 landing point",
);
if (score < 0) return;
results.push({
id: `landing:${point.uuid || index}`,
kind: "landing_point",
icon: "location_on",
typeLabel: "登陆点",
title: point.userData?.name || "未知登陆点",
subtitle:
[point.userData?.country, Array.isArray(point.userData?.cableNames) ? `${point.userData.cableNames.length} 条海缆` : ""]
.filter(Boolean)
.join(" · ") || "海缆登陆点",
score,
entity: point,
});
});
getSatelliteData().forEach((satellite, index) => {
const props = satellite?.properties;
const score = computeSearchScore(
normalizedQuery,
props?.name,
props?.norad_cat_id,
props?.inclination,
"卫星 satellite norad",
);
if (score < 0) return;
results.push({
id: `sat:${props?.norad_cat_id || index}`,
kind: "satellite",
icon: "satellite_alt",
typeLabel: "卫星",
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
subtitle: props?.norad_cat_id
? `NORAD ${props.norad_cat_id} · ${getSatellitePresentationInfo(props).constellationLabel}`
: `${getSatellitePresentationInfo(props).constellationLabel} · 在轨卫星`,
score,
entity: { index },
});
});
getBGPAnomalyMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.prefix,
marker.userData?.city,
marker.userData?.country,
marker.userData?.anomaly_type,
marker.userData?.incident_type,
marker.userData?.origin_asn,
marker.userData?.new_origin_asn,
"bgp 事件 anomaly prefix asn",
);
if (score < 0) return;
results.push({
id: `bgp:${marker.userData?.id || marker.uuid}`,
kind: "bgp",
icon: "hub",
typeLabel: "BGP事件",
title:
formatBGPAnomalyTypeLabel(
marker.userData?.incident_type || marker.userData?.anomaly_type,
) || "BGP 事件",
subtitle:
[
marker.userData?.collector,
marker.userData?.prefix,
formatBGPLocation(marker.userData?.city, marker.userData?.country),
]
.filter(Boolean)
.join(" · ") || "BGP 异常事件",
score,
entity: marker,
});
});
getBGPCollectorMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.city,
marker.userData?.country,
marker.userData?.status,
"bgp collector 观测站",
);
if (score < 0) return;
results.push({
id: `collector:${marker.userData?.collector || marker.uuid}`,
kind: "bgp_collector",
icon: "travel_explore",
typeLabel: "观测站",
title: marker.userData?.collector || "未知观测站",
subtitle:
[
formatBGPLocation(marker.userData?.city, marker.userData?.country),
formatBGPCollectorStatus(marker.userData?.status || "online"),
]
.filter(Boolean)
.join(" · ") || "BGP 观测站",
score,
entity: marker,
});
});
getComputeCenterMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.name,
marker.userData?.country,
marker.userData?.city,
marker.userData?.operator,
marker.userData?.vendor,
marker.userData?.site_type,
"算力中心 compute center gpu 超算",
);
if (score < 0) return;
results.push({
id: `compute:${marker.userData?.id || marker.uuid}`,
kind: "compute_center",
icon: "memory",
typeLabel: "算力中心",
title: marker.userData?.name || "未知算力中心",
subtitle: [
formatComputeCenterTypeLabel(marker.userData?.site_type),
marker.userData?.city,
marker.userData?.country,
].filter(Boolean).join(" · ") || "算力基础设施",
score,
entity: marker,
});
});
getVesselMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.name,
marker.userData?.mmsi,
marker.userData?.imo,
marker.userData?.flag,
marker.userData?.vessel_type_display,
marker.userData?.vessel_type_name,
"船只 船舶 ais vessel ship maritime",
);
if (score < 0) return;
results.push({
id: `vessel:${marker.userData?.mmsi || marker.uuid}`,
kind: "vessel",
icon: "directions_boat",
typeLabel: "船只",
title: marker.userData?.name || `MMSI ${marker.userData?.mmsi}`,
subtitle: [
marker.userData?.vessel_type_display || marker.userData?.vessel_type_name,
marker.userData?.flag,
marker.userData?.sog !== undefined ? `${marker.userData.sog} kn` : null,
].filter(Boolean).join(" · ") || "AIS 船只",
score,
entity: marker,
});
});
return results
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.title.localeCompare(right.title, "zh-CN");
})
.slice(0, SEARCH_RESULT_LIMIT);
}
async function handleSearchSelection(result) {
if (!result) return;
if (result.kind === "cable") {
await focusSearchCable(result.entity);
return;
}
if (result.kind === "landing_point") {
await focusSearchLandingPoint(result.entity);
return;
}
if (result.kind === "satellite") {
await focusSearchSatellite(result.entity.index);
return;
}
if (result.kind === "bgp" || result.kind === "bgp_collector") {
await focusSearchBGPMarker(result.entity);
return;
}
if (result.kind === "compute_center") {
await focusSearchComputeCenter(result.entity);
return;
}
if (result.kind === "vessel") {
await focusSearchVessel(result.entity);
}
}
function getBGPStatusText(bgpResult) {
if (bgpResult.totalCount > 0) {
return `${bgpResult.totalCount} 起活跃事件`;
}
if (bgpResult.anomalyCount > 0) {
return `${bgpResult.anomalyCount} 条活跃异常`;
}
return "当前无活跃事件";
}
function toCount(value) {
const count = Number(value);
return Number.isFinite(count) ? count : 0;
}
function formatBGPStatusFromSummary(summary) {
if (!summary) return "-";
if (summary.bgpIncidentCount > 0) {
return `${summary.bgpIncidentCount} 起活跃事件`;
}
if (summary.bgpAnomalyCount > 0) {
return `${summary.bgpAnomalyCount} 条活跃异常`;
}
return "当前无活跃事件";
}
async function loadEarthStatsSummary({
shouldApply = () => true,
cacheBust = false,
} = {}) {
try {
const url = new URL(PATHS.earthSummaryApi, window.location.origin);
if (cacheBust) {
url.searchParams.set("_", String(Date.now()));
}
const response = await fetch(url.toString(), {
cache: cacheBust ? "no-store" : "default",
});
if (!response.ok) {
throw new Error(`Earth summary HTTP ${response.status}`);
}
const payload = await response.json();
const stats = payload?.stats || {};
earthStatsSummary = {
cableCount: toCount(stats.cable_count),
landingPointCount: toCount(stats.landing_point_count),
satelliteCount: toCount(stats.satellite_count),
computeCenterCount: toCount(stats.compute_center_count),
vesselCount: toCount(stats.vessel_count),
vesselRawUniqueMmsi: toCount(stats.vessel_raw_unique_mmsi),
vesselLegacyUniqueMmsi: toCount(stats.vessel_legacy_unique_mmsi),
aisstreamConnectionState: stats.aisstream_connection_state || null,
aisstreamLastSeenAt: stats.aisstream_last_seen_at || null,
aisstreamLagSeconds: Number.isFinite(Number(stats.aisstream_lag_seconds))
? Number(stats.aisstream_lag_seconds)
: null,
bgpEventCount: toCount(stats.bgp_event_count),
bgpIncidentCount: toCount(stats.bgp_incident_count),
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
bgpCollectorCount: toCount(stats.bgp_collector_count),
};
if (shouldApply()) {
updateStatsSummary();
}
} catch (error) {
console.warn("全球态势聚合统计加载失败:", error);
}
}
function updateComputeCenterHud(computeCenterResult) {
const computeBtn = document.getElementById("toggle-compute-centers");
const unresolvedCount = Number(computeCenterResult?.unresolvedCount) || 0;
const unresolvedTooltip = formatUnresolvedComputeSuffix(unresolvedCount);
if (computeBtn) {
const tooltip = getShowComputeCenters()
? `隐藏算力中心${unresolvedTooltip}`
: `显示算力中心${unresolvedTooltip}`;
setLayerButtonState(computeBtn, {
active: getShowComputeCenters(),
loading: false,
tooltip: translateText(tooltip),
});
updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount);
}
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount}`);
}
function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
const count = Math.max(0, Number(unresolvedCount) || 0);
const layerRow = computeBtn.closest(".layer-row");
if (!layerRow) return;
let badge = layerRow.querySelector("[data-compute-center-unresolved-badge]");
if (count <= 0) {
delete computeBtn.dataset.unresolvedCount;
badge?.remove();
return;
}
if (!(badge instanceof HTMLButtonElement)) {
badge?.remove();
badge = document.createElement("button");
badge.type = "button";
badge.className = "layer-row-notification-badge";
badge.dataset.computeCenterUnresolvedBadge = "true";
layerRow.appendChild(badge);
}
badge.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
showComputeCenterUnresolvedQueue(event.currentTarget);
};
computeBtn.dataset.unresolvedCount = String(count);
badge.textContent = count > 99 ? "99+" : String(count);
const title = formatUnresolvedComputeTitle(count);
badge.title = title;
badge.setAttribute(
"aria-label",
isEnglishEarthLocale() ? `${title}, click to view` : `${title},点击查看`,
);
}
function syncComputeCenterUnresolvedCount(unresolvedCount) {
const computeBtn = document.getElementById("toggle-compute-centers");
if (!computeBtn) return;
let nextCount = unresolvedCount;
if (!Number.isFinite(Number(nextCount))) {
const current = Number(computeBtn.dataset.unresolvedCount) || 0;
nextCount = current + (Number(unresolvedCount?.delta) || 0);
}
updateComputeCenterUnresolvedBadge(computeBtn, nextCount);
}
function showComputeCenterUnresolvedQueue(anchorEl) {
const items = getUnresolvedComputeCenters();
const panelRect = document.getElementById("layer-toggles")?.getBoundingClientRect();
const anchorRect = anchorEl instanceof HTMLElement
? anchorEl.getBoundingClientRect()
: null;
const rect = panelRect || anchorRect;
openToolInfoCard(
"compute_center_unresolved",
{
totalCount: items.length,
items,
},
{
x: rect ? rect.right + 10 : 284,
y: rect ? rect.top : 120,
absolute: true,
anchorStable: true,
sticky: true,
preserveOnCruiseInterrupt: true,
},
);
}
function updateBGPHud(bgpResult) {
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
setLayerButtonState(bgpBtn, {
active: getShowBGP(),
loading: false,
tooltip: getShowBGP() ? ACTIVE_BGP_TOOLTIP_TEXT : "显示BGP观测",
});
}
setEarthStatValue("bgp-anomaly-count", `${bgpResult.totalCount}`);
setEarthStatValue("bgp-collector-count", `${bgpResult.collectorCount}`);
setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult));
}
function ensureCalloutConnector() {
if (!calloutConnector) {
calloutConnector = new CalloutConnector({ className: "callout-connector" });
}
return calloutConnector;
}
function ensurePresentationController() {
if (!presentationController) {
presentationController = new PresentationController({
connector: ensureCalloutConnector(),
});
}
return presentationController;
}
function invalidateToolInfoCardRestore(reason = "user_action") {
if (!activeToolInfoCard) return;
activeToolInfoCard.invalidated = true;
activeToolInfoCard.invalidateReason = reason;
}
function prepareToolInfoCard(type) {
const restoreCruise = isCruiseModeActive() && getAutoRotate();
const hadCruisePresentation = isCruisePresentationPinned();
const hadMotionPresentation = isMotionPresentationActive();
if (hadCruisePresentation || restoreCruise) {
stopCruiseMode({ preserveCard: false });
showStatusMessage(earthMessage("status.cruisePausedOpenCandidates"), "info");
} else if (hadMotionPresentation) {
presentationController?.dismiss?.("tool_card_open");
motionCruiseSequencer?.stop?.();
motionSharedCruiseSequencer?.stop?.();
showStatusMessage(earthMessage("status.motionPausedOpenCandidates"), "info");
}
activeToolInfoCard = {
token: ++toolInfoCardToken,
type,
restoreCruise,
modeAtOpen: getRotationMode(),
autoRotateAtOpen: getAutoRotate(),
invalidated: false,
invalidateReason: null,
};
}
function openToolInfoCard(type, data, options = {}) {
prepareToolInfoCard(type);
showInfoCard(type, data, options);
}
function resumeCruiseAfterToolInfoCard(context) {
if (
!context?.restoreCruise ||
context.invalidated ||
getRotationMode() !== context.modeAtOpen ||
getRotationMode() !== ROTATION_MODE.CRUISE ||
getAutoRotate() !== context.autoRotateAtOpen ||
!getAutoRotate()
) {
return;
}
showStatusMessage(earthMessage("status.candidatesClosedCruiseResumed"), "info");
ensureCruisePolling();
syncCruiseModuleKnownEventIds()
.catch((error) => {
console.warn("同步巡航模块失败:", error);
})
.finally(() => {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("恢复巡航模式失败:", error);
});
});
}
function handleToolInfoCardClosed() {
if (!activeToolInfoCard) return false;
const context = activeToolInfoCard;
activeToolInfoCard = null;
resumeCruiseAfterToolInfoCard(context);
return true;
}
function ensureBGPCruiseAdapter() {
if (cruiseBGPAdapter) return cruiseBGPAdapter;
cruiseBGPAdapter = createBGPCruiseAdapter({
camera,
earth: () => getEarth(),
getMarkers: () => getBGPAnomalyMarkers(),
connector: ensureCalloutConnector(),
focusView: focusTargetSwitchView,
setMarkerLocked: (marker) => {
setLegendMode("bgp");
setBGPMarkerState(marker, "locked");
},
clearMarkerState: (marker) => setBGPMarkerState(marker, "normal"),
showMarkerOverlay: (marker) => {
const earth = getEarth();
if (!marker || !earth) return;
showBGPEventOverlay(marker, earth);
},
applySatelliteHighlights: (marker) => {
if (!marker) return;
applyBGPEventSatelliteHighlights(marker);
},
showMarkerInfo: showBGPInfo,
hideInfo: hideInfoCard,
isInfoVisible: () =>
document.getElementById("info-panel")?.classList.contains("is-visible") === true ||
document.getElementById("earth-mobile-popup")?.classList.contains("is-visible") === true,
getLockedObject: () => lockedObject,
refreshMarkers: async () => {
const bgpResult = await loadBGPAnomalies(scene, getEarth());
updateBGPHud(bgpResult);
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
},
});
return cruiseBGPAdapter;
}
function ensureNewsCruiseAdapter() {
if (cruiseNewsAdapter) return cruiseNewsAdapter;
cruiseNewsAdapter = createNewsCruiseAdapter({
camera,
earth: () => getEarth(),
connector: ensureCalloutConnector(),
focusView: focusTargetSwitchView,
});
return cruiseNewsAdapter;
}
function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
const placement = getInteractableCruiseCardPlacement();
return {
x: placement.x,
y: placement.y,
absolute: true,
reveal,
anchorStable: true,
};
}
function getInteractableCruiseCardPlacement() {
const hudScale =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
const width = Math.min(INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
const height = Math.min(INTERACTABLE_CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale, window.innerHeight * 0.7);
const x = window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - width * 0.5;
const y = window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - height * 0.5;
return {
x: Math.min(
Math.max(INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX, x),
window.innerWidth - width - INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX,
),
y: Math.min(
Math.max(INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX, y),
window.innerHeight - height - INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX,
),
width,
height,
};
}
function getVisiblePresentationCardTarget() {
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;
}
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
function getInteractableCruiseItemId(candidate) {
return `interactable:${getMotionFocusKey(candidate)}`;
}
function createInteractableCruiseItem(candidate) {
if (!candidate) return null;
const coords = getMotionCandidateFocusCoords(candidate);
return {
id: getInteractableCruiseItemId(candidate),
moduleId: getMotionCandidateLayerId(candidate),
sortTimestamp: 0,
latitude: coords?.lat ?? null,
longitude: coords?.lon ?? null,
region: inferCruiseRegion(coords?.lat, coords?.lon),
payload: {
...candidate,
id: getInteractableCruiseItemId(candidate),
layerId: getMotionCandidateLayerId(candidate),
},
};
}
function getComputeCenterCruiseItems() {
return getComputeCenterMarkers().map((marker) =>
createInteractableCruiseItem({ type: "compute_center", object: marker }),
).filter(Boolean);
}
function getVesselCruiseItems() {
return getVesselMarkers().map((marker) =>
createInteractableCruiseItem({ type: "vessel", object: marker }),
).filter(Boolean);
}
function getCableCruiseItems() {
return getCableLines().map((cable) =>
createInteractableCruiseItem({ type: "cable", object: cable }),
).filter(Boolean);
}
function getSatelliteCruiseItems() {
const satelliteCount = Math.min(getSatelliteData().length, getSatellitePositions()?.length || 0);
const items = [];
for (let index = 0; index < satelliteCount; index += 1) {
items.push(createInteractableCruiseItem({
type: "satellite",
index,
object: getSatelliteAt(index),
}));
}
return items.filter(Boolean);
}
async function focusInteractableCruiseItem(item, options = {}) {
const coords = getMotionCandidateFocusCoords(item?.payload);
if (!coords) return;
await focusTargetSwitchView({
lat: coords.lat,
lon: coords.lon,
rotLon: coords.lon - 270,
zoom: Math.max(coords.zoom || 1.08, 1.08),
duration: options.interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
}
async function presentInteractableCruiseItem(item, { context } = {}) {
const candidate = item?.payload;
if (!candidate) return false;
const cardPlacement = getInteractableCruiseCardPlacement();
return ensurePresentationController().present(
{
id: `cruise:${item.id}`,
owner: "cruise",
card: {
render: ({ reveal = true } = {}) =>
showMotionCandidateInfo(candidate, getInteractableCruiseInfoOptions({ reveal })),
hide: () => hideInfoCard(),
},
connector: {
enabled: true,
animateOnReveal: true,
sourceProvider: () => getMotionCandidateAnchor(candidate),
targetProvider: () => getVisiblePresentationCardTarget() || cardPlacement,
options: {
routingMode: "adaptive",
sourceGapPx: 0,
targetGapPx: 12,
obstacleClearancePx: 8,
},
},
lifetime: { mode: "persistent" },
},
{ context },
);
}
async function hideInteractableCruisePresentation({ context } = {}) {
ensurePresentationController().dismiss("sequenced_hide");
return context?.wait
? context.wait(INTERACTABLE_CRUISE_PRESENTATION_HIDE_MS, { secondary: true })
: true;
}
function createInteractableCruiseModuleDefinition({ id, isActive, getItems }) {
return {
id,
isQueueSourceActive: isActive,
getSortedQueueItems: getItems,
focusQueueItem: focusInteractableCruiseItem,
presentQueueItem: presentInteractableCruiseItem,
hidePresentation: hideInteractableCruisePresentation,
clearCurrentHighlight: () => clearMotionFocusVisual(),
resetPresentation: () => ensurePresentationController().dismiss("owner_stop"),
repositionPresentation: () => ensurePresentationController().update(),
syncKnownEventIds: () => {},
};
}
function getCruiseModuleDefinitions() {
return [
{
id: CRUISE_MODULES.BGP,
ensureAdapter: ensureBGPCruiseAdapter,
isQueueSourceActive: () => getShowBGP(),
getSortedQueueItems: () =>
ensureBGPCruiseAdapter()
.getSortedMarkers()
.map((marker) => {
const markerId = marker?.userData?.id;
if (!markerId) return null;
const parsed = marker?.userData?.created_at_raw || marker?.userData?.created_at;
const timestamp = parsed ? new Date(parsed).getTime() : 0;
const coords = getBGPFocusCoords(marker);
return {
id: `bgp:${markerId}`,
moduleId: CRUISE_MODULES.BGP,
sortTimestamp: Number.isFinite(timestamp) ? timestamp : 0,
latitude: coords?.lat ?? null,
longitude: coords?.lon ?? null,
region: inferCruiseRegion(coords?.lat, coords?.lon),
payload: marker,
};
})
.filter(Boolean),
focusQueueItem: (item, options = {}) =>
ensureBGPCruiseAdapter().focusMarker(item?.payload, options),
presentQueueItem: (item, options = {}) =>
ensureBGPCruiseAdapter().presentMarker(item?.payload, options),
hidePresentation: (options = {}) =>
ensureBGPCruiseAdapter().hidePresentation(options),
clearCurrentHighlight: () => ensureBGPCruiseAdapter().clearCurrentHighlight(),
resetPresentation: () => ensureBGPCruiseAdapter().resetPresentation(),
repositionPresentation: (item) =>
ensureBGPCruiseAdapter().repositionConnector(item?.payload),
syncKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(),
pollForNewQueueItemIds: async () => {
const nextIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
return nextIds.map((id) => `bgp:${id}`);
},
},
{
id: CRUISE_MODULES.NEWS,
ensureAdapter: ensureNewsCruiseAdapter,
isQueueSourceActive: () => true,
ensureItemsLoaded: () => ensureNewsCruiseAdapter().ensureItemsLoaded(),
getSortedQueueItems: () =>
ensureNewsCruiseAdapter()
.getSortedItems()
.map((item) => ({
...item,
moduleId: CRUISE_MODULES.NEWS,
payload: item,
})),
focusQueueItem: (item, options = {}) =>
ensureNewsCruiseAdapter().focusItem(item?.payload, options),
presentQueueItem: (item, options = {}) =>
ensureNewsCruiseAdapter().presentItem(item?.payload, options),
hidePresentation: (options = {}) =>
ensureNewsCruiseAdapter().hidePresentation(options),
clearCurrentHighlight: () => ensureNewsCruiseAdapter().clearCurrentHighlight(),
resetPresentation: () => ensureNewsCruiseAdapter().resetPresentation(),
repositionPresentation: (item) =>
ensureNewsCruiseAdapter().repositionConnector(item?.payload),
syncKnownEventIds: () => ensureNewsCruiseAdapter().syncKnownEventIds(),
externalEventName: "earth:news-payload-updated",
extractQueuedItemIdsFromEvent: (event) => {
const itemIds = Array.isArray(event?.detail?.itemIds) ? event.detail.itemIds : [];
if (itemIds.length === 0) return [];
const adapter = ensureNewsCruiseAdapter();
const newIds = adapter.diffNewEventIds(itemIds);
adapter.syncKnownEventIds();
return newIds;
},
},
createInteractableCruiseModuleDefinition({
id: CRUISE_MODULES.COMPUTE_CENTERS,
isActive: () => getShowComputeCenters(),
getItems: getComputeCenterCruiseItems,
}),
createInteractableCruiseModuleDefinition({
id: CRUISE_MODULES.VESSELS,
isActive: () => getShowVessels(),
getItems: getVesselCruiseItems,
}),
createInteractableCruiseModuleDefinition({
id: CRUISE_MODULES.CABLES,
isActive: () => getShowCables(),
getItems: getCableCruiseItems,
}),
createInteractableCruiseModuleDefinition({
id: CRUISE_MODULES.SATELLITES,
isActive: () => getShowSatellites(),
getItems: getSatelliteCruiseItems,
}),
];
}
function getCruiseModuleDefinition(moduleId) {
return getCruiseModuleDefinitions().find((module) => module.id === moduleId) || null;
}
function getEnabledCruiseModuleDefinitions() {
const enabledModuleIds = new Set(getCruiseModules());
return getCruiseModuleDefinitions().filter((module) => enabledModuleIds.has(module.id));
}
function inferCruiseRegion(lat, lon) {
if (typeof lat !== "number" || typeof lon !== "number") return "global";
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return "global";
if (lon >= -170 && lon <= -30) return "americas";
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
return "asia-pacific";
}
function normalizeCruiseQueueItemRegion(item) {
const region = typeof item?.region === "string" ? item.region : "";
return region || inferCruiseRegion(item?.latitude, item?.longitude);
}
function getCruiseQueueItemSignature(items) {
return items
.map((item) => item?.id || "")
.filter(Boolean)
.sort()
.join("|");
}
function buildRegionCruiseQueueItems(items) {
const regionOrder = getCruiseRegionOrder();
const orderIndexByRegion = new Map(regionOrder.map((region, index) => [region, index]));
const buckets = new Map();
items.forEach((item) => {
const region = normalizeCruiseQueueItemRegion(item);
if (!buckets.has(region)) buckets.set(region, []);
buckets.get(region).push(item);
});
const orderedRegions = Array.from(buckets.keys()).sort((left, right) => {
const leftIndex = orderIndexByRegion.has(left) ? orderIndexByRegion.get(left) : regionOrder.length;
const rightIndex = orderIndexByRegion.has(right) ? orderIndexByRegion.get(right) : regionOrder.length;
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
return String(left || "").localeCompare(String(right || ""));
});
const orderedItems = [];
let cursor = 0;
let appended = true;
while (appended) {
appended = false;
orderedRegions.forEach((region) => {
const item = buckets.get(region)?.[cursor];
if (!item) return;
orderedItems.push(item);
appended = true;
});
cursor += 1;
}
return orderedItems;
}
function shuffleCruiseQueueItems(items) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return shuffled;
}
function buildRandomCruiseQueueItems(items) {
const signature = getCruiseQueueItemSignature(items);
const currentId = cruiseSequencer?.getCurrentItemId?.() || "";
const cacheIds = cruiseRandomQueueItems.map((item) => item?.id || "");
const cacheValid =
signature === cruiseRandomQueueSignature &&
cacheIds.length === items.length &&
cacheIds.every((id) => items.some((item) => item?.id === id));
if (cacheValid && currentId && cacheIds.at(-1) === currentId && items.length > 1) {
const currentItem = items.find((item) => item?.id === currentId) || null;
const remainingItems = items.filter((item) => item?.id !== currentId);
cruiseRandomQueueItems = currentItem
? [currentItem, ...shuffleCruiseQueueItems(remainingItems)]
: shuffleCruiseQueueItems(items);
return cruiseRandomQueueItems;
}
if (cacheValid) return cruiseRandomQueueItems;
cruiseRandomQueueSignature = signature;
cruiseRandomQueueItems = shuffleCruiseQueueItems(items);
return cruiseRandomQueueItems;
}
function getCruiseQueueItemsRaw() {
const items = [];
getEnabledCruiseModuleDefinitions().forEach((module) => {
if (!module.isQueueSourceActive?.()) return;
items.push(...(module.getSortedQueueItems?.() || []));
});
return items;
}
function getCruiseQueueItemsSorted() {
const items = getCruiseQueueItemsRaw();
const mode = getCruiseQueueMode();
if (mode === CRUISE_QUEUE_MODES.REGION) {
return buildRegionCruiseQueueItems(items);
}
if (mode === CRUISE_QUEUE_MODES.RANDOM) {
return buildRandomCruiseQueueItems(items);
}
return items;
}
function getCruiseModuleForItem(item) {
return getCruiseModuleDefinition(item?.moduleId);
}
function getCurrentCruiseBGPMarker() {
const item = cruiseSequencer?.getCurrentItem() ?? null;
return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null;
}
function getCurrentMotionSharedCruiseBGPMarker() {
const item = motionSharedCruiseSequencer?.getCurrentItem?.() ?? null;
return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null;
}
async function syncCruiseModuleKnownEventIds() {
const modules = getEnabledCruiseModuleDefinitions();
await Promise.all(
modules.map(async (module) => {
try {
await module.ensureItemsLoaded?.();
module.syncKnownEventIds?.();
} catch (error) {
console.warn(`同步巡航模块失败: ${module.id}`, error);
}
}),
);
}
function isCruisePresentationPinned() {
return cruiseSequencer?.isPresentationPinned() === true;
}
function setCruisePresentationVisible(visible) {
if (cruiseSequencer) {
cruiseSequencer.setPresentationVisible(visible);
}
if (!visible) {
getCruiseModuleDefinitions().forEach((module) => {
module.resetPresentation?.();
});
}
}
function clearCruiseHighlights() {
getCruiseModuleDefinitions().forEach((module) => {
module.clearCurrentHighlight?.();
});
}
function repositionCruiseConnector() {
if (!isCruisePresentationPinned()) return;
const item = cruiseSequencer?.getCurrentItem() ?? null;
const module = getCruiseModuleForItem(item);
module?.repositionPresentation?.(item);
}
function isCruiseModeActive() {
return getRotationMode() === ROTATION_MODE.CRUISE;
}
function ensureCruiseSequencer() {
if (cruiseSequencer) return cruiseSequencer;
cruiseSequencer = new CruiseSequencer({
isActive: () => isCruiseModeActive() && getAutoRotate(),
getItems: () => getCruiseQueueItemsSorted(),
getItemId: (item) => item?.id || null,
dwellMs: CRUISE_CONFIG.dwellMs,
transitionGapMs: CRUISE_TRANSITION_GAP_MS,
clearCurrent: () => {
clearCruiseHighlights();
clearLockedObject();
if (!shouldPreserveInfoCardOnCruiseInterrupt()) {
hideInfoCard();
}
setCruisePresentationVisible(false);
},
onStop: ({ preservePresentation }) => {
clearBGPSelection();
if (!preservePresentation && !lockedObject) {
hideInfoCard();
}
},
focusItem: async (item, { interrupt }) =>
getCruiseModuleForItem(item)?.focusQueueItem?.(item, { interrupt }),
presentItem: async (item, { context }) => {
setCruisePresentationVisible(true);
const presented = await getCruiseModuleForItem(item)?.presentQueueItem?.(item, {
context,
});
if (!presented) {
setCruisePresentationVisible(false);
}
return presented;
},
hideItem: async (item, { context }) => {
await getCruiseModuleForItem(item)?.hidePresentation?.({ context });
setCruisePresentationVisible(false);
},
});
return cruiseSequencer;
}
function interruptCruisePresentation({ resetLoop = false } = {}) {
ensureCruiseSequencer().interruptPresentation({ resetLoop });
setCruisePresentationVisible(false);
}
export function dismissCruisePresentation() {
if (!cruiseSequencer) return false;
const hasPresentation =
cruiseSequencer.isPresentationPinned?.() ||
Boolean(cruiseSequencer.getCurrentItemId?.());
if (!hasPresentation) return false;
interruptCruisePresentation({ resetLoop: true });
return true;
}
function stopCruiseMode({ preserveCard = false } = {}) {
ensureCruiseSequencer().stop({ preservePresentation: preserveCard });
if (!preserveCard) {
setCruisePresentationVisible(false);
}
}
async function advanceCruiseEvent({ interrupt = false } = {}) {
if (!isCruiseModeActive() || !getAutoRotate()) return;
await ensureCruiseSequencer().advance({ interrupt });
}
async function pollCruiseEventsIfNeeded() {
if (!isCruiseModeActive() || !getAutoRotate()) return;
try {
const modules = getEnabledCruiseModuleDefinitions().filter(
(module) => module.isQueueSourceActive?.() && typeof module.pollForNewQueueItemIds === "function",
);
const results = await Promise.all(
modules.map(async (module) => {
try {
return await module.pollForNewQueueItemIds();
} catch (error) {
console.warn(`巡航模式轮询失败: ${module.id}`, error);
return [];
}
}),
);
const newIds = results.flat();
if (newIds.length === 0) return;
ensureCruiseSequencer().enqueue(newIds);
if (!ensureCruiseSequencer().isBusy()) {
await advanceCruiseEvent({ interrupt: true });
}
} catch (error) {
console.warn("巡航模式轮询失败:", error);
}
}
function ensureCruisePolling() {
if (cruisePollTimerId) return;
cruisePollTimerId = window.setInterval(() => {
pollCruiseEventsIfNeeded().catch((error) => {
console.warn("巡航轮询失败:", error);
});
}, CRUISE_CONFIG.pollIntervalMs);
cleanupFns.push(() => {
if (cruisePollTimerId) {
clearInterval(cruisePollTimerId);
cruisePollTimerId = null;
}
});
}
function handleRotationModeChange(event) {
invalidateToolInfoCardRestore("rotation_mode_change");
const detailMode = event?.detail?.mode || getRotationMode();
const detailActive =
typeof event?.detail?.active === "boolean"
? event.detail.active
: getAutoRotate();
if (detailMode !== ROTATION_MODE.CRUISE) {
stopCruiseMode();
setupMotionControl();
if (detailMode !== ROTATION_MODE.MOTION) {
motionCruiseSequencer?.stop?.();
motionSharedCruiseSequencer?.stop?.();
}
return;
}
setupMotionControl();
ensureCruisePolling();
if (!detailActive) {
stopCruiseMode({ preserveCard: true });
return;
}
syncCruiseModuleKnownEventIds()
.catch((error) => {
console.warn("同步巡航模块失败:", error);
})
.finally(() => {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("启动巡航模式失败:", error);
});
});
}
function handleCruiseModulesChange() {
interruptCruisePresentation({ resetLoop: true });
motionSharedCruiseSequencer?.interruptPresentation?.({ resetLoop: true });
clearBGPSelection();
cruiseRandomQueueSignature = "";
cruiseRandomQueueItems = [];
const syncPromise = syncCruiseModuleKnownEventIds().catch((error) => {
console.warn("刷新巡航模块失败:", error);
});
if (!isCruiseModeActive()) {
return;
}
if (!getAutoRotate()) {
stopCruiseMode({ preserveCard: true });
return;
}
syncPromise.finally(() => {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("切换巡航模块后推进失败:", error);
});
});
}
function handleCruiseQueueSettingsChange() {
interruptCruisePresentation({ resetLoop: true });
motionSharedCruiseSequencer?.interruptPresentation?.({ resetLoop: true });
cruiseRandomQueueSignature = "";
cruiseRandomQueueItems = [];
if (!isCruiseModeActive() || !getAutoRotate()) {
return;
}
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("切换巡航队列后推进失败:", error);
});
}
function handleCruiseNextCardShortcut() {
if (!isCruiseModeActive()) {
showStatusMessage(earthMessage("status.cruiseModeRequired"), "info");
return;
}
if (!getAutoRotate()) {
showStatusMessage(earthMessage("status.cruisePausedSpace"), "info");
return;
}
interruptCruisePresentation({ resetLoop: true });
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("切换下一张巡航卡片失败:", error);
});
}
function handleCruiseModuleItemsUpdated(moduleId, event) {
const module = getCruiseModuleDefinition(moduleId);
if (!module) return;
const newIds = module.extractQueuedItemIdsFromEvent?.(event) || [];
if (newIds.length === 0) return;
if (!isCruiseModeActive() || !getAutoRotate() || !getCruiseModules().includes(moduleId)) {
return;
}
ensureCruiseSequencer().enqueue(newIds);
if (!ensureCruiseSequencer().isBusy()) {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn(`推进巡航模块失败: ${moduleId}`, error);
});
}
}
function clearSelectionAndInfo() {
clearLockedObject();
interruptCruisePresentation();
presentationController?.dismiss?.("background_click");
hideInfoCard();
}
function getBGPRelatedCableNames(marker) {
const items = Array.isArray(marker?.userData?.related_cables)
? marker.userData.related_cables
: [];
const names = [];
items.forEach((item) => {
const cableNames = Array.isArray(item?.cable_names) ? item.cable_names : [];
cableNames.forEach((name) => {
if (name && !names.includes(name)) {
names.push(name);
}
});
});
return names;
}
function getBGPInfrastructureSummary(marker) {
return {
cableCount: getBGPRelatedCableNames(marker).length,
regionCount: getBGPRelatedRegions(marker).length,
};
}
function buildBGPIncidentNarrative(marker, impactedRegions) {
const eventLabel = formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
);
const severityLabel = formatBGPSeverityLabel(
marker.userData.rawSeverity || marker.userData.severity,
);
const observedBy =
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors);
const routeLabel =
marker.userData.route_change ||
formatBGPRouteChange(
marker.userData.origin_asn,
marker.userData.new_origin_asn,
);
const cableCount = getBGPRelatedCableNames(marker).length;
const regionCount = impactedRegions.length;
return `${eventLabel}${severityLabel}${observedBy},影响${regionCount}个区域,关联${cableCount}条海缆线索${routeLabel && routeLabel !== "-" ? `,特征 ${routeLabel}` : ""}`;
}
function getBGPRelatedRegions(marker) {
if (Array.isArray(marker?.userData?.impacted_regions) && marker.userData.impacted_regions.length > 0) {
return marker.userData.impacted_regions;
}
if (
marker?.userData?.type === "bgp_collector" &&
typeof marker.userData.latitude === "number" &&
typeof marker.userData.longitude === "number"
) {
return [
{
collector: marker.userData.collector,
city: marker.userData.city,
country: marker.userData.country,
latitude: marker.userData.latitude,
longitude: marker.userData.longitude,
},
];
}
return [];
}
function applyBGPEventSatelliteHighlights(marker) {
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
getBGPRelatedRegions(marker),
{ limit: 6, maxAngleDeg: 20 },
);
marker.userData.related_satellite_count = relatedSatelliteIndices.length;
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
}
function applyBGPRelatedCablesAndLandingPoints(marker, camera) {
const relatedCableNames = getBGPRelatedCableNames(marker);
clearAllCableStates();
relatedCableNames.forEach((name) => {
getCableLines().forEach((cable) => {
if (cable.userData?.name === name) {
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
}
});
});
applyLandingPointVisualState(
relatedCableNames.length > 0 ? relatedCableNames : null,
relatedCableNames.length === 0,
camera,
);
}
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
allCables.forEach((cable) => {
const cableId = cable.userData.cableId;
const state = getCableState(cableId);
const hasFocus =
(lockedObjectType === "cable" && lockedObject) ||
(lockedObjectType === "satellite" && lockedSatellite) ||
(lockedObjectType === "bgp" && lockedObject) ||
(isCruiseModeActive() && isCruisePresentationPinned());
const isHoveredCable = isSameCable(cable, hoveredCable);
switch (state) {
case CABLE_STATE.LOCKED:
cable.material.opacity = isHoveredCable
? CABLE_CONFIG.lockedOpacityMax
: THREE.MathUtils.lerp(
CABLE_CONFIG.lockedOpacityMin,
CABLE_CONFIG.lockedOpacityMax,
pulse,
);
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.HOVERED:
cable.material.opacity = CABLE_CONFIG.lockedOpacityMax;
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.NORMAL:
default:
if (hasFocus) {
cable.material.opacity = CABLE_CONFIG.otherOpacity;
const origColor = cable.userData.originalColor;
const brightness = CABLE_CONFIG.otherBrightness;
cable.material.color.setRGB(
(((origColor >> 16) & 255) / 255) * brightness,
(((origColor >> 8) & 255) / 255) * brightness,
((origColor & 255) / 255) * brightness,
);
} else {
cable.material.opacity = 1;
cable.material.color.setHex(cable.userData.originalColor);
}
}
});
}
function updatePointerFromEvent(event) {
interactionMouse.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
interactionRaycaster.setFromCamera(interactionMouse, camera);
}
function getSatellitePointerIntersections(event) {
if (!renderer || !camera || !getShowSatellites()) return [];
const satPoints = getSatellitePoints();
const satPositions = getSatellitePositions();
if (!satPoints?.visible || !Array.isArray(satPositions) || satPositions.length === 0) {
return [];
}
const rect = renderer.domElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const shaderSize = satPoints.material?.uniforms?.size?.value;
const dotSizePx = Number.isFinite(shaderSize)
? shaderSize / dpr
: SATELLITE_CONFIG.dotBaseSize;
const pickRadiusPx = Math.max(12, Math.min(30, dotSizePx * 4));
const pickRadiusSq = pickRadiusPx * pickRadiusPx;
const hits = [];
satPoints.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
for (let index = 0; index < satelliteCount; index++) {
const position = satPositions[index]?.current;
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
if (
scratchSatelliteScreenPosition.z < -1 ||
scratchSatelliteScreenPosition.z > 1
) {
continue;
}
const screenX =
rect.left + (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * rect.width;
const screenY =
rect.top + (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * rect.height;
const dx = screenX - event.clientX;
const dy = screenY - event.clientY;
const distanceSq = dx * dx + dy * dy;
if (distanceSq <= pickRadiusSq) {
hits.push({ index, distanceSq });
}
}
hits.sort((a, b) => a.distanceSq - b.distanceSq);
return hits;
}
function buildLoadErrorMessage(errors) {
if (errors.length === 0) return "";
return earthMessage("status.loadFailedList", { items: errors });
}
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
const satBtn = document.getElementById("toggle-satellites");
if (satBtn) {
setLayerButtonState(satBtn, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏卫星" : "显示卫星",
});
}
const resolvedCount = getAuthoritativeSatelliteCount(satelliteCount);
setEarthStatValue("satellite-count", `${resolvedCount}`);
}
function getAuthoritativeSatelliteCount(fallback = getSatelliteCount()) {
return getAuthoritativeCount("satelliteCount", fallback);
}
function getAuthoritativeCount(summaryKey, fallback = 0) {
const summaryCount = Number(earthStatsSummary?.[summaryKey]);
if (Number.isFinite(summaryCount)) {
return summaryCount;
}
const fallbackCount = Number(fallback);
return Number.isFinite(fallbackCount) ? fallbackCount : 0;
}
function hasAuthoritativeZeroSummary(...summaryKeys) {
if (!earthStatsSummary || summaryKeys.length === 0) return false;
return summaryKeys.every((summaryKey) => {
const summaryCount = Number(earthStatsSummary?.[summaryKey]);
return Number.isFinite(summaryCount) && summaryCount <= 0;
});
}
function applySatelliteEmptyState() {
satelliteHydrationToken += 1;
clearSatelliteData();
updateSatelliteToggleUi(satellitesEnabled || getShowSatellites(), 0);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
}
function applyCableEmptyState() {
const earth = getEarth();
clearCableData(earth);
updateCableToggleUi(cablesEnabled || getShowCables());
setLegendItems("cables", getCableLegendItems());
refreshLegend();
}
function applyComputeCenterEmptyState() {
const earth = getEarth();
clearComputeCenterData(earth);
updateComputeCenterHud({
totalCount: 0,
unresolvedCount: 0,
});
setLegendItems("computeCenters", getComputeCenterLegendItems());
refreshLegend();
}
function applyVesselEmptyState() {
const earth = getEarth();
clearVesselData(earth);
updateVesselToggleUi(vesselsEnabled || getShowVessels(), 0);
setLegendItems("vessels", getVesselLegendItems());
refreshLegend();
}
function applyBGPEmptyState() {
const earth = getEarth();
clearBGPData(earth);
updateBGPHud({
totalCount: 0,
anomalyCount: 0,
collectorCount: 0,
});
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
ensureBGPCruiseAdapter().syncKnownEventIds();
}
function updateVesselHud(result = {}) {
const count = Number(result.totalCount ?? getVesselCount() ?? 0);
setEarthStatValue("vessel-count", `${count}`);
}
function formatRelativeTime(value) {
const date = value instanceof Date ? value : value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return null;
const elapsedSeconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
if (elapsedSeconds < 5) return "刚刚";
if (elapsedSeconds < 60) return `${elapsedSeconds} 秒前`;
const elapsedMinutes = Math.round(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes} 分钟前`;
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
function formatVesselLiveSummary() {
const stream = getVesselRealtimeStats();
if (stream.connected) {
const lastUpdate = formatRelativeTime(stream.lastUpdateAt);
if (stream.updates > 0) {
return `AISStream 实时已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
}
return "AISStream 实时已连接 · 等待首批更新";
}
const state = earthStatsSummary?.aisstreamConnectionState;
if (state === "connected") {
const lastSeen = formatRelativeTime(earthStatsSummary?.aisstreamLastSeenAt);
return `AISStream 后台已连接${lastSeen ? ` · 最近 ${lastSeen}` : ""}`;
}
if (state === "reconnecting") return "AISStream 正在重连";
if (state === "connecting") return "AISStream 正在连接";
return "AISStream 未连接";
}
function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
const vesselBtn = document.getElementById("toggle-vessels");
if (vesselBtn) {
setLayerButtonState(vesselBtn, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏船只" : "显示船只",
});
}
setEarthStatValue("vessel-count", `${getAuthoritativeCount("vesselCount", vesselCount)}`);
setEarthStatValue("vessel-live-summary", formatVesselLiveSummary());
}
function updateCableToggleUi(enabled) {
const cableBtn = document.getElementById("toggle-cables");
if (cableBtn) {
setLayerButtonState(cableBtn, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏线缆" : "显示线缆",
});
}
const cableCount = getAuthoritativeCount("cableCount", getCableLines().length);
const landingPointCount = getAuthoritativeCount(
"landingPointCount",
getLandingPoints().length,
);
setEarthStatValue("cable-count", `${cableCount}`);
setEarthStatValue("landing-point-count", `${landingPointCount}`);
}
async function ensureCablesEnabled() {
if (!scene || !camera || !renderer || destroyed) {
return 0;
}
const earth = getEarth();
if (!earth) return 0;
cablesEnabled = true;
if (getCableLines().length > 0 || getLandingPoints().length > 0) {
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
return getCableLines().length;
}
const requestToken = ++cableToggleToken;
clearCableData(earth);
// Load landing points first so they appear before cable lines
await loadLandingPoints(scene, earth, { silent: true });
const cableCount = await loadGeoJSONFromPath(scene, earth, {
silent: true,
});
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
clearCableData(earth);
return 0;
}
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
return cableCount;
}
function disableCables() {
cablesEnabled = false;
cableToggleToken += 1;
toggleCables(false);
updateCableToggleUi(false);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
}
async function ensureSatellitesEnabled() {
if (!scene || !camera || !renderer || destroyed) return 0;
const earth = getEarth();
if (!earth) return 0;
satellitesEnabled = true;
const requestToken = ++satelliteToggleToken;
if (!getSatellitePoints()) {
createSatellites(scene, earth);
}
clearSatelliteData();
const loadResult = await loadSatellites({
limit: getInitialSatelliteLoadLimit(),
cacheBust: true,
});
if (
requestToken !== satelliteToggleToken ||
!satellitesEnabled ||
destroyed
) {
resetSatelliteState();
return 0;
}
updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
scheduleSatellitePositionWarmup(() => {
if (
requestToken === satelliteToggleToken &&
satellitesEnabled &&
!destroyed
) {
toggleSatellites(true);
}
});
if (shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = ++satelliteHydrationToken;
hydrateAllSatellitesInBackground(
() =>
hydrationToken === satelliteHydrationToken &&
requestToken === satelliteToggleToken &&
satellitesEnabled &&
!destroyed,
);
}
return loadResult.count;
}
async function ensureVesselsEnabled() {
if (!scene || !camera || !renderer || destroyed) return 0;
const earth = getEarth();
if (!earth) return 0;
vesselsEnabled = true;
const zoom = getZoomLevel();
const result = await loadVessels(scene, earth, { zoom });
toggleVessels(true);
startVesselRealtime(earth, {
onUpdate: ({ totalCount }) => {
updateVesselToggleUi(true, totalCount);
updateStatsSummary();
},
});
updateVesselToggleUi(true, result.totalCount);
setLegendItems("vessels", getVesselLegendItems());
refreshLegend();
return result.totalCount;
}
function disableVessels() {
vesselsEnabled = false;
stopVesselRealtime();
toggleVessels(false);
clearVesselSelection();
updateVesselToggleUi(false, 0);
setLegendItems("vessels", getVesselLegendItems());
refreshLegend();
}
function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
satelliteHydrationToken += 1;
toggleSatellites(false);
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
}
function updateStatsSummary() {
const cableCount = getAuthoritativeCount("cableCount", getCableLines().length);
const landingPointCount = getAuthoritativeCount(
"landingPointCount",
getLandingPoints().length,
);
const satelliteCount = getAuthoritativeSatelliteCount();
const vesselCount = getAuthoritativeCount("vesselCount", getVesselCount());
const computeCenterCount = getAuthoritativeCount(
"computeCenterCount",
getComputeCenterCount(),
);
const bgpEventCount = getAuthoritativeCount("bgpEventCount", getBGPCount());
const bgpCollectorCount = getAuthoritativeCount(
"bgpCollectorCount",
getBGPCollectorCount(),
);
updateEarthStats({
cableCount: `${cableCount}`,
landingPointCount: `${landingPointCount}`,
satelliteCount: `${satelliteCount}`,
vesselCount: `${vesselCount}`,
vesselLiveSummary: formatVesselLiveSummary(),
computeCenterCount: `${computeCenterCount}`,
bgpAnomalyCount: `${bgpEventCount}`,
bgpCollectorCount: `${bgpCollectorCount}`,
bgpStatusSummary: getBGPCount()
? getBGPStatusSummary()
: formatBGPStatusFromSummary(earthStatsSummary),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
}
function getCurrentViewCenterCoords() {
const earth = getEarth();
if (!earth || !camera) return null;
scratchViewCenterWorld
.copy(camera.position)
.sub(earth.position)
.normalize()
.multiplyScalar(CONFIG.earthRadius);
earth.worldToLocal(scratchViewCenterWorld);
return vector3ToLatLon(scratchViewCenterWorld);
}
registerEarthClientErrorHandlers();
export function init() {
if (initialized && !destroyed) return;
destroyed = false;
initialized = true;
updateHudScale();
remountEarthBrand();
fetchEarthBrandConfig()
.then((brandConfig) => {
runtimeBrandConfig = brandConfig;
remountEarthBrand();
})
.catch((error) => {
console.warn("Earth brand config unavailable, using defaults.", error);
});
cleanupFns.push(onEarthLocaleChange(() => {
remountEarthBrand();
updateCableToggleUi(getShowCables());
updateSatelliteToggleUi(getShowSatellites());
updateComputeCenterHud({
totalCount: getComputeCenterCount(),
unresolvedCount: getUnresolvedComputeCenters().length,
});
updateVesselToggleUi(getShowVessels());
updateBGPHud({
totalCount: getBGPCount(),
collectorCount: getBGPCollectorCount(),
});
updateStatsSummary();
}));
initTVPanel();
initEarthAbout();
initEarthOobe();
initNewsPanel();
connectEarthUpdatesRealtime();
startEarthDataReconciliation();
initSearchPanel({
resolveResults: resolveEarthSearchResults,
onSelectResult: handleSearchSelection,
});
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
75,
getViewportAspect(),
0.1,
5000,
);
setZoomLevel(getDefaultEarthZoomLevel(), camera);
setSatelliteCamera(camera);
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
syncRendererViewport();
renderer.setClearColor(0x02040a, 1);
renderer.setPixelRatio(window.devicePixelRatio);
publishWebGLGpuDiagnostics(renderer);
const container = document.getElementById("container");
if (container) {
container.querySelector("canvas")?.remove();
container.appendChild(renderer.domElement);
}
sceneLights = addLights();
initInfoCard();
initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("vessels", getVesselLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
applyImmediateView(earthObj, camera);
targetRotation = {
x: earthObj.rotation.x,
y: earthObj.rotation.y,
};
inertialVelocity = { x: 0, y: 0 };
createClouds(scene, earthObj);
registerTerrainMesh(createTerrain(earthObj));
initCelestialLayer(scene, {
camera,
ambientLight: sceneLights?.ambientLight ?? null,
sunLight: sceneLights?.sunLight ?? null,
backLight: sceneLights?.backLight ?? null,
pointLight: sceneLights?.pointLight ?? null,
earth: earthObj,
});
setCelestialDayNightEnabled(true);
createGridLines(scene, earthObj);
createCountryBoundaryLayer(earthObj);
createSatellites(scene, earthObj);
setupControls(camera, renderer, scene, earthObj)
.catch((error) => {
console.error("初始化 Earth 控制项失败:", error);
void reportEarthClientLog({
level: "error",
category: "init",
module: "controls",
message: `初始化 Earth 控制项失败: ${error?.message || String(error)}`,
detail: error,
});
})
.finally(() => {
if (destroyed) return;
setupEventListeners();
initMotionDebugPanel();
setupMotionControl();
clock.start();
loadData();
animate();
registerGlobalApi();
});
}
function setupMotionControl() {
motionControlAdapter?.stop?.();
const motionModeActive = getRotationMode() === ROTATION_MODE.MOTION && getAutoRotate();
const motionDebugEnabled = motionModeActive && getMotionDebugEnabled();
const motionEnabled = motionModeActive;
setMotionDebugPanelVisible(motionDebugEnabled);
setMotionDebugPanelSkeletonOnly(getMotionDebugSkeletonOnly());
if (!motionEnabled) {
motionCruiseSequencer?.stop?.();
motionSharedCruiseSequencer?.stop?.();
}
motionControlAdapter = createMotionControlAdapter({
enabled: motionEnabled,
provider: getMotionProvider(),
onRotate: applyMotionRotate,
onZoom: applyMotionZoom,
onConfirm: applyMotionConfirm,
onFocus: applyMotionFocus,
onLayer: applyMotionLayerSwitch,
onStatus: (message, type) => showStatusMessage(message, type),
debugSkeleton: motionDebugEnabled,
enabledGestures: getMotionEnabledGestures(),
});
motionControlAdapter.start();
}
export function applyMotionRotate(axisOrDirection, directionOrIntensity = 1, maybeIntensity = 1) {
const earth = getEarth();
if (!earth) return false;
const axisCall = axisOrDirection === "horizontal" || axisOrDirection === "vertical";
const axis = axisCall ? axisOrDirection : "horizontal";
const direction = axisCall ? directionOrIntensity : axisOrDirection;
const intensity = axisCall ? maybeIntensity : directionOrIntensity;
const amount = Math.max(0.25, Math.min(1, Number(intensity) || 1));
const sign = direction === "left" || direction === "up" ? -1 : 1;
const rotationDelta = sign * MOTION_ROTATION_DELTA * amount;
if (getRotationMode() !== ROTATION_MODE.MOTION) {
setAutoRotate(false);
}
if (axis === "vertical") {
targetRotation.x += rotationDelta;
inertialVelocity.x = rotationDelta * MOTION_INERTIA_FACTOR;
} else {
targetRotation.y += rotationDelta;
inertialVelocity.y = rotationDelta * MOTION_INERTIA_FACTOR;
}
const label = {
left: "向左旋转",
right: "向右旋转",
up: "向上旋转",
down: "向下旋转",
}[direction] || "旋转";
showStatusMessage(earthMessage("status.motionPrefix", { text: label }), "info");
return true;
}
export function applyMotionZoom(direction, intensity = 1) {
if (!camera) return false;
const amount = Math.max(0.25, Math.min(1, Number(intensity) || 1));
const factor = 1 + 0.05 * amount;
const currentZoom = getZoomLevel();
const nextZoom = direction === "in" ? currentZoom * factor : currentZoom / factor;
setZoomLevel(nextZoom, camera);
showZoomStatusCapsule({ force: true });
return true;
}
export function applyMotionConfirm() {
const releaseGate = beginMotionActionGate("confirm");
if (!releaseGate) return false;
try {
let candidate = getCurrentMotionConfirmationCandidate();
if (!candidate) {
refreshMotionFocusCandidates({ force: true });
candidate = getCurrentMotionConfirmationCandidate();
}
if (candidate && confirmMotionCandidate(candidate)) {
return true;
}
if (lockedObject || lockedSatellite) {
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
showStatusMessage(earthMessage("status.motionConfirmCurrent"), "info");
return true;
}
showStatusMessage(earthMessage("status.motionSelectTargetFirst"), "info");
return false;
} finally {
releaseGate();
}
}
function registerGlobalApi() {
window.__planetEarth = {
reloadData,
clearSelection: () => {
presentationController?.dismiss?.("user_close");
hideInfoCard();
clearLockedObject();
},
celestial: {
getState: () => getCelestialDebugState(),
setOrientation: (nextEuler) => setCelestialOrientation(nextEuler),
setFollow: (nextFollow) => setCelestialFollow(nextFollow),
},
motion: {
rotate: applyMotionRotate,
zoom: applyMotionZoom,
confirm: applyMotionConfirm,
isConnected: () => Boolean(motionControlAdapter?.isConnected?.()),
setDebugVisible: setMotionDebugPanelVisible,
},
destroy,
init,
};
}
function addLights() {
const ambientLight = new THREE.AmbientLight(SCENE_LIGHT_CONFIG.ambient.color);
ambientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(
SCENE_LIGHT_CONFIG.sun.color,
SCENE_LIGHT_CONFIG.sun.intensity,
);
sunLight.position.set(
SCENE_LIGHT_CONFIG.sun.position.x,
SCENE_LIGHT_CONFIG.sun.position.y,
SCENE_LIGHT_CONFIG.sun.position.z,
);
sunLight.target.position.set(0, 0, 0);
scene.add(sunLight);
scene.add(sunLight.target);
const backLight = new THREE.DirectionalLight(
SCENE_LIGHT_CONFIG.back.color,
SCENE_LIGHT_CONFIG.back.intensity,
);
backLight.position.set(
SCENE_LIGHT_CONFIG.back.position.x,
SCENE_LIGHT_CONFIG.back.position.y,
SCENE_LIGHT_CONFIG.back.position.z,
);
scene.add(backLight);
const pointLight = new THREE.PointLight(
SCENE_LIGHT_CONFIG.point.color,
SCENE_LIGHT_CONFIG.point.intensity,
);
pointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
scene.add(pointLight);
return {
ambientLight,
sunLight,
backLight,
pointLight,
};
}
// Yield control to the browser so the renderer can paint a frame before the next step
const yieldFrame = (ms = 24) => new Promise((r) => setTimeout(r, ms));
function scheduleSatellitePositionWarmup(onReady) {
window.requestAnimationFrame(() => {
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
if (typeof onReady === "function") {
onReady();
}
});
}
function getInitialSatelliteLoadLimit() {
const configuredInitial = Number.isFinite(SATELLITE_CONFIG.initialLoadCount)
? Math.max(1, Math.floor(SATELLITE_CONFIG.initialLoadCount))
: null;
if (SATELLITE_CONFIG.maxCount > 0) {
return configuredInitial === null
? SATELLITE_CONFIG.maxCount
: Math.min(configuredInitial, SATELLITE_CONFIG.maxCount);
}
return configuredInitial;
}
function shouldHydrateFullSatelliteSet(loadResult) {
if (!SATELLITE_CONFIG.hydrateFullAfterInitialLoad) return false;
if (!loadResult || loadResult.requestedLimit === null) return false;
return loadResult.count >= loadResult.requestedLimit;
}
async function hydrateAllSatellitesInBackground(guardFn) {
try {
const loadResult = await loadSatellites({ limit: null, cacheBust: true });
if (!guardFn()) return;
updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
scheduleSatellitePositionWarmup();
} catch (error) {
console.warn("后台补全卫星全量数据失败:", error);
}
}
function scheduleEarthUpdatesReconnect() {
if (earthUpdatesReconnectTimer || destroyed) return;
const delay = Math.max(EARTH_UPDATES_RECONNECT_DELAY_MS, getEarthRealtimeCooldownMs());
earthUpdatesReconnectTimer = window.setTimeout(() => {
earthUpdatesReconnectTimer = null;
connectEarthUpdatesRealtime();
}, delay);
}
async function refreshBGPRealtimeLayer() {
if (!scene || !getEarth()) return;
const result = await loadBGPAnomalies(scene, getEarth());
updateBGPHud(result);
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
ensureBGPCruiseAdapter().syncKnownEventIds();
}
async function refreshCablesRealtimeLayer() {
const earth = getEarth();
if (!scene || !earth) return;
const wasEnabled = cablesEnabled || getShowCables();
if (!wasEnabled) {
clearCableData(earth);
updateCableToggleUi(false);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
return;
}
cablesEnabled = true;
await loadLandingPoints(scene, earth, { silent: true });
await loadGeoJSONFromPath(scene, earth, { silent: true });
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
}
function shouldRefreshLayerFromServerState(handler) {
return (
handler.isVisible?.() ||
handler.localCount?.() > 0 ||
handler.summaryKeys?.some((summaryKey) => getAuthoritativeCount(summaryKey, 0) > 0)
);
}
function shouldClearLayerFromServerState(handler) {
return hasAuthoritativeZeroSummary(...(handler.summaryKeys || []));
}
function getLayerSummaryTotal(handler) {
if (!earthStatsSummary || !Array.isArray(handler.summaryKeys)) return null;
let total = 0;
for (const summaryKey of handler.summaryKeys) {
const count = Number(earthStatsSummary?.[summaryKey]);
if (!Number.isFinite(count)) return null;
total += count;
}
return total;
}
function shouldRefreshLayerForCountMismatch(handler) {
if (!handler.refreshOnCountMismatch || !handler.isVisible?.()) return false;
const summaryTotal = getLayerSummaryTotal(handler);
if (summaryTotal === null) return false;
return summaryTotal !== (handler.localCount?.() || 0);
}
const EARTH_DATA_LAYER_HANDLERS = {
bgp: {
summaryKeys: ["bgpEventCount", "bgpCollectorCount"],
isVisible: () => getShowBGP(),
localCount: () => getBGPCount() + getBGPCollectorCount(),
clear: applyBGPEmptyState,
refresh: refreshBGPRealtimeLayer,
},
cables: {
summaryKeys: ["cableCount", "landingPointCount"],
isVisible: () => cablesEnabled || getShowCables(),
localCount: () => getCableSourceRecordCount(),
clear: applyCableEmptyState,
refresh: refreshCablesRealtimeLayer,
refreshOnCountMismatch: true,
},
computeCenters: {
summaryKeys: ["computeCenterCount"],
isVisible: () => getShowComputeCenters(),
localCount: () => getComputeCenterCount() + getUnresolvedComputeCenters().length,
clear: applyComputeCenterEmptyState,
refresh: refreshComputeCentersAfterLocationSave,
refreshOnCountMismatch: true,
},
satellites: {
summaryKeys: ["satelliteCount"],
isVisible: () => satellitesEnabled || getShowSatellites(),
localCount: () => getSatelliteCount(),
clear: applySatelliteEmptyState,
refresh: ensureSatellitesEnabled,
refreshOnCountMismatch: true,
},
vessels: {
summaryKeys: ["vesselCount"],
isVisible: () => vesselsEnabled || getShowVessels(),
localCount: () => getVesselCount(),
clear: applyVesselEmptyState,
refresh: ensureVesselsEnabled,
},
};
async function refreshDataBackedEarthLayer(layer, options = {}) {
const handler = EARTH_DATA_LAYER_HANDLERS[layer];
if (!handler) return false;
const shouldClearBeforeReload = options.strategy === "clear_then_reload";
if (shouldClearBeforeReload) {
handler.clear?.();
}
if (!shouldClearBeforeReload && shouldClearLayerFromServerState(handler)) {
handler.clear?.();
return true;
}
if (!options.force && !shouldRefreshLayerFromServerState(handler)) {
return false;
}
await handler.refresh?.();
return true;
}
async function refreshInteractablesRealtimeLayer() {
const earth = getEarth();
if (!earth) return;
await refreshEarthInteractables(earth);
}
async function refreshEarthLayerFromUpdate(layer, options = {}) {
if (EARTH_DATA_LAYER_HANDLERS[layer]) {
await refreshDataBackedEarthLayer(layer, options);
} else if (layer === "news") {
await refreshEarthNews({ silent: true });
} else if (layer === "interactables") {
await refreshInteractablesRealtimeLayer();
}
}
async function flushPendingEarthUpdates() {
if (earthUpdateFlushPromise) return earthUpdateFlushPromise;
const layers = Array.from(pendingEarthUpdateLayers);
const strategies = new Map(pendingEarthUpdateStrategies);
const isDatabaseChange = pendingEarthUpdateIsDatabaseChange;
pendingEarthUpdateLayers.clear();
pendingEarthUpdateStrategies.clear();
pendingEarthUpdateIsDatabaseChange = false;
if (layers.length === 0) return Promise.resolve();
earthUpdateFlushPromise = (async () => {
if (isDatabaseChange) {
await loadEarthStatsSummary({
shouldApply: () => !destroyed,
cacheBust: true,
});
}
const results = await Promise.allSettled(
layers.map((layer) => refreshEarthLayerFromUpdate(layer, {
force: isDatabaseChange,
strategy: strategies.get(layer),
})),
);
const failed = results.some((result) => result.status === "rejected");
if (failed) {
console.warn("部分 Earth 图层实时刷新失败:", { layers, results });
}
updateStatsSummary();
if (isCruiseModeActive()) {
syncCruiseModuleKnownEventIds().catch((error) => {
console.warn("实时刷新后同步巡航模块失败:", error);
});
}
})()
.catch((error) => {
console.warn("Earth 实时刷新失败:", { layers, error });
})
.finally(() => {
earthUpdateFlushPromise = null;
if (pendingEarthUpdateLayers.size > 0 && !earthUpdateFlushTimer) {
earthUpdateFlushTimer = window.setTimeout(() => {
earthUpdateFlushTimer = null;
flushPendingEarthUpdates();
}, EARTH_UPDATE_CLIENT_DEBOUNCE_MS);
}
});
return earthUpdateFlushPromise;
}
function scheduleEarthUpdateFlush() {
if (earthUpdateFlushTimer || earthUpdateFlushPromise) return;
earthUpdateFlushTimer = window.setTimeout(() => {
earthUpdateFlushTimer = null;
flushPendingEarthUpdates();
}, EARTH_UPDATE_CLIENT_DEBOUNCE_MS);
}
function handleEarthUpdateFrame(payload = {}) {
if (payload.entity === "interactable") {
const earth = getEarth();
const applied = applyEarthInteractableEvent(earth, payload);
if (applied) {
updateStatsSummary();
}
return;
}
const layers = Array.isArray(payload.layers) ? payload.layers : [];
if (layers.length === 0) return;
const isDatabaseChange = payload.action === "database_changed";
const strategy = typeof payload.refresh_strategy === "string" ? payload.refresh_strategy : "";
layers.forEach((layer) => {
pendingEarthUpdateLayers.add(layer);
if (strategy === "clear_then_reload" || !pendingEarthUpdateStrategies.has(layer)) {
pendingEarthUpdateStrategies.set(layer, strategy || "reload");
}
});
pendingEarthUpdateIsDatabaseChange = pendingEarthUpdateIsDatabaseChange || isDatabaseChange;
scheduleEarthUpdateFlush();
}
async function reconcileEarthDataFromServer() {
if (destroyed || earthDataReconcileInFlight) return;
if (document.visibilityState === "hidden") return;
earthDataReconcileInFlight = true;
try {
await loadEarthStatsSummary({
shouldApply: () => !destroyed,
cacheBust: true,
});
if (destroyed) return;
let changed = false;
for (const [layer, handler] of Object.entries(EARTH_DATA_LAYER_HANDLERS)) {
if (shouldClearLayerFromServerState(handler) && handler.localCount?.() > 0) {
handler.clear?.();
changed = true;
continue;
}
const serverHasData = handler.summaryKeys?.some(
(summaryKey) => getAuthoritativeCount(summaryKey, 0) > 0,
);
if (
(handler.isVisible?.() && serverHasData && handler.localCount?.() === 0) ||
shouldRefreshLayerForCountMismatch(handler)
) {
await refreshDataBackedEarthLayer(layer, { force: true });
changed = true;
}
}
if (changed) {
updateStatsSummary();
}
} catch (error) {
console.warn("Earth 数据一致性巡检失败:", error);
} finally {
earthDataReconcileInFlight = false;
}
}
function startEarthDataReconciliation() {
if (earthDataReconcileTimer || destroyed) return;
earthDataReconcileTimer = window.setInterval(
() => {
reconcileEarthDataFromServer();
},
EARTH_DATA_RECONCILE_INTERVAL_MS,
);
}
function connectEarthUpdatesRealtime() {
if (earthUpdatesSocket || destroyed) return;
if (!canAttemptEarthRealtime()) {
scheduleEarthUpdatesReconnect();
return;
}
const socket = new WebSocket(getEarthRealtimeUrl());
earthUpdatesSocket = socket;
socket.onopen = () => {
socket.__planetOpened = true;
recordEarthRealtimeOpen();
if (earthUpdatesReconnectTimer) {
window.clearTimeout(earthUpdatesReconnectTimer);
earthUpdatesReconnectTimer = null;
}
socket.send(JSON.stringify({
type: "subscribe",
data: {
channel: EARTH_UPDATES_CHANNEL,
},
}));
};
socket.onmessage = (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "heartbeat" && message.data?.action === "ping") {
socket.send(JSON.stringify({ type: "heartbeat" }));
return;
}
if (message.type !== "data_frame" || message.channel !== EARTH_UPDATES_CHANNEL) return;
handleEarthUpdateFrame(message.payload || {});
};
socket.onclose = () => {
if (earthUpdatesSocket === socket) {
earthUpdatesSocket = null;
}
if (!socket.__planetOpened) {
recordEarthRealtimeFailure();
}
scheduleEarthUpdatesReconnect();
};
socket.onerror = () => {
socket.close();
};
}
async function loadData() {
if (!scene || !camera || !renderer) return;
if (isDataLoading) return;
const earth = getEarth();
if (!earth) return;
const loadToken = ++currentLoadToken;
isDataLoading = true;
hideError();
clearSelectionAndInfo();
// Always begin as a white sphere so every layer appears explicitly
clearEarthTexture();
clearBGPData(earth);
clearCableData(earth);
clearComputeCenterData(earth);
clearVesselData(earth);
clearEarthInteractables(earth);
clearSatelliteData();
clearCountryBoundaryHover();
setLoadingMessage(earthMessage("loading.initializing"));
setLoading(true);
await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
loadEarthStatsSummary({
shouldApply: () => loadToken === currentLoadToken && !destroyed,
}).catch((error) => {
console.warn("后台刷新全球态势统计失败:", error);
});
const errors = [];
const startupLoaders = createLayerStartupTaskMap({
scene,
earth,
setLoadingMessage,
yieldFrame,
refreshLegend,
setLegendItems,
getComputeCenterLegendItems,
updateCableToggleUi,
updateSatelliteToggleUi,
updateComputeCenterHud,
updateVesselHud,
updateBGPHud,
getShowComputeCenters,
getShowVessels,
getShowCountryBoundaries,
getShowBGP,
isEarthTextureVisible: () => getEarthTextureVisible(),
isCloudsEnabled: () => getShowClouds(),
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
hydrateAllSatellitesInBackground,
syncBGPKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(),
isCancelled: () => loadToken !== currentLoadToken || destroyed,
isCablesEnabled: () => cablesEnabled,
isSatellitesEnabled: () => satellitesEnabled,
nextSatelliteHydrationToken: () => ++satelliteHydrationToken,
getSatelliteHydrationToken: () => satelliteHydrationToken,
reportError: (label, reason) => {
errors.push({ label, reason });
void reportEarthClientLog({
level: "error",
category: "startup-load",
module: "layer-startup",
message: `${label}加载失败: ${reason?.message || String(reason)}`,
detail: reason,
});
},
});
const startupLayers = getStartupLoadLayers();
const startupLoadQueue = startupLayers
.map((layer) => ({
layer,
run: startupLoaders[layer.id],
}))
.filter((entry) => typeof entry.run === "function");
for (const entry of startupLoadQueue) {
await entry.run(entry.layer);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
}
loadEarthInteractables(earth, { silent: true }).catch((error) => {
console.warn("Earth interactables 加载失败:", error);
});
// Step 6 — Terrain (if enabled)
if (getShowTerrain()) {
const terrainLayer = startupLayers.find((layer) => layer.id === "terrain");
const terrainMessage = resolveStartupMessage(
terrainLayer,
"load",
earthMessage("startup.terrain"),
);
setLoadingMessage(terrainMessage);
await yieldFrame(24);
}
updateStatsSummary();
updateCableToggleUi(cablesEnabled);
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("vessels", getVesselLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
setLoading(false);
isDataLoading = false;
if (getRotationMode() === ROTATION_MODE.CRUISE && getAutoRotate()) {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("初始化后启动巡航失败:", error);
});
}
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
showError(errorMessage);
queueStatusMessage(errorMessage, "error");
} else {
hideError();
queueStatusMessage(earthMessage("status.dataLoaded"), "success");
}
applyDeferredLayerVisibilitySettings()
.catch((error) => {
console.warn("恢复 Earth 图层可见性失败:", error);
})
.finally(() => {
if (loadToken === currentLoadToken && !destroyed) {
scheduleTerrainPrefetch();
}
});
}
const POSITION_UPDATE_FORCE_DELTA = 250;
const SEARCH_RESULT_LIMIT = 28;
const SEARCH_CARD_X_RATIO = 0.68;
const SEARCH_CARD_Y_RATIO = 0.18;
export async function reloadData() {
await loadData();
}
export function getSatellitesEnabled() {
return satellitesEnabled;
}
export function getVesselsEnabled() {
return vesselsEnabled;
}
export async function setCablesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === cablesEnabled) {
updateCableToggleUi(enabled);
return getCableLines().length;
}
if (!enabled) {
clearSelectionAndInfo();
disableCables();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "线缆", visible: false }), "info");
}
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage(earthMessage("loading.cableData"));
setLoading(true);
hideError();
}
try {
const cableCount = await ensureCablesEnabled();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "线缆", visible: true }), "info");
}
return cableCount;
} catch (error) {
cablesEnabled = false;
clearCableData(getEarth());
updateCableToggleUi(false);
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "线缆", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "cables",
message: `线缆加载失败: ${reason}`,
detail: error,
});
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
export async function setCountryBoundariesEnabled(
enabled,
{ suppressStatus = false } = {},
) {
if (!enabled) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "国界", visible: false }), "info");
}
return 0;
}
try {
const countryCount = await ensureCountryBoundariesReady();
const textureOn = getEarthTextureVisible();
toggleCountryBoundaries(true, {
showTint: !textureOn,
showLandFill: true,
suppressLandFill: false,
});
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
refreshLegend();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "国界", visible: true }), "info");
}
return countryCount;
} catch (error) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "国界", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "country-boundaries",
message: `国界加载失败: ${reason}`,
detail: error,
});
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
}
}
export async function reloadCountryBoundaries({ suppressStatus = false } = {}) {
const wasVisible = getShowCountryBoundaries();
clearCountryBoundaryHover();
clearCountryBoundaryData();
if (wasVisible) {
return await setCountryBoundariesEnabled(true, { suppressStatus });
}
const countryCount = await ensureCountryBoundariesReady();
const textureOn = getEarthTextureVisible();
toggleCountryBoundaries(false, {
showTint: !textureOn,
showLandFill: true,
suppressLandFill: false,
});
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
refreshLegend();
return countryCount;
}
let _dayNightBeforeTextureOff = null;
let _terrainBeforeTextureOff = null;
export async function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) {
setEarthTextureVisible(enabled);
if (enabled) {
try {
await loadEarthTexture();
setEarthTextureVisible(true);
} catch (error) {
console.warn("高清材质加载失败:", error);
setEarthTextureVisible(false);
}
}
setSurfaceTintEnabled(!enabled);
setLandFillEnabled(true);
setLandFillSuppressed(false);
setTerrainLayerInteractable(enabled);
setDayNightInteractable(enabled);
if (!enabled) {
if (_terrainBeforeTextureOff === null) {
_terrainBeforeTextureOff = getShowTerrain();
}
toggleTerrain(false);
if (_dayNightBeforeTextureOff === null) {
_dayNightBeforeTextureOff = getDayNightEnabled();
}
setDayNightEnabledExternal(false, { persist: false });
} else {
if (_terrainBeforeTextureOff !== null) {
toggleTerrain(_terrainBeforeTextureOff);
_terrainBeforeTextureOff = null;
}
if (_dayNightBeforeTextureOff !== null) {
setDayNightEnabledExternal(_dayNightBeforeTextureOff, { persist: false });
_dayNightBeforeTextureOff = null;
}
}
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerEnabled", { layer: "高清材质", enabled }), "info");
}
return enabled;
}
export function getHighResTextureEnabled() {
return getEarthTextureVisible();
}
export async function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) {
toggleClouds(enabled);
if (enabled) {
try {
await loadCloudTexture();
toggleClouds(true);
} catch (error) {
console.warn("大气云图加载失败:", error);
toggleClouds(false);
}
}
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "大气云图", visible: enabled }), "info");
}
return enabled;
}
export function getAtmosphereCloudsEnabled() {
return getShowClouds();
}
export async function setSatellitesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === satellitesEnabled) {
updateSatelliteToggleUi(enabled);
return getSatelliteCount();
}
if (!enabled) {
clearSelectionAndInfo();
disableSatellites();
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage(earthMessage("loading.satelliteData"));
setLoading(true);
hideError();
}
try {
const satelliteCount = await ensureSatellitesEnabled();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "卫星", visible: true }), "info");
}
return satelliteCount;
} catch (error) {
satellitesEnabled = false;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "卫星", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "satellites",
message: `卫星加载失败: ${reason}`,
detail: error,
});
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
export async function setVesselsEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === vesselsEnabled) {
updateVesselToggleUi(enabled);
return getVesselCount();
}
if (!enabled) {
clearSelectionAndInfo();
disableVessels();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "船只", visible: false }), "info");
}
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage(earthMessage("loading.vesselData"));
setLoading(true);
hideError();
}
try {
const vesselCount = await ensureVesselsEnabled();
if (!suppressStatus) {
showStatusMessage(earthMessage("status.layerVisibility", { layer: "船只", visible: true }), "info");
}
return vesselCount;
} catch (error) {
vesselsEnabled = false;
clearVesselData(getEarth());
updateVesselToggleUi(false, 0);
const reason = error?.message || String(error);
const message = earthMessage("status.layerLoadFailed", { layer: "船只", error: reason });
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "vessels",
message: `船只加载失败: ${reason}`,
detail: error,
});
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
function setupEventListeners() {
const handleResize = () => onWindowResize();
const handleVisibilityChange = () => onVisibilityChange();
const handlePointerMove = (event) => onPointerMove(event);
const handlePointerDown = (event) => onPointerDown(event);
const handlePointerUp = (event) => onPointerUp(event);
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
const handleRotationMode = (event) => handleRotationModeChange(event);
const handleCruiseModules = () => handleCruiseModulesChange();
const handleCruiseNextCard = () => handleCruiseNextCardShortcut();
const handleInfoCardDrag = () => {
repositionCruiseConnector();
repositionMotionConnector();
};
const handleInfoCardVisibilityChange = (event) => {
if (event?.detail?.visible !== false) return;
const closedToolCard = handleToolInfoCardClosed();
if (!closedToolCard && isMotionPresentationActive()) {
presentationController?.dismiss?.("user_close");
}
};
const handlePreviewLocationCandidate = (event) => {
previewLocationCandidate(event?.detail || {}).catch((error) => {
console.warn("预览候选位置失败:", error);
});
};
const handleComputeCenterLocationSaved = (event) => {
const detail = event?.detail || {};
spawnComputeCenterAfterLocationSave(detail)
.then((spawnResult) => {
if (!spawnResult) {
// No optimistic marker (e.g. scene not ready yet); rely on refresh
// for the user-visible confirmation.
return refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
})
.catch((error) => {
// Swallow refresh failure: the save itself succeeded, so we
// must not surface this as a save failure.
console.warn("后台校准算力中心图层失败:", error);
showStatusMessage(earthMessage("status.coordinatesSavedLater"), "info");
});
}
// Optimistic marker is on screen; reconcile in the background.
refreshComputeCentersAfterLocationSave().catch((error) => {
console.warn("后台校准算力中心图层失败:", error);
});
return null;
})
.catch((error) => {
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
showStatusMessage(earthMessage("status.coordinatesSavedSyncing"), "info");
refreshComputeCentersAfterLocationSave()
.then(() => {
showStatusMessage(earthMessage("status.computeCoordinatesSaved"), "success");
})
.catch((refreshError) => {
console.warn("后台校准算力中心图层失败:", refreshError);
});
});
};
const handleComputeCenterUnresolvedCountChange = (event) => {
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
};
const handleMotionDebugModeChange = (event) => {
const motionModeActive = getRotationMode() === ROTATION_MODE.MOTION && getAutoRotate();
const nextEnabled = motionModeActive && Boolean(event?.detail?.enabled);
setMotionDebugPanelVisible(nextEnabled);
setMotionDebugPanelSkeletonOnly(Boolean(event?.detail?.skeletonOnly));
motionControlAdapter?.sendCommand?.(
"set_debug_options",
{ skeleton: nextEnabled },
"earth-motion-debug-toggle",
);
motionControlAdapter?.setEnabledGestures?.(
Array.isArray(event?.detail?.enabledGestures)
? event.detail.enabledGestures
: getMotionEnabledGestures(),
);
};
const handleMotionDebugClose = () => {
setMotionDebugEnabled(false);
setupMotionControl();
};
bindListener(window, "resize", handleResize);
bindListener(document, "visibilitychange", handleVisibilityChange);
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(window, "earth:cruise-modules-change", handleCruiseModules);
bindListener(window, "earth:cruise-queue-settings-change", handleCruiseQueueSettingsChange);
bindListener(window, "earth:cruise-next-card", handleCruiseNextCard);
bindListener(window, "earth:info-card-drag", handleInfoCardDrag);
bindListener(window, "earth:info-card-visibility-change", handleInfoCardVisibilityChange);
bindListener(
window,
"earth:preview-location-candidate",
handlePreviewLocationCandidate,
);
bindListener(
window,
"earth:compute-center-location-saved",
handleComputeCenterLocationSaved,
);
bindListener(
window,
"earth:compute-center-unresolved-count-change",
handleComputeCenterUnresolvedCountChange,
);
bindListener(window, "earth:motion-debug-mode-change", handleMotionDebugModeChange);
bindListener(window, MOTION_DEBUG_CLOSE_EVENT, handleMotionDebugClose);
getCruiseModuleDefinitions().forEach((module) => {
if (!module.externalEventName) return;
bindListener(window, module.externalEventName, (event) => {
handleCruiseModuleItemsUpdated(module.id, event);
});
});
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
bindListener(window, "pointermove", handlePointerMove);
bindListener(window, "pointerup", handlePointerUp);
bindListener(window, "pointercancel", handlePointerUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
if (renderer?.domElement) {
renderer.domElement.style.touchAction = "none";
}
}
function onVisibilityChange() {
if (document.hidden) {
shouldRefreshSatellitesAfterVisibilityResume = true;
return;
}
if (!shouldRefreshSatellitesAfterVisibilityResume) return;
shouldRefreshSatellitesAfterVisibilityResume = false;
// Drop the elapsed background time so the next RAF does not replay it.
clock.getDelta();
updateSatellitePositions(0, true, { resetTrails: true });
}
function updateHudScale() {
const widthScale = window.innerWidth / HUD_CONFIG.scaleReferenceWidth;
const heightScale = window.innerHeight / HUD_CONFIG.scaleReferenceHeight;
const nextScale = THREE.MathUtils.clamp(
Math.min(widthScale, heightScale),
HUD_CONFIG.minScale,
HUD_CONFIG.maxScale,
);
document.documentElement.style.setProperty(
"--hud-scale",
nextScale.toFixed(3),
);
}
function onWindowResize() {
updateHudScale();
syncRendererViewport();
repositionCruiseConnector();
repositionMotionConnector();
}
function getFrontFacingCables(cableLines) {
const earth = getEarth();
if (!earth) return cableLines;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return cableLines.filter((cable) => {
if (!cable.userData.localCenter) {
return true;
}
scratchCableCenter.copy(cable.userData.localCenter);
cable.localToWorld(scratchCableCenter);
scratchCableDirection
.subVectors(scratchCableCenter, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchCableDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function getComputeCenterPointerIntersections() {
const earth = getEarth();
return getComputeCenterIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) {
clearTransientHoverState();
// Info card stays visible at its click position; just maintain BGP visual state
if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (
!lockedObject &&
!lockedSatellite &&
!isCruisePresentationPinned() &&
!isMotionPresentationActive() &&
!isInfoCardSticky()
) {
hideInfoCard();
}
hideTooltip();
return;
}
if (isDragging) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
}
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
isLongDrag = true;
}
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
const dragRotationFactor = getDragRotationFactor();
const rotationDeltaY = deltaX * dragRotationFactor;
const rotationDeltaX = deltaY * dragRotationFactor;
targetRotation.y += rotationDeltaY;
targetRotation.x += rotationDeltaX;
inertialVelocity.y = rotationDeltaY;
inertialVelocity.x = rotationDeltaX;
previousMousePosition = { x: event.clientX, y: event.clientY };
if (!document.body.classList.contains("layout-mode-mobile")) {
clearCountryBoundaryHover();
}
hideTooltip();
return;
}
updatePointerFromEvent(event);
const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
const bgpAnomalyIntersects = getShowBGP()
? getBGPEventPointerIntersections()
: [];
const bgpCollectorIntersects = getShowBGP()
? getBGPCollectorPointerIntersections()
: [];
const computeCenterIntersects = getShowComputeCenters()
? getComputeCenterPointerIntersections()
: [];
const vesselPick = getVesselHoverIntersections();
const vesselIntersects = vesselPick.intersects;
const earthInteractableIntersects = getEarthInteractablePointerIntersections();
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
const satIntersects = getSatellitePointerIntersections(event);
if (satIntersects.length > 0) {
const satIndex = satIntersects[0].index;
hoveredSatIndexFromIntersect = satIndex;
hoveredSat = selectSatellite(satIndex);
}
const hoveredBGPMarker = getPrimaryBGPHoverTarget(
bgpAnomalyIntersects,
bgpCollectorIntersects,
);
const hoveredClusterHit = getPrimaryClusterHit(
bgpAnomalyIntersects,
bgpCollectorIntersects,
computeCenterIntersects,
earthInteractableIntersects,
);
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
clearTransientHoverState();
}
const hoveredComputeCenterMarker = getFirstObjectIntersection(computeCenterIntersects);
const hoveredVesselMarker =
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
const hoveredEarthInteractableMarker = getFirstObjectIntersection(earthInteractableIntersects);
const earthPoint = screenToEarthCoords(
event.clientX,
event.clientY,
camera,
getEarthSurfacePickTarget() || earth,
document.body,
interactionRaycaster,
interactionMouse,
);
const surfaceHover = earthPoint
? { coords: vector3ToLatLon(earthPoint), hoveredCountry: null }
: null;
if (surfaceHover) {
updateCoordinatesDisplay(
surfaceHover.coords.lat,
surfaceHover.coords.lon,
surfaceHover.coords.alt,
);
surfaceHover.hoveredCountry = getShowCountryBoundaries()
? updateCountryBoundaryHover(surfaceHover.coords)
: null;
if (!getShowCountryBoundaries()) {
clearCountryBoundaryHover();
}
} else {
clearCountryBoundaryHover();
}
if (
hoveredComputeCenter &&
!isSameComputeCenter(hoveredComputeCenter, hoveredComputeCenterMarker)
) {
clearTransientHoverState();
}
if (
vesselPick.checked &&
hoveredVessel &&
!isSameVessel(hoveredVessel, hoveredVesselMarker)
) {
clearTransientHoverState();
}
if (
hoveredEarthInteractable &&
!isSameEarthInteractable(hoveredEarthInteractable, hoveredEarthInteractableMarker)
) {
clearTransientHoverState();
}
if (
hoveredCable &&
(!cableIntersects.length ||
!isSameCable(cableIntersects[0]?.object, hoveredCable))
) {
clearTransientHoverState();
}
if (
hoveredSatelliteIndex !== null &&
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
) {
clearTransientHoverState();
}
let objectTooltipShown = false;
if (
hoveredClusterHit &&
!hoveredBGPMarker &&
!hoveredComputeCenterMarker &&
lockedObjectType !== "bgp" &&
lockedObjectType !== "bgp_collector" &&
lockedObjectType !== "compute_center"
) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getClusterBriefHtml(hoveredClusterHit),
);
objectTooltipShown = true;
} else if (
hoveredBGPMarker &&
getShowBGP() &&
lockedObjectType !== "bgp" &&
lockedObjectType !== "bgp_collector"
) {
applyBGPHoverState(hoveredBGPMarker);
if (hoveredBGPMarker.userData?.type === "bgp") {
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPBriefHtml(hoveredBGPMarker));
} else {
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPCollectorBriefHtml(hoveredBGPMarker));
}
objectTooltipShown = true;
} else if (
hoveredComputeCenterMarker &&
getShowComputeCenters() &&
lockedObjectType !== "compute_center"
) {
applyComputeCenterHoverState(hoveredComputeCenterMarker);
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getComputeCenterBriefHtml(hoveredComputeCenterMarker),
);
objectTooltipShown = true;
} else if (
vesselPick.checked &&
hoveredVesselMarker &&
getShowVessels() &&
lockedObjectType !== "vessel"
) {
applyVesselHoverState(hoveredVesselMarker);
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getVesselBriefHtml(hoveredVesselMarker),
);
objectTooltipShown = true;
} else if (
hoveredEarthInteractableMarker &&
lockedObjectType !== "earth_interactable"
) {
applyEarthInteractableHoverState(hoveredEarthInteractableMarker);
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getEarthInteractableBriefHtml(hoveredEarthInteractableMarker),
);
objectTooltipShown = true;
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) {
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
}
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getCableBriefHtml(cable));
objectTooltipShown = true;
} else if (hoveredSat?.properties) {
hoveredSatellite = hoveredSat;
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
const satPositions = getSatellitePositions();
if (satPositions && satPositions[hoveredSatelliteIndex]) {
setSatelliteRingState(
hoveredSatelliteIndex,
"hover",
satPositions[hoveredSatelliteIndex].current,
);
}
}
setHoveredSatelliteIndex(hoveredSatelliteIndex);
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
objectTooltipShown = true;
} else if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "compute_center" && lockedObject) {
applyComputeCenterHoverState(lockedObject);
} else if (lockedObjectType === "vessel" && lockedObject) {
applyVesselHoverState(lockedObject);
} else if (lockedObjectType === "earth_interactable" && lockedObject) {
applyEarthInteractableHoverState(lockedObject);
} else if (
!lockedObjectType &&
!isCruisePresentationPinned() &&
!isMotionPresentationActive() &&
!isInfoCardSticky()
) {
resetTransientBGPStates();
resetTransientComputeCenterStates();
if (vesselPick.checked) {
resetTransientVesselStates();
}
resetTransientEarthInteractableStates();
hideInfoCard();
}
if (!objectTooltipShown) {
if (surfaceHover) {
const { coords, hoveredCountry } = surfaceHover;
const hoverInfoMode = getSurfaceHoverInfoMode();
const shouldShowCountry =
hoverInfoMode === SURFACE_HOVER_INFO_MODES.COUNTRY ||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
const shouldShowPosition =
hoverInfoMode === SURFACE_HOVER_INFO_MODES.POSITION ||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
const positionHtml = shouldShowPosition
? getSurfacePositionBriefHtml(coords)
: "";
if (hoveredCountry && shouldShowCountry && shouldShowPosition) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
`${getCountryBoundaryBriefHtml(hoveredCountry)}<br>${positionHtml}`,
);
return;
}
if (hoveredCountry && shouldShowCountry) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getCountryBoundaryBriefHtml(hoveredCountry),
);
return;
}
if (shouldShowPosition) {
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
positionHtml,
);
} else {
hideTooltip();
}
} else {
clearCountryBoundaryHover();
hideTooltip();
}
}
}
function onMouseDown(event) {
if (isEventOnHud(event)) {
return;
}
const earth = getEarth();
isDragging = true;
dragStartTime = Date.now();
isLongDrag = false;
previousMousePosition = { x: event.clientX, y: event.clientY };
inertialVelocity = { x: 0, y: 0 };
if (earth) {
targetRotation = {
x: earth.rotation.x,
y: earth.rotation.y,
};
}
clearDocumentSelection();
setGlobeDraggingUiState(true);
document.getElementById("container")?.classList.add("dragging");
hideTooltip();
}
function onMouseUp() {
isDragging = false;
setGlobeDraggingUiState(false);
clearDocumentSelection();
document.getElementById("container")?.classList.remove("dragging");
}
function onPointerDown(event) {
if (isEventOnHud(event)) return;
if (event.pointerType !== "touch" && event.button !== 0) return;
renderer?.domElement?.setPointerCapture?.(event.pointerId);
clearDocumentSelection();
if (event.pointerType === "touch") {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
renderer?.domElement?.setPointerCapture?.(event.pointerId);
if (activeTouchPoints.size === 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
pinchGesture = {
distance: getTouchDistance(firstPoint, secondPoint),
startZoom: getZoomLevel(),
statusShown: false,
};
activeDragPointerId = null;
onMouseUp();
return;
}
}
activeDragPointerId = event.pointerId;
pointerDragDistance = 0;
suppressNextClick = false;
onMouseDown(event);
}
function onPointerMove(event) {
if (activeDragPointerId === event.pointerId && isDragging) {
clearDocumentSelection();
}
if (event.pointerType === "touch") {
if (activeTouchPoints.has(event.pointerId)) {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
}
if (pinchGesture && activeTouchPoints.size >= 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
const nextDistance = getTouchDistance(firstPoint, secondPoint);
if (pinchGesture.distance > 0) {
const scale = nextDistance / pinchGesture.distance;
setZoomLevel(pinchGesture.startZoom * scale, camera);
showZoomStatusCapsule({ force: !pinchGesture.statusShown });
pinchGesture.statusShown = true;
suppressNextClick = true;
hideTooltip();
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
onMouseMove(event);
return;
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
}
onMouseMove(event);
}
function onPointerUp(event) {
if (event.pointerType === "touch") {
activeTouchPoints.delete(event.pointerId);
if (activeTouchPoints.size < 2) {
pinchGesture = null;
}
}
if (activeDragPointerId === event.pointerId) {
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
suppressNextClick = true;
isLongDrag = true;
}
renderer?.domElement?.releasePointerCapture?.(event.pointerId);
activeDragPointerId = null;
pointerDragDistance = 0;
onMouseUp();
}
}
function onMouseLeave() {
clearCountryBoundaryHover();
hideTooltip();
}
function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
if (suppressNextClick) {
suppressNextClick = false;
return;
}
updatePointerFromEvent(event);
invalidateToolInfoCardRestore("scene_click");
if (isMotionPresentationActive()) {
presentationController?.dismiss?.("replace");
}
const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()),
);
const bgpAnomalyIntersects = getShowBGP()
? getBGPEventPointerIntersections()
: [];
const bgpCollectorIntersects = getShowBGP()
? getBGPCollectorPointerIntersections()
: [];
const computeCenterIntersects = getShowComputeCenters()
? getComputeCenterPointerIntersections()
: [];
const vesselIntersects = getShowVessels()
? getVesselPointerIntersections()
: [];
const earthInteractableIntersects = getEarthInteractablePointerIntersections();
const satIntersects = getSatellitePointerIntersections(event);
const clickedBGPMarker = getShowBGP()
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
: null;
const clickedComputeCenterMarker = getFirstObjectIntersection(computeCenterIntersects);
const clickedVesselMarker = vesselIntersects.length > 0
? vesselIntersects[0].object
: null;
const clickedEarthInteractableMarker = getFirstObjectIntersection(earthInteractableIntersects);
if (clickedBGPMarker?.userData?.type === "bgp") {
interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedBGPMarker;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "bgp";
lastBGPClickTime = Date.now();
lastBGPClickCollector = clickedMarker.userData?.collector || null;
lastBGPClickType = "bgp";
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPEventOverlay(clickedMarker, earth);
applyBGPEventSatelliteHighlights(clickedMarker);
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
earthMessage("status.bgpSelected", {
collector: clickedMarker.userData.collector,
regionCount: incidentSummary.regionCount,
cableCount: incidentSummary.cableCount,
}),
"info",
);
return;
}
if (clickedBGPMarker?.userData?.type === "bgp_collector") {
interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedBGPMarker;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "bgp_collector";
lastBGPClickTime = Date.now();
lastBGPClickCollector = clickedMarker.userData?.collector || null;
lastBGPClickType = "bgp_collector";
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPCollectorCoverageOverlay(clickedMarker, earth);
clickedMarker.userData.related_satellite_count = 0;
showBGPCollectorInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
earthMessage("status.selected", { target: "观测站", name: clickedMarker.userData.collector }),
"info",
);
return;
}
if (clickedComputeCenterMarker?.userData?.type === "compute_center") {
interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedComputeCenterMarker;
setComputeCenterMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "compute_center";
setAutoRotate(false);
showComputeCenterInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
earthMessage("status.selected", {
target: "算力中心",
name: clickedMarker.userData?.name || "未知节点",
}),
"info",
);
return;
}
if (clickedVesselMarker?.userData?.type === "vessel") {
interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedVesselMarker;
setVesselMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "vessel";
setAutoRotate(false);
showVesselInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showVesselTrack(clickedMarker, earth).catch((error) => {
console.warn("船只轨迹加载失败:", error);
});
showStatusMessage(
earthMessage("status.selected", {
target: "船只",
name: clickedMarker.userData?.name || clickedMarker.userData?.mmsi,
}),
"info",
);
return;
}
if (clickedEarthInteractableMarker?.userData?.type === "earth_interactable") {
const clickedMarker = clickedEarthInteractableMarker;
if (confirmMotionCandidate({
type: "earth_interactable",
object: clickedMarker,
screen: { x: event.clientX, y: event.clientY },
distancePxSq: 0,
}, { showMotionStatus: false })) {
showStatusMessage(
earthMessage("status.selected", {
target: "交互点",
name: clickedMarker.userData?.label || clickedMarker.userData?.name || clickedMarker.userData?.id || "未知目标",
}),
"info",
);
}
return;
}
if (cableIntersects.length > 0 && getShowCables()) {
interruptCruisePresentation();
clearLockedObject();
const clickedCable = cableIntersects[0].object;
const cableId = clickedCable.userData.cableId;
setCableState(cableId, CABLE_STATE.LOCKED);
lockedObject = clickedCable;
lockedObjectType = "cable";
setAutoRotate(false);
handleCableClick(clickedCable);
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
return;
}
if (satIntersects.length > 0) {
const now = Date.now();
const clickX = event.clientX;
const clickY = event.clientY;
const frontFacingSats = satIntersects;
if (frontFacingSats.length === 0) return;
let selectedIndex = frontFacingSats[0].index;
if (
frontFacingSats.length > 1 &&
now - lastSatClickTime < 500 &&
Math.abs(clickX - lastSatClickPos.x) < 30 &&
Math.abs(clickY - lastSatClickPos.y) < 30
) {
const currentIdx = frontFacingSats.findIndex(
(sat) => sat.index === lastSatClickIndex,
);
selectedIndex =
frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index;
}
lastSatClickTime = now;
lastSatClickIndex = selectedIndex;
lastSatClickPos = { x: clickX, y: clickY };
const sat = selectSatellite(selectedIndex);
if (!sat?.properties) return;
confirmMotionCandidate({
type: "satellite",
index: selectedIndex,
object: sat,
screen: { x: event.clientX, y: event.clientY },
distancePxSq: 0,
}, { showMotionStatus: false });
showStatusMessage(earthMessage("status.selected", { name: sat.properties.name }), "info");
return;
}
if (!isLongDrag) {
if (isCruiseModeActive()) {
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
hideInfoCard();
setAutoRotate(true);
return;
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
hideInfoCard();
setAutoRotate(true);
}
}
function animate() {
if (destroyed) return;
animationFrameId = requestAnimationFrame(animate);
const earth = getEarth();
const deltaTime = clock.getDelta() * 1000;
const hasInertia = hasActiveGlobeInertia();
if (getAutoRotate() && getRotationMode() === ROTATION_MODE.ROTATE && earth) {
earth.rotation.y += getAutoRotationSpeed() * (deltaTime / 16);
// Keep the drag target aligned with autorotation only when the user is not
// actively dragging and there is no residual inertial motion to preserve.
if (!isDragging && !hasInertia) {
targetRotation.y = earth.rotation.y;
targetRotation.x = earth.rotation.x;
}
}
if (earth) {
if (isDragging) {
// Smoothly follow the drag target to match the legacy interaction feel.
earth.rotation.x +=
(targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
earth.rotation.y +=
(targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
} else if (
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY
) {
// Continue rotating after release and gradually decay the motion.
targetRotation.x += inertialVelocity.x * (deltaTime / 16);
targetRotation.y += inertialVelocity.y * (deltaTime / 16);
earth.rotation.x +=
(targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
earth.rotation.y +=
(targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
inertialVelocity.x *= Math.pow(INERTIA_DAMPING, deltaTime / 16);
inertialVelocity.y *= Math.pow(INERTIA_DAMPING, deltaTime / 16);
} else {
inertialVelocity.x = 0;
inertialVelocity.y = 0;
targetRotation.x = earth.rotation.x;
targetRotation.y = earth.rotation.y;
}
}
// Force matrixWorld to be current before any isFacingCamera checks below.
// Without this, getWorldPosition() on LP sprites reads the previous frame's
// transform (earth rotation is updated above but scene.updateMatrixWorld()
// only runs inside renderer.render(), which hasn't been called yet).
earth?.updateMatrixWorld(true);
refreshMotionFocusCandidates();
applyCableVisualState();
const activeCruiseMarker =
isCruiseModeActive() && isCruisePresentationPinned()
? getCurrentCruiseBGPMarker()
: getRotationMode() === ROTATION_MODE.MOTION && motionSharedCruiseSequencer?.isPresentationPinned?.()
? getCurrentMotionSharedCruiseBGPMarker()
: null;
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
updateVesselVisualState(lockedObjectType, lockedObject, camera);
updateEarthInteractableVisualState(lockedObjectType, lockedObject, camera);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
} else if (
lockedObjectType === "satellite" && lockedSatellite
) {
applyLandingPointVisualState(null, true, camera);
} else if (lockedObjectType === "bgp" && lockedObject) {
applyBGPRelatedCablesAndLandingPoints(lockedObject, camera);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
clearAllCableStates();
resetLandingPointVisualState(camera);
} else if (activeCruiseMarker) {
applyBGPRelatedCablesAndLandingPoints(activeCruiseMarker, camera);
} else {
resetLandingPointVisualState(camera);
}
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateSatelliteIdleBreathingVisual(
!isDragging &&
!hasActiveGlobeInertia() &&
activeTouchPoints.size === 0 &&
!pinchGesture,
);
updateSatellitePointSize();
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
const currentSunDirection = getSunDirection();
setEarthSunDirection(currentSunDirection);
setSatelliteSunDirection(currentSunDirection);
updateMobileCenterCountryHighlight({
camera,
earth: getEarthSurfacePickTarget() || earth,
renderer,
now: performance.now(),
});
updateCountryBoundaryTiles({
camera,
earth: getEarthSurfacePickTarget() || earth,
renderer,
viewZoom: getZoomLevel(),
});
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (
lockedObjectType === "satellite" &&
lockedSatelliteIndex !== null &&
satPositions?.[lockedSatelliteIndex]
) {
updateLockedRingPosition(satPositions[lockedSatelliteIndex].current);
} else if (
hoveredSatelliteIndex !== null &&
satPositions?.[hoveredSatelliteIndex]
) {
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
}
repositionCruiseConnector();
repositionMotionConnector();
renderer.render(scene, camera);
}
export function destroy() {
if (destroyed) return;
destroyed = true;
currentLoadToken += 1;
isDataLoading = false;
setGlobeDraggingUiState(false);
clearDocumentSelection();
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
teardownControls();
if (earthUpdatesReconnectTimer) {
window.clearTimeout(earthUpdatesReconnectTimer);
earthUpdatesReconnectTimer = null;
}
if (earthDataReconcileTimer) {
window.clearInterval(earthDataReconcileTimer);
earthDataReconcileTimer = null;
}
if (earthUpdateFlushTimer) {
window.clearTimeout(earthUpdateFlushTimer);
earthUpdateFlushTimer = null;
}
pendingEarthUpdateLayers.clear();
pendingEarthUpdateStrategies.clear();
pendingEarthUpdateIsDatabaseChange = false;
if (earthUpdatesSocket) {
const socket = earthUpdatesSocket;
earthUpdatesSocket = null;
socket.close();
}
motionControlAdapter?.stop?.();
motionCruiseSequencer?.stop?.();
motionSharedCruiseSequencer?.stop?.();
motionControlAdapter = null;
presentationController?.dismiss?.("destroy");
while (cleanupFns.length) {
const cleanup = cleanupFns.pop();
cleanup?.();
}
clearLockedObject();
clearMobileCenterCountryHighlight();
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());
clearCountryBoundaryData();
resetSatelliteState();
clearUiState();
disposeCelestialLayer();
clearTerrainData();
if (scene) {
disposeSceneObject(scene);
}
if (renderer) {
renderer.dispose();
if (typeof renderer.forceContextLoss === "function") {
renderer.forceContextLoss();
}
renderer.domElement?.remove();
}
scene = null;
camera = null;
renderer = null;
sceneLights = null;
initialized = false;
delete window.__planetEarth;
}
document.addEventListener("DOMContentLoaded", init);