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,
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,
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 targetRotation = { x: 0, y: 0 };
let inertialVelocity = { 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 = [];
const DRAG_ROTATION_FACTOR = 0.005;
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
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);
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
? "重新同步卫星、海底光缆与登陆点数据"
: "同步卫星、海底光缆与登陆点数据",
);
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 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 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;
const rotationDeltaY = deltaX * DRAG_ROTATION_FACTOR;
const rotationDeltaX = deltaY * DRAG_ROTATION_FACTOR;
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);
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) {
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;
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;
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();
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);