872 lines
23 KiB
JavaScript
872 lines
23 KiB
JavaScript
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 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();
|
||
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}°<br>经度: ${coords.lon}°<br>海拔: ${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;
|
||
|
||
if (getAutoRotate() && earth) {
|
||
earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16);
|
||
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);
|