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: 简化重启脚本
This commit is contained in:
rayd1o
2026-03-17 04:10:24 +08:00
parent 02991730e5
commit c82e1d5a04
26 changed files with 1770 additions and 248 deletions

View File

@@ -17,14 +17,76 @@ body {
position: relative;
width: 100vw;
height: 100vh;
/* user-select: none;
-webkit-user-select: none; */
}
#container.dragging {
cursor: grabbing;
}
/* Zoom Toolbar - Top Center */
#zoom-toolbar {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 15px;
background: rgba(10, 10, 30, 0.9);
padding: 10px 20px;
border-radius: 30px;
border: 1px solid rgba(77, 184, 255, 0.3);
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
z-index: 200;
}
#zoom-toolbar .zoom-percent {
font-size: 1.2rem;
font-weight: 600;
color: #4db8ff;
min-width: 60px;
text-align: center;
cursor: pointer;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.2s ease;
}
#zoom-toolbar .zoom-percent:hover {
background: rgba(77, 184, 255, 0.2);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
}
#zoom-toolbar .zoom-btn {
width: 36px;
height: 36px;
min-width: 36px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.2);
color: #4db8ff;
font-size: 22px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
flex: 0 0 auto;
}
#zoom-toolbar .zoom-btn:hover {
background: rgba(77, 184, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#zoom-toolbar #zoom-slider {
width: 100px;
margin-left: 10px;
}
#loading {
position: absolute;
top: 50%;
@@ -147,3 +209,128 @@ input[type="range"]::-webkit-slider-thumb {
display: none;
user-select: none;
}
/* Control Toolbar - Stellarium/Star Walk style */
#control-toolbar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
background: rgba(10, 10, 30, 0.9);
border-radius: 30px;
padding: 8px 12px;
border: 1px solid rgba(77, 184, 255, 0.3);
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
z-index: 200;
transition: all 0.3s ease;
}
#control-toolbar.collapsed {
padding: 8px 8px 8px 12px;
}
#control-toolbar.collapsed .toolbar-items {
width: 0;
padding: 0;
overflow: hidden;
opacity: 0;
}
#toolbar-toggle {
font-size: 0;
min-width: 32px;
line-height: 1;
transition: all 0.3s ease;
}
.toggle-circle {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(77, 184, 255, 0.8);
box-shadow: 0 0 8px rgba(77, 184, 255, 0.6);
}
#control-toolbar:not(.collapsed) #toolbar-toggle {
background: rgba(77, 184, 255, 0.3);
}
.toolbar-items {
display: flex;
gap: 4px;
width: auto;
padding: 0 8px 0 4px;
overflow: visible;
opacity: 1;
transition: all 0.3s ease;
border-right: 1px solid rgba(77, 184, 255, 0.3);
margin-right: 4px;
}
.toolbar-btn {
position: relative;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.15);
color: #4db8ff;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.toolbar-btn:hover {
background: rgba(77, 184, 255, 0.35);
transform: scale(1.1);
box-shadow: 0 0 15px rgba(77, 184, 255, 0.5);
}
.toolbar-btn:active {
transform: scale(0.95);
}
.toolbar-btn.active {
background: rgba(77, 184, 255, 0.4);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
}
.toolbar-btn .tooltip {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
pointer-events: none;
z-index: 100;
}
.toolbar-btn:hover .tooltip {
opacity: 1;
visibility: visible;
bottom: 52px;
}
.toolbar-btn .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}

View File

@@ -29,3 +29,33 @@
color: #4db8ff;
font-weight: 500;
}
#satellite-info {
position: absolute;
bottom: 20px;
right: 290px;
background-color: rgba(10, 10, 30, 0.9);
border-radius: 10px;
padding: 15px;
width: 220px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 229, 255, 0.3);
border: 1px solid rgba(0, 229, 255, 0.3);
font-size: 0.85rem;
backdrop-filter: blur(5px);
}
#satellite-info .stats-item {
margin-bottom: 6px;
display: flex;
justify-content: space-between;
}
#satellite-info .stats-label {
color: #aaa;
}
#satellite-info .stats-value {
color: #00e5ff;
font-weight: 500;
}

View File

@@ -95,11 +95,153 @@
#info-panel .zoom-buttons {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
gap: 15px;
margin-top: 10px;
width: 100%;
}
#info-panel .zoom-percent-container {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
#info-panel .zoom-percent {
font-size: 1.4rem;
font-weight: 600;
color: #4db8ff;
min-width: 70px;
text-align: center;
cursor: pointer;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.2s ease;
}
#info-panel .zoom-percent:hover {
background: rgba(77, 184, 255, 0.2);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
}
#info-panel .zoom-buttons .zoom-btn {
width: 36px;
height: 36px;
min-width: 36px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.2);
color: #4db8ff;
font-size: 22px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
flex: 0 0 auto;
}
#info-panel .zoom-buttons .zoom-btn:hover {
background: rgba(77, 184, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#info-panel .zoom-buttons button {
flex: 1;
min-width: 60px;
}
/* Info Card - Unified details panel (inside info-panel) */
.info-card {
margin-top: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 0;
overflow: hidden;
}
.info-card.no-border {
background: transparent;
border: none;
}
.info-card-header {
display: flex;
align-items: center;
padding: 10px 12px;
background: rgba(77, 184, 255, 0.1);
gap: 8px;
}
.info-card-icon {
font-size: 18px;
}
.info-card-header h3 {
flex: 1;
margin: 0;
font-size: 1rem;
color: #4db8ff;
}
.info-card-content {
padding: 10px 12px;
max-height: 200px;
overflow-y: auto;
}
.info-card-property {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.info-card-property:last-child {
border-bottom: none;
}
.info-card-label {
color: #aaa;
font-size: 0.85rem;
}
.info-card-value {
color: #4db8ff;
font-weight: 500;
font-size: 0.9rem;
text-align: right;
max-width: 180px;
word-break: break-word;
}
/* Cable type */
.info-card.cable {
border-color: rgba(255, 200, 0, 0.4);
}
.info-card.cable .info-card-header {
background: rgba(255, 200, 0, 0.15);
}
.info-card.cable .info-card-header h3 {
color: #ffc800;
}
/* Satellite type */
.info-card.satellite {
border-color: rgba(0, 229, 255, 0.4);
}
.info-card.satellite .info-card-header {
background: rgba(0, 229, 255, 0.15);
}
.info-card.satellite .info-card-header h3 {
color: #00e5ff;
}

View File

@@ -3,12 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D球形地图 - 海底电缆系统</title>
<title>智能星球计划 - 现实层宇宙全息感知</title>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1"
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
}
}
</script>
@@ -20,58 +21,40 @@
</head>
<body>
<div id="container">
<div id="zoom-toolbar">
<button id="zoom-out" class="zoom-btn"></button>
<span id="zoom-value" class="zoom-percent">100%</span>
<button id="zoom-in" class="zoom-btn">+</button>
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.01" value="1">
</div>
<div id="info-panel">
<h1>全球海底电缆系统</h1>
<div class="subtitle">3D地形球形地图可视化 | 高分辨率卫星图</div>
<div class="zoom-controls">
<div style="width: 100%;">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">缩放控制</h3>
<div class="zoom-buttons">
<button id="zoom-in">放大</button>
<button id="zoom-out">缩小</button>
<button id="zoom-reset">重置</button>
</div>
<div class="slider-container" style="margin-top: 10px;">
<div class="slider-label">
<span>缩放级别:</span>
<span id="zoom-value">1.0x</span>
</div>
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.1" value="1">
</div>
<h1>智能星球计划</h1>
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
<div id="info-card" class="info-card" style="display: none;">
<div class="info-card-header">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
</div>
<div id="info-card-content"></div>
</div>
<div id="cable-details" class="cable-info">
<h3 id="cable-name">点击电缆查看详情</h3>
<div class="cable-property">
<span class="property-label">所有者:</span>
<span id="cable-owner" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">状态:</span>
<span id="cable-status" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">长度:</span>
<span id="cable-length" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">经纬度:</span>
<span id="cable-coords" class="property-value">-</span>
</div>
<div class="cable-property">
<span class="property-label">投入使用时间:</span>
<span id="cable-rfs" class="property-value">-</span>
</div>
</div>
<div class="controls">
<button id="rotate-toggle">暂停旋转</button>
<button id="reset-view">重置视图</button>
<button id="toggle-terrain">显示地形</button>
<button id="reload-data">重新加载数据</button>
</div>
<div id="error-message" class="error-message"></div>
</div>
<div id="control-toolbar">
<div class="toolbar-items">
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">🔄<span class="tooltip">自动旋转</span></button>
<button id="reset-view" class="toolbar-btn" title="重置视图">🎯<span class="tooltip">重置视图</span></button>
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">⛰️<span class="tooltip">显示/隐藏地形</span></button>
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">🛰️<span class="tooltip">显示/隐藏卫星</span></button>
<button id="toggle-trails" class="toolbar-btn" title="显示/隐藏轨迹"><span class="tooltip">显示/隐藏轨迹</span></button>
<button id="reload-data" class="toolbar-btn" title="重新加载数据">🔃<span class="tooltip">重新加载数据</span></button>
</div>
<button id="toolbar-toggle" class="toolbar-btn" title="展开/收起工具栏"><span class="toggle-circle"></span></button>
</div>
<div id="coordinates-display">
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
<div class="coord-item">
@@ -124,6 +107,10 @@
<span class="stats-label">地形:</span>
<span class="stats-value" id="terrain-status">开启</span>
</div>
<div class="stats-item">
<span class="stats-label">卫星:</span>
<span class="stats-value" id="satellite-count">0 颗</span>
</div>
<div class="stats-item">
<span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span>

View File

@@ -4,7 +4,8 @@ import * as THREE from 'three';
import { CONFIG, CABLE_COLORS, PATHS } from './constants.js';
import { latLonToVector3 } from './utils.js';
import { updateCableDetails, updateEarthStats, showStatusMessage } from './ui.js';
import { updateEarthStats, showStatusMessage } from './ui.js';
import { showInfoCard } from './info-card.js';
export let cableLines = [];
export let landingPoints = [];
@@ -312,8 +313,7 @@ export function handleCableClick(cable) {
lockedCable = cable;
const data = cable.userData;
// console.log(data)
updateCableDetails({
showInfoCard('cable', {
name: data.name,
owner: data.owner,
status: data.status,
@@ -327,14 +327,6 @@ export function handleCableClick(cable) {
export function clearCableSelection() {
lockedCable = null;
updateCableDetails({
name: '点击电缆查看详情',
owner: '-',
status: '-',
length: '-',
coords: '-',
rfs: '-'
});
}
export function getCableLines() {

View File

@@ -3,6 +3,8 @@
import { CONFIG } from './constants.js';
import { updateZoomDisplay, showStatusMessage } from './ui.js';
import { toggleTerrain } from './earth.js';
import { reloadData } from './main.js';
import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js';
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -20,20 +22,86 @@ export function setupControls(camera, renderer, scene, earth) {
}
function setupZoomControls(camera) {
document.getElementById('zoom-in').addEventListener('click', () => {
zoomLevel = Math.min(zoomLevel + 0.5, CONFIG.maxZoom);
applyZoom(camera);
});
let zoomInterval = null;
let lastDirection = 0;
let isSnapped = false;
document.getElementById('zoom-out').addEventListener('click', () => {
zoomLevel = Math.max(zoomLevel - 0.5, CONFIG.minZoom);
applyZoom(camera);
});
const MIN_PERCENT = CONFIG.minZoom * 100;
const MAX_PERCENT = CONFIG.maxZoom * 100;
document.getElementById('zoom-reset').addEventListener('click', () => {
zoomLevel = 1.0;
function adjustZoom(direction) {
const currentPercent = Math.round(zoomLevel * 100);
let newPercent;
if (direction > 0) {
if (!isSnapped || currentPercent % 10 !== 0) {
newPercent = Math.ceil(currentPercent / 10) * 10;
if (newPercent <= currentPercent) newPercent = currentPercent + 10;
isSnapped = true;
} else {
newPercent = currentPercent + 10;
}
} else {
if (!isSnapped || currentPercent % 10 !== 0) {
newPercent = Math.floor(currentPercent / 10) * 10;
if (newPercent >= currentPercent) newPercent = currentPercent - 10;
isSnapped = true;
} else {
newPercent = currentPercent - 10;
}
}
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
zoomLevel = newPercent / 100;
applyZoom(camera);
showStatusMessage('缩放已重置', 'info');
}
function startZoom(direction) {
isSnapped = false;
lastDirection = direction;
adjustZoom(direction);
zoomInterval = setInterval(() => {
adjustZoom(direction);
}, 150);
}
function stopZoom() {
if (zoomInterval) {
clearInterval(zoomInterval);
zoomInterval = null;
}
}
document.getElementById('zoom-in').addEventListener('mousedown', () => startZoom(1));
document.getElementById('zoom-in').addEventListener('mouseup', stopZoom);
document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom);
document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); startZoom(1); });
document.getElementById('zoom-in').addEventListener('touchend', stopZoom);
document.getElementById('zoom-out').addEventListener('mousedown', () => startZoom(-1));
document.getElementById('zoom-out').addEventListener('mouseup', stopZoom);
document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom);
document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); startZoom(-1); });
document.getElementById('zoom-out').addEventListener('touchend', stopZoom);
document.getElementById('zoom-value').addEventListener('click', function() {
const startZoom = zoomLevel;
const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoom;
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
animateValue(0, 1, 600, (progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
const distance = startDistance + (targetDistance - startDistance) * ease;
updateZoomDisplay(zoomLevel, distance.toFixed(0));
}, () => {
zoomLevel = 1.0;
showStatusMessage('缩放已重置到100%', 'info');
});
});
const slider = document.getElementById('zoom-slider');
@@ -111,13 +179,12 @@ export function resetView(camera) {
}
function setupRotateControls(camera, earth) {
document.getElementById('rotate-toggle').addEventListener('click', () => {
document.getElementById('rotate-toggle').addEventListener('click', function() {
toggleAutoRotate();
const isOn = autoRotate;
showStatusMessage(isOn ? '自动旋转已开启' : '自动旋转已暂停', 'info');
showStatusMessage(autoRotate ? '自动旋转已开启' : '自动旋转已暂停', 'info');
});
document.getElementById('reset-view').addEventListener('click', () => {
document.getElementById('reset-view').addEventListener('click', function() {
if (!earthObj) return;
const startRotX = earthObj.rotation.x;
@@ -143,18 +210,45 @@ function setupRotateControls(camera, earth) {
}
function setupTerrainControls() {
document.getElementById('toggle-terrain').addEventListener('click', () => {
document.getElementById('toggle-terrain').addEventListener('click', function() {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
const btn = document.getElementById('toggle-terrain');
btn.textContent = showTerrain ? '隐藏地形' : '显示地形';
this.classList.toggle('active', showTerrain);
this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
});
document.getElementById('reload-data').addEventListener('click', () => {
showStatusMessage('重新加载数据...', 'info');
window.location.reload();
document.getElementById('toggle-satellites').addEventListener('click', function() {
const showSats = !getShowSatellites();
toggleSatellites(showSats);
this.classList.toggle('active', showSats);
this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星';
document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗';
showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info');
});
document.getElementById('toggle-trails').addEventListener('click', function() {
const isActive = this.classList.contains('active');
const showTrails = !isActive;
toggleTrails(showTrails);
this.classList.toggle('active', showTrails);
this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹';
showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info');
});
document.getElementById('reload-data').addEventListener('click', async () => {
await reloadData();
showStatusMessage('数据已重新加载', 'success');
});
const toolbarToggle = document.getElementById('toolbar-toggle');
const toolbar = document.getElementById('control-toolbar');
if (toolbarToggle && toolbar) {
toolbarToggle.addEventListener('click', () => {
toolbar.classList.toggle('collapsed');
});
}
}
function setupMouseControls(camera, renderer) {
@@ -192,7 +286,9 @@ export function setAutoRotate(value) {
autoRotate = value;
const btn = document.getElementById('rotate-toggle');
if (btn) {
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
btn.classList.toggle('active', value);
const tooltip = btn.querySelector('.tooltip');
if (tooltip) tooltip.textContent = value ? '暂停旋转' : '自动旋转';
}
}
@@ -200,7 +296,9 @@ export function toggleAutoRotate() {
autoRotate = !autoRotate;
const btn = document.getElementById('rotate-toggle');
if (btn) {
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
btn.classList.toggle('active', autoRotate);
const tooltip = btn.querySelector('.tooltip');
if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '自动旋转';
}
if (window.clearLockedCable) {
window.clearLockedCable();

View File

@@ -0,0 +1,121 @@
// info-card.js - Unified info card module
let currentType = null;
const CARD_CONFIG = {
cable: {
icon: '🛥️',
title: '电缆详情',
className: 'cable',
fields: [
{ key: 'name', label: '名称' },
{ key: 'owner', label: '所有者' },
{ key: 'status', label: '状态' },
{ key: 'length', label: '长度' },
{ key: 'coords', label: '经纬度' },
{ key: 'rfs', label: '投入使用' }
]
},
satellite: {
icon: '🛰️',
title: '卫星详情',
className: 'satellite',
fields: [
{ key: 'name', label: '名称' },
{ key: 'norad_id', label: 'NORAD ID' },
{ key: 'inclination', label: '倾角', unit: '°' },
{ key: 'period', label: '周期', unit: '分钟' },
{ key: 'perigee', label: '近地点', unit: 'km' },
{ key: 'apogee', label: '远地点', unit: 'km' }
]
},
supercomputer: {
icon: '🖥️',
title: '超算详情',
className: 'supercomputer',
fields: [
{ key: 'name', label: '名称' },
{ key: 'rank', label: '排名' },
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' }
]
},
gpu_cluster: {
icon: '🎮',
title: 'GPU集群详情',
className: 'gpu_cluster',
fields: [
{ key: 'name', label: '名称' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' }
]
}
};
export function initInfoCard() {
// Close button removed - now uses external clear button
}
export function setInfoCardNoBorder(noBorder = true) {
const card = document.getElementById('info-card');
if (card) {
card.classList.toggle('no-border', noBorder);
}
}
export function showInfoCard(type, data) {
const config = CARD_CONFIG[type];
if (!config) {
console.warn('Unknown info card type:', type);
return;
}
currentType = type;
const card = document.getElementById('info-card');
const icon = document.getElementById('info-card-icon');
const title = document.getElementById('info-card-title');
const content = document.getElementById('info-card-content');
card.className = 'info-card ' + config.className;
icon.textContent = config.icon;
title.textContent = config.title;
let html = '';
for (const field of config.fields) {
let value = data[field.key];
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') {
value = value + ' ' + field.unit;
}
html += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
card.style.display = 'block';
}
export function hideInfoCard() {
const card = document.getElementById('info-card');
if (card) {
card.style.display = 'none';
}
currentType = null;
}
export function getCurrentType() {
return currentType;
}

View File

@@ -8,14 +8,15 @@ import {
updateCoordinatesDisplay,
updateZoomDisplay,
updateEarthStats,
updateCableDetails,
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;
@@ -49,11 +50,13 @@ export function init() {
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);
@@ -80,7 +83,19 @@ function addLights() {
scene.add(pointLight);
}
async function loadData() {
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('开始加载电缆数据...');
@@ -88,11 +103,29 @@ async function loadData() {
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) {
@@ -159,7 +192,7 @@ function onMouseMove(event, camera) {
});
hoveredCable = null;
}
if (intersects.length > 0) {
const cable = intersects[0].object;
const cableId = cable.userData.cableId;
@@ -171,34 +204,25 @@ function onMouseMove(event, camera) {
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;
document.getElementById('cable-name').textContent =
userData.name || userData.shortname || '未命名电缆';
document.getElementById('cable-owner').textContent = userData.owner || '-';
document.getElementById('cable-status').textContent = userData.status || '-';
document.getElementById('cable-length').textContent = userData.length || '-';
document.getElementById('cable-coords').textContent = '-';
document.getElementById('cable-rfs').textContent = userData.rfs || '-';
hideTooltip();
} else {
if (lockedCable && lockedCableData) {
document.getElementById('cable-name').textContent =
lockedCableData.name || lockedCableData.shortname || '未命名电缆';
document.getElementById('cable-owner').textContent = lockedCableData.owner || '-';
document.getElementById('cable-status').textContent = lockedCableData.status || '-';
document.getElementById('cable-length').textContent = lockedCableData.length || '-';
document.getElementById('cable-coords').textContent = '-';
document.getElementById('cable-rfs').textContent = lockedCableData.rfs || '-';
if (lockedCable) {
handleCableClick(lockedCable);
} else {
document.getElementById('cable-name').textContent = '点击电缆查看详情';
document.getElementById('cable-owner').textContent = '-';
document.getElementById('cable-status').textContent = '-';
document.getElementById('cable-length').textContent = '-';
document.getElementById('cable-coords').textContent = '-';
document.getElementById('cable-rfs').textContent = '-';
hideInfoCard();
}
const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
@@ -278,6 +302,45 @@ function onClick(event, camera, renderer) {
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;
@@ -315,6 +378,8 @@ function animate() {
});
}
updateSatellitePositions(16);
renderer.render(scene, camera);
}
@@ -334,4 +399,9 @@ window.clearLockedCable = function() {
clearCableSelection();
};
window.clearSelection = function() {
hideInfoCard();
window.clearLockedCable();
};
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,321 @@
// satellites.js - Satellite visualization module with real SGP4 positions and animations
import * as THREE from 'three';
import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js';
import { CONFIG } from './constants.js';
let satellitePoints = null;
let satelliteTrails = null;
let satelliteData = [];
let showSatellites = false;
let showTrails = true;
let animationTime = 0;
let selectedSatellite = null;
let satellitePositions = [];
const SATELLITE_API = '/api/v1/visualization/geo/satellites?limit=2000';
const MAX_SATELLITES = 500;
const TRAIL_LENGTH = 30;
export function createSatellites(scene, earthObj) {
const positions = new Float32Array(MAX_SATELLITES * 3);
const colors = new Float32Array(MAX_SATELLITES * 3);
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const pointsMaterial = new THREE.PointsMaterial({
size: 3,
vertexColors: true,
transparent: true,
opacity: 0.9,
sizeAttenuation: true
});
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false;
satellitePoints.userData = { type: 'satellitePoints' };
earthObj.add(satellitePoints);
const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3);
const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3);
const trailGeometry = new THREE.BufferGeometry();
trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3));
const trailMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending
});
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
satelliteTrails.visible = false;
satelliteTrails.userData = { type: 'satelliteTrails' };
earthObj.add(satelliteTrails);
satellitePositions = [];
for (let i = 0; i < MAX_SATELLITES; i++) {
satellitePositions.push({
current: new THREE.Vector3(),
trail: []
});
}
return satellitePoints;
}
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const noradId = props.norad_cat_id;
const inclination = props.inclination || 53;
const raan = props.raan || 0;
const eccentricity = props.eccentricity || 0.0001;
const argOfPerigee = props.arg_of_perigee || 0;
const meanAnomaly = props.mean_anomaly || 0;
const meanMotion = props.mean_motion || 15;
const epoch = props.epoch || '';
const year = epoch && epoch.length >= 4 ? parseInt(epoch.substring(0, 4)) : time.getUTCFullYear();
const month = epoch && epoch.length >= 7 ? parseInt(epoch.substring(5, 7)) : time.getUTCMonth() + 1;
const day = epoch && epoch.length >= 10 ? parseInt(epoch.substring(8, 10)) : time.getUTCDate();
const tleLine1 = `1 ${String(noradId).padStart(5, '0')}U 00001A ${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}.00000000 .00000000 00000-0 00000-0 0 9999`;
const tleLine2 = `2 ${String(noradId).padStart(5, '0')} ${String(raan.toFixed(4)).padStart(8, ' ')} ${String(inclination.toFixed(4)).padStart(8, ' ')} ${String(eccentricity.toFixed(7)).replace('0.', '.')} ${String(argOfPerigee.toFixed(4)).padStart(8, ' ')} ${String(meanAnomaly.toFixed(4)).padStart(8, ' ')} ${String(meanMotion.toFixed(8)).padStart(11, ' ')} 0 9999`;
const satrec = twoline2satrec(tleLine1, tleLine2);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
const x = positionAndVelocity.position.x;
const y = positionAndVelocity.position.y;
const z = positionAndVelocity.position.z;
if (!x || !y || !z) {
return null;
}
const r = Math.sqrt(x * x + y * y + z * z);
const earthRadius = 6371;
const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05;
const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale);
} catch (e) {
return null;
}
}
function generateFallbackPosition(satellite, index, total) {
const radius = CONFIG.earthRadius + 5;
const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
const hash = String(noradId).split('').reduce((a, b) => a + b.charCodeAt(0), 0);
const randomOffset = (hash % 1000) / 1000;
const normalizedIndex = index / total;
const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180);
const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1);
const adjustedPhi = Math.abs(phi % Math.PI);
const adjustedTheta = theta + randomOffset * Math.PI * 2;
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
const y = radius * Math.cos(adjustedPhi);
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
return new THREE.Vector3(x, y, z);
}
export async function loadSatellites() {
try {
const response = await fetch(SATELLITE_API);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
satelliteData = data.features || [];
console.log(`Loaded ${satelliteData.length} satellites`);
return satelliteData;
} catch (error) {
console.error('Failed to load satellites:', error);
return [];
}
}
export function updateSatellitePositions(deltaTime = 0) {
if (!satellitePoints || satelliteData.length === 0) return;
animationTime += deltaTime * 0.001;
const positions = satellitePoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array;
const trailPositions = satelliteTrails.geometry.attributes.position.array;
const trailColors = satelliteTrails.geometry.attributes.color.array;
const baseTime = new Date();
const count = Math.min(satelliteData.length, 500);
for (let i = 0; i < count; i++) {
const satellite = satelliteData[i];
const props = satellite.properties;
const timeOffset = (i / count) * 2 * Math.PI * 0.1;
const adjustedTime = new Date(baseTime.getTime() + timeOffset * 1000 * 60 * 10);
let pos = computeSatellitePosition(satellite, adjustedTime);
if (!pos) {
pos = generateFallbackPosition(satellite, i, count);
}
satellitePositions[i].current.copy(pos);
satellitePositions[i].trail.push(pos.clone());
if (satellitePositions[i].trail.length > TRAIL_LENGTH) {
satellitePositions[i].trail.shift();
}
positions[i * 3] = pos.x;
positions[i * 3 + 1] = pos.y;
positions[i * 3 + 2] = pos.z;
const inclination = props?.inclination || 53;
const name = props?.name || '';
const isStarlink = name.includes('STARLINK');
const isGeo = inclination > 20 && inclination < 30;
const isIridium = name.includes('IRIDIUM');
let r, g, b;
if (isStarlink) {
r = 0.0; g = 0.9; b = 1.0;
} else if (isGeo) {
r = 1.0; g = 0.8; b = 0.0;
} else if (isIridium) {
r = 1.0; g = 0.5; b = 0.0;
} else if (inclination > 50 && inclination < 70) {
r = 0.0; g = 1.0; b = 0.3;
} else {
r = 1.0; g = 1.0; b = 1.0;
}
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
const trail = satellitePositions[i].trail;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
if (j < trail.length) {
const t = trail[j];
trailPositions[trailIdx] = t.x;
trailPositions[trailIdx + 1] = t.y;
trailPositions[trailIdx + 2] = t.z;
const alpha = j / trail.length;
trailColors[trailIdx] = r * alpha;
trailColors[trailIdx + 1] = g * alpha;
trailColors[trailIdx + 2] = b * alpha;
} else {
trailPositions[trailIdx] = 0;
trailPositions[trailIdx + 1] = 0;
trailPositions[trailIdx + 2] = 0;
trailColors[trailIdx] = 0;
trailColors[trailIdx + 1] = 0;
trailColors[trailIdx + 2] = 0;
}
}
}
for (let i = count; i < 2000; i++) {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
trailPositions[trailIdx] = 0;
trailPositions[trailIdx + 1] = 0;
trailPositions[trailIdx + 2] = 0;
}
}
satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count);
satelliteTrails.geometry.attributes.position.needsUpdate = true;
satelliteTrails.geometry.attributes.color.needsUpdate = true;
}
export function toggleSatellites(visible) {
showSatellites = visible;
if (satellitePoints) {
satellitePoints.visible = visible;
}
if (satelliteTrails) {
satelliteTrails.visible = visible && showTrails;
}
}
export function toggleTrails(visible) {
showTrails = visible;
if (satelliteTrails) {
satelliteTrails.visible = visible && showSatellites;
}
}
export function getShowSatellites() {
return showSatellites;
}
export function getSatelliteCount() {
return satelliteData.length;
}
export function getSatelliteAt(index) {
if (index >= 0 && index < satelliteData.length) {
return satelliteData[index];
}
return null;
}
export function getSatelliteData() {
return satelliteData;
}
export function selectSatellite(index) {
selectedSatellite = index;
return getSatelliteAt(index);
}
export function getSelectedSatellite() {
return selectedSatellite;
}
export function getSatellitePoints() {
return satellitePoints;
}

View File

@@ -22,8 +22,9 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) {
// Update zoom display
export function updateZoomDisplay(zoomLevel, distance) {
document.getElementById('zoom-value').textContent = zoomLevel.toFixed(1) + 'x';
document.getElementById('zoom-level').textContent = '缩放: ' + zoomLevel.toFixed(1) + 'x';
const percent = Math.round(zoomLevel * 100);
document.getElementById('zoom-value').textContent = percent + '%';
document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
document.getElementById('zoom-slider').value = zoomLevel;
document.getElementById('camera-distance').textContent = distance + ' km';
}

View File

@@ -20,7 +20,11 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
export function vector3ToLatLon(vector) {
const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI);
const lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
while (lon <= -180) lon += 360;
while (lon > 180) lon -= 360;
return {
lat: parseFloat(lat.toFixed(4)),
@@ -30,26 +34,43 @@ export function vector3ToLatLon(vector) {
}
// Convert screen coordinates to Earth surface 3D coordinates
export function screenToEarthCoords(x, y, camera, earth) {
export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) {
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(
(x / window.innerWidth) * 2 - 1,
-(y / window.innerHeight) * 2 + 1
);
const mouse = new THREE.Vector2();
if (domElement === document.body) {
mouse.x = (clientX / window.innerWidth) * 2 - 1;
mouse.y = -(clientY / window.innerHeight) * 2 + 1;
} else {
const rect = domElement.getBoundingClientRect();
mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
}
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(earth);
if (intersects.length > 0) {
return intersects[0].point;
const localPoint = intersects[0].point.clone();
earth.worldToLocal(localPoint);
return localPoint;
}
return null;
}
// Calculate simplified distance between two points
export function calculateDistance(lat1, lon1, lat2, lon2) {
const dx = lon2 - lon1;
const dy = lat2 - lat1;
return Math.sqrt(dx * dx + dy * dy);
// Calculate accurate spherical distance between two points (Haversine formula)
export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) {
const toRad = (angle) => (angle * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return radius * c;
}