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

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

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