import * as THREE from "three"; import { createNoise3D } from "simplex-noise"; import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js"; import { vector3ToLatLon, screenToEarthCoords } from "./utils.js"; import { showStatusMessage, updateCoordinatesDisplay, updateZoomDisplay, updateEarthStats, setLoading, setLoadingMessage, showTooltip, hideTooltip, showError, hideError, clearUiState, } from "./ui.js"; import { createEarth, createClouds, createTerrain, createStars, createGridLines, getEarth, } from "./earth.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, getShowTerrain, setAutoRotate, resetView, getZoomLevel, teardownControls, } from "./controls.js"; import { initInfoCard, showInfoCard, hideInfoCard, setInfoCardNoBorder, } from "./info-card.js"; import { initLegend, setLegendMode, refreshLegend, setLegendItems, } from "./legend.js"; import { mountBrand } from "./brand.js"; export let scene; export let camera; export let renderer; let simplex; 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; 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 cleanupFns = []; const DRAG_SMOOTHING_FACTOR = 0.18; const INERTIA_DAMPING = 0.92; const INERTIA_MIN_VELOCITY = 0.00008; const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测"; const HUD_INTERACTIVE_SELECTORS = [ "#info-panel", "#info-panel *", "#right-toolbar-group", "#right-toolbar-group *", "#coordinates-display", "#coordinates-display *", "#legend", "#legend *", "#earth-stats", "#earth-stats *", ]; 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 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) { 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, }); } function showSatelliteInfo(props) { 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, }); } function showBGPInfo(marker) { 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, }); } function showBGPCollectorInfo(marker) { 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"), }); } 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 clearSelectionAndInfo() { clearLockedObject(); 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 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); switch (state) { case CABLE_STATE.LOCKED: cable.material.opacity = CABLE_CONFIG.lockedOpacityMin + pulse * (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin); cable.material.color.setRGB(1, 1, 1); break; case CABLE_STATE.HOVERED: cable.material.opacity = 1; cable.material.color.setRGB(1, 1, 1); break; case CABLE_STATE.NORMAL: default: if ( (lockedObjectType === "cable" && lockedObject) || (lockedObjectType === "satellite" && lockedSatellite) || (lockedObjectType === "bgp" && lockedObject) ) { 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) { satBtn.classList.toggle("active", enabled); const tooltip = satBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星"; } const satelliteCountEl = document.getElementById("satellite-count"); if (satelliteCountEl) { satelliteCountEl.textContent = `${satelliteCount} 颗`; } } function updateCableToggleUi(enabled) { const cableBtn = document.getElementById("toggle-cables"); if (cableBtn) { cableBtn.classList.toggle("active", enabled); const tooltip = cableBtn.querySelector(".earth-toolbar-tooltip"); if (tooltip) tooltip.textContent = 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); const [cableCount] = await Promise.all([ loadGeoJSONFromPath(scene, earth), loadLandingPoints(scene, earth), ]); 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 satelliteCount = await loadSatellites(); if ( requestToken !== satelliteToggleToken || !satellitesEnabled || destroyed ) { resetSatelliteState(); return 0; } updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true); toggleSatellites(true); updateSatelliteToggleUi(true, satelliteCount); setLegendItems("satellites", getSatelliteLegendItems()); refreshLegend(); return satelliteCount; } function disableSatellites() { satellitesEnabled = false; satelliteToggleToken += 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 卫星图", }); } 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; simplex = createNoise3D(); updateHudScale(); const brandRoot = document.getElementById("brand-root"); mountBrand( brandRoot, HUD_CONFIG.brandLanguage || "zh", ); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( 75, getViewportAspect(), 0.1, 1000, ); camera.position.z = CONFIG.defaultCameraZ; setSatelliteCamera(camera); renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance", }); syncRendererViewport(); renderer.setClearColor(0x0a0a1a, 1); renderer.setPixelRatio(window.devicePixelRatio); const container = document.getElementById("container"); if (container) { container.querySelector("canvas")?.remove(); container.appendChild(renderer.domElement); } addLights(); initInfoCard(); initLegend(); setLegendItems("cables", getCableLegendItems()); setLegendItems("satellites", getSatelliteLegendItems()); setLegendItems("bgp", getBGPLegendItems()); const earthObj = createEarth(scene); targetRotation = { x: earthObj.rotation.x, y: earthObj.rotation.y, }; inertialVelocity = { x: 0, y: 0 }; createClouds(scene, earthObj); createTerrain(scene, earthObj, simplex); createStars(scene); createGridLines(scene, earthObj); createSatellites(scene, earthObj); setupControls(camera, renderer, scene, earthObj); resetView(camera); setupEventListeners(); clock.start(); loadData(); animate(); registerGlobalApi(); } function registerGlobalApi() { window.__planetEarth = { reloadData, clearSelection: () => { hideInfoCard(); clearLockedObject(); }, destroy, init, }; } function addLights() { scene.add(new THREE.AmbientLight(0x404060)); const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); directionalLight.position.set(5, 3, 5); scene.add(directionalLight); 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); } async function loadData(showWhiteSphere = false) { if (!scene || !camera || !renderer) return; if (isDataLoading) return; const earth = getEarth(); if (!earth) return; const loadToken = ++currentLoadToken; isDataLoading = true; hideError(); setLoadingMessage( showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...", showWhiteSphere ? "重新同步卫星、海底光缆、登陆点与BGP态势数据" : "同步卫星、海底光缆、登陆点与BGP态势数据", ); setLoading(true); clearSelectionAndInfo(); if (showWhiteSphere && earth.material) { earthTexture = earth.material.map; earth.material.map = null; earth.material.color.setHex(0xffffff); earth.material.needsUpdate = true; } const results = await Promise.allSettled([ cablesEnabled ? ensureCablesEnabled() : Promise.resolve(0), satellitesEnabled ? ensureSatellitesEnabled() : Promise.resolve(0), (async () => { clearBGPData(earth); const bgpResult = await loadBGPAnomalies(scene, earth); toggleBGP(true); updateBGPHud(bgpResult); return bgpResult; })(), ]); if (loadToken !== currentLoadToken) { isDataLoading = false; return; } const errors = []; if (results[0].status === "rejected") { errors.push({ label: "电缆", reason: results[0].reason }); } if (results[1].status === "rejected") { errors.push({ label: "卫星", reason: results[1].reason }); } if (results[2].status === "rejected") { errors.push({ label: "BGP态势", reason: results[2].reason }); } if (errors.length > 0) { const errorMessage = buildLoadErrorMessage(errors); showError(errorMessage); showStatusMessage(errorMessage, "error"); } else { hideError(); showStatusMessage("数据已重新加载", "success"); } updateStatsSummary(); updateCableToggleUi(cablesEnabled); updateSatelliteToggleUi(satellitesEnabled); setLegendItems("cables", getCableLegendItems()); setLegendItems("satellites", getSatelliteLegendItems()); setLegendItems("bgp", getBGPLegendItems()); refreshLegend(); setLoading(false); isDataLoading = false; if (showWhiteSphere && earth.material) { earth.material.map = earthTexture; earth.material.color.setHex(0xffffff); earth.material.needsUpdate = true; } } const POSITION_UPDATE_FORCE_DELTA = 250; export async function reloadData() { await loadData(true); } 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(); bindListener(window, "resize", handleResize); bindListener(window, "pagehide", handlePageHide); bindListener(window, "beforeunload", handlePageHide); 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(); } 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(); if (lockedObjectType === "bgp" && lockedObject) { applyBGPHoverState(lockedObject); showBGPInfo(lockedObject); } else if (lockedObjectType === "bgp_collector" && lockedObject) { applyBGPHoverState(lockedObject); showBGPCollectorInfo(lockedObject); } else if (lockedObjectType === "cable" && lockedObject) { showCableInfo(lockedObject); } else if (lockedObjectType === "satellite" && lockedSatellite) { showSatelliteInfo(lockedSatellite.properties); } else { 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(); } if ( hoveredBGPMarker && getShowBGP() && lockedObjectType !== "bgp" && lockedObjectType !== "bgp_collector" ) { applyBGPHoverState(hoveredBGPMarker); if (hoveredBGPMarker.userData?.type === "bgp") { showBGPInfo(hoveredBGPMarker); } else { showBGPCollectorInfo(hoveredBGPMarker); } setInfoCardNoBorder(true); hideTooltip(); } else if (cableIntersects.length > 0 && getShowCables()) { const cable = cableIntersects[0].object; hoveredCable = cable; if (!isSameCable(cable, lockedObject)) { setCableState(cable.userData.cableId, CABLE_STATE.HOVERED); } showCableInfo(cable); setInfoCardNoBorder(true); hideTooltip(); } else if (hoveredSat?.properties) { hoveredSatellite = hoveredSat; hoveredSatelliteIndex = hoveredSatIndexFromIntersect; if (hoveredSatelliteIndex !== lockedSatelliteIndex) { const satPositions = getSatellitePositions(); if (satPositions && satPositions[hoveredSatelliteIndex]) { setSatelliteRingState( hoveredSatelliteIndex, "hover", satPositions[hoveredSatelliteIndex].current, ); } } showSatelliteInfo(hoveredSat.properties); setInfoCardNoBorder(true); } else if (lockedObjectType === "bgp" && lockedObject) { applyBGPHoverState(lockedObject); showBGPInfo(lockedObject); } else if (lockedObjectType === "bgp_collector" && lockedObject) { applyBGPHoverState(lockedObject); showBGPCollectorInfo(lockedObject); } else if (lockedObjectType === "cable" && lockedObject) { showCableInfo(lockedObject); } else if (lockedObjectType === "satellite" && lockedSatellite) { const satPositions = getSatellitePositions(); if (lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex]) { setSatelliteRingState( lockedSatelliteIndex, "locked", satPositions[lockedSatelliteIndex].current, ); } showSatelliteInfo(lockedSatellite.properties); } else { resetTransientBGPStates(); hideInfoCard(); } 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 + 10, event.clientY + 10, `纬度: ${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") { 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); { const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions( getBGPRelatedRegions(clickedMarker), { limit: 6, maxAngleDeg: 20 }, ); clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length; highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc"); } const incidentSummary = getBGPInfrastructureSummary(clickedMarker); showBGPInfo(clickedMarker); showStatusMessage( `已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`, "info", ); return; } if (clickedBGPMarker?.userData?.type === "bgp_collector") { 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); showStatusMessage( `已选择观测站: ${clickedMarker.userData.collector}`, "info", ); return; } if (cableIntersects.length > 0 && getShowCables()) { clearLockedObject(); const clickedCable = cableIntersects[0].object; const cableId = clickedCable.userData.cableId; setCableState(cableId, CABLE_STATE.LOCKED); lockedObject = clickedCable; lockedObjectType = "cable"; setAutoRotate(false); handleCableClick(clickedCable); 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; 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); showStatusMessage("已选择: " + sat.properties.name, "info"); return; } if (!isLongDrag) { clearLockedObject(); 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() && 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(); updateBGPVisualState(lockedObjectType, lockedObject, camera); if (lockedObjectType === "cable" && lockedObject) { applyLandingPointVisualState(lockedObject.userData.name, false, camera); } else if ( lockedObjectType === "satellite" && lockedSatellite ) { applyLandingPointVisualState(null, true, camera); } else if (lockedObjectType === "bgp" && lockedObject) { const relatedCableNames = getBGPRelatedCableNames(lockedObject); 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, ); } else if (lockedObjectType === "bgp_collector" && lockedObject) { clearAllCableStates(); resetLandingPointVisualState(camera); } else { resetLandingPointVisualState(camera); } updateSatellitePositions(deltaTime); updateBreathingPhase(deltaTime); updateRelatedSatelliteHighlights(); const satPositions = getSatellitePositions(); if ( lockedObjectType === "satellite" && lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex] ) { updateLockedRingPosition(satPositions[lockedSatelliteIndex].current); } else if ( hoveredSatelliteIndex !== null && satPositions?.[hoveredSatelliteIndex] ) { updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current); } 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(); if (scene) { disposeSceneObject(scene); } if (renderer) { renderer.dispose(); if (typeof renderer.forceContextLoss === "function") { renderer.forceContextLoss(); } renderer.domElement?.remove(); } scene = null; camera = null; renderer = null; initialized = false; delete window.__planetEarth; } document.addEventListener("DOMContentLoaded", init);