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

## Changelog

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

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

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

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

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

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

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

View File

@@ -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;
}