Files
planet/frontend/public/earth/js/main.js
rayd1o c82e1d5a04 fix: 修复3D地球坐标映射多个严重bug
## Bug修复详情

### 1. 致命错误:球面距离计算 (calculateDistance)
- 问题:使用勾股定理计算经纬度距离,在球体表面完全错误
- 修复:改用Haversine公式计算球面大圆距离
- 影响:赤道1度=111km,极地1度=19km,原计算误差巨大

### 2. 经度范围规范化 (vector3ToLatLon)
- 问题:Math.atan2返回[-180°,180°],转换后可能超出标准范围
- 修复:添加while循环规范化到[-180, 180]区间
- 影响:避免本初子午线附近返回360°的异常值

### 3. 屏幕坐标转换支持非全屏 (screenToEarthCoords)
- 问题:假设Canvas永远全屏,非全屏时点击偏移严重
- 修复:新增domElement参数,使用getBoundingClientRect()计算相对坐标
- 影响:嵌入式3D地球组件也能精准拾取

### 4. 地球旋转时经纬度映射错误
- 问题:Raycaster返回世界坐标,未考虑地球自转
- 修复:使用earth.worldToLocal()转换到本地坐标空间
- 影响:地球旋转时经纬度显示正确跟随

## 新增功能

- CelesTrak卫星数据采集器
- Space-Track卫星数据采集器
- 卫星可视化模块(500颗,实时SGP4轨道计算)
- 海底光缆悬停显示info-card
- 统一info-card组件
- 工具栏按钮(Stellarium风格)
- 缩放控制(百分比显示)
- Docker volume映射(代码热更新)

## 文件变更

- utils.js: 坐标转换核心逻辑修复
- satellites.js: 新增卫星可视化
- cables.js: 悬停交互支持
- main.js: 悬停/锁定逻辑
- controls.js: 工具栏UI
- info-card.js: 统一卡片组件
- docker-compose.yml: volume映射
- restart.sh: 简化重启脚本
2026-03-17 04:10:24 +08:00

408 lines
12 KiB
JavaScript

import * as THREE from 'three';
import { createNoise3D } from 'simplex-noise';
import { CONFIG } from './constants.js';
import { latLonToVector3, vector3ToLatLon, screenToEarthCoords } from './utils.js';
import {
showStatusMessage,
updateCoordinatesDisplay,
updateZoomDisplay,
updateEarthStats,
setLoading,
showTooltip,
hideTooltip
} from './ui.js';
import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById } from './cables.js';
import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, selectSatellite, getSatelliteData, getSatellitePoints } from './satellites.js';
import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate } from './controls.js';
import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js';
export let scene, camera, renderer;
let simplex;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
let hoveredCable = null;
let lockedCable = null;
let lockedCableData = null;
window.addEventListener('error', (e) => {
console.error('全局错误:', e.error);
showStatusMessage('加载错误: ' + e.error?.message, 'error');
});
window.addEventListener('unhandledrejection', (e) => {
console.error('未处理的Promise错误:', e.reason);
});
export function init() {
simplex = createNoise3D();
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = CONFIG.defaultCameraZ;
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);
document.getElementById('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);
setupEventListeners(camera, renderer);
loadData();
animate();
}
function addLights() {
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
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);
}
let earthTexture = null;
async function loadData(showWhiteSphere = false) {
if (showWhiteSphere) {
const earth = getEarth();
if (earth && earth.material) {
earthTexture = earth.material.map;
earth.material.map = null;
earth.material.color.setHex(0xffffff);
earth.material.needsUpdate = true;
}
}
setLoading(true);
try {
console.log('开始加载电缆数据...');
await loadGeoJSONFromPath(scene, getEarth());
console.log('电缆数据加载完成');
await loadLandingPoints(scene, getEarth());
console.log('登陆点数据加载完成');
const satCount = await loadSatellites();
console.log(`卫星数据加载完成: ${satCount}`);
updateSatellitePositions();
console.log('卫星位置已更新');
} catch (error) {
console.error('加载数据失败:', error);
showStatusMessage('加载数据失败: ' + error.message, 'error');
}
setLoading(false);
if (showWhiteSphere) {
const earth = getEarth();
if (earth && earth.material) {
earth.material.map = earthTexture;
earth.material.color.setHex(0xffffff);
earth.material.needsUpdate = true;
}
}
}
export async function reloadData() {
await loadData(true);
}
function setupEventListeners(camera, renderer) {
window.addEventListener('resize', () => onWindowResize(camera, renderer));
renderer.domElement.addEventListener('mousemove', (e) => onMouseMove(e, camera));
renderer.domElement.addEventListener('mousedown', onMouseDown);
renderer.domElement.addEventListener('mouseup', onMouseUp);
renderer.domElement.addEventListener('click', (e) => onClick(e, camera, renderer));
}
function onWindowResize(camera, renderer) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function getFrontFacingCables(cableLines, camera) {
const earth = getEarth();
if (!earth) return cableLines;
const cameraDir = new THREE.Vector3();
camera.getWorldDirection(cameraDir);
return cableLines.filter(cable => {
const cablePos = new THREE.Vector3();
cable.geometry.computeBoundingBox();
const boundingBox = cable.geometry.boundingBox;
if (boundingBox) {
boundingBox.getCenter(cablePos);
cable.localToWorld(cablePos);
}
const toCamera = new THREE.Vector3().subVectors(camera.position, earth.position).normalize();
const toCable = new THREE.Vector3().subVectors(cablePos, earth.position).normalize();
return toCamera.dot(toCable) > 0;
});
}
function onMouseMove(event, camera) {
const earth = getEarth();
if (!earth) return;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
const allCableLines = getCableLines();
const frontCables = getFrontFacingCables(allCableLines, camera);
const intersects = raycaster.intersectObjects(frontCables);
if (hoveredCable && hoveredCable !== lockedCable) {
const prevCableId = hoveredCable.userData.cableId;
const prevSameCables = getCablesById(prevCableId);
prevSameCables.forEach(c => {
if (c.userData.originalColor !== undefined) {
c.material.color.setHex(c.userData.originalColor);
}
});
hoveredCable = null;
}
if (intersects.length > 0) {
const cable = intersects[0].object;
const cableId = cable.userData.cableId;
const sameCables = getCablesById(cableId);
if (cable !== lockedCable) {
sameCables.forEach(c => {
c.material.color.setHex(0xffffff);
c.material.opacity = 1;
});
hoveredCable = 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
});
setInfoCardNoBorder(true);
}
const userData = cable.userData;
hideTooltip();
} else {
if (lockedCable) {
handleCableClick(lockedCable);
} else {
hideInfoCard();
}
const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
if (!isDragging) {
showTooltip(event.clientX + 10, event.clientY + 10,
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${coords.alt.toFixed(1)} km`);
}
}
}
if (isDragging) {
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 };
}
}
function onMouseDown(event) {
isDragging = true;
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, camera, renderer) {
const earth = getEarth();
if (!earth) return;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
const allCableLines = getCableLines();
const frontCables = getFrontFacingCables(allCableLines, camera);
const intersects = raycaster.intersectObjects(frontCables);
if (intersects.length > 0) {
if (lockedCable) {
const prevCableId = lockedCable.userData.cableId;
const prevSameCables = getCablesById(prevCableId);
prevSameCables.forEach(c => {
if (c.userData.originalColor !== undefined) {
c.material.color.setHex(c.userData.originalColor);
}
});
}
const clickedCable = intersects[0].object;
const cableId = clickedCable.userData.cableId;
const sameCables = getCablesById(cableId);
sameCables.forEach(c => {
c.material.color.setHex(0xffffff);
c.material.opacity = 1;
});
lockedCable = clickedCable;
lockedCableData = { ...clickedCable.userData };
setAutoRotate(false);
handleCableClick(clickedCable);
showInfoCard('cable', {
name: clickedCable.userData.name,
owner: clickedCable.userData.owner,
status: clickedCable.userData.status,
length: clickedCable.userData.length,
coords: clickedCable.userData.coords,
rfs: clickedCable.userData.rfs
});
} else if (getShowSatellites()) {
const satIntersects = raycaster.intersectObject(getSatellitePoints());
if (satIntersects.length > 0) {
const index = satIntersects[0].index;
const sat = selectSatellite(index);
if (sat && sat.properties) {
const props = sat.properties;
const meanMotion = props.mean_motion || 0;
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-';
const ecc = props.eccentricity || 0;
const earthRadius = 6371;
const perigee = (earthRadius * (1 - ecc)).toFixed(0);
const apogee = (earthRadius * (1 + ecc)).toFixed(0);
showInfoCard('satellite', {
name: props.name,
norad_id: props.norad_cat_id,
inclination: props.inclination ? props.inclination.toFixed(2) : '-',
period: period,
perigee: perigee,
apogee: apogee
});
showStatusMessage('已选择: ' + props.name, 'info');
}
}
} else {
if (lockedCable) {
const prevCableId = lockedCable.userData.cableId;
const prevSameCables = getCablesById(prevCableId);
prevSameCables.forEach(c => {
if (c.userData.originalColor !== undefined) {
c.material.color.setHex(c.userData.originalColor);
}
});
lockedCable = null;
lockedCableData = null;
}
setAutoRotate(true);
clearCableSelection();
}
}
function animate() {
requestAnimationFrame(animate);
const earth = getEarth();
if (getAutoRotate() && earth) {
earth.rotation.y += CONFIG.rotationSpeed;
}
if (lockedCable) {
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
const glowIntensity = 0.7 + pulse * 0.3;
const cableId = lockedCable.userData.cableId;
const sameCables = getCablesById(cableId);
sameCables.forEach(c => {
c.material.opacity = 0.6 + pulse * 0.4;
c.material.color.setRGB(glowIntensity, glowIntensity, glowIntensity);
});
}
updateSatellitePositions(16);
renderer.render(scene, camera);
}
window.clearLockedCable = function() {
if (lockedCable) {
const cableId = lockedCable.userData.cableId;
const sameCables = getCablesById(cableId);
sameCables.forEach(c => {
if (c.userData.originalColor !== undefined) {
c.material.color.setHex(c.userData.originalColor);
c.material.opacity = 1.0;
}
});
lockedCable = null;
lockedCableData = null;
}
clearCableSelection();
};
window.clearSelection = function() {
hideInfoCard();
window.clearLockedCable();
};
document.addEventListener('DOMContentLoaded', init);