import * as THREE from "three"; import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE, SATELLITE_CONFIG, PATHS, BGP_CONFIG, CRUISE_CONFIG, ROTATION_MODE, } from "./constants.js"; import { vector3ToLatLon, screenToEarthCoords } from "./utils.js"; import { showStatusMessage, queueStatusMessage, updateCoordinatesDisplay, updateZoomDisplay, updateEarthStats, 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 } from "./terrain.js"; import { initCelestialLayer, updateCelestialLayer, disposeCelestialLayer, getCelestialDebugState, getSunDirection, setCelestialOrientation, setCelestialFollow, } 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, 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, setAutoRotate, applyImmediateView, focusEarthView, getZoomLevel, teardownControls, } from "./controls.js"; import { setLayerButtonState, } from "./layer-button-state.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"; 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 cruiseTimerId = null; let cruiseHideCardTimerId = null; let cruisePollTimerId = null; let cruiseCurrentMarkerId = null; let cruiseCurrentIndex = -1; let cruiseQueuedMarkerIds = []; let cruiseKnownEventIds = new Set(); let cruiseCardPinned = false; let cruiseConnectorEl = null; let cruiseConnectorPolyline = null; let cruiseConnectorStartpoint = null; let cruiseConnectorEndpoint = null; let cruiseConnectorNeedsAnimation = false; let cruiseCardPlacement = null; let cruiseSequenceToken = 0; let cruisePresentationPhase = "hidden"; let cruiseAdvanceInFlight = false; let cruiseAdvanceQueued = false; let cruiseAdvanceInterrupt = false; let cruiseCancelNotifier = null; function createCruisePresentationTimer() { let endsAt = 0; return { start(durationMs) { endsAt = Date.now() + Math.max(0, durationMs); }, stop() { endsAt = 0; }, isActive() { return endsAt > 0 && Date.now() < endsAt; }, }; } const cruisePresentationTimer = createCruisePresentationTimer(); 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_CONNECTOR_DRAW_MS = 420; const CRUISE_TRANSITION_GAP_MS = 24; const CRUISE_PRESENTATION_HIDE_MS = 220; const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200; 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 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 *", ]; function bindListener(target, eventName, handler, options) { if (!target) return; target.addEventListener(eventName, handler, options); cleanupFns.push(() => target.removeEventListener(eventName, handler, options), ); } function waitForCruiseDelay(durationMs, sequenceToken, { useHideTimer = false } = {}) { return new Promise((resolve) => { let settled = false; const settle = (ok) => { if (settled) return; settled = true; if (cruiseCancelNotifier === onCancel) cruiseCancelNotifier = null; resolve(ok); }; const onCancel = () => settle(false); const timerId = window.setTimeout(() => { if (useHideTimer) { if (cruiseHideCardTimerId === timerId) cruiseHideCardTimerId = null; } else if (cruiseTimerId === timerId) { cruiseTimerId = null; } settle(sequenceToken === cruiseSequenceToken); }, durationMs); if (useHideTimer) { cruiseHideCardTimerId = timerId; } else { cruiseTimerId = timerId; } cruiseCancelNotifier = onCancel; }); } async function waitForCruiseConnectorReady(sequenceToken) { const startedAt = performance.now(); while (sequenceToken === cruiseSequenceToken) { if (!isCruiseModeActive() || !getAutoRotate()) { return false; } const ready = updateCruiseConnector(); if (ready) { return true; } if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) { return false; } await nextAnimationFrame(); } return false; } function getViewportAspect() { return window.innerWidth / window.innerHeight; } function nextAnimationFrame() { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } 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 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 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; } } const bgpCountEl = document.getElementById("bgp-anomaly-count"); if (bgpCountEl) { bgpCountEl.textContent = `${bgpResult.totalCount} 起`; } const bgpCollectorEl = document.getElementById("bgp-collector-count"); if (bgpCollectorEl) { bgpCollectorEl.textContent = `${bgpResult.collectorCount} 个`; } const bgpStatusEl = document.getElementById("bgp-status-summary"); if (bgpStatusEl) { bgpStatusEl.textContent = getBGPStatusText(bgpResult); } } function clearCruiseTimers() { if (cruiseTimerId) { clearTimeout(cruiseTimerId); cruiseTimerId = null; } if (cruiseHideCardTimerId) { clearTimeout(cruiseHideCardTimerId); cruiseHideCardTimerId = null; } } function ensureCruiseConnector() { if (cruiseConnectorEl instanceof SVGSVGElement) { return cruiseConnectorEl; } const container = document.getElementById("container"); if (!(container instanceof HTMLElement)) return null; const connector = document.createElementNS("http://www.w3.org/2000/svg", "svg"); connector.id = "info-card-cruise-link"; connector.setAttribute("class", "info-card-cruise-link"); connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); connector.setAttribute("preserveAspectRatio", "none"); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); const startpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle"); const endpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle"); startpoint.setAttribute("r", "4"); endpoint.setAttribute("r", "4"); connector.appendChild(startpoint); connector.appendChild(polyline); connector.appendChild(endpoint); container.appendChild(connector); cruiseConnectorEl = connector; cruiseConnectorPolyline = polyline; cruiseConnectorStartpoint = startpoint; cruiseConnectorEndpoint = endpoint; connector.addEventListener("animationend", (event) => { if ( event.animationName === "cruiseConnectorDraw" && cruiseConnectorEl?.classList.contains("is-visible") ) { if (cruiseConnectorPolyline) { cruiseConnectorPolyline.style.strokeDashoffset = "0"; } cruiseConnectorEl.classList.remove("is-animating"); } }); return connector; } function hideCruiseConnector() { const connector = ensureCruiseConnector(); if (!connector) return; connector.classList.remove("is-visible"); connector.classList.remove("is-animating"); cruiseCardPlacement = null; cruisePresentationPhase = "hidden"; } function hideCruiseConnectorVisual() { const connector = ensureCruiseConnector(); if (!connector) return; connector.classList.remove("is-visible"); connector.classList.remove("is-animating"); } function showCruiseConnectorVisual() { const connector = ensureCruiseConnector(); if (!connector) return; connector.classList.add("is-visible"); } function setCruiseCardPinned(pinned) { cruiseCardPinned = pinned; if (!pinned) { cruisePresentationTimer.stop(); hideCruiseConnector(); } } function interruptCruisePresentation() { ++cruiseSequenceToken; clearCruiseTimers(); const notifier = cruiseCancelNotifier; cruiseCancelNotifier = null; notifier?.(); setCruiseCardPinned(false); } function beginCruisePresentationHide() { if (!lockedObject) { hideInfoCard(); } cruisePresentationPhase = "hidden"; const connector = ensureCruiseConnector(); connector?.classList.remove("is-visible"); connector?.classList.remove("is-animating"); } function scheduleCruiseCardHide() { if (cruiseHideCardTimerId) { clearTimeout(cruiseHideCardTimerId); cruiseHideCardTimerId = null; } } function clearCruiseMarkerHighlight() { if (!cruiseCurrentMarkerId) return; const marker = getBGPAnomalyMarkers().find( (item) => item.userData?.id === cruiseCurrentMarkerId, ); if (marker && lockedObject !== marker) { setBGPMarkerState(marker, "normal"); } cruiseCurrentMarkerId = null; } function getBGPMarkerTimestamp(marker) { const rawValue = marker?.userData?.created_at_raw; const parsedValue = rawValue ? new Date(rawValue).getTime() : 0; return Number.isFinite(parsedValue) ? parsedValue : 0; } function getCruiseMarkersSorted() { return getBGPAnomalyMarkers() .slice() .sort((a, b) => getBGPMarkerTimestamp(b) - getBGPMarkerTimestamp(a)); } function getCruiseMarkerScreenCoords(marker) { if (!marker || !camera) return null; scratchBGPWorldPosition.copy(marker.position); marker.parent?.localToWorld(scratchBGPWorldPosition); const projected = scratchBGPWorldPosition.clone().project(camera); if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) { return null; } return { x: ((projected.x + 1) * 0.5) * window.innerWidth, y: ((1 - projected.y) * 0.5) * window.innerHeight, }; } function getCruiseCardScreenCoords(marker) { const markerCoords = getCruiseMarkerScreenCoords(marker); if (!markerCoords) return null; const hudScale = Number.parseFloat( getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), ) || 1; const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7); const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32); const x = window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5; const y = window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5; const margin = 12; const clampedX = Math.min( Math.max(margin, x), Math.max(margin, window.innerWidth - estimatedCardWidth - margin), ); const clampedY = Math.min( Math.max(margin, y), Math.max(margin, window.innerHeight - estimatedCardHeight - margin), ); const anchorY = clampedY + Math.max(18 * hudScale, estimatedCardHeight * 0.18); return { x: clampedX, y: clampedY, width: estimatedCardWidth, height: estimatedCardHeight, anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx, anchorY, }; } function showCruiseEventCard(marker) { const coords = cruiseCardPlacement || getCruiseCardScreenCoords(marker); if (!coords) return; showBGPInfo(marker, { x: coords.x, y: coords.y, absolute: true, }); } function isCruiseInfoCardVisible() { return document.getElementById("info-panel")?.classList.contains("is-visible") === true; } function computeCruiseConnectorPoints(marker) { const markerCoords = getCruiseMarkerScreenCoords(marker); if (!markerCoords) return null; const targetCardCoords = cruiseCardPlacement || getCruiseCardScreenCoords(marker); if (!targetCardCoords) return null; const panelAnchorX = targetCardCoords.anchorX; const panelAnchorY = targetCardCoords.anchorY; const horizontalDirection = markerCoords.x <= panelAnchorX ? 1 : -1; const startX = markerCoords.x + horizontalDirection * CRUISE_CONFIG.linkMarkerGapPx; const startY = markerCoords.y; const elbowX = panelAnchorX - horizontalDirection * (CRUISE_CONFIG.linkElbowOffsetPx + CRUISE_CONFIG.linkPanelGapPx); const elbowY = Math.min(startY, panelAnchorY) + CRUISE_CONFIG.linkElbowDropPx; if (Math.abs(panelAnchorX - startX) < 8 && Math.abs(panelAnchorY - startY) < 8) { return null; } return { startX, startY, elbowX, elbowY, panelAnchorX, panelAnchorY }; } function applyCruiseConnectorPoints(pts) { const polyline = cruiseConnectorPolyline; const startpoint = cruiseConnectorStartpoint; const endpoint = cruiseConnectorEndpoint; if (!polyline || !startpoint || !endpoint) return 0; polyline.setAttribute( "points", `${pts.startX.toFixed(2)},${pts.startY.toFixed(2)} ` + `${pts.elbowX.toFixed(2)},${pts.elbowY.toFixed(2)} ` + `${pts.panelAnchorX.toFixed(2)},${pts.panelAnchorY.toFixed(2)}`, ); startpoint.setAttribute("cx", pts.startX.toFixed(2)); startpoint.setAttribute("cy", pts.startY.toFixed(2)); endpoint.setAttribute("cx", pts.panelAnchorX.toFixed(2)); endpoint.setAttribute("cy", pts.panelAnchorY.toFixed(2)); const totalLength = typeof polyline.getTotalLength === "function" ? polyline.getTotalLength() : 0; return totalLength; } function updateCruiseConnector() { const connector = ensureCruiseConnector(); if ( !connector || !cruiseConnectorPolyline || !cruiseConnectorStartpoint || !cruiseConnectorEndpoint || !cruiseCardPinned || cruisePresentationPhase === "hidden" || !cruiseCurrentMarkerId ) { return false; } const marker = getBGPAnomalyMarkers().find( (item) => item.userData?.id === cruiseCurrentMarkerId, ); if (!marker) return false; const pts = computeCruiseConnectorPoints(marker); if (!pts) return false; connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); const totalLength = applyCruiseConnectorPoints(pts); const polyline = cruiseConnectorPolyline; polyline.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : ""; polyline.style.strokeDashoffset = totalLength > 0 ? `${cruiseConnectorNeedsAnimation ? totalLength : 0}` : ""; connector.style.setProperty( "--connector-length", totalLength > 0 ? `${totalLength}` : "0px", ); connector.classList.add("is-visible"); if (cruiseConnectorNeedsAnimation && totalLength > 0) { connector.classList.remove("is-animating"); void connector.getBoundingClientRect(); polyline.style.strokeDashoffset = `${totalLength}`; connector.classList.add("is-animating"); cruiseConnectorNeedsAnimation = false; } return totalLength > 0; } function repositionCruiseConnector() { if (!cruiseCardPinned || cruisePresentationPhase === "hidden" || !cruiseCurrentMarkerId) return; const connector = cruiseConnectorEl; if (!connector || !connector.classList.contains("is-visible")) return; const marker = getBGPAnomalyMarkers().find( (item) => item.userData?.id === cruiseCurrentMarkerId, ); if (!marker) return; const pts = computeCruiseConnectorPoints(marker); if (!pts) return; connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); applyCruiseConnectorPoints(pts); } function syncCruiseKnownEventIds() { cruiseKnownEventIds = new Set( getBGPAnomalyMarkers() .map((marker) => marker.userData?.id) .filter(Boolean), ); } function isCruiseModeActive() { return getRotationMode() === ROTATION_MODE.CRUISE; } function stopCruiseMode({ preserveCard = false } = {}) { clearCruiseTimers(); clearCruiseMarkerHighlight(); clearBGPSelection(); cruiseCurrentIndex = -1; cruiseQueuedMarkerIds = []; cruiseAdvanceQueued = false; cruiseAdvanceInterrupt = false; if (!preserveCard) { setCruiseCardPinned(false); } if (!preserveCard && !lockedObject) { hideInfoCard(); } } async function focusCruiseMarker(marker, { interrupt = false } = {}) { const earth = getEarth(); if (!marker || !earth || !isCruiseModeActive() || !getAutoRotate()) return; const sequenceToken = ++cruiseSequenceToken; const abortPresentation = () => { hideInfoCard(); setCruiseCardPinned(false); }; const shouldAbortSequence = () => sequenceToken !== cruiseSequenceToken || !isCruiseModeActive() || !getAutoRotate(); clearCruiseTimers(); clearCruiseMarkerHighlight(); clearLockedObject(); hideInfoCard(); setCruiseCardPinned(false); cruiseCurrentMarkerId = marker.userData?.id || null; cruiseConnectorNeedsAnimation = true; cruiseCardPlacement = getCruiseCardScreenCoords(marker); cruiseCurrentIndex = getCruiseMarkersSorted().findIndex( (item) => item.userData?.id === cruiseCurrentMarkerId, ); setLegendMode("bgp"); setBGPMarkerState(marker, "locked"); showBGPEventOverlay(marker, earth); applyBGPEventSatelliteHighlights(marker); await focusEarthView(camera, { lat: marker.userData?.latitude ?? 0, lon: marker.userData?.longitude ?? 0, rotLon: (marker.userData?.longitude ?? 0) - 270, zoom: 1.0, duration: interrupt ? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78) : CRUISE_CONFIG.focusDurationMs, suppressStatus: true, }); if ( shouldAbortSequence() ) { abortPresentation(); return; } setCruiseCardPinned(true); cruisePresentationPhase = "connector"; showCruiseConnectorVisual(); const connectorReady = await waitForCruiseConnectorReady(sequenceToken); if ( !connectorReady || shouldAbortSequence() ) { abortPresentation(); return; } const connectorDelayCompleted = await waitForCruiseDelay( CRUISE_CONNECTOR_DRAW_MS, sequenceToken, ); if ( !connectorDelayCompleted || shouldAbortSequence() ) { abortPresentation(); return; } cruisePresentationPhase = "card"; showCruiseEventCard(marker); await nextAnimationFrame(); if (!isCruiseInfoCardVisible()) { showCruiseEventCard(marker); await nextAnimationFrame(); } if (!isCruiseInfoCardVisible() || shouldAbortSequence()) { abortPresentation(); return; } cruisePresentationTimer.start(CRUISE_CONFIG.dwellMs); const dwellDelayCompleted = await waitForCruiseDelay( CRUISE_CONFIG.dwellMs, sequenceToken, ); if ( !dwellDelayCompleted || shouldAbortSequence() ) { abortPresentation(); return; } beginCruisePresentationHide(); const hideDelayCompleted = await waitForCruiseDelay( CRUISE_PRESENTATION_HIDE_MS, sequenceToken, { useHideTimer: true }, ); if ( !hideDelayCompleted || shouldAbortSequence() ) { abortPresentation(); return; } setCruiseCardPinned(false); const transitionGapCompleted = await waitForCruiseDelay( CRUISE_TRANSITION_GAP_MS, sequenceToken, ); if ( !transitionGapCompleted || shouldAbortSequence() ) { abortPresentation(); return; } void advanceCruiseEvent(); } async function performCruiseAdvance({ interrupt = false } = {}) { if (!isCruiseModeActive() || !getAutoRotate()) return; const markers = getCruiseMarkersSorted(); if (markers.length === 0) return; let targetMarker = null; while (cruiseQueuedMarkerIds.length > 0 && !targetMarker) { const queuedId = cruiseQueuedMarkerIds.shift(); targetMarker = markers.find((marker) => marker.userData?.id === queuedId) || null; } if (!targetMarker) { const nextIndex = cruiseCurrentIndex >= 0 ? (cruiseCurrentIndex + 1) % markers.length : 0; targetMarker = markers[nextIndex] || markers[0]; } await focusCruiseMarker(targetMarker, { interrupt }); } async function advanceCruiseEvent({ interrupt = false } = {}) { if (!isCruiseModeActive() || !getAutoRotate()) return; cruiseAdvanceQueued = true; cruiseAdvanceInterrupt = cruiseAdvanceInterrupt || interrupt; if (cruiseAdvanceInFlight) { return; } cruiseAdvanceInFlight = true; try { while (cruiseAdvanceQueued && isCruiseModeActive() && getAutoRotate()) { const nextInterrupt = cruiseAdvanceInterrupt; cruiseAdvanceQueued = false; cruiseAdvanceInterrupt = false; await performCruiseAdvance({ interrupt: nextInterrupt }); } } finally { cruiseAdvanceInFlight = false; } } async function pollCruiseEventsIfNeeded() { if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return; try { const [incidentResponse, anomalyResponse] = await Promise.all([ fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`), fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`), ]); if (!incidentResponse.ok || !anomalyResponse.ok) return; const [incidentPayload, anomalyPayload] = await Promise.all([ incidentResponse.json(), anomalyResponse.json(), ]); const incidentFeatures = Array.isArray(incidentPayload?.features) ? incidentPayload.features : []; const anomalyFeatures = Array.isArray(anomalyPayload?.features) ? anomalyPayload.features : []; const selectedFeatures = incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures; const nextIds = selectedFeatures .map((feature) => { const properties = feature?.properties || {}; const coords = feature?.geometry?.coordinates || []; return ( properties.id || properties.incident_key || `${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}` ); }) .filter(Boolean); const newIds = nextIds.filter((id) => !cruiseKnownEventIds.has(id)); if (newIds.length === 0) return; const bgpResult = await loadBGPAnomalies(scene, getEarth()); updateBGPHud(bgpResult); setLegendItems("bgp", getBGPLegendItems()); refreshLegend(); syncCruiseKnownEventIds(); cruiseQueuedMarkerIds = Array.from( new Set([...newIds, ...cruiseQueuedMarkerIds]), ); if ( cruisePresentationPhase === "hidden" && !cruiseCardPinned && !cruisePresentationTimer.isActive() ) { 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(); syncCruiseKnownEventIds(); 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() && cruiseCardPinned); 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 ? "隐藏卫星" : "显示卫星", }); } const satelliteCountEl = document.getElementById("satellite-count"); if (satelliteCountEl) { satelliteCountEl.textContent = `${satelliteCount} 颗`; } } function updateCableToggleUi(enabled) { const cableBtn = document.getElementById("toggle-cables"); if (cableBtn) { setLayerButtonState(cableBtn, { active: enabled, loading: false, tooltip: enabled ? "隐藏线缆" : "显示线缆", }); } const cableCountEl = document.getElementById("cable-count"); if (cableCountEl) { cableCountEl.textContent = `${getCableLines().length}个`; } const landingPointCountEl = document.getElementById("landing-point-count"); if (landingPointCountEl) { landingPointCountEl.textContent = `${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: document.getElementById("landing-point-count")?.textContent || 0, 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(); 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, sunLight: sceneLights?.sunLight ?? null, backLight: sceneLights?.backLight ?? null, earth: earthObj, }); 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(0x404060); scene.add(ambientLight); const sunLight = new THREE.DirectionalLight(0xffffff, 1.2); sunLight.position.set(5, 3, 5); sunLight.target.position.set(0, 0, 0); scene.add(sunLight); scene.add(sunLight.target); const backLight = new THREE.DirectionalLight(0x446688, 0.3); backLight.position.set(-5, 0, -5); scene.add(backLight); const pointLight = new THREE.PointLight(0xffffff, 0.4); pointLight.position.set(10, 10, 10); 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); // Step 2 — Landing points if (cablesEnabled) { setLoadingMessage("正在加载登陆点..."); await yieldFrame(12); try { await loadLandingPoints(scene, earth, { silent: true }); } catch (err) { errors.push({ label: "登陆点", reason: err }); } if (loadToken !== currentLoadToken) { isDataLoading = false; return; } await yieldFrame(16); } // Step 3 — Cables if (cablesEnabled) { setLoadingMessage("正在加载海缆..."); await yieldFrame(12); try { const cableCount = await loadGeoJSONFromPath(scene, earth, { silent: true, }); if (loadToken === currentLoadToken && cablesEnabled) { toggleCables(true); updateCableToggleUi(true); setLegendItems("cables", getCableLegendItems()); refreshLegend(); } } catch (err) { errors.push({ label: "海缆", reason: err }); } if (loadToken !== currentLoadToken) { isDataLoading = false; return; } await yieldFrame(16); } // Step 4 — Satellites if (satellitesEnabled) { setLoadingMessage("正在加载卫星..."); await yieldFrame(12); try { clearSatelliteData(); const loadResult = await loadSatellites({ limit: getInitialSatelliteLoadLimit(), }); if (loadToken === currentLoadToken && satellitesEnabled) { updateSatelliteToggleUi(true, loadResult.count); setLegendItems("satellites", getSatelliteLegendItems()); refreshLegend(); scheduleSatellitePositionWarmup(() => { if ( loadToken === currentLoadToken && satellitesEnabled && !destroyed ) { toggleSatellites(true); } }); if (shouldHydrateFullSatelliteSet(loadResult)) { const hydrationToken = ++satelliteHydrationToken; hydrateAllSatellitesInBackground( () => hydrationToken === satelliteHydrationToken && loadToken === currentLoadToken && satellitesEnabled && !destroyed, ); } } } catch (err) { errors.push({ label: "卫星", reason: err }); } if (loadToken !== currentLoadToken) { isDataLoading = false; return; } await yieldFrame(16); } // Step 5 — BGP setLoadingMessage("正在加载BGP态势..."); await yieldFrame(12); try { const bgpResult = await loadBGPAnomalies(scene, earth); if (loadToken === currentLoadToken) { toggleBGP(true); updateBGPHud(bgpResult); syncCruiseKnownEventIds(); } } catch (err) { errors.push({ label: "BGP态势", reason: err }); } if (loadToken !== currentLoadToken) { isDataLoading = false; return; } await yieldFrame(16); // Step 6 — Terrain (if enabled) if (getShowTerrain()) { setLoadingMessage("正在渲染地形..."); 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; export async function reloadData() { await loadData(); } export async function setCablesEnabled(enabled) { if (enabled === cablesEnabled) { updateCableToggleUi(enabled); return getCableLines().length; } if (!enabled) { clearSelectionAndInfo(); disableCables(); showStatusMessage("线缆已隐藏", "info"); return 0; } setLoadingMessage("正在加载线缆数据..."); setLoading(true); hideError(); try { const cableCount = await ensureCablesEnabled(); showStatusMessage("线缆已显示", "info"); return cableCount; } catch (error) { cablesEnabled = false; clearCableData(getEarth()); updateCableToggleUi(false); const message = `线缆加载失败: ${error?.message || String(error)}`; showError(message); showStatusMessage(message, "error"); throw error; } finally { setLoading(false); } } export async function setSatellitesEnabled(enabled) { if (enabled === satellitesEnabled) { updateSatelliteToggleUi(enabled); return getSatelliteCount(); } if (!enabled) { clearSelectionAndInfo(); disableSatellites(); return 0; } setLoadingMessage("正在加载卫星数据..."); setLoading(true); hideError(); try { const satelliteCount = await ensureSatellitesEnabled(); showStatusMessage("卫星已显示", "info"); return satelliteCount; } catch (error) { satellitesEnabled = false; resetSatelliteState(); updateSatelliteToggleUi(false, 0); const message = `卫星加载失败: ${error?.message || String(error)}`; showError(message); showStatusMessage(message, "error"); throw error; } finally { setLoading(false); } } function setupEventListeners() { const handleResize = () => onWindowResize(); const handleMouseMove = (event) => onMouseMove(event); const handleMouseDown = (event) => onMouseDown(event); const handleMouseUp = () => onMouseUp(); 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(window, "mousemove", handleMouseMove); bindListener(renderer.domElement, "mousedown", handleMouseDown); bindListener(window, "mouseup", handleMouseUp); bindListener(renderer.domElement, "mouseleave", handleMouseLeave); bindListener(renderer.domElement, "click", handleClick); } 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) > 0; }); } 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) > 0; }); } 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 && !cruiseCardPinned) { hideInfoCard(); } hideTooltip(); return; } if (isDragging) { if (Date.now() - dragStartTime > 500) { 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 && !cruiseCardPinned) { 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); showTooltip( event.clientX + TOOLTIP_COORDS_OFFSET, event.clientY + TOOLTIP_COORDS_OFFSET, `纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${coords.alt.toFixed(1)} km`, ); } 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 onMouseLeave() { hideTooltip(); } function onClick(event) { const earth = getEarth(); if (!earth) return; if (isEventOnHud(event)) 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) { interruptCruisePresentation(); 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() && cruiseCardPinned && cruiseCurrentMarkerId) ? getBGPAnomalyMarkers().find((m) => m.userData?.id === cruiseCurrentMarkerId) ?? 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);