diff --git a/frontend/package.json b/frontend/package.json
index ea1f1290..53c725ab 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,7 +11,9 @@
"react-dom": "^18.2.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0",
+ "simplex-noise": "^4.0.1",
"socket.io-client": "^4.7.2",
+ "three": "^0.160.0",
"zustand": "^4.4.7"
},
"devDependencies": {
diff --git a/frontend/public/earth/3dearthmult.html b/frontend/public/earth/3dearthmult.html
index cc8909ea..df28cc52 100644
--- a/frontend/public/earth/3dearthmult.html
+++ b/frontend/public/earth/3dearthmult.html
@@ -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');
diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css
new file mode 100644
index 00000000..abc9af32
--- /dev/null
+++ b/frontend/public/earth/css/base.css
@@ -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;
+}
diff --git a/frontend/public/earth/css/coordinates-display.css b/frontend/public/earth/css/coordinates-display.css
new file mode 100644
index 00000000..a18ca4e6
--- /dev/null
+++ b/frontend/public/earth/css/coordinates-display.css
@@ -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);
+}
diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css
new file mode 100644
index 00000000..67bd0f7b
--- /dev/null
+++ b/frontend/public/earth/css/earth-stats.css
@@ -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;
+}
diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css
new file mode 100644
index 00000000..183d0f97
--- /dev/null
+++ b/frontend/public/earth/css/info-panel.css
@@ -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;
+}
diff --git a/frontend/public/earth/css/legend.css b/frontend/public/earth/css/legend.css
new file mode 100644
index 00000000..b268ce00
--- /dev/null
+++ b/frontend/public/earth/css/legend.css
@@ -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;
+}
diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html
new file mode 100644
index 00000000..d116193a
--- /dev/null
+++ b/frontend/public/earth/index.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+ 3D球形地图 - 海底电缆系统
+
+
+
+
+
+
+
+
+
+
+
全球海底电缆系统
+
3D地形球形地图可视化 | 高分辨率卫星图
+
+
+
缩放控制
+
+
+
+
+
+
+
+
+
+
点击电缆查看详情
+
+ 所有者:
+ -
+
+
+ 状态:
+ -
+
+
+ 长度:
+ -
+
+
+ 经纬度:
+ -
+
+
+ 投入使用时间:
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
坐标信息
+
+ 经度:
+ 0.00°
+
+
+ 纬度:
+ 0.00°
+
+
缩放: 1.0x
+
鼠标位置: 无
+
+
+
+
+
+
地球信息
+
+ 电缆系统:
+ 0个
+
+
+ 状态:
+ -
+
+
+ 登陆点:
+ 0个
+
+
+ 地形:
+ 开启
+
+
+ 视角距离:
+ 300 km
+
+
+ 纹理质量:
+ 8K 卫星图
+
+
+
+
+
+
正在加载3D地球和电缆数据...
+
使用8K高分辨率卫星纹理 | 大陆轮廓更清晰
+
+
+
+
+
+
+
+
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
new file mode 100644
index 00000000..436c2805
--- /dev/null
+++ b/frontend/public/earth/js/cables.js
@@ -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;
+}
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
new file mode 100644
index 00000000..1010c9d8
--- /dev/null
+++ b/frontend/public/earth/js/constants.js
@@ -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
+};
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
new file mode 100644
index 00000000..97cf206d
--- /dev/null
+++ b/frontend/public/earth/js/controls.js
@@ -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;
+}
diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js
new file mode 100644
index 00000000..6afc3046
--- /dev/null
+++ b/frontend/public/earth/js/earth.js
@@ -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;
+}
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
new file mode 100644
index 00000000..b94b4b9b
--- /dev/null
+++ b/frontend/public/earth/js/main.js
@@ -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}°
经度: ${coords.lon}°
海拔: ${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);
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
new file mode 100644
index 00000000..2723a272
--- /dev/null
+++ b/frontend/public/earth/js/ui.js
@@ -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';
+}
diff --git a/frontend/public/earth/js/utils.js b/frontend/public/earth/js/utils.js
new file mode 100644
index 00000000..84f0bda2
--- /dev/null
+++ b/frontend/public/earth/js/utils.js
@@ -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);
+}