5288 lines
163 KiB
JavaScript
5288 lines
163 KiB
JavaScript
import * as THREE from "three";
|
||
|
||
import {
|
||
CONFIG,
|
||
CRUISE_MODULES,
|
||
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 { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
|
||
import {
|
||
showStatusMessage,
|
||
queueStatusMessage,
|
||
updateCoordinatesDisplay,
|
||
updateZoomDisplay,
|
||
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,
|
||
} from "./country-boundaries.js";
|
||
import {
|
||
loadGeoJSONFromPath,
|
||
loadLandingPoints,
|
||
handleCableClick,
|
||
clearCableSelection,
|
||
getCableLines,
|
||
getCableLegendItems,
|
||
getCableState,
|
||
setCableState,
|
||
clearAllCableStates,
|
||
applyLandingPointVisualState,
|
||
resetLandingPointVisualState,
|
||
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 {
|
||
setupControls,
|
||
getAutoRotate,
|
||
getCruiseModules,
|
||
getRotationMode,
|
||
getShowTerrain,
|
||
getStartupLoadLayers,
|
||
setAutoRotate,
|
||
applyImmediateView,
|
||
focusEarthView,
|
||
getZoomLevel,
|
||
getDefaultEarthZoomLevel,
|
||
setZoomLevel,
|
||
showZoomStatusCapsule,
|
||
teardownControls,
|
||
getDayNightEnabled,
|
||
getMotionDebugEnabled,
|
||
getMotionDebugSkeletonOnly,
|
||
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,
|
||
} from "./info-card.js";
|
||
import {
|
||
initLegend,
|
||
setLegendMode,
|
||
refreshLegend,
|
||
setLegendItems,
|
||
} from "./legend.js";
|
||
import { mountBrand } from "./brand.js";
|
||
import { initTVPanel } from "./tv.js";
|
||
import { initNewsPanel, 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";
|
||
|
||
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;
|
||
|
||
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 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 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;
|
||
let presentationController = null;
|
||
let motionControlAdapter = null;
|
||
let motionCruiseAdapter = null;
|
||
let motionCruiseSequencer = 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 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;
|
||
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 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();
|
||
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();
|
||
clearCountryBoundaryHover();
|
||
hoveredBGP = null;
|
||
hoveredComputeCenter = null;
|
||
hoveredVessel = 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 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 getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
|
||
if (bgpAnomalyIntersects.length > 0) {
|
||
return bgpAnomalyIntersects[0].object;
|
||
}
|
||
if (bgpCollectorIntersects.length > 0) {
|
||
return bgpCollectorIntersects[0].object;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getPrimaryBGPClickTarget(
|
||
event,
|
||
bgpAnomalyIntersects,
|
||
bgpCollectorIntersects,
|
||
) {
|
||
const anomalyMarker = bgpAnomalyIntersects[0]?.object || null;
|
||
const collectorMarker = bgpCollectorIntersects[0]?.object || null;
|
||
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;
|
||
}
|
||
|
||
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"
|
||
) {
|
||
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"
|
||
) {
|
||
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) => {
|
||
candidates.push({ type: "bgp", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||
});
|
||
getBGPCollectorIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||
candidates.push({ type: "bgp_collector", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||
});
|
||
}
|
||
if (getShowComputeCenters()) {
|
||
getComputeCenterIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||
candidates.push({ type: "compute_center", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||
});
|
||
}
|
||
if (getShowVessels()) {
|
||
getVesselIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||
candidates.push({ type: "vessel", 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) => !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";
|
||
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?.()) return;
|
||
const item = motionCruiseSequencer.getCurrentItem?.();
|
||
if (!item) return;
|
||
ensureMotionCruiseAdapter().repositionConnector();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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 === "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 === "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 {
|
||
refreshMotionFocusCandidates({ force: true });
|
||
if (motionFocusCandidates.length === 0) {
|
||
const layer = getCurrentMotionFocusLayer();
|
||
showStatusMessage(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(`动捕: 已切换到${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("动捕: 当前没有可切换的可见图层", "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(`动捕: 已切换到${nextLayer.label}图层`, "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 === "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);
|
||
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 ||
|
||
"目标"
|
||
);
|
||
}
|
||
|
||
function confirmMotionCandidate(candidate) {
|
||
const earth = getEarth();
|
||
if (!candidate || !earth) return false;
|
||
interruptCruisePresentation();
|
||
clearLockedObject();
|
||
setAutoRotate(false);
|
||
const sourceCoords = candidate.screen || 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 === "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;
|
||
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
|
||
showStatusMessage(`动捕: 已确认${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>${name}</strong>${status ? `<br>${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("zh-CN", { hour12: false })
|
||
: "-",
|
||
}, coords);
|
||
}
|
||
|
||
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>${name}</strong><br>${vesselType} · ${speed} kn`;
|
||
}
|
||
|
||
function getComputeCenterBriefHtml(marker) {
|
||
const name = marker.userData?.name || "算力中心";
|
||
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
|
||
const location = [marker.userData?.city, marker.userData?.country]
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
const precision = marker.userData?.is_estimated ? " · 估算位置" : "";
|
||
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
|
||
}
|
||
|
||
function getCountryBoundaryBriefHtml(country) {
|
||
const name = country?.nameZh || country?.name || "未知国家";
|
||
const code = country?.isoA3 || country?.isoA2 || "-";
|
||
const continent = country?.continent || "-";
|
||
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${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 `纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${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
|
||
? `${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
|
||
? `共${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
|
||
? `${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;
|
||
return `<strong>${name}</strong><br>${count} 条事件`;
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
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(`已定位登陆点:${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(`已定位卫星:${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(`已定位 BGP 事件:${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(`已定位观测站:${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(
|
||
`已定位算力中心:${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("算力中心坐标已保存", "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(
|
||
`已定位船只:${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 } = {}) {
|
||
try {
|
||
const response = await fetch(PATHS.earthSummaryApi);
|
||
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 =
|
||
unresolvedCount > 0 ? `(${unresolvedCount} 个待定位)` : "";
|
||
|
||
if (computeBtn) {
|
||
setLayerButtonState(computeBtn, {
|
||
active: getShowComputeCenters(),
|
||
loading: false,
|
||
tooltip: getShowComputeCenters()
|
||
? `隐藏算力中心${unresolvedTooltip}`
|
||
: `显示算力中心${unresolvedTooltip}`,
|
||
});
|
||
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);
|
||
badge.title = `${count} 个算力中心待定位`;
|
||
badge.setAttribute("aria-label", `${count} 个算力中心待定位,点击查看`);
|
||
}
|
||
|
||
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,
|
||
},
|
||
);
|
||
}
|
||
|
||
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("已暂停巡航,正在打开候选列表", "info");
|
||
} else if (hadMotionPresentation) {
|
||
presentationController?.dismiss?.("tool_card_open");
|
||
motionCruiseSequencer?.stop?.();
|
||
showStatusMessage("已暂停动捕目标展示,正在打开候选列表", "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("候选列表已关闭,巡航已恢复", "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;
|
||
return {
|
||
id: getInteractableCruiseItemId(candidate),
|
||
moduleId: getMotionCandidateLayerId(candidate),
|
||
sortTimestamp: 0,
|
||
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;
|
||
return {
|
||
id: `bgp:${markerId}`,
|
||
moduleId: CRUISE_MODULES.BGP,
|
||
sortTimestamp: Number.isFinite(timestamp) ? timestamp : 0,
|
||
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 sortCruiseQueueItems(items) {
|
||
return items.sort((left, right) => {
|
||
const timestampDiff = (right?.sortTimestamp || 0) - (left?.sortTimestamp || 0);
|
||
if (timestampDiff !== 0) return timestampDiff;
|
||
return String(left?.id || "").localeCompare(String(right?.id || ""));
|
||
});
|
||
}
|
||
|
||
function getCruiseQueueItemsSorted() {
|
||
const items = [];
|
||
getEnabledCruiseModuleDefinitions().forEach((module) => {
|
||
if (!module.isQueueSourceActive?.()) return;
|
||
items.push(...(module.getSortedQueueItems?.() || []));
|
||
});
|
||
return sortCruiseQueueItems(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;
|
||
}
|
||
|
||
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();
|
||
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);
|
||
}
|
||
|
||
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?.();
|
||
}
|
||
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 });
|
||
clearBGPSelection();
|
||
|
||
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 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 errors
|
||
.map(
|
||
({ label, reason }) =>
|
||
`${label}加载失败: ${reason?.message || String(reason)}`,
|
||
)
|
||
.join(";");
|
||
}
|
||
|
||
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
|
||
const satBtn = document.getElementById("toggle-satellites");
|
||
if (satBtn) {
|
||
setLayerButtonState(satBtn, {
|
||
active: enabled,
|
||
loading: false,
|
||
tooltip: enabled ? "隐藏卫星" : "显示卫星",
|
||
});
|
||
}
|
||
|
||
const resolvedCount = satelliteCount || earthStatsSummary?.satelliteCount || 0;
|
||
setEarthStatValue("satellite-count", `${resolvedCount} 颗`);
|
||
}
|
||
|
||
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", `${vesselCount || 0} 艘`);
|
||
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 = getCableLines().length || earthStatsSummary?.cableCount || 0;
|
||
const landingPointCount =
|
||
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
|
||
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(),
|
||
});
|
||
|
||
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 result = await loadVessels(scene, earth);
|
||
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 = getCableLines().length || earthStatsSummary?.cableCount || 0;
|
||
const landingPointCount =
|
||
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
|
||
const satelliteCount = getSatelliteCount() || earthStatsSummary?.satelliteCount || 0;
|
||
const vesselCount = getVesselCount() || earthStatsSummary?.vesselCount || 0;
|
||
const computeCenterCount =
|
||
getComputeCenterCount() || earthStatsSummary?.computeCenterCount || 0;
|
||
const bgpEventCount = getBGPCount() || earthStatsSummary?.bgpEventCount || 0;
|
||
const bgpCollectorCount =
|
||
getBGPCollectorCount() || earthStatsSummary?.bgpCollectorCount || 0;
|
||
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();
|
||
const brandRoot = document.getElementById("brand-root");
|
||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||
initTVPanel();
|
||
initNewsPanel();
|
||
initSearchPanel({
|
||
resolveResults: resolveEarthSearchResults,
|
||
onSelectResult: handleSearchSelection,
|
||
});
|
||
|
||
scene = new THREE.Scene();
|
||
camera = new THREE.PerspectiveCamera(
|
||
75,
|
||
getViewportAspect(),
|
||
0.1,
|
||
5000,
|
||
);
|
||
camera.position.z = CONFIG.defaultCameraZ;
|
||
setSatelliteCamera(camera);
|
||
|
||
renderer = new THREE.WebGLRenderer({
|
||
antialias: true,
|
||
alpha: false,
|
||
powerPreference: "high-performance",
|
||
});
|
||
syncRendererViewport();
|
||
renderer.setClearColor(0x02040a, 1);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
|
||
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?.();
|
||
}
|
||
motionControlAdapter = createMotionControlAdapter({
|
||
enabled: motionEnabled,
|
||
provider: getMotionProvider(),
|
||
onRotate: applyMotionRotate,
|
||
onZoom: applyMotionZoom,
|
||
onConfirm: applyMotionConfirm,
|
||
onFocus: applyMotionFocus,
|
||
onLayer: applyMotionLayerSwitch,
|
||
onStatus: (message, type) => showStatusMessage(message, type),
|
||
});
|
||
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(`动捕: ${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 = motionFocusedCandidate || motionFocusCandidates[motionFocusIndex] || null;
|
||
if (!candidate) {
|
||
refreshMotionFocusCandidates({ force: true });
|
||
candidate = motionFocusedCandidate || motionFocusCandidates[motionFocusIndex] || null;
|
||
}
|
||
if (candidate && confirmMotionCandidate(candidate)) {
|
||
return true;
|
||
}
|
||
if (lockedObject || lockedSatellite) {
|
||
window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
|
||
showStatusMessage("动捕: 已确认当前目标", "info");
|
||
return true;
|
||
}
|
||
showStatusMessage("动捕: 请先选择目标", "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 });
|
||
if (!guardFn()) return;
|
||
updateSatelliteToggleUi(true, loadResult.count);
|
||
setLegendItems("satellites", getSatelliteLegendItems());
|
||
refreshLegend();
|
||
scheduleSatellitePositionWarmup();
|
||
} catch (error) {
|
||
console.warn("后台补全卫星全量数据失败:", error);
|
||
}
|
||
}
|
||
|
||
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);
|
||
clearSatelliteData();
|
||
clearCountryBoundaryHover();
|
||
|
||
setLoadingMessage("正在初始化...");
|
||
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; }
|
||
}
|
||
|
||
// Step 6 — Terrain (if enabled)
|
||
if (getShowTerrain()) {
|
||
const terrainLayer = startupLayers.find((layer) => layer.id === "terrain");
|
||
const terrainMessage = resolveStartupMessage(
|
||
terrainLayer,
|
||
"load",
|
||
"正在渲染地形...",
|
||
);
|
||
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("数据已加载", "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("线缆已隐藏", "info");
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
if (!suppressLoadingUi) {
|
||
setLoadingMessage("正在加载线缆数据...");
|
||
setLoading(true);
|
||
hideError();
|
||
}
|
||
|
||
try {
|
||
const cableCount = await ensureCablesEnabled();
|
||
if (!suppressStatus) {
|
||
showStatusMessage("线缆已显示", "info");
|
||
}
|
||
return cableCount;
|
||
} catch (error) {
|
||
cablesEnabled = false;
|
||
clearCableData(getEarth());
|
||
updateCableToggleUi(false);
|
||
const message = `线缆加载失败: ${error?.message || String(error)}`;
|
||
void reportEarthClientLog({
|
||
level: "error",
|
||
category: "layer-toggle",
|
||
module: "cables",
|
||
message,
|
||
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("国界已隐藏", "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("国界已显示", "info");
|
||
}
|
||
return countryCount;
|
||
} catch (error) {
|
||
toggleCountryBoundaries(false);
|
||
clearCountryBoundaryHover();
|
||
const message = `国界加载失败: ${error?.message || String(error)}`;
|
||
void reportEarthClientLog({
|
||
level: "error",
|
||
category: "layer-toggle",
|
||
module: "country-boundaries",
|
||
message,
|
||
detail: error,
|
||
});
|
||
if (!suppressStatus) {
|
||
showStatusMessage(message, "error");
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
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(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(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("正在加载卫星数据...");
|
||
setLoading(true);
|
||
hideError();
|
||
}
|
||
|
||
try {
|
||
const satelliteCount = await ensureSatellitesEnabled();
|
||
if (!suppressStatus) {
|
||
showStatusMessage("卫星已显示", "info");
|
||
}
|
||
return satelliteCount;
|
||
} catch (error) {
|
||
satellitesEnabled = false;
|
||
resetSatelliteState();
|
||
updateSatelliteToggleUi(false, 0);
|
||
const message = `卫星加载失败: ${error?.message || String(error)}`;
|
||
void reportEarthClientLog({
|
||
level: "error",
|
||
category: "layer-toggle",
|
||
module: "satellites",
|
||
message,
|
||
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("船只已隐藏", "info");
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
if (!suppressLoadingUi) {
|
||
setLoadingMessage("正在加载船只数据...");
|
||
setLoading(true);
|
||
hideError();
|
||
}
|
||
|
||
try {
|
||
const vesselCount = await ensureVesselsEnabled();
|
||
if (!suppressStatus) {
|
||
showStatusMessage("船只已显示", "info");
|
||
}
|
||
return vesselCount;
|
||
} catch (error) {
|
||
vesselsEnabled = false;
|
||
clearVesselData(getEarth());
|
||
updateVesselToggleUi(false, 0);
|
||
const message = `船只加载失败: ${error?.message || String(error)}`;
|
||
void reportEarthClientLog({
|
||
level: "error",
|
||
category: "layer-toggle",
|
||
module: "vessels",
|
||
message,
|
||
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 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("算力中心坐标已保存", "success");
|
||
})
|
||
.catch((error) => {
|
||
// Swallow refresh failure: the save itself succeeded, so we
|
||
// must not surface this as a save failure.
|
||
console.warn("后台校准算力中心图层失败:", error);
|
||
showStatusMessage("坐标已保存,地图稍后同步", "info");
|
||
});
|
||
}
|
||
// Optimistic marker is on screen; reconcile in the background.
|
||
refreshComputeCentersAfterLocationSave().catch((error) => {
|
||
console.warn("后台校准算力中心图层失败:", error);
|
||
});
|
||
return null;
|
||
})
|
||
.catch((error) => {
|
||
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
|
||
showStatusMessage("坐标已保存,正在同步地图...", "info");
|
||
refreshComputeCentersAfterLocationSave()
|
||
.then(() => {
|
||
showStatusMessage("算力中心坐标已保存", "success");
|
||
})
|
||
.catch((refreshError) => {
|
||
console.warn("后台校准算力中心图层失败:", refreshError);
|
||
});
|
||
});
|
||
};
|
||
const handleComputeCenterUnresolvedCountChange = (event) => {
|
||
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
|
||
};
|
||
const handleMotionDebugModeChange = () => {
|
||
setupMotionControl();
|
||
};
|
||
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: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 };
|
||
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;
|
||
|
||
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,
|
||
);
|
||
|
||
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
|
||
clearTransientHoverState();
|
||
}
|
||
|
||
const hoveredComputeCenterMarker =
|
||
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
|
||
const hoveredVesselMarker =
|
||
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
|
||
|
||
if (
|
||
hoveredComputeCenter &&
|
||
!isSameComputeCenter(hoveredComputeCenter, hoveredComputeCenterMarker)
|
||
) {
|
||
clearTransientHoverState();
|
||
}
|
||
if (
|
||
vesselPick.checked &&
|
||
hoveredVessel &&
|
||
!isSameVessel(hoveredVessel, hoveredVesselMarker)
|
||
) {
|
||
clearTransientHoverState();
|
||
}
|
||
|
||
if (
|
||
hoveredCable &&
|
||
(!cableIntersects.length ||
|
||
!isSameCable(cableIntersects[0]?.object, hoveredCable))
|
||
) {
|
||
clearTransientHoverState();
|
||
}
|
||
|
||
if (
|
||
hoveredSatelliteIndex !== null &&
|
||
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
|
||
) {
|
||
clearTransientHoverState();
|
||
}
|
||
|
||
let objectTooltipShown = false;
|
||
|
||
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 (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 &&
|
||
!isCruisePresentationPinned() &&
|
||
!isMotionPresentationActive() &&
|
||
!isInfoCardSticky()
|
||
) {
|
||
resetTransientBGPStates();
|
||
resetTransientComputeCenterStates();
|
||
if (vesselPick.checked) {
|
||
resetTransientVesselStates();
|
||
}
|
||
hideInfoCard();
|
||
}
|
||
|
||
if (!objectTooltipShown) {
|
||
const earthPoint = screenToEarthCoords(
|
||
event.clientX,
|
||
event.clientY,
|
||
camera,
|
||
getEarthSurfacePickTarget() || earth,
|
||
document.body,
|
||
interactionRaycaster,
|
||
interactionMouse,
|
||
);
|
||
if (earthPoint) {
|
||
const coords = vector3ToLatLon(earthPoint);
|
||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||
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 hoveredCountry = shouldShowCountry && getShowCountryBoundaries()
|
||
? updateCountryBoundaryHover(coords)
|
||
: null;
|
||
const positionHtml = shouldShowPosition
|
||
? getSurfacePositionBriefHtml(coords)
|
||
: "";
|
||
|
||
if (!shouldShowCountry) {
|
||
clearCountryBoundaryHover();
|
||
}
|
||
|
||
if (hoveredCountry && shouldShowPosition) {
|
||
showTooltip(
|
||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||
`${getCountryBoundaryBriefHtml(hoveredCountry)}<br>${positionHtml}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (hoveredCountry) {
|
||
showTooltip(
|
||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||
getCountryBoundaryBriefHtml(hoveredCountry),
|
||
);
|
||
return;
|
||
}
|
||
|
||
clearCountryBoundaryHover();
|
||
if (shouldShowPosition) {
|
||
showTooltip(
|
||
event.clientX + TOOLTIP_COORDS_OFFSET,
|
||
event.clientY + TOOLTIP_COORDS_OFFSET,
|
||
positionHtml,
|
||
);
|
||
} else {
|
||
hideTooltip();
|
||
}
|
||
} else {
|
||
clearCountryBoundaryHover();
|
||
hideTooltip();
|
||
}
|
||
} else {
|
||
clearCountryBoundaryHover();
|
||
}
|
||
}
|
||
|
||
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 satIntersects = getSatellitePointerIntersections(event);
|
||
|
||
const clickedBGPMarker = getShowBGP()
|
||
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
|
||
: null;
|
||
const clickedComputeCenterMarker = computeCenterIntersects.length > 0
|
||
? computeCenterIntersects[0].object
|
||
: null;
|
||
const clickedVesselMarker = vesselIntersects.length > 0
|
||
? vesselIntersects[0].object
|
||
: null;
|
||
|
||
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(
|
||
`已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${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(
|
||
`已选择观测站: ${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(
|
||
`已选择算力中心: ${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(
|
||
`已选择船只: ${clickedMarker.userData?.name || clickedMarker.userData?.mmsi}`,
|
||
"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;
|
||
|
||
interruptCruisePresentation();
|
||
clearLockedObject();
|
||
|
||
lockedObject = sat;
|
||
lockedObjectType = "satellite";
|
||
lockedSatellite = sat;
|
||
lockedSatelliteIndex = selectedIndex;
|
||
setLockedSatelliteIndex(selectedIndex);
|
||
showPredictedOrbit(sat);
|
||
setAutoRotate(false);
|
||
|
||
const satPositions = getSatellitePositions();
|
||
if (satPositions?.[selectedIndex]) {
|
||
setSatelliteRingState(
|
||
selectedIndex,
|
||
"locked",
|
||
satPositions[selectedIndex].current,
|
||
);
|
||
if (hoveredSatelliteIndex === selectedIndex) {
|
||
setHoveredSatelliteIndex(selectedIndex);
|
||
}
|
||
}
|
||
|
||
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
|
||
showStatusMessage("已选择: " + 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 += CONFIG.rotationSpeed * (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()
|
||
: null;
|
||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
|
||
updateVesselVisualState(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(),
|
||
});
|
||
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();
|
||
motionControlAdapter?.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);
|