import * as THREE from "three"; import { createNoise3D } from "simplex-noise"; import { CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js"; import { vector3ToLatLon, screenToEarthCoords } from "./utils.js"; import { showStatusMessage, updateCoordinatesDisplay, updateZoomDisplay, updateEarthStats, setLoading, showTooltip, hideTooltip, showError, hideError, clearUiState, } from "./ui.js"; import { createEarth, createClouds, createTerrain, createStars, createGridLines, getEarth, } from "./earth.js"; import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getShowCables, clearCableData, } from "./cables.js"; import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, getShowSatellites, getSatelliteCount, selectSatellite, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, updateBreathingPhase, isSatelliteFrontFacing, setSatelliteCamera, setLockedSatelliteIndex, resetSatelliteState, clearSatelliteData, } from "./satellites.js"; import { setupControls, getAutoRotate, getShowTerrain, setAutoRotate, resetView, teardownControls, } from "./controls.js"; import { initInfoCard, showInfoCard, hideInfoCard, setInfoCardNoBorder, } from "./info-card.js"; export let scene; export let camera; export let renderer; let simplex; let isDragging = false; let previousMousePosition = { x: 0, y: 0 }; let hoveredCable = 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 earthTexture = null; let animationFrameId = null; let initialized = false; let destroyed = false; let isDataLoading = false; let currentLoadToken = 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 cleanupFns = []; function bindListener(target, eventName, handler, options) { if (!target) return; target.addEventListener(eventName, handler, options); cleanupFns.push(() => target.removeEventListener(eventName, handler, options), ); } 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; hoveredSatellite = null; hoveredSatelliteIndex = null; lockedObject = null; lockedObjectType = null; lockedSatellite = null; lockedSatelliteIndex = null; setLockedSatelliteIndex(null); } export function clearLockedObject() { hidePredictedOrbit(); clearAllCableStates(); clearCableSelection(); setSatelliteRingState(null, "none", null); clearRuntimeSelection(); } 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 showCableInfo(cable) { 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); showInfoCard("satellite", { name: props?.name || "-", norad_id: props?.norad_cat_id, inclination: props?.inclination ? props.inclination.toFixed(2) : "-", period, perigee, apogee, }); } 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) ) { 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 updateStatsSummary() { updateEarthStats({ cableCount: getCableLines().length, landingPointCount: document.getElementById("landing-point-count")?.textContent || 0, 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(); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000, ); camera.position.z = CONFIG.defaultCameraZ; setSatelliteCamera(camera); renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance", }); renderer.setSize(window.innerWidth, window.innerHeight); 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(); const earthObj = createEarth(scene); 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(); setLoading(true); clearLockedObject(); hideInfoCard(); 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([ loadGeoJSONFromPath(scene, earth), loadLandingPoints(scene, earth), (async () => { clearSatelliteData(); const satelliteCount = await loadSatellites(); const satelliteCountEl = document.getElementById("satellite-count"); if (satelliteCountEl) { satelliteCountEl.textContent = `${satelliteCount} 颗`; } updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true); toggleSatellites(true); const satBtn = document.getElementById("toggle-satellites"); if (satBtn) { satBtn.classList.add("active"); const tooltip = satBtn.querySelector(".tooltip"); if (tooltip) tooltip.textContent = "隐藏卫星"; } return satelliteCount; })(), ]); 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: "卫星", reason: results[2].reason }); } if (errors.length > 0) { const errorMessage = buildLoadErrorMessage(errors); showError(errorMessage); showStatusMessage(errorMessage, "error"); } else { hideError(); showStatusMessage("数据已重新加载", "success"); } updateStatsSummary(); 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); } function setupEventListeners() { const handleResize = () => onWindowResize(); const handleMouseMove = (event) => onMouseMove(event); const handleMouseDown = (event) => onMouseDown(event); const handleMouseUp = () => onMouseUp(); const handleClick = (event) => onClick(event); const handlePageHide = () => destroy(); bindListener(window, "resize", handleResize); bindListener(window, "pagehide", handlePageHide); bindListener(window, "beforeunload", handlePageHide); bindListener(renderer.domElement, "mousemove", handleMouseMove); bindListener(renderer.domElement, "mousedown", handleMouseDown); bindListener(renderer.domElement, "mouseup", handleMouseUp); bindListener(renderer.domElement, "mouseleave", handleMouseUp); bindListener(renderer.domElement, "click", handleClick); } function onWindowResize() { if (!camera || !renderer) return; camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } 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 onMouseMove(event) { const earth = getEarth(); if (!earth) return; if (isDragging) { if (Date.now() - dragStartTime > 500) { isLongDrag = true; } const deltaX = event.clientX - previousMousePosition.x; const deltaY = event.clientY - previousMousePosition.y; earth.rotation.y += deltaX * 0.005; earth.rotation.x += deltaY * 0.005; previousMousePosition = { x: event.clientX, y: event.clientY }; hideTooltip(); return; } updatePointerFromEvent(event); const frontCables = getFrontFacingCables(getCableLines()); const cableIntersects = interactionRaycaster.intersectObjects(frontCables); 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); } } } } if ( hoveredCable && (!cableIntersects.length || !isSameCable(cableIntersects[0]?.object, hoveredCable)) ) { if (!isSameCable(hoveredCable, lockedObject)) { setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL); } hoveredCable = null; } if ( hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== hoveredSatIndexFromIntersect ) { if (hoveredSatelliteIndex !== lockedSatelliteIndex) { setSatelliteRingState(hoveredSatelliteIndex, "none", null); } hoveredSatelliteIndex = null; } 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 === "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 { 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) { isDragging = true; dragStartTime = Date.now(); isLongDrag = false; previousMousePosition = { x: event.clientX, y: event.clientY }; document.getElementById("container")?.classList.add("dragging"); hideTooltip(); } function onMouseUp() { isDragging = false; document.getElementById("container")?.classList.remove("dragging"); } function onClick(event) { const earth = getEarth(); if (!earth) return; updatePointerFromEvent(event); const cableIntersects = interactionRaycaster.intersectObjects( getFrontFacingCables(getCableLines()), ); const satIntersects = getShowSatellites() ? interactionRaycaster.intersectObject(getSatellitePoints()) : []; 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; if (getAutoRotate() && earth) { earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16); } applyCableVisualState(); if (lockedObjectType === "cable" && lockedObject) { applyLandingPointVisualState(lockedObject.userData.name, false); } else if (lockedObjectType === "satellite" && lockedSatellite) { applyLandingPointVisualState(null, true); } else { resetLandingPointVisualState(); } updateSatellitePositions(deltaTime); updateBreathingPhase(deltaTime); 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()); 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);