From 6cb4398f3ad610cab89193c7316c49d4441795c9 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 11 Mar 2026 15:54:50 +0800 Subject: [PATCH] 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 --- frontend/package.json | 2 + frontend/public/earth/3dearthmult.html | 89 ++--- frontend/public/earth/css/base.css | 149 ++++++++ .../public/earth/css/coordinates-display.css | 47 +++ frontend/public/earth/css/earth-stats.css | 31 ++ frontend/public/earth/css/info-panel.css | 105 ++++++ frontend/public/earth/css/legend.css | 28 ++ frontend/public/earth/index.html | 148 ++++++++ frontend/public/earth/js/cables.js | 337 ++++++++++++++++++ frontend/public/earth/js/constants.js | 30 ++ frontend/public/earth/js/controls.js | 217 +++++++++++ frontend/public/earth/js/earth.js | 240 +++++++++++++ frontend/public/earth/js/main.js | 292 +++++++++++++++ frontend/public/earth/js/ui.js | 79 ++++ frontend/public/earth/js/utils.js | 55 +++ 15 files changed, 1805 insertions(+), 44 deletions(-) create mode 100644 frontend/public/earth/css/base.css create mode 100644 frontend/public/earth/css/coordinates-display.css create mode 100644 frontend/public/earth/css/earth-stats.css create mode 100644 frontend/public/earth/css/info-panel.css create mode 100644 frontend/public/earth/css/legend.css create mode 100644 frontend/public/earth/index.html create mode 100644 frontend/public/earth/js/cables.js create mode 100644 frontend/public/earth/js/constants.js create mode 100644 frontend/public/earth/js/controls.js create mode 100644 frontend/public/earth/js/earth.js create mode 100644 frontend/public/earth/js/main.js create mode 100644 frontend/public/earth/js/ui.js create mode 100644 frontend/public/earth/js/utils.js 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地形球形地图可视化 | 高分辨率卫星图
+
+
+

缩放控制

+
+ + + +
+
+
+ 缩放级别: + 1.0x +
+ +
+
+
+
+

点击电缆查看详情

+
+ 所有者: + - +
+
+ 状态: + - +
+
+ 长度: + - +
+
+ 经纬度: + - +
+
+ 投入使用时间: + - +
+
+
+ + + + +
+
+
+ +
+

坐标信息

+
+ 经度: + 0.00° +
+
+ 纬度: + 0.00° +
+
缩放: 1.0x
+
鼠标位置: 无
+
+ +
+

图例

+
+
+ Americas II +
+
+
+ AU Aleutian A +
+
+
+ AU Aleutian B +
+
+
+ 其他电缆 +
+
+ +
+

地球信息

+
+ 电缆系统: + 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); +}