release: bump version to 0.30.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.29.2",
|
||||
"version": "0.30.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -311,6 +311,7 @@
|
||||
0 0 18px rgba(145, 186, 255, 0.06);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.01em;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
@@ -334,8 +335,10 @@
|
||||
.earth-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.earth-status-dot {
|
||||
@@ -350,6 +353,8 @@
|
||||
}
|
||||
|
||||
.earth-status-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -608,10 +613,73 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-item--stacked {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earth-settings-item--stacked:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.earth-settings-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.earth-settings-slider {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
appearance: none;
|
||||
background: linear-gradient(90deg, rgba(132, 164, 204, 0.32), rgba(94, 130, 172, 0.5));
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-settings-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.22) 55%, transparent 70%),
|
||||
linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, rgba(164, 196, 236, 0.95), rgba(85, 127, 181, 0.92));
|
||||
border: 1px solid rgba(222, 236, 252, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
0 6px 16px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.earth-settings-slider-value {
|
||||
flex: 0 0 auto;
|
||||
min-width: 46px;
|
||||
text-align: right;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-settings-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -469,6 +469,30 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
|
||||
@@ -245,9 +245,12 @@ export function clearCableData(earthObj = null) {
|
||||
clearLandingPoints(earthObj);
|
||||
}
|
||||
|
||||
export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载电缆数据...");
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
if (!response.ok) {
|
||||
@@ -344,11 +347,14 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
textureQuality: "8K 卫星图",
|
||||
});
|
||||
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
}
|
||||
return cableLines.length;
|
||||
}
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj) {
|
||||
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载登陆点数据...");
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
@@ -434,7 +440,9 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
}
|
||||
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
if (!silent) {
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
}
|
||||
return validCount;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,25 @@ export const CELESTIAL_CONFIG = {
|
||||
backLightColor: 0x2b4c78,
|
||||
};
|
||||
|
||||
export const TERRAIN_CONFIG = {
|
||||
enabled: true,
|
||||
tileSize: 256,
|
||||
baseZoom: 4,
|
||||
geometryWidthSegments: 320,
|
||||
geometryHeightSegments: 320,
|
||||
baseRadiusOffset: 0.04,
|
||||
exaggeration: 34,
|
||||
landRevealFadeMeters: 220,
|
||||
maxConcurrentRequests: 10,
|
||||
opacity: 0.62,
|
||||
color: 0x7f9d7f,
|
||||
emissive: 0x061008,
|
||||
specular: 0x233126,
|
||||
shininess: 10,
|
||||
urlTemplate:
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
|
||||
114
frontend/public/earth/js/controls.js
vendored
114
frontend/public/earth/js/controls.js
vendored
@@ -4,6 +4,12 @@ import * as THREE from "three";
|
||||
import { CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
|
||||
import { toggleTerrain } from "./earth.js";
|
||||
import {
|
||||
ensureTerrainReady,
|
||||
isTerrainReady,
|
||||
getTerrainOpacity,
|
||||
setTerrainOpacity,
|
||||
} from "./terrain.js";
|
||||
import {
|
||||
reloadData,
|
||||
clearLockedObject,
|
||||
@@ -58,6 +64,44 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
let settingsModalTimer = null;
|
||||
let settingsSheetAnimation = null;
|
||||
let terrainToggleToken = 0;
|
||||
|
||||
function getViewRotation(targetLat, targetRotLon) {
|
||||
const latRot = (targetLat * Math.PI) / 180;
|
||||
return {
|
||||
x: EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient,
|
||||
y: -((targetRotLon * Math.PI) / 180),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTerrainUiState(button, enabled) {
|
||||
showTerrain = enabled;
|
||||
toggleTerrain(enabled);
|
||||
updateLayerButtonState(button, enabled);
|
||||
setButtonTooltip(button, enabled ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus) terrainStatus.textContent = enabled ? "开启" : "关闭";
|
||||
}
|
||||
|
||||
export function applyImmediateView(targetEarthObj, camera, options = {}) {
|
||||
if (!targetEarthObj) return;
|
||||
|
||||
const {
|
||||
lat = EARTH_CONFIG.chinaLat,
|
||||
rotLon = EARTH_CONFIG.chinaRotLon,
|
||||
zoom = 1.0,
|
||||
} = options;
|
||||
const nextRotation = getViewRotation(lat, rotLon);
|
||||
|
||||
targetEarthObj.rotation.x = nextRotation.x;
|
||||
targetEarthObj.rotation.y = nextRotation.y;
|
||||
zoomLevel = zoom;
|
||||
|
||||
if (camera) {
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSettingsSheetAnimation() {
|
||||
if (settingsSheetAnimation) {
|
||||
@@ -316,6 +360,32 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
const terrainOpacitySlider = document.getElementById("terrain-opacity-slider");
|
||||
const terrainOpacityValue = document.getElementById("terrain-opacity-value");
|
||||
const syncTerrainOpacityUi = (nextOpacity) => {
|
||||
const safeOpacity = Math.round(nextOpacity * 100);
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
terrainOpacitySlider.value = nextOpacity.toFixed(2);
|
||||
}
|
||||
if (terrainOpacityValue) {
|
||||
terrainOpacityValue.textContent = `${safeOpacity}%`;
|
||||
}
|
||||
};
|
||||
|
||||
syncTerrainOpacityUi(getTerrainOpacity());
|
||||
|
||||
if (terrainOpacitySlider instanceof HTMLInputElement) {
|
||||
bindListener(terrainOpacitySlider, "input", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
const nextOpacity = Number.parseFloat(target.value);
|
||||
const appliedOpacity = setTerrainOpacity(
|
||||
Number.isFinite(nextOpacity) ? nextOpacity : getTerrainOpacity(),
|
||||
);
|
||||
syncTerrainOpacityUi(appliedOpacity);
|
||||
});
|
||||
}
|
||||
|
||||
syncAllHudPanelToggles();
|
||||
}
|
||||
|
||||
@@ -690,10 +760,7 @@ export function resetView(camera) {
|
||||
if (!earthObj) return;
|
||||
|
||||
function animateToView(targetLat, targetLon, targetRotLon) {
|
||||
const latRot = (targetLat * Math.PI) / 180;
|
||||
const targetRotX =
|
||||
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
|
||||
const targetRotY = -((targetRotLon * Math.PI) / 180);
|
||||
const targetRotation = getViewRotation(targetLat, targetRotLon);
|
||||
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
@@ -706,8 +773,10 @@ export function resetView(camera) {
|
||||
800,
|
||||
(progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
|
||||
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
|
||||
earthObj.rotation.x =
|
||||
startRotX + (targetRotation.x - startRotX) * ease;
|
||||
earthObj.rotation.y =
|
||||
startRotY + (targetRotation.y - startRotY) * ease;
|
||||
|
||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
@@ -878,15 +947,30 @@ function setupTerrainControls() {
|
||||
showStatusMessage("搜索功能待开发", "info");
|
||||
});
|
||||
|
||||
bindListener(terrainBtn, "click", function () {
|
||||
showTerrain = !showTerrain;
|
||||
toggleTerrain(showTerrain);
|
||||
updateLayerButtonState(this, showTerrain);
|
||||
setButtonTooltip(this, showTerrain ? "隐藏地形" : "显示地形");
|
||||
const terrainStatus = document.getElementById("terrain-status");
|
||||
if (terrainStatus)
|
||||
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
|
||||
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
|
||||
bindListener(terrainBtn, "click", async function () {
|
||||
const nextShowTerrain = !showTerrain;
|
||||
const toggleToken = ++terrainToggleToken;
|
||||
|
||||
if (!nextShowTerrain) {
|
||||
applyTerrainUiState(this, false);
|
||||
showStatusMessage("地形已隐藏", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isTerrainReady()) {
|
||||
showStatusMessage("正在加载真实地形数据...", "info");
|
||||
await ensureTerrainReady();
|
||||
}
|
||||
if (toggleToken !== terrainToggleToken) return;
|
||||
|
||||
applyTerrainUiState(this, true);
|
||||
showStatusMessage("真实地形已显示", "success");
|
||||
} catch (error) {
|
||||
console.error("加载真实地形失败:", error);
|
||||
applyTerrainUiState(this, false);
|
||||
showStatusMessage("真实地形暂时不可用", "error");
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(satellitesBtn, "click", async function () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
@@ -212,34 +212,34 @@ export function createClouds(scene, earthObj) {
|
||||
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();
|
||||
|
||||
export function createTerrain(earthObj) {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
|
||||
TERRAIN_CONFIG.geometryWidthSegments,
|
||||
TERRAIN_CONFIG.geometryHeightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x00aa00,
|
||||
flatShading: true,
|
||||
color: TERRAIN_CONFIG.color,
|
||||
emissive: TERRAIN_CONFIG.emissive,
|
||||
specular: TERRAIN_CONFIG.specular,
|
||||
shininess: TERRAIN_CONFIG.shininess,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.7
|
||||
opacity: TERRAIN_CONFIG.opacity,
|
||||
flatShading: false,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
});
|
||||
|
||||
|
||||
terrain = new THREE.Mesh(geometry, material);
|
||||
terrain.name = "earth-real-terrain";
|
||||
terrain.visible = false;
|
||||
terrain.renderOrder = 0.5;
|
||||
earthObj.add(terrain);
|
||||
|
||||
|
||||
return terrain;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as THREE from "three";
|
||||
import { createNoise3D } from "simplex-noise";
|
||||
|
||||
import { CONFIG, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
|
||||
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
|
||||
import {
|
||||
showStatusMessage,
|
||||
queueStatusMessage,
|
||||
updateCoordinatesDisplay,
|
||||
updateZoomDisplay,
|
||||
updateEarthStats,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
clearEarthTexture,
|
||||
setEarthSunDirection,
|
||||
} from "./earth.js";
|
||||
import { registerTerrainMesh, clearTerrainData } from "./terrain.js";
|
||||
import {
|
||||
initCelestialLayer,
|
||||
updateCelestialLayer,
|
||||
@@ -118,7 +119,7 @@ import {
|
||||
getAutoRotate,
|
||||
getShowTerrain,
|
||||
setAutoRotate,
|
||||
resetView,
|
||||
applyImmediateView,
|
||||
getZoomLevel,
|
||||
teardownControls,
|
||||
updateLayerButtonState,
|
||||
@@ -142,7 +143,6 @@ export let scene;
|
||||
export let camera;
|
||||
export let renderer;
|
||||
|
||||
let simplex;
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let targetRotation = { x: 0, y: 0 };
|
||||
@@ -807,8 +807,10 @@ async function ensureCablesEnabled() {
|
||||
|
||||
clearCableData(earth);
|
||||
// Load landing points first so they appear before cable lines
|
||||
await loadLandingPoints(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
await loadLandingPoints(scene, earth, { silent: true });
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||
silent: true,
|
||||
});
|
||||
|
||||
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
|
||||
clearCableData(earth);
|
||||
@@ -913,7 +915,6 @@ export function init() {
|
||||
|
||||
destroyed = false;
|
||||
initialized = true;
|
||||
simplex = createNoise3D();
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
@@ -952,13 +953,14 @@ export function init() {
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
const earthObj = createEarth(scene);
|
||||
applyImmediateView(earthObj, camera);
|
||||
targetRotation = {
|
||||
x: earthObj.rotation.x,
|
||||
y: earthObj.rotation.y,
|
||||
};
|
||||
inertialVelocity = { x: 0, y: 0 };
|
||||
createClouds(scene, earthObj);
|
||||
createTerrain(scene, earthObj, simplex);
|
||||
registerTerrainMesh(createTerrain(earthObj));
|
||||
initCelestialLayer(scene, {
|
||||
camera,
|
||||
sunLight: sceneLights?.sunLight ?? null,
|
||||
@@ -969,7 +971,6 @@ export function init() {
|
||||
createSatellites(scene, earthObj);
|
||||
|
||||
setupControls(camera, renderer, scene, earthObj);
|
||||
resetView(camera);
|
||||
setupEventListeners();
|
||||
|
||||
clock.start();
|
||||
@@ -1054,7 +1055,7 @@ async function loadData() {
|
||||
setLoadingMessage("正在加载登陆点...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadLandingPoints(scene, earth);
|
||||
await loadLandingPoints(scene, earth, { silent: true });
|
||||
} catch (err) {
|
||||
errors.push({ label: "登陆点", reason: err });
|
||||
}
|
||||
@@ -1067,7 +1068,9 @@ async function loadData() {
|
||||
setLoadingMessage("正在加载海缆...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth, {
|
||||
silent: true,
|
||||
});
|
||||
if (loadToken === currentLoadToken && cablesEnabled) {
|
||||
toggleCables(true);
|
||||
updateCableToggleUi(true);
|
||||
@@ -1147,10 +1150,10 @@ async function loadData() {
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = buildLoadErrorMessage(errors);
|
||||
showError(errorMessage);
|
||||
showStatusMessage(errorMessage, "error");
|
||||
queueStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
showStatusMessage("数据已加载", "success");
|
||||
queueStatusMessage("数据已加载", "success");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1787,6 +1790,7 @@ export function destroy() {
|
||||
resetSatelliteState();
|
||||
clearUiState();
|
||||
disposeCelestialLayer();
|
||||
clearTerrainData();
|
||||
|
||||
if (scene) {
|
||||
disposeSceneObject(scene);
|
||||
|
||||
304
frontend/public/earth/js/terrain.js
Normal file
304
frontend/public/earth/js/terrain.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, TERRAIN_CONFIG } from "./constants.js";
|
||||
import { vector3ToLatLon } from "./utils.js";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371000;
|
||||
const TERRAIN_COLOR_STOPS = [
|
||||
{ height: 0, color: new THREE.Color(0x5f7f5b) },
|
||||
{ height: 800, color: new THREE.Color(0x7f9564) },
|
||||
{ height: 1800, color: new THREE.Color(0x9e956c) },
|
||||
{ height: 3200, color: new THREE.Color(0x9f866a) },
|
||||
{ height: 5200, color: new THREE.Color(0xc6c0b1) },
|
||||
{ height: 7800, color: new THREE.Color(0xe8e5de) },
|
||||
];
|
||||
|
||||
let terrainMesh = null;
|
||||
let terrainLoadPromise = null;
|
||||
let terrainReady = false;
|
||||
let terrainFailed = false;
|
||||
let terrainTileCache = new Map();
|
||||
let terrainVertexSamples = null;
|
||||
let terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
|
||||
function clampLatitude(lat) {
|
||||
return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878);
|
||||
}
|
||||
|
||||
function buildTileUrl(z, x, y) {
|
||||
return TERRAIN_CONFIG.urlTemplate
|
||||
.replace("{z}", String(z))
|
||||
.replace("{x}", String(x))
|
||||
.replace("{y}", String(y));
|
||||
}
|
||||
|
||||
function getTerrainCanvas(size) {
|
||||
if (typeof OffscreenCanvas !== "undefined") {
|
||||
return new OffscreenCanvas(size, size);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function decodeTerrainTile(z, x, y) {
|
||||
const cacheKey = `${z}/${x}/${y}`;
|
||||
if (terrainTileCache.has(cacheKey)) {
|
||||
return terrainTileCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const tilePromise = (async () => {
|
||||
const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize);
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize);
|
||||
bitmap.close?.();
|
||||
const { data, width, height } = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
TERRAIN_CONFIG.tileSize,
|
||||
);
|
||||
return { data, width, height };
|
||||
})();
|
||||
|
||||
terrainTileCache.set(cacheKey, tilePromise);
|
||||
return tilePromise;
|
||||
}
|
||||
|
||||
function decodeTerrariumHeight(tile, pixelX, pixelY) {
|
||||
const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1);
|
||||
const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1);
|
||||
const index = (safeY * tile.width + safeX) * 4;
|
||||
const r = tile.data[index];
|
||||
const g = tile.data[index + 1];
|
||||
const b = tile.data[index + 2];
|
||||
return (r * 256 + g + b / 256) - 32768;
|
||||
}
|
||||
|
||||
function latLonToTileSample(lat, lon, z, tileSize) {
|
||||
const n = 2 ** z;
|
||||
const clampedLat = clampLatitude(lat);
|
||||
const latRad = THREE.MathUtils.degToRad(clampedLat);
|
||||
const normalizedX = ((lon + 180) / 360) * n;
|
||||
const normalizedY =
|
||||
((1 -
|
||||
Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) /
|
||||
2) *
|
||||
n;
|
||||
|
||||
const tileX = THREE.MathUtils.euclideanModulo(
|
||||
Math.floor(normalizedX),
|
||||
n,
|
||||
);
|
||||
const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1);
|
||||
const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize);
|
||||
const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize);
|
||||
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
pixelX,
|
||||
pixelY,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerrainVertexSamples(positionAttribute) {
|
||||
const z = TERRAIN_CONFIG.baseZoom;
|
||||
const tileSize = TERRAIN_CONFIG.tileSize;
|
||||
const samples = [];
|
||||
|
||||
for (let i = 0; i < positionAttribute.count; i++) {
|
||||
const direction = new THREE.Vector3(
|
||||
positionAttribute.getX(i),
|
||||
positionAttribute.getY(i),
|
||||
positionAttribute.getZ(i),
|
||||
).normalize();
|
||||
const { lat, lon } = vector3ToLatLon(direction);
|
||||
const sample = latLonToTileSample(lat, lon, z, tileSize);
|
||||
samples.push({
|
||||
index: i,
|
||||
direction,
|
||||
...sample,
|
||||
});
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
function sampleTerrainColor(heightMeters) {
|
||||
if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) {
|
||||
return TERRAIN_COLOR_STOPS[0].color;
|
||||
}
|
||||
|
||||
for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) {
|
||||
const lower = TERRAIN_COLOR_STOPS[i - 1];
|
||||
const upper = TERRAIN_COLOR_STOPS[i];
|
||||
if (heightMeters <= upper.height) {
|
||||
const t =
|
||||
(heightMeters - lower.height) / Math.max(upper.height - lower.height, 1);
|
||||
return lower.color.clone().lerp(upper.color, t);
|
||||
}
|
||||
}
|
||||
|
||||
return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color;
|
||||
}
|
||||
|
||||
async function runWithConcurrency(items, limit, worker) {
|
||||
const queue = [...items];
|
||||
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function fetchRequiredTiles(samples) {
|
||||
const uniqueKeys = Array.from(
|
||||
new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)),
|
||||
);
|
||||
const resolvedTiles = new Map();
|
||||
|
||||
await runWithConcurrency(
|
||||
uniqueKeys,
|
||||
TERRAIN_CONFIG.maxConcurrentRequests,
|
||||
async (key) => {
|
||||
const [z, x, y] = key.split("/").map(Number);
|
||||
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
||||
},
|
||||
);
|
||||
|
||||
return resolvedTiles;
|
||||
}
|
||||
|
||||
function applyTerrainDisplacement(samples, mesh, resolvedTiles) {
|
||||
const geometry = mesh.geometry;
|
||||
const positionAttribute = geometry.getAttribute("position");
|
||||
let colorAttribute = geometry.getAttribute("color");
|
||||
if (!colorAttribute || colorAttribute.itemSize !== 4) {
|
||||
colorAttribute = new THREE.BufferAttribute(
|
||||
new Float32Array(positionAttribute.count * 4),
|
||||
4,
|
||||
);
|
||||
geometry.setAttribute("color", colorAttribute);
|
||||
}
|
||||
const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset;
|
||||
|
||||
samples.forEach((sample) => {
|
||||
const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`;
|
||||
const tile = resolvedTiles.get(tileKey);
|
||||
if (!tile) return;
|
||||
const rawElevationMeters = decodeTerrariumHeight(
|
||||
tile,
|
||||
sample.pixelX,
|
||||
sample.pixelY,
|
||||
);
|
||||
const elevationMeters = Math.max(0, rawElevationMeters);
|
||||
const heightWorld =
|
||||
(elevationMeters / EARTH_RADIUS_METERS) *
|
||||
CONFIG.earthRadius *
|
||||
TERRAIN_CONFIG.exaggeration;
|
||||
const radius = baseRadius + heightWorld;
|
||||
const tint = sampleTerrainColor(elevationMeters);
|
||||
const landAlpha = THREE.MathUtils.clamp(
|
||||
elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
positionAttribute.setXYZ(
|
||||
sample.index,
|
||||
sample.direction.x * radius,
|
||||
sample.direction.y * radius,
|
||||
sample.direction.z * radius,
|
||||
);
|
||||
colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha);
|
||||
});
|
||||
|
||||
positionAttribute.needsUpdate = true;
|
||||
colorAttribute.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
export function registerTerrainMesh(mesh) {
|
||||
terrainMesh = mesh;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainLoadPromise = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
terrainVertexSamples = mesh
|
||||
? buildTerrainVertexSamples(mesh.geometry.getAttribute("position"))
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isTerrainReady() {
|
||||
return terrainReady;
|
||||
}
|
||||
|
||||
export async function ensureTerrainReady() {
|
||||
if (!terrainMesh || !TERRAIN_CONFIG.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (terrainReady) {
|
||||
return true;
|
||||
}
|
||||
if (terrainLoadPromise) {
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
terrainLoadPromise = (async () => {
|
||||
try {
|
||||
const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples);
|
||||
applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles);
|
||||
terrainReady = true;
|
||||
terrainFailed = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
terrainFailed = true;
|
||||
console.error("加载真实地形失败:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
terrainLoadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return terrainLoadPromise;
|
||||
}
|
||||
|
||||
export function clearTerrainData() {
|
||||
terrainMesh = null;
|
||||
terrainLoadPromise = null;
|
||||
terrainReady = false;
|
||||
terrainFailed = false;
|
||||
terrainVertexSamples = null;
|
||||
terrainTileCache = new Map();
|
||||
terrainOpacity = TERRAIN_CONFIG.opacity;
|
||||
}
|
||||
|
||||
export function setTerrainOpacity(nextOpacity) {
|
||||
terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1);
|
||||
if (terrainMesh?.material) {
|
||||
terrainMesh.material.opacity = terrainOpacity;
|
||||
terrainMesh.material.needsUpdate = true;
|
||||
}
|
||||
return terrainOpacity;
|
||||
}
|
||||
|
||||
export function getTerrainOpacity() {
|
||||
return terrainOpacity;
|
||||
}
|
||||
@@ -9,6 +9,11 @@ let statusQueue = [];
|
||||
let statusBusy = false;
|
||||
let loadingActive = false;
|
||||
let loadingLockedWidth = 0;
|
||||
let pendingLoadingMessage = "";
|
||||
|
||||
function createStatusEntry(message, type = "info") {
|
||||
return { message, type };
|
||||
}
|
||||
|
||||
function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
@@ -113,7 +118,15 @@ function startTransientStatus(message, type = "info") {
|
||||
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
statusQueue.push({ message, type });
|
||||
if (loadingActive) {
|
||||
statusQueue.unshift(createStatusEntry(message, type));
|
||||
return;
|
||||
}
|
||||
startTransientStatus(message, type);
|
||||
}
|
||||
|
||||
export function queueStatusMessage(message, type = "info") {
|
||||
statusQueue.push(createStatusEntry(message, type));
|
||||
processStatusQueue();
|
||||
}
|
||||
|
||||
@@ -179,7 +192,12 @@ export function setLoading(loading) {
|
||||
loadingActive = true;
|
||||
statusBusy = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
buildStatusContent(statusEl, "正在加载...", "loading");
|
||||
buildStatusContent(
|
||||
statusEl,
|
||||
pendingLoadingMessage || "正在加载...",
|
||||
"loading",
|
||||
);
|
||||
pendingLoadingMessage = "";
|
||||
statusEl.className = `${STATUS_BASE_CLASS} loading`;
|
||||
setElementDisplay(statusEl, true, "inline-flex");
|
||||
statusEl.offsetHeight;
|
||||
@@ -188,6 +206,7 @@ export function setLoading(loading) {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
});
|
||||
} else {
|
||||
pendingLoadingMessage = "";
|
||||
if (!statusEl.classList.contains("loading")) {
|
||||
loadingActive = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
@@ -205,7 +224,10 @@ export function setLoading(loading) {
|
||||
|
||||
export function setLoadingMessage(title) {
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) return;
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) {
|
||||
pendingLoadingMessage = title;
|
||||
return;
|
||||
}
|
||||
const textEl = statusEl.querySelector(".earth-status-text");
|
||||
if (textEl) {
|
||||
textEl.textContent = title;
|
||||
@@ -255,6 +277,7 @@ export function clearUiState() {
|
||||
statusQueue = [];
|
||||
statusBusy = false;
|
||||
loadingActive = false;
|
||||
pendingLoadingMessage = "";
|
||||
|
||||
const statusEl = getElement("status-message");
|
||||
if (statusEl) {
|
||||
|
||||
Reference in New Issue
Block a user