import * as THREE from "three"; import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE, SATELLITE_CONFIG, PATHS, BGP_CONFIG, CRUISE_CONFIG, ROTATION_MODE, SCENE_LIGHT_CONFIG, } 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, createGridLines, getEarth, loadEarthTexture, clearEarthTexture, setEarthSunDirection, } from "./earth.js"; import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js"; import { initCelestialLayer, updateCelestialLayer, disposeCelestialLayer, getCelestialDebugState, getSunDirection, setCelestialOrientation, setCelestialFollow, setCelestialDayNightEnabled, } from "./celestial.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, selectSatellite, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, highlightRelatedSatellites, clearRelatedSatelliteHighlights, getRelatedSatelliteIndicesForRegions, updateRelatedSatelliteHighlights, updateBreathingPhase, isSatelliteFrontFacing, setSatelliteCamera, setLockedSatelliteIndex, resetSatelliteState, clearSatelliteData, } from "./satellites.js"; import { loadBGPAnomalies, getBGPAnomalyMarkers, 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 { setupControls, getAutoRotate, getRotationMode, getShowTerrain, getStartupLoadLayers, setAutoRotate, applyImmediateView, focusEarthView, getZoomLevel, setZoomLevel, teardownControls, } 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 { initInfoCard, showInfoCard, hideInfoCard, } 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"; 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 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 earthTexture = null; let animationFrameId = null; let initialized = false; let destroyed = false; let isDataLoading = false; let currentLoadToken = 0; let cablesEnabled = true; let satellitesEnabled = true; let cableToggleToken = 0; let satelliteToggleToken = 0; let satelliteHydrationToken = 0; let sceneLights = null; let cruisePollTimerId = null; let cruiseConnector = null; let cruiseBGPAdapter = null; let cruiseSequencer = null; let activeDragPointerId = null; let activeTouchPoints = new Map(); let pinchGesture = null; let pointerDragDistance = 0; let suppressNextClick = 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 scratchBGPDirection = new THREE.Vector3(); const scratchBGPWorldPosition = 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 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 *", "#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 getDragRotationFactor() { const zoom = Math.max(getZoomLevel(), 0.01); const scale = THREE.MathUtils.clamp( 1 / zoom, CONFIG.dragRotationScaleMin, CONFIG.dragRotationScaleMax, ); return CONFIG.dragRotationFactorBase * scale; } 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; hoveredSatellite = null; hoveredSatelliteIndex = null; lockedObject = null; lockedObjectType = null; lockedSatellite = null; lockedSatelliteIndex = null; setLockedSatelliteIndex(null); clearSelectedSatelliteLegend(); } export function clearLockedObject() { hidePredictedOrbit(); clearAllCableStates(); clearCableSelection(); clearBGPSelection(); 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 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 clearTransientHoverState() { resetTransientBGPStates(); hoveredBGP = 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; } 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 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 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 `${name}${status ? `
${status}` : ""}`; } function showSatelliteInfo(props, coords) { const meanMotion = props?.mean_motion || 0; const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-"; const ecc = props?.eccentricity || 0; const perigee = (6371 * (1 - ecc)).toFixed(0); const apogee = (6371 * (1 + ecc)).toFixed(0); setSelectedSatelliteLegend(props); setLegendItems("satellites", getSatelliteLegendItems()); setLegendMode("satellites"); showInfoCard("satellite", { name: props?.name || "-", norad_id: props?.norad_cat_id, inclination: props?.inclination ? props.inclination.toFixed(2) : "-", period, perigee, apogee, }, coords); } function getSatelliteBriefHtml(props) { const name = props?.name || "未知卫星"; const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : ""; return `${name}${id ? `
${id}` : ""}`; } 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 `${type}${collector ? `
${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 `${name}
${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 }; } 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); } 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"); } } 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}` : "在轨卫星", 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, }); }); 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); } } function getBGPStatusText(bgpResult) { if (bgpResult.totalCount > 0) { return `${bgpResult.totalCount} 起活跃事件`; } if (bgpResult.anomalyCount > 0) { return `${bgpResult.anomalyCount} 条活跃异常`; } return "当前无活跃事件"; } function updateBGPHud(bgpResult) { const bgpBtn = document.getElementById("toggle-bgp"); if (bgpBtn) { bgpBtn.classList.add("active"); const tooltip = bgpBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) { tooltip.textContent = ACTIVE_BGP_TOOLTIP_TEXT; } } setEarthStatValue("bgp-anomaly-count", `${bgpResult.totalCount} 起`); setEarthStatValue("bgp-collector-count", `${bgpResult.collectorCount} 个`); setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult)); } function ensureCruiseConnector() { if (!cruiseConnector) { cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" }); } return cruiseConnector; } function ensureBGPCruiseAdapter() { if (cruiseBGPAdapter) return cruiseBGPAdapter; cruiseBGPAdapter = createBGPCruiseAdapter({ camera, getMarkers: () => getBGPAnomalyMarkers(), connector: ensureCruiseConnector(), focusView: (options) => focusEarthView(camera, options), 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, getLockedObject: () => lockedObject, refreshMarkers: async () => { const bgpResult = await loadBGPAnomalies(scene, getEarth()); updateBGPHud(bgpResult); setLegendItems("bgp", getBGPLegendItems()); refreshLegend(); }, }); return cruiseBGPAdapter; } function isCruisePresentationPinned() { return cruiseSequencer?.isPresentationPinned() === true; } function setCruisePresentationVisible(visible) { if (cruiseSequencer) { cruiseSequencer.setPresentationVisible(visible); } if (!visible) { ensureBGPCruiseAdapter().resetPresentation(); } } function clearCruiseMarkerHighlight() { ensureBGPCruiseAdapter().clearCurrentHighlight(); } function getCruiseMarkersSorted() { return ensureBGPCruiseAdapter().getSortedMarkers(); } function repositionCruiseConnector() { if (!isCruisePresentationPinned()) return; const marker = cruiseSequencer?.getCurrentItem() ?? null; ensureBGPCruiseAdapter().repositionConnector(marker); } function isCruiseModeActive() { return getRotationMode() === ROTATION_MODE.CRUISE; } function ensureCruiseSequencer() { if (cruiseSequencer) return cruiseSequencer; cruiseSequencer = new CruiseSequencer({ isActive: () => isCruiseModeActive() && getAutoRotate(), getItems: () => getCruiseMarkersSorted(), getItemId: (marker) => marker?.userData?.id || null, dwellMs: CRUISE_CONFIG.dwellMs, transitionGapMs: CRUISE_TRANSITION_GAP_MS, clearCurrent: () => { clearCruiseMarkerHighlight(); clearLockedObject(); hideInfoCard(); setCruisePresentationVisible(false); }, onStop: ({ preservePresentation }) => { clearBGPSelection(); if (!preservePresentation && !lockedObject) { hideInfoCard(); } }, focusItem: async (marker, { interrupt }) => ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }), presentItem: async (marker, { context }) => { setCruisePresentationVisible(true); const presented = await ensureBGPCruiseAdapter().presentMarker(marker, { context, }); if (!presented) { setCruisePresentationVisible(false); } return presented; }, hideItem: async (_marker, { context }) => { await ensureBGPCruiseAdapter().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() || !getShowBGP()) return; try { const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds(); if (newIds.length === 0) return; ensureCruiseSequencer().enqueue(newIds); if (!ensureCruiseSequencer().isBusy()) { await advanceCruiseEvent({ interrupt: true }); } } catch (error) { console.warn("巡航模式轮询 BGP 事件失败:", 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) { const detailMode = event?.detail?.mode || getRotationMode(); const detailActive = typeof event?.detail?.active === "boolean" ? event.detail.active : getAutoRotate(); if (detailMode !== ROTATION_MODE.CRUISE) { stopCruiseMode(); return; } ensureCruisePolling(); ensureBGPCruiseAdapter().syncKnownEventIds(); if (!detailActive) { stopCruiseMode({ preserveCard: true }); return; } advanceCruiseEvent({ interrupt: true }).catch((error) => { console.warn("启动巡航模式失败:", error); }); } function clearSelectionAndInfo() { clearLockedObject(); interruptCruisePresentation(); 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()); switch (state) { case CABLE_STATE.LOCKED: case CABLE_STATE.HOVERED: cable.material.opacity = 1; 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 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 ? "隐藏卫星" : "显示卫星", }); } setEarthStatValue("satellite-count", `${satelliteCount} 颗`); } function updateCableToggleUi(enabled) { const cableBtn = document.getElementById("toggle-cables"); if (cableBtn) { setLayerButtonState(cableBtn, { active: enabled, loading: false, tooltip: enabled ? "隐藏线缆" : "显示线缆", }); } setEarthStatValue("cable-count", `${getCableLines().length}个`); setEarthStatValue("landing-point-count", `${getLandingPoints().length}个`); } 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; } function disableSatellites() { satellitesEnabled = false; satelliteToggleToken += 1; satelliteHydrationToken += 1; resetSatelliteState(); updateSatelliteToggleUi(false, 0); setLegendItems("satellites", getSatelliteLegendItems()); refreshLegend(); } function updateStatsSummary() { updateEarthStats({ cableCount: getCableLines().length, landingPointCount: getLandingPoints().length, bgpAnomalyCount: `${getBGPCount()} 条`, bgpCollectorCount: `${getBGPCollectorCount()} 个`, bgpStatusSummary: getBGPStatusSummary(), 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); } window.addEventListener("error", (event) => { console.error("全局错误:", event.error); }); window.addEventListener("unhandledrejection", (event) => { console.error("未处理的 Promise 错误:", event.reason); }); 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("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); createSatellites(scene, earthObj); setupControls(camera, renderer, scene, earthObj); setupEventListeners(); clock.start(); loadData(); animate(); registerGlobalApi(); } function registerGlobalApi() { window.__planetEarth = { reloadData, clearSelection: () => { hideInfoCard(); clearLockedObject(); }, celestial: { getState: () => getCelestialDebugState(), setOrientation: (nextEuler) => setCelestialOrientation(nextEuler), setFollow: (nextFollow) => setCelestialFollow(nextFollow), }, 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); clearSatelliteData(); setLoadingMessage("正在初始化..."); setLoading(true); await yieldFrame(18); if (loadToken !== currentLoadToken) { isDataLoading = false; return; } const errors = []; // Step 1 — Earth texture setLoadingMessage("正在加载地球纹理..."); await yieldFrame(12); try { await loadEarthTexture(); } catch (err) { // texture failure is non-fatal } if (loadToken !== currentLoadToken) { isDataLoading = false; return; } await yieldFrame(16); const startupLoaders = createLayerStartupTaskMap({ scene, earth, setLoadingMessage, yieldFrame, refreshLegend, setLegendItems, updateCableToggleUi, updateSatelliteToggleUi, updateBGPHud, getShowBGP, 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 }); }, }); 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("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"); } } 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 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)}`; if (!suppressLoadingUi) { showError(message); } if (!suppressStatus) { showStatusMessage(message, "error"); } throw error; } finally { if (!suppressLoadingUi) { setLoading(false); } } } 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)}`; if (!suppressLoadingUi) { showError(message); } if (!suppressStatus) { showStatusMessage(message, "error"); } throw error; } finally { if (!suppressLoadingUi) { setLoading(false); } } } function setupEventListeners() { const handleResize = () => onWindowResize(); 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); bindListener(window, "resize", handleResize); bindListener(window, "pagehide", handlePageHide); bindListener(window, "beforeunload", handlePageHide); bindListener(window, "earth:rotation-mode-change", handleRotationMode); 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 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(); } 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 getFrontFacingBGPMarkers(markers) { const earth = getEarth(); if (!earth) return markers; scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); return markers.filter((marker) => { scratchBGPWorldPosition.copy(marker.position); marker.parent?.localToWorld(scratchBGPWorldPosition); scratchBGPDirection .subVectors(scratchBGPWorldPosition, earth.position) .normalize(); return ( scratchCameraToEarth.dot(scratchBGPDirection) > 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()) { 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 }; hideTooltip(); return; } updatePointerFromEvent(event); const frontCables = getFrontFacingCables(getCableLines()); const cableIntersects = interactionRaycaster.intersectObjects(frontCables); const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers( getBGPAnomalyMarkers(), ); const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers( getBGPCollectorMarkers(), ); const bgpAnomalyIntersects = getShowBGP() ? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) : []; const bgpCollectorIntersects = getShowBGP() ? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) : []; let hoveredSat = null; let hoveredSatIndexFromIntersect = null; if (getShowSatellites()) { const satPoints = getSatellitePoints(); if (satPoints) { const satIntersects = interactionRaycaster.intersectObject(satPoints); if (satIntersects.length > 0) { const satIndex = satIntersects[0].index; if (isSatelliteFrontFacing(satIndex, camera)) { hoveredSatIndexFromIntersect = satIndex; hoveredSat = selectSatellite(satIndex); } } } } const hoveredBGPMarker = getPrimaryBGPHoverTarget( bgpAnomalyIntersects, bgpCollectorIntersects, ); if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) { 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 (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, ); } } 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 && !isCruisePresentationPinned()) { resetTransientBGPStates(); hideInfoCard(); } if (!objectTooltipShown) { const earthPoint = screenToEarthCoords( event.clientX, event.clientY, camera, earth, document.body, interactionRaycaster, interactionMouse, ); if (earthPoint) { const coords = vector3ToLatLon(earthPoint); updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt); const elevMeters = sampleElevationAt(coords.lat, coords.lon); const elevText = elevMeters !== null ? elevMeters >= 1000 ? `${(elevMeters / 1000).toFixed(2)} km` : `${Math.round(elevMeters)} m` : "—"; showTooltip( event.clientX + TOOLTIP_COORDS_OFFSET, event.clientY + TOOLTIP_COORDS_OFFSET, `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${elevText}`, ); } else { hideTooltip(); } } } function onMouseDown(event) { if (isEventOnHud(event)) { return; } const earth = getEarth(); isDragging = true; dragStartTime = Date.now(); isLongDrag = false; previousMousePosition = { x: event.clientX, y: event.clientY }; inertialVelocity = { x: 0, y: 0 }; if (earth) { targetRotation = { x: earth.rotation.x, y: earth.rotation.y, }; } document.getElementById("container")?.classList.add("dragging"); hideTooltip(); } function onMouseUp() { isDragging = false; document.getElementById("container")?.classList.remove("dragging"); } function onPointerDown(event) { if (isEventOnHud(event)) return; if (event.pointerType !== "touch" && event.button !== 0) return; 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(), }; activeDragPointerId = null; onMouseUp(); return; } } activeDragPointerId = event.pointerId; pointerDragDistance = 0; suppressNextClick = false; onMouseDown(event); } function onPointerMove(event) { 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); 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; } activeDragPointerId = null; pointerDragDistance = 0; onMouseUp(); } } function onMouseLeave() { hideTooltip(); } function onClick(event) { const earth = getEarth(); if (!earth) return; if (isEventOnHud(event)) return; if (suppressNextClick) { suppressNextClick = false; return; } updatePointerFromEvent(event); const cableIntersects = interactionRaycaster.intersectObjects( getFrontFacingCables(getCableLines()), ); const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers( getBGPAnomalyMarkers(), ); const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers( getBGPCollectorMarkers(), ); const bgpAnomalyIntersects = getShowBGP() ? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) : []; const bgpCollectorIntersects = getShowBGP() ? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) : []; const satIntersects = getShowSatellites() ? interactionRaycaster.intersectObject(getSatellitePoints()) : []; const clickedBGPMarker = getShowBGP() ? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects) : 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 (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); { const cableLandingRegions = getLandingPoints() .filter((lp) => lp.userData.cableNames?.includes(clickedCable.userData.name)) .map((lp) => { const { lat, lon } = vector3ToLatLon(lp.position); return { latitude: lat, longitude: lon }; }); const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions( cableLandingRegions, { limit: 6, maxAngleDeg: 20 }, ); highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR); } 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.filter((sat) => isSatelliteFrontFacing(sat.index, camera), ); 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, ); } 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 = Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY || Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY; 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; } } applyCableVisualState(); const activeCruiseMarker = isCruiseModeActive() && isCruisePresentationPinned() ? cruiseSequencer?.getCurrentItem() ?? null : null; updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker); 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); updateRelatedSatelliteHighlights(); updateCelestialLayer(new Date(), camera); setEarthSunDirection(getSunDirection()); 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(); renderer.render(scene, camera); } export function destroy() { if (destroyed) return; destroyed = true; currentLoadToken += 1; isDataLoading = false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } teardownControls(); while (cleanupFns.length) { const cleanup = cleanupFns.pop(); cleanup?.(); } clearLockedObject(); clearCableData(getEarth()); clearBGPData(getEarth()); 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);