feat(earth): Modularize 3D Earth page with ES Modules

## Changelog

### New Features
- Modularized 3D earth HTML page from single 1918-line file into ES Modules
- Split CSS into separate module files (base, info-panel, coordinates-display, legend, earth-stats)
- Split JS into separate modules (constants, utils, ui, earth, cables, controls, main)

### 3D Earth Rendering
- Use Three.js r128 (via esm.sh CDN) for color consistency with original
- Earth with 8K satellite texture and proper material settings
- Cloud layer with transparency and additive blending
- Starfield background (8000 stars)
- Latitude/longitude grid lines that rotate with Earth

### Cable System
- Load cable data from geo.json with great circle path calculation
- Support for MultiLineString and LineString geometry types
- Cable color from geo.json properties.color field
- Landing points loading from landing-point-geo.geojson

### User Interactions
- Mouse hover: highlight cable and show details
- Mouse click: lock cable with pulsing glow effect
- Click cable to pause rotation, click elsewhere to resume
- Click rotation toggle button to resume rotation and clear highlight
- Reset view with smooth animation (800ms cubic ease-out)
- Mouse wheel zoom support
- Drag to rotate Earth

### UI/UX Improvements
- Tooltip shows latitude, longitude, and altitude
- Prevent tooltip text selection during drag
- Hide tooltip during drag operation
- Blue border tooltip styling matching original
- Cursor changes to grabbing during drag
- Front-facing cable detection (only detect cables facing camera)

### Bug Fixes
- Grid lines now rotate with Earth (added as Earth child)
- Reset view button now works correctly
- Fixed camera reference in reset view
- Fixed autoRotate state management when clearing locked cable

### Original HTML
- Copied original 3dearthmult.html to public folder for reference
This commit is contained in:
rayd1o
2026-03-11 15:54:50 +08:00
parent 4ada75ca14
commit 6cb4398f3a
15 changed files with 1805 additions and 44 deletions

View File

@@ -11,7 +11,9 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-resizable": "^3.1.3", "react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"three": "^0.160.0",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1009,14 +1009,14 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
); );
} }
// 从后端API加载GeoJSON数据 // 从固定路径加载GeoJSON数据
async function loadGeoJSONFromPath() { async function loadGeoJSONFromPath() {
try { try {
const API_PATH = '/api/v1/visualization/geo/cables'; console.log(`正在从 ${GEOJSON_PATH} 加载GeoJSON数据...`);
console.log(`正在从 ${API_PATH} 加载GeoJSON数据...`);
showStatusMessage('正在加载电缆数据...', 'warning'); showStatusMessage('正在加载电缆数据...', 'warning');
const response = await fetch(API_PATH); // 使用fetch API加载本地文件
const response = await fetch(GEOJSON_PATH);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP错误: ${response.status} ${response.statusText}`); throw new Error(`HTTP错误: ${response.status} ${response.statusText}`);
@@ -1034,12 +1034,14 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
} catch (error) { } catch (error) {
console.error('加载GeoJSON数据失败:', error); console.error('加载GeoJSON数据失败:', error);
// 显示错误信息
const errorEl = document.getElementById('error-message'); const errorEl = document.getElementById('error-message');
errorEl.textContent = `无法加载API数据: ${error.message}`; errorEl.textContent = `无法加载文件 ${GEOJSON_PATH}: ${error.message}`;
errorEl.style.display = 'block'; errorEl.style.display = 'block';
showStatusMessage('数据加载失败,请检查后端服务', 'error'); showStatusMessage('数据加载失败,请检查文件路径', 'error');
// 加载示例数据作为后备
setTimeout(() => { setTimeout(() => {
loadFallbackData(); loadFallbackData();
}, 2000); }, 2000);
@@ -1049,10 +1051,9 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
async function loadLandingPoints() { async function loadLandingPoints() {
try { try {
const API_PATH = '/api/v1/visualization/geo/landing-points'; console.log(`正在从 ${GEOJSON_PATH} 加载GeoJSON数据...`);
console.log(`正在从 ${API_PATH} 加载登陆点数据...`); // 方式1从本地文件加载如果你下载了landing_points.geojson
const response = await fetch('./landing-point-geo.geojson');
const response = await fetch(API_PATH);
// 方式2或者直接从API加载取消下面注释 // 方式2或者直接从API加载取消下面注释
// const response = await fetch('https://services.arcgis.com/6DIQcwlPy8knb6sg/arcgis/rest/services/SubmarineCables/FeatureServer/0/query?where=1%3D1&outFields=*&returnGeometry=true&f=geojson'); // const response = await fetch('https://services.arcgis.com/6DIQcwlPy8knb6sg/arcgis/rest/services/SubmarineCables/FeatureServer/0/query?where=1%3D1&outFields=*&returnGeometry=true&f=geojson');

View File

@@ -0,0 +1,149 @@
/* base.css - 公共基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #0a0a1a;
color: #fff;
overflow: hidden;
}
#container {
position: relative;
width: 100vw;
height: 100vh;
/* user-select: none;
-webkit-user-select: none; */
}
#container.dragging {
cursor: grabbing;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2rem;
color: #4db8ff;
z-index: 100;
text-align: center;
background-color: rgba(10, 10, 30, 0.95);
padding: 30px;
border-radius: 10px;
border: 1px solid #4db8ff;
box-shadow: 0 0 30px rgba(77,184,255,0.3);
}
#loading-spinner {
border: 4px solid rgba(77, 184, 255, 0.3);
border-top: 4px solid #4db8ff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
color: #ff4444;
margin-top: 10px;
font-size: 0.9rem;
display: none;
padding: 10px;
background-color: rgba(255, 68, 68, 0.1);
border-radius: 5px;
border-left: 3px solid #ff4444;
}
.terrain-controls {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.slider-container {
margin-bottom: 10px;
}
.slider-label {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9rem;
}
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: rgba(0, 102, 204, 0.3);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4db8ff;
cursor: pointer;
box-shadow: 0 0 10px #4db8ff;
}
.status-message {
position: absolute;
top: 20px;
right: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 10px 15px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(5px);
}
.status-message.success {
color: #44ff44;
border-left: 3px solid #44ff44;
}
.status-message.warning {
color: #ffff44;
border-left: 3px solid #ffff44;
}
.status-message.error {
color: #ff4444;
border-left: 3px solid #ff4444;
}
.tooltip {
position: absolute;
background-color: rgba(10, 10, 30, 0.95);
border: 1px solid #4db8ff;
border-radius: 5px;
padding: 5px 10px;
font-size: 0.8rem;
color: #fff;
pointer-events: none;
z-index: 100;
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
display: none;
user-select: none;
}

View File

@@ -0,0 +1,47 @@
/* coordinates-display */
#coordinates-display {
position: absolute;
top: 20px;
right: 250px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 10px 15px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
min-width: 180px;
backdrop-filter: blur(5px);
}
#coordinates-display .coord-item {
margin-bottom: 5px;
display: flex;
justify-content: space-between;
}
#coordinates-display .coord-label {
color: #aaa;
}
#coordinates-display .coord-value {
color: #4db8ff;
font-weight: 500;
}
#coordinates-display #zoom-level {
margin-top: 5px;
color: #ffff44;
font-weight: 500;
text-align: center;
font-size: 1rem;
}
#coordinates-display .mouse-coords {
font-size: 0.8rem;
color: #aaa;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}

View File

@@ -0,0 +1,31 @@
/* earth-stats */
#earth-stats {
position: absolute;
bottom: 20px;
right: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 15px;
width: 250px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
backdrop-filter: blur(5px);
}
#earth-stats .stats-item {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
#earth-stats .stats-label {
color: #aaa;
}
#earth-stats .stats-value {
color: #4db8ff;
font-weight: 500;
}

View File

@@ -0,0 +1,105 @@
/* info-panel */
#info-panel {
position: absolute;
top: 20px;
left: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 20px;
width: 320px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
backdrop-filter: blur(5px);
}
#info-panel h1 {
font-size: 1.8rem;
margin-bottom: 5px;
color: #4db8ff;
text-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#info-panel .subtitle {
color: #aaa;
margin-bottom: 20px;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 10px;
}
#info-panel .cable-info {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
#info-panel .cable-info h3 {
color: #4db8ff;
margin-bottom: 8px;
font-size: 1.2rem;
}
#info-panel .cable-property {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9rem;
}
#info-panel .property-label {
color: #aaa;
}
#info-panel .property-value {
color: #fff;
font-weight: 500;
}
#info-panel .controls {
display: flex;
justify-content: space-between;
margin-top: 20px;
flex-wrap: wrap;
gap: 10px;
}
#info-panel button {
background: linear-gradient(135deg, #0066cc, #004c99);
color: white;
border: none;
padding: 8px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
flex: 1;
min-width: 120px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
#info-panel button:hover {
background: linear-gradient(135deg, #0088ff, #0066cc);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,102,204,0.4);
}
#info-panel .zoom-controls {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
#info-panel .zoom-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
#info-panel .zoom-buttons button {
flex: 1;
min-width: 60px;
}

View File

@@ -0,0 +1,28 @@
/* legend */
#legend {
position: absolute;
bottom: 20px;
left: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 15px;
width: 220px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
backdrop-filter: blur(5px);
}
#legend .legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
#legend .legend-color {
width: 20px;
height: 20px;
border-radius: 3px;
margin-right: 10px;
}

View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D球形地图 - 海底电缆系统</title>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1"
}
}
</script>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/info-panel.css">
<link rel="stylesheet" href="css/coordinates-display.css">
<link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css">
</head>
<body>
<div id="container">
<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>
</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="coordinates-display">
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
<div class="coord-item">
<span class="coord-label">经度:</span>
<span id="longitude-value" class="coord-value">0.00°</span>
</div>
<div class="coord-item">
<span class="coord-label">纬度:</span>
<span id="latitude-value" class="coord-value">0.00°</span>
</div>
<div id="zoom-level">缩放: 1.0x</div>
<div class="mouse-coords" id="mouse-coords">鼠标位置: 无</div>
</div>
<div id="legend">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">图例</h3>
<div class="legend-item">
<div class="legend-color" style="background-color: #ff4444;"></div>
<span>Americas II</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #44ff44;"></div>
<span>AU Aleutian A</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4444ff;"></div>
<span>AU Aleutian B</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffff44;"></div>
<span>其他电缆</span>
</div>
</div>
<div id="earth-stats">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">地球信息</h3>
<div class="stats-item">
<span class="stats-label">电缆系统:</span>
<span class="stats-value" id="cable-count">0个</span>
</div>
<div class="stats-item">
<span class="stats-label">状态:</span>
<span class="stats-value" id="cable-status-summary">-</span>
</div>
<div class="stats-item">
<span class="stats-label">登陆点:</span>
<span class="stats-value" id="landing-point-count">0个</span>
</div>
<div class="stats-item">
<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="camera-distance">300 km</span>
</div>
<div class="stats-item">
<span class="stats-label">纹理质量:</span>
<span class="stats-value" id="texture-quality">8K 卫星图</span>
</div>
</div>
<div id="loading">
<div id="loading-spinner"></div>
<div>正在加载3D地球和电缆数据...</div>
<div style="font-size:0.9rem; margin-top:10px; color:#aaa;">使用8K高分辨率卫星纹理 | 大陆轮廓更清晰</div>
</div>
<div id="status-message" class="status-message" style="display: none;"></div>
<div id="tooltip" class="tooltip"></div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,337 @@
// cables.js - Cable loading and rendering module
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';
export let cableLines = [];
export let landingPoints = [];
export let lockedCable = null;
function getCableColor(properties) {
if (properties.color) {
if (typeof properties.color === 'string' && properties.color.startsWith('#')) {
return parseInt(properties.color.substring(1), 16);
} else if (typeof properties.color === 'number') {
return properties.color;
}
}
const cableName = properties.Name || properties.cableName || properties.shortname || '';
if (cableName.includes('Americas II')) {
return CABLE_COLORS['Americas II'];
} else if (cableName.includes('AU Aleutian A')) {
return CABLE_COLORS['AU Aleutian A'];
} else if (cableName.includes('AU Aleutian B')) {
return CABLE_COLORS['AU Aleutian B'];
}
return CABLE_COLORS.default;
}
function createCableLine(points, color, properties, earthObj) {
if (points.length < 2) return null;
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
const lineMaterial = new THREE.LineBasicMaterial({
color: color,
linewidth: 1,
transparent: true,
opacity: 1.0,
depthTest: true,
depthWrite: true
});
const cableLine = new THREE.Line(lineGeometry, lineMaterial);
cableLine.userData = {
type: 'cable',
name: properties.Name || properties.cableName || 'Unknown',
owner: properties.owner || properties.owners || '-',
status: properties.status || '-',
length: properties.length || '-',
coords: '-',
rfs: properties.rfs || '-',
originalColor: color
};
cableLine.renderOrder = 1;
return cableLine;
}
function calculateGreatCirclePoints(lat1, lon1, lat2, lon2, radius, segments = 50) {
const points = [];
const phi1 = lat1 * Math.PI / 180;
const lambda1 = lon1 * Math.PI / 180;
const phi2 = lat2 * Math.PI / 180;
const lambda2 = lon2 * Math.PI / 180;
const dLambda = Math.min(Math.abs(lambda2 - lambda1), 2 * Math.PI - Math.abs(lambda2 - lambda1));
const cosDelta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
if (delta < 0.01) {
const p1 = latLonToVector3(lat1, lon1, radius);
const p2 = latLonToVector3(lat2, lon2, radius);
return [p1, p2];
}
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const sinDelta = Math.sin(delta);
const A = Math.sin((1 - t) * delta) / sinDelta;
const B = Math.sin(t * delta) / sinDelta;
const x1 = Math.cos(phi1) * Math.cos(lambda1);
const y1 = Math.cos(phi1) * Math.sin(lambda1);
const z1 = Math.sin(phi1);
const x2 = Math.cos(phi2) * Math.cos(lambda2);
const y2 = Math.cos(phi2) * Math.sin(lambda2);
const z2 = Math.sin(phi2);
let x = A * x1 + B * x2;
let y = A * y1 + B * y2;
let z = A * z1 + B * z2;
const norm = Math.sqrt(x*x + y*y + z*z);
x = x / norm * radius;
y = y / norm * radius;
z = z / norm * radius;
const lat = Math.asin(z / radius) * 180 / Math.PI;
let lon = Math.atan2(y, x) * 180 / Math.PI;
if (lon > 180) lon -= 360;
if (lon < -180) lon += 360;
const point = latLonToVector3(lat, lon, radius);
points.push(point);
}
return points;
}
export async function loadGeoJSONFromPath(scene, earthObj) {
try {
console.log('正在加载电缆数据...');
showStatusMessage('正在加载电缆数据...', 'warning');
const response = await fetch(PATHS.geoJSON);
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
const data = await response.json();
cableLines.forEach(line => earthObj.remove(line));
cableLines = [];
if (!data.features || !Array.isArray(data.features)) {
throw new Error('无效的GeoJSON格式');
}
const cableCount = data.features.length;
document.getElementById('cable-count').textContent = cableCount + '个';
const inServiceCount = data.features.filter(
feature => feature.properties && feature.properties.status === 'In Service'
).length;
const statusEl = document.getElementById('cable-status-summary');
if (statusEl) {
statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
}
for (const feature of data.features) {
const geometry = feature.geometry;
const properties = feature.properties || {};
if (!geometry || !geometry.coordinates) continue;
const color = getCableColor(properties);
console.log('电缆:', properties.Name, '颜色:', color);
if (geometry.type === 'MultiLineString') {
for (const lineCoords of geometry.coordinates) {
if (!lineCoords || lineCoords.length < 2) continue;
const points = [];
for (let i = 0; i < lineCoords.length - 1; i++) {
const lon1 = lineCoords[i][0];
const lat1 = lineCoords[i][1];
const lon2 = lineCoords[i + 1][0];
const lat2 = lineCoords[i + 1][1];
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
if (i === 0) {
points.push(...segment);
} else {
points.push(...segment.slice(1));
}
}
if (points.length >= 2) {
const line = createCableLine(points, color, properties, earthObj);
if (line) {
cableLines.push(line);
earthObj.add(line);
console.log('添加线缆成功');
}
}
}
} else if (geometry.type === 'LineString') {
const allCoords = geometry.coordinates;
const points = [];
for (let i = 0; i < allCoords.length - 1; i++) {
const lon1 = allCoords[i][0];
const lat1 = allCoords[i][1];
const lon2 = allCoords[i + 1][0];
const lat2 = allCoords[i + 1][1];
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
if (i === 0) {
points.push(...segment);
} else {
points.push(...segment.slice(1));
}
}
if (points.length >= 2) {
const line = createCableLine(points, color, properties, earthObj);
if (line) {
cableLines.push(line);
earthObj.add(line);
}
}
}
}
updateEarthStats({
cableCount: cableLines.length,
landingPointCount: landingPoints.length,
terrainOn: false,
textureQuality: '8K 卫星图'
});
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, 'success');
document.getElementById('loading').style.display = 'none';
} catch (error) {
console.error('加载电缆数据失败:', error);
showStatusMessage('加载电缆数据失败: ' + error.message, 'error');
}
}
export async function loadLandingPoints(scene, earthObj) {
try {
console.log('正在加载登陆点数据...');
const response = await fetch('./landing-point-geo.geojson');
if (!response.ok) {
console.error('HTTP错误:', response.status);
return;
}
const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
console.error('无效的GeoJSON格式');
return;
}
landingPoints = [];
let validCount = 0;
const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16);
const sphereMaterial = new THREE.MeshStandardMaterial({
color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5
});
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {};
if (typeof lon !== 'number' || typeof lat !== 'number' ||
isNaN(lon) || isNaN(lat) ||
Math.abs(lat) > 90 || Math.abs(lon) > 180) {
continue;
}
const position = latLonToVector3(lat, lon, 100.1);
if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) {
continue;
}
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial.clone());
sphere.position.copy(position);
sphere.userData = {
type: 'landingPoint',
name: properties.name || '未知登陆站',
cableName: properties.cable_system || '未知系统',
country: properties.country || '未知国家',
status: properties.status || 'Unknown'
};
earthObj.add(sphere);
landingPoints.push(sphere);
validCount++;
}
console.log(`成功创建 ${validCount} 个登陆点标记`);
showStatusMessage(`成功加载 ${validCount} 个登陆点`, 'success');
const lpCountEl = document.getElementById('landing-point-count');
if (lpCountEl) {
lpCountEl.textContent = validCount + '个';
}
} catch (error) {
console.error('加载登陆点数据失败:', error);
}
}
export function handleCableClick(cable) {
lockedCable = cable;
const data = cable.userData;
updateCableDetails({
name: data.name,
owner: data.owner,
status: data.status,
length: data.length,
coords: data.coords,
rfs: data.rfs
});
showStatusMessage(`已锁定: ${data.name}`, 'info');
}
export function clearCableSelection() {
lockedCable = null;
updateCableDetails({
name: '点击电缆查看详情',
owner: '-',
status: '-',
length: '-',
coords: '-',
rfs: '-'
});
}
export function getCableLines() {
return cableLines;
}
export function getLandingPoints() {
return landingPoints;
}

View File

@@ -0,0 +1,30 @@
// constants.js - Global constants and configuration
// Scene configuration
export const CONFIG = {
defaultCameraZ: 300,
minZoom: 0.5,
maxZoom: 5.0,
earthRadius: 100,
rotationSpeed: 0.002,
};
// Paths
export const PATHS = {
geoJSON: './geo.json',
};
// Cable colors mapping
export const CABLE_COLORS = {
'Americas II': 0xff4444,
'AU Aleutian A': 0x44ff44,
'AU Aleutian B': 0x4444ff,
'default': 0xffff44
};
// Grid configuration
export const GRID_CONFIG = {
latitudeStep: 10,
longitudeStep: 30,
gridStep: 5
};

217
frontend/public/earth/js/controls.js vendored Normal file
View File

@@ -0,0 +1,217 @@
// controls.js - Zoom, rotate and toggle controls
import { CONFIG } from './constants.js';
import { updateZoomDisplay, showStatusMessage } from './ui.js';
import { toggleTerrain } from './earth.js';
export let autoRotate = true;
export let zoomLevel = 1.0;
export let showTerrain = false;
export let isDragging = false;
let earthObj = null;
export function setupControls(camera, renderer, scene, earth) {
earthObj = earth;
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
}
function setupZoomControls(camera) {
document.getElementById('zoom-in').addEventListener('click', () => {
zoomLevel = Math.min(zoomLevel + 0.5, CONFIG.maxZoom);
applyZoom(camera);
});
document.getElementById('zoom-out').addEventListener('click', () => {
zoomLevel = Math.max(zoomLevel - 0.5, CONFIG.minZoom);
applyZoom(camera);
});
document.getElementById('zoom-reset').addEventListener('click', () => {
zoomLevel = 1.0;
applyZoom(camera);
showStatusMessage('缩放已重置', 'info');
});
const slider = document.getElementById('zoom-slider');
slider?.addEventListener('input', (e) => {
zoomLevel = parseFloat(e.target.value);
applyZoom(camera);
});
}
function setupWheelZoom(camera, renderer) {
renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
} else {
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
}, { passive: false });
}
function applyZoom(camera) {
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
const distance = camera.position.z.toFixed(0);
updateZoomDisplay(zoomLevel, distance);
}
function animateValue(start, end, duration, onUpdate, onComplete) {
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const current = start + (end - start) * easeProgress;
onUpdate(current);
if (progress < 1) {
requestAnimationFrame(update);
} else if (onComplete) {
onComplete();
}
}
requestAnimationFrame(update);
}
export function resetView(camera) {
if (!earthObj) return;
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const targetRotX = 23.5 * Math.PI / 180;
const targetRotY = 0;
const targetZoom = 1.0;
animateValue(0, 1, 800, (progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}, () => {
zoomLevel = 1.0;
showStatusMessage('视图已重置', 'info');
});
if (typeof window.clearLockedCable === 'function') {
window.clearLockedCable();
}
}
function setupRotateControls(camera, earth) {
document.getElementById('rotate-toggle').addEventListener('click', () => {
toggleAutoRotate();
const isOn = autoRotate;
showStatusMessage(isOn ? '自动旋转已开启' : '自动旋转已暂停', 'info');
});
document.getElementById('reset-view').addEventListener('click', () => {
if (!earthObj) return;
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const targetRotX = 23.5 * Math.PI / 180;
const targetRotY = 0;
const targetZoom = 1.0;
animateValue(0, 1, 800, (progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}, () => {
zoomLevel = 1.0;
showStatusMessage('视图已重置', 'info');
});
});
}
function setupTerrainControls() {
document.getElementById('toggle-terrain').addEventListener('click', () => {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
const btn = document.getElementById('toggle-terrain');
btn.textContent = showTerrain ? '隐藏地形' : '显示地形';
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
});
document.getElementById('reload-data').addEventListener('click', () => {
showStatusMessage('重新加载数据...', 'info');
window.location.reload();
});
}
function setupMouseControls(camera, renderer) {
let previousMousePosition = { x: 0, y: 0 };
renderer.domElement.addEventListener('mousedown', (e) => {
isDragging = true;
previousMousePosition = { x: e.clientX, y: e.clientY };
});
renderer.domElement.addEventListener('mouseup', () => {
isDragging = false;
});
renderer.domElement.addEventListener('mousemove', (e) => {
if (isDragging) {
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
if (earth) {
earth.rotation.y += deltaX * 0.005;
earth.rotation.x += deltaY * 0.005;
}
previousMousePosition = { x: e.clientX, y: e.clientY };
}
});
}
export function getAutoRotate() {
return autoRotate;
}
export function setAutoRotate(value) {
autoRotate = value;
const btn = document.getElementById('rotate-toggle');
if (btn) {
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
}
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
const btn = document.getElementById('rotate-toggle');
if (btn) {
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
}
if (window.clearLockedCable) {
window.clearLockedCable();
}
return autoRotate;
}
export function getZoomLevel() {
return zoomLevel;
}
export function getShowTerrain() {
return showTerrain;
}

View File

@@ -0,0 +1,240 @@
// earth.js - 3D Earth creation module
import * as THREE from 'three';
import { CONFIG } from './constants.js';
import { latLonToVector3 } from './utils.js';
export let earth = null;
export let clouds = null;
export let terrain = null;
const textureLoader = new THREE.TextureLoader();
export function createEarth(scene) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
const material = new THREE.MeshPhongMaterial({
color: 0xffffff,
specular: 0x111111,
shininess: 10,
emissive: 0x000000,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
});
earth = new THREE.Mesh(geometry, material);
earth.rotation.x = 23.5 * Math.PI / 180;
scene.add(earth);
const textureUrls = [
'./8k_earth_daymap.jpg',
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
'https://assets.codepen.io/982762/earth_texture_2048.jpg'
];
let textureLoaded = false;
textureLoader.load(
textureUrls[0],
function(texture) {
console.log('高分辨率地球纹理加载成功');
textureLoaded = true;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.anisotropy = 16;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
material.map = texture;
material.needsUpdate = true;
document.getElementById('loading').style.display = 'none';
},
function(xhr) {
console.log('纹理加载中: ' + (xhr.loaded / xhr.total * 100) + '%');
},
function(err) {
console.log('第一个纹理加载失败,尝试第二个...');
textureLoader.load(
textureUrls[1],
function(texture) {
console.log('第二个纹理加载成功');
textureLoaded = true;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.anisotropy = 16;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
material.map = texture;
material.needsUpdate = true;
document.getElementById('loading').style.display = 'none';
},
null,
function(err) {
console.log('所有纹理加载失败');
document.getElementById('loading').style.display = 'none';
}
);
}
);
return earth;
}
export function createClouds(scene, earthObj) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
const material = new THREE.MeshPhongMaterial({
transparent: true,
linewidth: 2,
opacity: 0.15,
depthTest: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide
});
clouds = new THREE.Mesh(geometry, material);
earthObj.add(clouds);
textureLoader.load(
'https://threejs.org/examples/textures/planets/earth_clouds_1024.png',
function(texture) {
material.map = texture;
material.needsUpdate = true;
},
undefined,
function(err) {
console.log('云层纹理加载失败');
}
);
return clouds;
}
export function createTerrain(scene, earthObj, simplex) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
const positionAttribute = geometry.getAttribute('position');
for (let i = 0; i < positionAttribute.count; i++) {
const x = positionAttribute.getX(i);
const y = positionAttribute.getY(i);
const z = positionAttribute.getZ(i);
const noise = simplex(x / 20, y / 20, z / 20);
const height = 1 + noise * 0.02;
positionAttribute.setXYZ(i, x * height, y * height, z * height);
}
geometry.computeVertexNormals();
const material = new THREE.MeshPhongMaterial({
color: 0x00aa00,
flatShading: true,
transparent: true,
opacity: 0.7
});
terrain = new THREE.Mesh(geometry, material);
terrain.visible = false;
earthObj.add(terrain);
return terrain;
}
export function toggleTerrain(visible) {
if (terrain) {
terrain.visible = visible;
}
}
export function createStars(scene) {
const starGeometry = new THREE.BufferGeometry();
const starCount = 8000;
const starPositions = new Float32Array(starCount * 3);
for (let i = 0; i < starCount * 3; i += 3) {
const r = 800 + Math.random() * 200;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
starPositions[i] = r * Math.sin(phi) * Math.cos(theta);
starPositions[i + 1] = r * Math.sin(phi) * Math.sin(theta);
starPositions[i + 2] = r * Math.cos(phi);
}
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMaterial = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.5,
transparent: true,
blending: THREE.AdditiveBlending
});
const stars = new THREE.Points(starGeometry, starMaterial);
scene.add(stars);
return stars;
}
let latitudeLines = [];
let longitudeLines = [];
export function createGridLines(scene, earthObj) {
latitudeLines.forEach(line => scene.remove(line));
longitudeLines.forEach(line => scene.remove(line));
latitudeLines = [];
longitudeLines = [];
const earthRadius = 100.1;
const gridMaterial = new THREE.LineBasicMaterial({
color: 0x44aaff,
transparent: true,
opacity: 0.2,
linewidth: 1
});
for (let lat = -75; lat <= 75; lat += 15) {
const points = [];
for (let lon = -180; lon <= 180; lon += 5) {
const point = latLonToVector3(lat, lon, earthRadius);
points.push(point);
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'latitude', value: lat };
earthObj.add(line);
latitudeLines.push(line);
}
for (let lon = -180; lon <= 180; lon += 30) {
const points = [];
for (let lat = -90; lat <= 90; lat += 5) {
const point = latLonToVector3(lat, lon, earthRadius);
points.push(point);
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'longitude', value: lon };
earthObj.add(line);
longitudeLines.push(line);
}
}
export function getEarth() {
return earth;
}
export function getClouds() {
return clouds;
}

View File

@@ -0,0 +1,292 @@
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,
updateCableDetails,
setLoading,
showTooltip,
hideTooltip
} from './ui.js';
import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines } from './cables.js';
import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate } from './controls.js';
export let scene, camera, renderer;
let simplex;
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
let hoveredCable = null;
let lockedCable = 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();
const earthObj = createEarth(scene);
createClouds(scene, earthObj);
createTerrain(scene, earthObj, simplex);
createStars(scene);
createGridLines(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);
}
async function loadData() {
setLoading(true);
try {
console.log('开始加载电缆数据...');
await loadGeoJSONFromPath(scene, getEarth());
console.log('电缆数据加载完成');
await loadLandingPoints(scene, getEarth());
console.log('登陆点数据加载完成');
} catch (error) {
console.error('加载数据失败:', error);
showStatusMessage('加载数据失败: ' + error.message, 'error');
}
setLoading(false);
}
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) {
if (hoveredCable.userData.originalColor !== undefined) {
hoveredCable.material.color.setHex(hoveredCable.userData.originalColor);
}
hoveredCable = null;
}
if (intersects.length > 0) {
const cable = intersects[0].object;
if (cable !== lockedCable) {
cable.material.color.setHex(0xffffff);
cable.material.opacity = 1;
hoveredCable = cable;
}
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) {
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 = '-';
}
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 && lockedCable !== intersects[0].object) {
if (lockedCable.userData.originalColor !== undefined) {
lockedCable.material.color.setHex(lockedCable.userData.originalColor);
}
}
lockedCable = intersects[0].object;
lockedCable.material.color.setHex(0xffffff);
setAutoRotate(false);
handleCableClick(intersects[0].object);
} else {
if (lockedCable) {
if (lockedCable.userData.originalColor !== undefined) {
lockedCable.material.color.setHex(lockedCable.userData.originalColor);
}
lockedCable = 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;
lockedCable.material.opacity = 0.6 + pulse * 0.4;
const glowIntensity = 0.7 + pulse * 0.3;
lockedCable.material.color.setRGB(glowIntensity, glowIntensity, glowIntensity);
}
renderer.render(scene, camera);
}
window.clearLockedCable = function() {
if (lockedCable) {
if (lockedCable.userData.originalColor !== undefined) {
lockedCable.material.color.setHex(lockedCable.userData.originalColor);
lockedCable.material.opacity = 1.0;
}
lockedCable = null;
}
clearCableSelection();
};
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,79 @@
// ui.js - UI update functions
// Show status message
export function showStatusMessage(message, type = 'info') {
const statusEl = document.getElementById('status-message');
statusEl.textContent = message;
statusEl.className = `status-message ${type}`;
statusEl.style.display = 'block';
setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
// Update coordinates display
export function updateCoordinatesDisplay(lat, lon, alt = 0) {
document.getElementById('longitude-value').textContent = lon.toFixed(2) + '°';
document.getElementById('latitude-value').textContent = lat.toFixed(2) + '°';
document.getElementById('mouse-coords').textContent =
`鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
}
// 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';
document.getElementById('zoom-slider').value = zoomLevel;
document.getElementById('camera-distance').textContent = distance + ' km';
}
// Update cable details
export function updateCableDetails(cable) {
document.getElementById('cable-name').textContent = cable.name || 'Unknown';
document.getElementById('cable-owner').textContent = cable.owner || '-';
document.getElementById('cable-status').textContent = cable.status || '-';
document.getElementById('cable-length').textContent = cable.length || '-';
document.getElementById('cable-coords').textContent = cable.coords || '-';
document.getElementById('cable-rfs').textContent = cable.rfs || '-';
}
// Update earth stats
export function updateEarthStats(stats) {
document.getElementById('cable-count').textContent = stats.cableCount || 0;
document.getElementById('landing-point-count').textContent = stats.landingPointCount || 0;
document.getElementById('terrain-status').textContent = stats.terrainOn ? '开启' : '关闭';
document.getElementById('texture-quality').textContent = stats.textureQuality || '8K 卫星图';
}
// Show/hide loading
export function setLoading(loading) {
const loadingEl = document.getElementById('loading');
loadingEl.style.display = loading ? 'block' : 'none';
}
// Show tooltip
export function showTooltip(x, y, content) {
const tooltip = document.getElementById('tooltip');
tooltip.innerHTML = content;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
tooltip.style.display = 'block';
}
// Hide tooltip
export function hideTooltip() {
document.getElementById('tooltip').style.display = 'none';
}
// Show error message
export function showError(message) {
const errorEl = document.getElementById('error-message');
errorEl.textContent = message;
errorEl.style.display = 'block';
}
// Hide error message
export function hideError() {
document.getElementById('error-message').style.display = 'none';
}

View File

@@ -0,0 +1,55 @@
// utils.js - Utility functions for coordinate conversion
import * as THREE from 'three';
import { CONFIG } from './constants.js';
// Convert latitude/longitude to 3D vector
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lon + 180) * (Math.PI / 180);
const x = -(radius * Math.sin(phi) * Math.cos(theta));
const z = radius * Math.sin(phi) * Math.sin(theta);
const y = radius * Math.cos(phi);
return new THREE.Vector3(x, y, z);
}
// Convert 3D vector to latitude/longitude
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;
return {
lat: parseFloat(lat.toFixed(4)),
lon: parseFloat(lon.toFixed(4)),
alt: radius - CONFIG.earthRadius
};
}
// Convert screen coordinates to Earth surface 3D coordinates
export function screenToEarthCoords(x, y, camera, earth) {
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(
(x / window.innerWidth) * 2 - 1,
-(y / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(earth);
if (intersects.length > 0) {
return intersects[0].point;
}
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);
}