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:
@@ -1009,50 +1009,51 @@ function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||
);
|
||||
}
|
||||
|
||||
// 从后端API加载GeoJSON数据
|
||||
async function loadGeoJSONFromPath() {
|
||||
try {
|
||||
const API_PATH = '/api/v1/visualization/geo/cables';
|
||||
console.log(`正在从 ${API_PATH} 加载GeoJSON数据...`);
|
||||
showStatusMessage('正在加载电缆数据...', 'warning');
|
||||
|
||||
const response = await fetch(API_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const geoJsonData = await response.json();
|
||||
|
||||
if (geoJsonData.features && Array.isArray(geoJsonData.features)) {
|
||||
loadCablesFromGeoJSON(geoJsonData);
|
||||
showStatusMessage(`成功加载 ${geoJsonData.features.length} 条电缆数据`, 'success');
|
||||
} else {
|
||||
throw new Error('无效的GeoJSON格式: 缺少features数组');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载GeoJSON数据失败:', error);
|
||||
|
||||
const errorEl = document.getElementById('error-message');
|
||||
errorEl.textContent = `无法加载API数据: ${error.message}`;
|
||||
errorEl.style.display = 'block';
|
||||
|
||||
showStatusMessage('数据加载失败,请检查后端服务', 'error');
|
||||
|
||||
setTimeout(() => {
|
||||
loadFallbackData();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadLandingPoints() {
|
||||
try {
|
||||
const API_PATH = '/api/v1/visualization/geo/landing-points';
|
||||
console.log(`正在从 ${API_PATH} 加载登陆点数据...`);
|
||||
|
||||
const response = await fetch(API_PATH);
|
||||
// 从固定路径加载GeoJSON数据
|
||||
async function loadGeoJSONFromPath() {
|
||||
try {
|
||||
console.log(`正在从 ${GEOJSON_PATH} 加载GeoJSON数据...`);
|
||||
showStatusMessage('正在加载电缆数据...', 'warning');
|
||||
|
||||
// 使用fetch API加载本地文件
|
||||
const response = await fetch(GEOJSON_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const geoJsonData = await response.json();
|
||||
|
||||
if (geoJsonData.features && Array.isArray(geoJsonData.features)) {
|
||||
loadCablesFromGeoJSON(geoJsonData);
|
||||
showStatusMessage(`成功加载 ${geoJsonData.features.length} 条电缆数据`, 'success');
|
||||
} else {
|
||||
throw new Error('无效的GeoJSON格式: 缺少features数组');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载GeoJSON数据失败:', error);
|
||||
|
||||
// 显示错误信息
|
||||
const errorEl = document.getElementById('error-message');
|
||||
errorEl.textContent = `无法加载文件 ${GEOJSON_PATH}: ${error.message}`;
|
||||
errorEl.style.display = 'block';
|
||||
|
||||
showStatusMessage('数据加载失败,请检查文件路径', 'error');
|
||||
|
||||
// 加载示例数据作为后备
|
||||
setTimeout(() => {
|
||||
loadFallbackData();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadLandingPoints() {
|
||||
try {
|
||||
console.log(`正在从 ${GEOJSON_PATH} 加载GeoJSON数据...`);
|
||||
// 方式1:从本地文件加载(如果你下载了landing_points.geojson)
|
||||
const response = await fetch('./landing-point-geo.geojson');
|
||||
|
||||
// 方式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');
|
||||
|
||||
149
frontend/public/earth/css/base.css
Normal file
149
frontend/public/earth/css/base.css
Normal 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;
|
||||
}
|
||||
47
frontend/public/earth/css/coordinates-display.css
Normal file
47
frontend/public/earth/css/coordinates-display.css
Normal 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);
|
||||
}
|
||||
31
frontend/public/earth/css/earth-stats.css
Normal file
31
frontend/public/earth/css/earth-stats.css
Normal 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;
|
||||
}
|
||||
105
frontend/public/earth/css/info-panel.css
Normal file
105
frontend/public/earth/css/info-panel.css
Normal 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;
|
||||
}
|
||||
28
frontend/public/earth/css/legend.css
Normal file
28
frontend/public/earth/css/legend.css
Normal 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;
|
||||
}
|
||||
148
frontend/public/earth/index.html
Normal file
148
frontend/public/earth/index.html
Normal 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>
|
||||
337
frontend/public/earth/js/cables.js
Normal file
337
frontend/public/earth/js/cables.js
Normal 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;
|
||||
}
|
||||
30
frontend/public/earth/js/constants.js
Normal file
30
frontend/public/earth/js/constants.js
Normal 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
217
frontend/public/earth/js/controls.js
vendored
Normal 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;
|
||||
}
|
||||
240
frontend/public/earth/js/earth.js
Normal file
240
frontend/public/earth/js/earth.js
Normal 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;
|
||||
}
|
||||
292
frontend/public/earth/js/main.js
Normal file
292
frontend/public/earth/js/main.js
Normal 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);
|
||||
79
frontend/public/earth/js/ui.js
Normal file
79
frontend/public/earth/js/ui.js
Normal 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';
|
||||
}
|
||||
55
frontend/public/earth/js/utils.js
Normal file
55
frontend/public/earth/js/utils.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user