release: bump version to 0.41.0

This commit is contained in:
linkong
2026-04-27 13:58:29 +08:00
parent f9c1334365
commit 3ea99a9529
25 changed files with 1652 additions and 171 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.40.5",
"version": "0.41.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -827,6 +827,12 @@
rgba(255, 255, 255, 0.04);
}
.earth-mobile-layer-card.is-disabled,
.earth-mobile-layer-card:disabled {
cursor: not-allowed;
opacity: 0.46;
}
.earth-mobile-layer-card-icon {
font-size: 22px;
color: var(--hud-accent-strong);
@@ -1401,6 +1407,12 @@
transform: translateX(16px);
}
label.is-disabled.earth-mobile-settings-card {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
.earth-mobile-settings-slider-row {
display: flex;
align-items: center;
@@ -2478,6 +2490,12 @@
transform: translateX(calc(16px * var(--hud-scale)));
}
.earth-settings-item.is-disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
@media (max-width: 960px) {
.earth-settings-sheet {
top: 24px;

View File

@@ -271,6 +271,16 @@
opacity: 1;
}
.layer-row-toggle.is-disabled {
cursor: not-allowed;
opacity: 0.35;
}
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-label,
.layer-row:has(.layer-row-toggle.is-disabled) .layer-row-icon {
opacity: 0.4;
}
/* Thumb */
.layer-row-toggle-track::after {
content: "";

File diff suppressed because one or more lines are too long

View File

@@ -108,33 +108,13 @@
<!-- Layer rows -->
<div class="layer-panel-list" id="layer-panel-list">
<div class="layer-row" data-layer-name="地形 terrain">
<span class="material-symbols-rounded layer-row-icon">landscape</span>
<div class="layer-row" data-layer-name="海缆 subsea cables">
<span class="material-symbols-rounded layer-row-icon">cable</span>
<div class="layer-row-copy">
<span class="layer-row-label">地形</span>
<span class="layer-row-meta">Terrain</span>
<span class="layer-row-label">海缆</span>
<span class="layer-row-meta">Subsea Cables</span>
</div>
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
<div class="layer-row-copy">
<span class="layer-row-label">经纬线</span>
<span class="layer-row-meta">Graticule</span>
</div>
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="卫星 satellites">
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
<div class="layer-row-copy">
<span class="layer-row-label">卫星</span>
<span class="layer-row-meta">Satellites</span>
</div>
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
@@ -148,13 +128,13 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="海缆 subsea cables">
<span class="material-symbols-rounded layer-row-icon">cable</span>
<div class="layer-row" data-layer-name="卫星 satellites">
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
<div class="layer-row-copy">
<span class="layer-row-label">海缆</span>
<span class="layer-row-meta">Subsea Cables</span>
<span class="layer-row-label">卫星</span>
<span class="layer-row-meta">Satellites</span>
</div>
<button id="toggle-cables" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换海缆显示">
<button id="toggle-satellites" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换卫星显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
@@ -178,6 +158,56 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="地形 terrain">
<span class="material-symbols-rounded layer-row-icon">landscape</span>
<div class="layer-row-copy">
<span class="layer-row-label">地形</span>
<span class="layer-row-meta">Terrain</span>
</div>
<button id="toggle-terrain" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换地形显示" data-status-target="terrain-status">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="高清材质 纹理 texture hd earth">
<span class="material-symbols-rounded layer-row-icon">globe</span>
<div class="layer-row-copy">
<span class="layer-row-label">高清材质</span>
<span class="layer-row-meta">High-Res Texture</span>
</div>
<button id="toggle-earth-high-res-texture" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换高清材质显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="大气 云图 云层 clouds atmosphere">
<span class="material-symbols-rounded layer-row-icon">cloud</span>
<div class="layer-row-copy">
<span class="layer-row-label">大气云图</span>
<span class="layer-row-meta">Cloud Layer</span>
</div>
<button id="toggle-atmosphere-clouds" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换大气云图显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="国界 国家 borders countries boundary">
<span class="material-symbols-rounded layer-row-icon">public</span>
<div class="layer-row-copy">
<span class="layer-row-label">国界</span>
<span class="layer-row-meta">Country Borders</span>
</div>
<button id="toggle-country-boundaries" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换国界显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
<div class="layer-row-copy">
<span class="layer-row-label">经纬线</span>
<span class="layer-row-meta">Graticule</span>
</div>
<button id="toggle-grid-lines" class="layer-row-toggle" type="button" role="switch" aria-checked="false" title="切换经纬线显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
</div>
<!-- Empty search state -->

View File

@@ -5,6 +5,7 @@ import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const textureCache = new Map();
let showComputeCenters = true;
let supercomputerCount = 0;
@@ -196,7 +197,7 @@ function createComputeCenterMarker(markerData) {
),
);
marker.scale.setScalar(baseScale);
marker.renderOrder = 8;
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
marker.visible = showComputeCenters;
marker.userData = {
...markerData,

View File

@@ -155,7 +155,7 @@ export const TERRAIN_CONFIG = {
baseZoom: 4,
geometryWidthSegments: 320,
geometryHeightSegments: 320,
baseRadiusOffset: 0.04,
baseRadiusOffset: 0.16,
exaggeration: 34,
landRevealFadeMeters: 220,
maxConcurrentRequests: 10,
@@ -168,6 +168,32 @@ export const TERRAIN_CONFIG = {
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
};
export const COUNTRY_BOUNDARY_CONFIG = {
dataPath: "/earth/data/countries-admin0.min.geojson",
lineAltitudeOffset: 0.24,
hoverAltitudeOffset: 0.32,
lineColor: 0x7fc7ff,
lineOpacity: 0.58,
lineRenderOrder: 2.2,
dimmedLineOpacity: 0.18,
hoverLineColor: 0xff3b1f,
hoverLineOpacity: 1.0,
hoverLineRenderOrder: 2.3,
hoverGlowOpacity: 0.38,
hoverGlowLineWidth: 3,
hoverGlowRenderOrderOffset: 0.01,
hoverGlowRadiusOffset: 0.04,
tintAltitudeOffset: 0.04,
tintColor: 0x0b1830,
tintRenderOrder: 0.2,
landColor: 0x080f1b,
landOpacity: 1.0,
landAltitudeOffset: 0.08,
landRenderOrder: 0.86,
landMaskWidth: 2048,
landMaskHeight: 1024,
};
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
@@ -393,19 +419,43 @@ export const PREDICTED_ORBIT_CONFIG = {
};
export const GRID_CONFIG = {
latitudeStep: 10,
radiusOffset: 0.14,
color: 0xc0e0ff,
opacity: 0.08,
lineWidth: 1,
renderOrder: 2.05,
latitudeStep: 15,
longitudeStep: 30,
gridStep: 5
segmentStep: 5,
};
export const CLOUD_LAYER_CONFIG = {
radiusOffset: 3,
widthSegments: 64,
heightSegments: 64,
opacity: 0.15,
textureUrl: "./assets/earth_clouds_1024.png",
};
export const STARFIELD_CONFIG = {
count: 8000,
minRadius: 800,
radiusJitter: 200,
color: 0xffffff,
size: 0.5,
};
export const EARTH_MATERIAL_CONFIG = {
// Diffuse color multiplies with texture — pure white = full saturation,
// slightly grey-blue pulls perceived saturation down without a custom shader.
color: 0xcdd8e6,
// Base sphere sits below the country fill and high-res texture overlays.
// Keep it dark so a delayed overlay never flashes or reads as a white layer.
color: 0x010609,
specular: 0x1a2d45,
shininess: 12,
emissive: 0x050a12,
opacity: 0.96,
emissive: 0x010609,
opacity: 1,
textureOverlayAltitudeOffset: 0.1,
textureOverlayOpacity: 0.88,
textureOverlayRenderOrder: 0.96,
// Depth-mask occluder keeps far-side objects hidden behind the earth
occluderRadiusFactor: 0.999,

View File

@@ -29,6 +29,11 @@ import {
clearLockedObject,
clearLockedObjectAndInfo,
setCablesEnabled,
setCountryBoundariesEnabled,
setHighResTextureEnabled,
getHighResTextureEnabled,
setAtmosphereCloudsEnabled,
getAtmosphereCloudsEnabled,
setSatellitesEnabled,
getSatellitesEnabled,
} from "./main.js";
@@ -41,6 +46,7 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries } from "./country-boundaries.js";
import {
toggleComputeCenters,
getShowComputeCenters,
@@ -73,6 +79,7 @@ export let rotationMode = ROTATION_MODE.ROTATE;
let dayNightEnabled = true;
let defaultEarthZoom = CONFIG.defaultViewZoom;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
let earthObj = null;
let listeners = [];
@@ -331,9 +338,31 @@ function getMobileLayerButtons(layerId) {
).filter((button) => button instanceof HTMLButtonElement);
}
function getLayerDisabledState(layerId) {
if (layerId === "trails" && !getSatellitesEnabled()) {
return {
disabled: true,
statusText: "不可用",
tooltip: "卫星关闭时不可用",
};
}
if (layerId === "terrain" && !getHighResTextureEnabled()) {
return {
disabled: true,
statusText: "不可用",
tooltip: "高清材质关闭时不可用",
};
}
return {
disabled: false,
statusText: null,
tooltip: null,
};
}
function syncMobileLayerCards() {
const summary = document.getElementById("mobile-layer-summary");
const definitions = getSortedLayerDefinitions();
const definitions = getDisplayLayerDefinitions();
let activeCount = 0;
definitions.forEach((definition) => {
@@ -342,11 +371,19 @@ function syncMobileLayerCards() {
activeCount += 1;
}
getMobileLayerButtons(definition.id).forEach((button) => {
const disabledState = getLayerDisabledState(definition.id);
button.classList.toggle("is-active", visible);
button.classList.toggle("is-disabled", disabledState.disabled);
button.disabled = disabledState.disabled;
button.setAttribute("aria-checked", visible ? "true" : "false");
if (disabledState.tooltip) {
button.title = disabledState.tooltip;
} else {
button.removeAttribute("title");
}
const status = button.querySelector("[data-mobile-layer-status]");
if (status) {
status.textContent = visible ? "开启" : "关闭";
status.textContent = disabledState.statusText || (visible ? "开启" : "关闭");
}
});
});
@@ -360,7 +397,7 @@ function renderMobileLayerCards() {
const list = document.getElementById("mobile-layer-list");
if (!(list instanceof HTMLElement)) return;
const definitions = getSortedLayerDefinitions();
const definitions = getDisplayLayerDefinitions();
list.innerHTML = definitions
.map((definition) => `
<button
@@ -384,6 +421,7 @@ function renderMobileLayerCards() {
bindListener(button, "click", async (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
if (target.disabled || target.classList.contains("is-disabled")) return;
const layerId = target.dataset.mobileLayerButton;
const definition = layerId ? getLayerDefinition(layerId) : null;
if (!definition) return;
@@ -594,6 +632,21 @@ function getSortedLayerDefinitions({ includeUnprioritized = true } = {}) {
.sort(compareLayerDefinitionsByStartupPriority);
}
function getDisplayLayerDefinitions() {
return Array.from(layerRegistry.values()).sort((left, right) => {
const leftOrder = Number.isFinite(left?.displayOrder)
? left.displayOrder
: Number.POSITIVE_INFINITY;
const rightOrder = Number.isFinite(right?.displayOrder)
? right.displayOrder
: Number.POSITIVE_INFINITY;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return String(left?.id || "").localeCompare(String(right?.id || ""));
});
}
function shouldIncludeLayerInStartupLoad(definition) {
if (!Number.isFinite(definition?.startupPriority)) {
return false;
@@ -658,13 +711,22 @@ function getCurrentSharedSettingsSnapshot() {
};
}
function getDefaultLayerVisibilitySnapshot() {
return Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.defaultActive)]),
);
}
function captureEarthSettingsDefaults() {
if (!earthSettingsDefaults) {
const panelVisibility = getCurrentPanelVisibilitySnapshot();
const shared = getCurrentSharedSettingsSnapshot();
earthSettingsDefaults = {
version: 2,
shared,
shared: {
...shared,
layerVisibility: getDefaultLayerVisibilitySnapshot(),
},
views: {
desktop: {
panelVisibility: { ...panelVisibility },
@@ -680,7 +742,7 @@ function captureEarthSettingsDefaults() {
function cloneEarthSettings(settings) {
return {
version: 2,
version: 3,
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
@@ -753,6 +815,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
});
if ((rawSettings?.version || 0) < 3 && inputLayerVisibility.gridLines === true) {
normalizedLayerVisibility.gridLines = defaults.shared.layerVisibility.gridLines;
}
const nextRotationMode =
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
@@ -779,7 +845,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
);
return {
version: 2,
version: 3,
shared: {
rotationMode: nextRotationMode,
cruiseModules: nextCruiseModules.length > 0
@@ -805,7 +871,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
function getPersistedLayers() {
return getSortedLayerDefinitions().filter((layer) => layer.persist !== false);
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
}
function getLayerDefinition(layerId) {
@@ -1149,6 +1215,7 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
} else if (enabled) {
setEarthStatValue("satellite-count", `${getSatelliteCount()}`);
}
syncTrailsAvailability();
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
@@ -1179,6 +1246,61 @@ function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = fa
return enabled;
}
async function setCountryBoundariesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
try {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "国界加载中...",
});
}
await setCountryBoundariesEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏国界" : "显示国界",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
} catch (error) {
console.error("切换国界显示失败:", error);
setLayerButtonState(button, {
active: false,
loading: false,
tooltip: "显示国界",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return false;
}
}
function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
setHighResTextureEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏高清材质" : "显示高清材质",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
}
function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏大气云图" : "显示大气云图",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
return enabled;
}
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
toggleBGP(enabled);
@@ -1216,9 +1338,11 @@ function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleTrails(enabled);
const disabledState = getLayerDisabledState("trails");
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏轨迹" : "显示轨迹",
disabled: disabledState.disabled,
tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"),
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
@@ -1228,6 +1352,17 @@ function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false
return enabled;
}
function syncTrailsAvailability() {
const trailsEnabled = getShowTrails();
const disabledState = getLayerDisabledState("trails");
setLayerButtonState(getLayerButton("trails"), {
active: trailsEnabled,
disabled: disabledState.disabled,
tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"),
});
syncMobileLayerCards();
}
async function setCablesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
try {
@@ -1253,23 +1388,6 @@ async function applyLayerVisibilitySettings(layerVisibility = {}, options = {})
function getBuiltinLayerDefinitions() {
return [
{
id: "terrain",
buttonId: "toggle-terrain",
icon: "landscape",
label: "地形",
meta: "Terrain",
keywords: "地形 terrain",
defaultActive: false,
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
{
id: "gridLines",
buttonId: "toggle-grid-lines",
@@ -1277,8 +1395,9 @@ function getBuiltinLayerDefinitions() {
label: "经纬线",
meta: "Graticule",
keywords: "经纬线 graticule 经纬 latitude longitude",
defaultActive: true,
startupPriority: null,
defaultActive: false,
displayOrder: 100,
startupPriority: 10,
startupMode: "visible",
startupLabel: "经纬线",
startupMessage: "",
@@ -1287,36 +1406,55 @@ function getBuiltinLayerDefinitions() {
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
icon: "satellite_alt",
label: "卫星",
meta: "Satellites",
keywords: "卫星 satellites",
defaultActive: false,
startupPriority: 30,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
getVisible: () => getSatellitesEnabled(),
id: "countryBoundaries",
buttonId: "toggle-country-boundaries",
icon: "public",
label: "国界",
meta: "Country Borders",
keywords: "国界 国家 borders countries boundary",
defaultActive: true,
displayOrder: 90,
startupPriority: 20,
startupMode: "preload",
startupLabel: "海陆基座",
startupMessage: "正在加载海陆基座...",
getVisible: () => getShowCountryBoundaries(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
setCountryBoundariesLayerEnabled(getLayerButton("countryBoundaries"), visible, options),
},
{
id: "trails",
buttonId: "toggle-trails",
icon: "timeline",
label: "轨迹",
meta: "Trails",
keywords: "轨迹 trails",
id: "earthHighResTexture",
buttonId: "toggle-earth-high-res-texture",
icon: "globe",
label: "高清材质",
meta: "High-Res Texture",
keywords: "高清 材质 纹理 texture hd 地表 earth",
defaultActive: true,
startupPriority: null,
displayOrder: 70,
startupPriority: 30,
startupMode: "visible",
startupLabel: "轨迹",
startupMessage: "",
getVisible: () => getShowTrails(),
startupLabel: "高清材质",
startupMessage: "正在启用高清材质...",
getVisible: () => getHighResTextureEnabled(),
setVisible: (visible, options = {}) =>
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
setHighResTextureLayerEnabled(getLayerButton("earthHighResTexture"), visible, options),
},
{
id: "atmosphereClouds",
buttonId: "toggle-atmosphere-clouds",
icon: "cloud",
label: "大气云图",
meta: "Cloud Layer",
keywords: "大气 云图 云层 clouds atmosphere",
defaultActive: true,
displayOrder: 80,
startupPriority: 40,
startupMode: "visible",
startupLabel: "大气云图",
startupMessage: "",
getVisible: () => getAtmosphereCloudsEnabled(),
setVisible: (visible, options = {}) =>
setAtmosphereCloudsLayerEnabled(getLayerButton("atmosphereClouds"), visible, options),
},
{
id: "cables",
@@ -1326,7 +1464,8 @@ function getBuiltinLayerDefinitions() {
meta: "Subsea Cables",
keywords: "海缆 subsea cables",
defaultActive: true,
startupPriority: 20,
displayOrder: 10,
startupPriority: 50,
startupMode: "visible",
startupLabel: "海缆",
startupMessage: {
@@ -1345,7 +1484,8 @@ function getBuiltinLayerDefinitions() {
meta: "Compute Centers",
keywords: "算力中心 compute centers gpu 超算",
defaultActive: true,
startupPriority: 35,
displayOrder: 40,
startupPriority: 60,
startupMode: "preload",
startupLabel: "算力中心",
startupMessage: "正在加载算力中心...",
@@ -1361,7 +1501,8 @@ function getBuiltinLayerDefinitions() {
meta: "Routing Signals",
keywords: "bgp观测 routing signals",
defaultActive: true,
startupPriority: 40,
displayOrder: 50,
startupPriority: 70,
startupMode: "preload",
startupLabel: "BGP态势",
startupMessage: "正在加载BGP态势...",
@@ -1369,6 +1510,58 @@ function getBuiltinLayerDefinitions() {
setVisible: (visible, options = {}) =>
setBGPLayerEnabled(getLayerButton("bgp"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
icon: "satellite_alt",
label: "卫星",
meta: "Satellites",
keywords: "卫星 satellites",
defaultActive: false,
displayOrder: 30,
startupPriority: 80,
startupMode: "visible",
startupLabel: "卫星",
startupMessage: "正在加载卫星...",
getVisible: () => getSatellitesEnabled(),
setVisible: (visible, options = {}) =>
setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options),
},
{
id: "trails",
buttonId: "toggle-trails",
icon: "timeline",
label: "轨迹",
meta: "Trails",
keywords: "轨迹 trails",
defaultActive: true,
displayOrder: 20,
startupPriority: null,
startupMode: "visible",
startupLabel: "轨迹",
startupMessage: "",
getVisible: () => getShowTrails(),
setVisible: (visible, options = {}) =>
setTrailsLayerEnabled(getLayerButton("trails"), visible, options),
},
{
id: "terrain",
buttonId: "toggle-terrain",
icon: "landscape",
label: "地形",
meta: "Terrain",
keywords: "地形 terrain",
defaultActive: false,
displayOrder: 60,
startupPriority: null,
startupMode: "visible",
startupLabel: "地形",
startupMessage: "正在渲染地形...",
statusTarget: "terrain-status",
getVisible: () => showTerrain,
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
];
}
@@ -1430,6 +1623,7 @@ function syncLayerRowDefinition(definition, { appendIfMissing = false } = {}) {
function registerLayerDefinition(definition, options = {}) {
const normalizedDefinition = {
persist: true,
displayOrder: null,
startupPriority: null,
startupMode: "visible",
startupLabel: "",
@@ -1818,6 +2012,31 @@ function applyDayNightEnabled(enabled, { persist = true } = {}) {
if (persist) persistEarthSettings();
}
export function setDayNightEnabledExternal(enabled, { persist = true } = {}) {
applyDayNightEnabled(enabled, { persist });
}
export function getDayNightEnabled() {
return dayNightEnabled;
}
export function setTerrainLayerInteractable(enabled) {
const button = getLayerButton("terrain");
setLayerButtonState(button, {
disabled: !enabled,
tooltip: enabled ? null : "高清材质关闭时不可用",
});
syncMobileLayerCards();
}
export function setDayNightInteractable(enabled) {
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((input) => {
input.disabled = !enabled;
const label = input.closest("label");
if (label) label.classList.toggle("is-disabled", !enabled);
});
}
function setupSettingsControls() {
const settingsTrigger = document.getElementById("settings-trigger");
const settingsClose = document.getElementById("settings-close");
@@ -1960,7 +2179,7 @@ function setupSettingsControls() {
});
captureEarthSettingsDefaults();
applyEarthSettings(loadEarthSettings());
settingsApplyPromise = applyEarthSettings(loadEarthSettings());
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
@@ -2283,7 +2502,7 @@ function resetCleanup() {
listeners = [];
}
export function setupControls(camera, renderer, scene, earth) {
export async function setupControls(camera, renderer, scene, earth) {
resetCleanup();
activeCamera = camera;
earthObj = earth;
@@ -2292,6 +2511,8 @@ export function setupControls(camera, renderer, scene, earth) {
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
await settingsApplyPromise;
syncTrailsAvailability();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
@@ -2622,7 +2843,11 @@ function bindLayerButton(row, definition) {
if (button.dataset.layerBound === "true") return;
bindListener(button, "click", async function () {
if (this.classList.contains("is-loading")) {
if (
this.disabled ||
this.classList.contains("is-loading") ||
this.classList.contains("is-disabled")
) {
return;
}
await definition.setVisible(!definition.getVisible());
@@ -2638,6 +2863,7 @@ export function registerLayer({
keywords = "",
defaultActive = false,
persist = true,
displayOrder = null,
startupPriority = null,
startupMode = "visible",
startupLabel = "",
@@ -2658,6 +2884,7 @@ export function registerLayer({
keywords,
defaultActive,
persist,
displayOrder,
startupPriority,
startupMode,
startupLabel,

View File

@@ -0,0 +1,500 @@
import * as THREE from "three";
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
// ─── Module state ──────────────────────────────────────────────────────────────
let _earthObj = null;
let _features = [];
let _landMesh = null;
let _tintMesh = null;
let _boundaryLines = null;
let _hoverGlowLines = null;
let _hoverLines = null;
let _hoveredFeature = null;
let _hoveredGroupKey = null;
let _visible = false;
let _landFillEnabled = true;
let _landFillSuppressed = false;
let _tintEnabled = false;
let _loaded = false;
let _loadPromise = null;
const OCEAN_HEX = 0x010609;
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
function hexToStyle(hex) {
return `#${hex.toString(16).padStart(6, "0")}`;
}
function hexToRgb(hex) {
return [
(hex >> 16) & 255,
(hex >> 8) & 255,
hex & 255,
];
}
function buildLandTexture(features) {
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
const oceanRgb = hexToRgb(OCEAN_HEX);
if (!ctx) {
const oceanData = new Uint8Array(width * height * 4);
for (let i = 0; i < oceanData.length; i += 4) {
oceanData[i] = oceanRgb[0];
oceanData[i + 1] = oceanRgb[1];
oceanData[i + 2] = oceanRgb[2];
oceanData[i + 3] = 255;
}
const fallbackTexture = new THREE.DataTexture(
oceanData,
width,
height,
THREE.RGBAFormat,
);
fallbackTexture.needsUpdate = true;
return fallbackTexture;
}
// Ocean background
ctx.fillStyle = hexToStyle(OCEAN_HEX);
ctx.fillRect(0, 0, width, height);
// Land polygons using evenodd fill rule so holes (lakes, islands) work correctly
ctx.fillStyle = hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor);
for (const feat of features) {
const geom = feat.geometry;
if (!geom) continue;
const polys =
geom.type === "Polygon" ? [geom.coordinates] :
geom.type === "MultiPolygon" ? geom.coordinates : null;
if (!polys) continue;
for (const rings of polys) {
ctx.beginPath();
for (const ring of rings) {
for (let i = 0; i < ring.length; i++) {
// equirectangular: x = (lon+180)/360*width, y = (90-lat)/180*height
const px = ((ring[i][0] + 180) / 360) * width;
const py = ((90 - ring[i][1]) / 180) * height;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath();
}
ctx.fill("evenodd");
}
}
const imageData = ctx.getImageData(0, 0, width, height);
const tex = new THREE.DataTexture(
new Uint8Array(imageData.data),
width,
height,
THREE.RGBAFormat,
);
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.generateMipmaps = false;
tex.flipY = true;
tex.needsUpdate = true;
return tex;
}
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
function makeLandMesh(tex) {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
const geo = new THREE.SphereGeometry(r, 128, 64);
const mat = new THREE.MeshBasicMaterial({
color: 0xffffff,
map: tex,
transparent: COUNTRY_BOUNDARY_CONFIG.landOpacity < 1,
opacity: COUNTRY_BOUNDARY_CONFIG.landOpacity,
depthTest: true,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.name = "country-land-ocean";
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
mesh.visible = false;
mesh.raycast = () => {};
return mesh;
}
function makeTintMesh() {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
const geo = new THREE.SphereGeometry(r, 64, 32);
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
const mesh = new THREE.Mesh(geo, mat);
mesh.name = "country-tint";
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.tintRenderOrder;
mesh.visible = false;
mesh.raycast = () => {};
return mesh;
}
// ─── Boundary line geometry ────────────────────────────────────────────────────
function ringToSegments(ring, radius, out) {
const n = ring.length;
if (n < 2) return;
for (let i = 0; i < n - 1; i++) {
out.push(latLonToVector3(ring[i][1], ring[i][0], radius));
out.push(latLonToVector3(ring[i+1][1], ring[i+1][0], radius));
}
}
function featureToSegments(geom, radius) {
const pts = [];
if (!geom) return pts;
if (geom.type === "Polygon") {
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
} else if (geom.type === "MultiPolygon") {
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
}
return pts;
}
function buildBoundaryLines(features) {
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
transparent: true,
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
depthTest: true,
depthWrite: false,
});
const all = [];
for (const feat of features) {
const pts = featureToSegments(feat.geometry, r);
all.push(...pts);
}
const geo = all.length > 0
? new THREE.BufferGeometry().setFromPoints(all)
: new THREE.BufferGeometry();
const lines = new THREE.LineSegments(geo, mat);
lines.name = "country-boundary-all";
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function buildHoverLines() {
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
transparent: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity < 1,
opacity: COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity,
depthTest: false,
depthWrite: false,
});
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
lines.name = "country-hover";
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function buildHoverGlowLines() {
const mat = new THREE.LineBasicMaterial({
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
transparent: true,
opacity: COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity,
depthTest: false,
depthWrite: false,
blending: THREE.AdditiveBlending,
linewidth: COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth,
});
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
lines.name = "country-hover-glow";
lines.renderOrder =
COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder -
COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset;
lines.visible = false;
lines.raycast = () => {};
return lines;
}
function setBoundaryLinesDimmed(dimmed) {
if (!_boundaryLines?.material) return;
_boundaryLines.material.opacity = dimmed
? COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity
: COUNTRY_BOUNDARY_CONFIG.lineOpacity;
_boundaryLines.material.needsUpdate = true;
}
function clearHoverLineGeometries() {
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
}
function featureListToSegments(features, radius) {
return features.flatMap(f => featureToSegments(f.geometry, radius));
}
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
function pointInRing(lat, lon, ring) {
let inside = false;
const n = ring.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if ((yi > lat) !== (yj > lat) && lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
function featureContains(lat, lon, feat) {
const geom = feat.geometry;
if (!geom) return false;
if (geom.type === "Polygon") {
if (!pointInRing(lat, lon, geom.coordinates[0])) return false;
return geom.coordinates.slice(1).every(h => !pointInRing(lat, lon, h));
}
if (geom.type === "MultiPolygon") {
return geom.coordinates.some(poly =>
pointInRing(lat, lon, poly[0]) &&
poly.slice(1).every(h => !pointInRing(lat, lon, h))
);
}
return false;
}
function makeCountryInfo(feat) {
if (!feat) return null;
const p = feat.properties || {};
return {
name: p.NAME_EN || p.NAME || p.ADMIN || "",
nameZh: p.NAME_ZH || null,
isoA3: p.ISO_A3 || p.ADM0_A3 || null,
isoA2: p.ISO_A2 || null,
continent: p.CONTINENT || null,
};
}
function getCountryHighlightGroupKey(feat) {
const p = feat?.properties || {};
const isoA3 = p.ISO_A3 || p.ADM0_A3 || "";
if (isoA3 === "CHN" || isoA3 === "TWN") {
return "CHN_TWN";
}
return isoA3 || p.ISO_A2 || p.NAME_EN || p.NAME || p.ADMIN || null;
}
function getHighlightFeatures(feat) {
const groupKey = getCountryHighlightGroupKey(feat);
if (!groupKey) return feat ? [feat] : [];
return _features.filter(f => getCountryHighlightGroupKey(f) === groupKey);
}
// ─── Public API ────────────────────────────────────────────────────────────────
/** Called during init (before data load). Creates the placeholder tint sphere. */
export function createCountryBoundaryLayer(earthObj) {
_earthObj = earthObj;
_tintMesh = makeTintMesh();
_earthObj.add(_tintMesh);
}
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
export async function loadCountryBoundaries() {
if (_loaded) return _features.length;
if (_loadPromise) return _loadPromise;
_loadPromise = (async () => {
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
const geojson = await resp.json();
_features = (geojson.features || []).filter(f => f.geometry);
const tex = buildLandTexture(_features);
_landMesh = makeLandMesh(tex);
_earthObj.add(_landMesh);
_boundaryLines = buildBoundaryLines(_features);
_earthObj.add(_boundaryLines);
_hoverGlowLines = buildHoverGlowLines();
_earthObj.add(_hoverGlowLines);
_hoverLines = buildHoverLines();
_earthObj.add(_hoverLines);
_loaded = true;
return _features.length;
})();
return _loadPromise;
}
/** Load if not yet loaded, then return feature count. */
export async function ensureCountryBoundariesReady() {
if (!_loaded) await loadCountryBoundaries();
return _features.length;
}
/**
* Show or hide the country boundary lines.
* The land/ocean fill is the base earth map and stays independent from this
* line visibility switch.
* @param {boolean} visible
* @param {{ showTint?: boolean, showLandFill?: boolean, suppressLandFill?: boolean }} [opts]
* showLandFill whether to show the base land/ocean fill.
* Defaults to the current stored value so callers that only
* care about visibility don't need to repeat it.
* suppressLandFill temporarily keep the fill below the high-res texture
* without changing the layer's own fill state.
*/
export function toggleCountryBoundaries(
visible,
{ showTint = false, showLandFill = null, suppressLandFill = null } = {},
) {
_visible = Boolean(visible);
if (showLandFill !== null) _landFillEnabled = Boolean(showLandFill);
if (suppressLandFill !== null) _landFillSuppressed = Boolean(suppressLandFill);
if (_landMesh) {
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
if (_boundaryLines) _boundaryLines.visible = _visible;
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
if (_hoverLines) _hoverLines.visible = _visible;
if (!_visible) {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
}
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
}
/**
* Show or hide the land/ocean canvas fill independently of boundary lines.
*/
export function setLandFillEnabled(enabled) {
_landFillEnabled = Boolean(enabled);
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
export function setLandFillSuppressed(enabled) {
_landFillSuppressed = Boolean(enabled);
if (_landMesh) _landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
/**
* Enable / disable the solid dark tint overlay (used when high-res texture is off).
*/
export function setSurfaceTintEnabled(enabled) {
_tintEnabled = Boolean(enabled);
if (_tintMesh) _tintMesh.visible = _visible && _tintEnabled;
}
export function getShowCountryBoundaries() {
return _visible;
}
/** Clear the hover highlight without hiding the full layer. */
export function clearCountryBoundaryHover() {
if (!_hoveredFeature) return;
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
}
/**
* Update hover highlight for the given lat/lon coords.
* Returns a country-info object when hovering over land, or null over ocean.
*/
export function updateCountryBoundaryHover(coords) {
if (!_loaded || !_visible) return null;
const { lat, lon } = coords;
const found = _features.find(f => featureContains(lat, lon, f)) || null;
const groupKey = getCountryHighlightGroupKey(found);
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
_hoveredFeature = found;
_hoveredGroupKey = groupKey;
if (_hoverLines) {
if (!found) {
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
} else {
setBoundaryLinesDimmed(true);
const highlightFeatures = getHighlightFeatures(found);
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
if (_hoverGlowLines) {
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
_hoverGlowLines.geometry.setFromPoints(glowPts);
}
const corePts = featureListToSegments(highlightFeatures, coreRadius);
_hoverLines.geometry.setFromPoints(corePts);
}
}
}
return found ? makeCountryInfo(found) : null;
}
/** Dispose all Three.js objects and reset state. */
export function clearCountryBoundaryData() {
_hoveredFeature = null;
_hoveredGroupKey = null;
function disposeObj(obj) {
if (!obj) return;
if (_earthObj) _earthObj.remove(obj);
obj.geometry?.dispose();
if (obj.material) {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
}
}
disposeObj(_hoverLines);
disposeObj(_hoverGlowLines);
disposeObj(_boundaryLines);
disposeObj(_landMesh);
disposeObj(_tintMesh);
_hoverLines = null;
_hoverGlowLines = null;
_boundaryLines = null;
_landMesh = null;
_tintMesh = null;
_features = [];
_loaded = false;
_loadPromise = null;
_visible = false;
_landFillEnabled = true;
_landFillSuppressed = false;
_tintEnabled = false;
}
export function getCountryBoundaryLegendItems() {
return [
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
];
}

View File

@@ -1,18 +1,31 @@
// earth.js - 3D Earth creation module
import * as THREE from 'three';
import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, TERRAIN_CONFIG } from './constants.js';
import {
CLOUD_LAYER_CONFIG,
CONFIG,
EARTH_CONFIG,
EARTH_MATERIAL_CONFIG,
GRID_CONFIG,
STARFIELD_CONFIG,
TERRAIN_CONFIG,
} from './constants.js';
import { latLonToVector3 } from './utils.js';
export let earth = null;
export let clouds = null;
export let terrain = null;
let showGridLines = true;
let showGridLines = false;
let showClouds = true;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
let _earthTextureOverlay = null;
let _earthTextureOverlayMaterial = null;
let _earthShader = null;
let _dayNightEnabled = true;
let _loadedTexture = null;
let _textureVisible = true;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -103,7 +116,7 @@ export function createEarth(scene) {
specular: C.specular,
shininess: C.shininess,
emissive: C.emissive,
transparent: true,
transparent: C.opacity < 1,
opacity: C.opacity,
side: THREE.FrontSide,
depthWrite: true,
@@ -117,6 +130,30 @@ export function createEarth(scene) {
earth.rotation.x = EARTH_CONFIG.tiltRad;
scene.add(earth);
const textureOverlayGeometry = new THREE.SphereGeometry(
CONFIG.earthRadius + C.textureOverlayAltitudeOffset,
128,
128,
);
_earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
specular: C.specular,
shininess: C.shininess,
transparent: true,
opacity: C.textureOverlayOpacity,
side: THREE.FrontSide,
depthWrite: false,
depthTest: true,
});
_earthTextureOverlay = new THREE.Mesh(
textureOverlayGeometry,
_earthTextureOverlayMaterial,
);
_earthTextureOverlay.name = "earth-high-res-texture-overlay";
_earthTextureOverlay.renderOrder = C.textureOverlayRenderOrder;
_earthTextureOverlay.visible = false;
earth.add(_earthTextureOverlay);
// Depth-mask occluder — invisible sphere slightly inside the earth,
// writes to the depth buffer so far-side cables/satellites are occluded.
const occluderGeometry = new THREE.SphereGeometry(
@@ -198,10 +235,14 @@ export function createEarth(scene) {
}
export function createClouds(scene, earthObj) {
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64);
const geometry = new THREE.SphereGeometry(
CONFIG.earthRadius + CLOUD_LAYER_CONFIG.radiusOffset,
CLOUD_LAYER_CONFIG.widthSegments,
CLOUD_LAYER_CONFIG.heightSegments,
);
const material = new THREE.MeshPhongMaterial({
transparent: true,
opacity: 0.15,
opacity: CLOUD_LAYER_CONFIG.opacity,
depthTest: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
@@ -209,10 +250,12 @@ export function createClouds(scene, earthObj) {
});
clouds = new THREE.Mesh(geometry, material);
clouds.name = "earth-atmosphere-clouds";
clouds.visible = showClouds;
earthObj.add(clouds);
textureLoader.load(
'./assets/earth_clouds_1024.png',
CLOUD_LAYER_CONFIG.textureUrl,
function(texture) {
material.map = texture;
material.needsUpdate = true;
@@ -226,6 +269,17 @@ export function createClouds(scene, earthObj) {
return clouds;
}
export function toggleClouds(visible) {
showClouds = Boolean(visible);
if (clouds) {
clouds.visible = showClouds;
}
}
export function getShowClouds() {
return showClouds;
}
export function createTerrain(earthObj) {
const geometry = new THREE.SphereGeometry(
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
@@ -251,7 +305,8 @@ export function createTerrain(earthObj) {
terrain = new THREE.Mesh(geometry, material);
terrain.name = "earth-real-terrain";
terrain.visible = false;
terrain.renderOrder = 0.5;
terrain.renderOrder = 1.2;
terrain.raycast = () => {};
earthObj.add(terrain);
return terrain;
@@ -265,11 +320,11 @@ export function toggleTerrain(visible) {
export function createStars(scene) {
const starGeometry = new THREE.BufferGeometry();
const starCount = 8000;
const starCount = STARFIELD_CONFIG.count;
const starPositions = new Float32Array(starCount * 3);
for (let i = 0; i < starCount * 3; i += 3) {
const r = 800 + Math.random() * 200;
const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
@@ -281,8 +336,8 @@ export function createStars(scene) {
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMaterial = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.5,
color: STARFIELD_CONFIG.color,
size: STARFIELD_CONFIG.size,
transparent: true,
blending: THREE.AdditiveBlending
});
@@ -302,17 +357,19 @@ export function createGridLines(scene, earthObj) {
latitudeLines = [];
longitudeLines = [];
const earthRadius = 100.1;
const earthRadius = CONFIG.earthRadius + GRID_CONFIG.radiusOffset;
const gridMaterial = new THREE.LineBasicMaterial({
color: 0x44aaff,
color: GRID_CONFIG.color,
transparent: true,
opacity: 0.2,
linewidth: 1
opacity: GRID_CONFIG.opacity,
linewidth: GRID_CONFIG.lineWidth,
depthTest: true,
depthWrite: false,
});
for (let lat = -75; lat <= 75; lat += 15) {
for (let lat = -75; lat <= 75; lat += GRID_CONFIG.latitudeStep) {
const points = [];
for (let lon = -180; lon <= 180; lon += 5) {
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.segmentStep) {
const point = latLonToVector3(lat, lon, earthRadius);
points.push(point);
}
@@ -320,14 +377,15 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'latitude', value: lat };
line.renderOrder = GRID_CONFIG.renderOrder;
line.visible = showGridLines;
earthObj.add(line);
latitudeLines.push(line);
}
for (let lon = -180; lon <= 180; lon += 30) {
for (let lon = -180; lon <= 180; lon += GRID_CONFIG.longitudeStep) {
const points = [];
for (let lat = -90; lat <= 90; lat += 5) {
for (let lat = -90; lat <= 90; lat += GRID_CONFIG.segmentStep) {
const point = latLonToVector3(lat, lon, earthRadius);
points.push(point);
}
@@ -335,6 +393,7 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'longitude', value: lon };
line.renderOrder = GRID_CONFIG.renderOrder;
line.visible = showGridLines;
earthObj.add(line);
longitudeLines.push(line);
@@ -359,14 +418,23 @@ export function getEarth() {
return earth;
}
export function getEarthSurfacePickTarget() {
return _earthTextureOverlay?.visible ? _earthTextureOverlay : earth;
}
export function getClouds() {
return clouds;
}
export function clearEarthTexture() {
if (!_earthMaterial) return;
_earthMaterial.map = null;
_earthMaterial.needsUpdate = true;
_loadedTexture = null;
if (_earthTextureOverlayMaterial) {
_earthTextureOverlayMaterial.map = null;
_earthTextureOverlayMaterial.needsUpdate = true;
}
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = false;
}
}
export function setEarthSunDirection(direction) {
@@ -390,10 +458,9 @@ export function setDayNightEnabled(enabled) {
_earthMaterial.emissiveMap = null;
} else {
// Full bright: zero diffuse so directional light has no effect;
// use original color as emissive map to show texture uniformly.
_earthMaterial.color.setRGB(0, 0, 0);
_earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color);
_earthMaterial.emissiveMap = _earthMaterial.map;
_earthMaterial.emissiveMap = null;
}
_earthMaterial.needsUpdate = true;
}
@@ -401,7 +468,7 @@ export function setDayNightEnabled(enabled) {
export function loadEarthTexture() {
return new Promise((resolve) => {
if (!_earthMaterial) { resolve(); return; }
if (!_earthTextureOverlayMaterial) { resolve(); return; }
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
const tryLoad = (index) => {
@@ -418,12 +485,12 @@ export function loadEarthTexture() {
texture.anisotropy = 16;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
_earthMaterial.map = texture;
// If day/night is currently disabled, sync emissiveMap to the newly loaded texture
if (!_dayNightEnabled) {
_earthMaterial.emissiveMap = texture;
_loadedTexture = texture;
_earthTextureOverlayMaterial.map = texture;
_earthTextureOverlayMaterial.needsUpdate = true;
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = _textureVisible;
}
_earthMaterial.needsUpdate = true;
resolve();
},
null,
@@ -433,3 +500,18 @@ export function loadEarthTexture() {
tryLoad(0);
});
}
export function setEarthTextureVisible(visible) {
_textureVisible = Boolean(visible);
if (_earthTextureOverlay) {
_earthTextureOverlay.visible = _textureVisible && Boolean(_loadedTexture);
}
if (_earthTextureOverlayMaterial) {
_earthTextureOverlayMaterial.map = _loadedTexture || null;
_earthTextureOverlayMaterial.needsUpdate = true;
}
}
export function getEarthTextureVisible() {
return _textureVisible;
}

View File

@@ -23,12 +23,14 @@ export function setLayerButtonState(button, options = {}) {
const {
active = null,
loading = false,
disabled = null,
tooltip = null,
statusText = null,
} = options;
button.classList.toggle("is-loading", loading);
button.toggleAttribute("aria-busy", loading);
button.disabled = loading;
button.disabled = loading || (disabled === true);
button.classList.toggle("is-disabled", disabled === true);
if (typeof active === "boolean") {
updateLayerButtonState(button, active);
}

View File

@@ -18,6 +18,11 @@ import {
loadComputeCenters,
toggleComputeCenters,
} from "./compute-centers.js";
import {
getCountryBoundaryLegendItems,
loadCountryBoundaries,
toggleCountryBoundaries,
} from "./country-boundaries.js";
/**
* Layer startup task registry.
@@ -72,10 +77,11 @@ export function registerLayerStartupTask(id, taskFactory) {
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCountryBoundaryStartupTask();
registerCableStartupTask();
registerSatelliteStartupTask();
registerComputeCenterStartupTask();
registerBGPStartupTask();
registerSatelliteStartupTask();
}
function registerCableStartupTask() {
@@ -176,6 +182,32 @@ function registerBGPStartupTask() {
});
}
function registerCountryBoundaryStartupTask() {
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载海陆基座..."),
);
await context.yieldFrame(12);
try {
await loadCountryBoundaries();
if (!context.isCancelled()) {
const textureOn = context.isEarthTextureVisible();
toggleCountryBoundaries(context.getShowCountryBoundaries(), {
showTint: !textureOn,
showLandFill: true,
suppressLandFill: false,
});
context.setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "国界", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerComputeCenterStartupTask() {
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
context.setLoadingMessage(

View File

@@ -3,6 +3,7 @@ import { createHUDPanel } from "./hud-panels.js";
const LEGEND_MODES = {
cables: { title: "海缆" },
satellites: { title: "卫星" },
countryBoundaries: { title: "国界" },
computeCenters: { title: "算力" },
bgp: { title: "BGP" },
};
@@ -12,6 +13,7 @@ let legendPanel = null;
let legendItemsByMode = {
cables: [],
satellites: [],
countryBoundaries: [],
computeCenters: [],
bgp: [],
};

View File

@@ -33,11 +33,17 @@ import {
createEarth,
createClouds,
createTerrain,
getShowClouds,
createGridLines,
getEarth,
getEarthSurfacePickTarget,
loadEarthTexture,
clearEarthTexture,
setEarthSunDirection,
setEarthTextureVisible,
getEarthTextureVisible,
toggleClouds,
toggleTerrain,
} from "./earth.js";
import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js";
import {
@@ -50,6 +56,19 @@ import {
setCelestialFollow,
setCelestialDayNightEnabled,
} from "./celestial.js";
import {
clearCountryBoundaryData,
clearCountryBoundaryHover,
createCountryBoundaryLayer,
ensureCountryBoundariesReady,
getCountryBoundaryLegendItems,
getShowCountryBoundaries,
setLandFillEnabled,
setLandFillSuppressed,
setSurfaceTintEnabled,
toggleCountryBoundaries,
updateCountryBoundaryHover,
} from "./country-boundaries.js";
import {
loadGeoJSONFromPath,
loadLandingPoints,
@@ -162,6 +181,10 @@ import {
getZoomLevel,
setZoomLevel,
teardownControls,
getDayNightEnabled,
setDayNightEnabledExternal,
setTerrainLayerInteractable,
setDayNightInteractable,
} from "./controls.js";
import {
createLayerStartupTaskMap,
@@ -227,7 +250,7 @@ let destroyed = false;
let isDataLoading = false;
let currentLoadToken = 0;
let cablesEnabled = true;
let satellitesEnabled = true;
let satellitesEnabled = false;
let cableToggleToken = 0;
let satelliteToggleToken = 0;
let satelliteHydrationToken = 0;
@@ -254,6 +277,8 @@ const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const scratchComputeCenterDirection = new THREE.Vector3();
const scratchComputeCenterWorldPosition = new THREE.Vector3();
const scratchSatelliteWorldPosition = new THREE.Vector3();
const scratchSatelliteScreenPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = [];
@@ -472,6 +497,7 @@ function resetTransientComputeCenterStates() {
function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
clearCountryBoundaryHover();
hoveredBGP = null;
hoveredComputeCenter = null;
@@ -648,6 +674,13 @@ function getComputeCenterBriefHtml(marker) {
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
}
function getCountryBoundaryBriefHtml(country) {
const name = country?.nameZh || country?.name || "未知国家";
const code = country?.isoA3 || country?.isoA2 || "-";
const continent = country?.continent || "-";
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
}
function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
@@ -1216,11 +1249,11 @@ function getBGPStatusText(bgpResult) {
function updateComputeCenterHud(computeCenterResult) {
const computeBtn = document.getElementById("toggle-compute-centers");
if (computeBtn) {
computeBtn.classList.add("active");
const tooltip = computeBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = "隐藏算力中心";
}
setLayerButtonState(computeBtn, {
active: getShowComputeCenters(),
loading: false,
tooltip: getShowComputeCenters() ? "隐藏算力中心" : "显示算力中心",
});
}
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount}`);
@@ -1229,11 +1262,11 @@ function updateComputeCenterHud(computeCenterResult) {
function updateBGPHud(bgpResult) {
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
bgpBtn.classList.add("active");
const tooltip = bgpBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = ACTIVE_BGP_TOOLTIP_TEXT;
}
setLayerButtonState(bgpBtn, {
active: getShowBGP(),
loading: false,
tooltip: getShowBGP() ? ACTIVE_BGP_TOOLTIP_TEXT : "显示BGP观测",
});
}
setEarthStatValue("bgp-anomaly-count", `${bgpResult.totalCount}`);
@@ -1780,6 +1813,58 @@ function updatePointerFromEvent(event) {
interactionRaycaster.setFromCamera(interactionMouse, camera);
}
function getSatellitePointerIntersections(event) {
if (!renderer || !camera || !getShowSatellites()) return [];
const satPoints = getSatellitePoints();
const satPositions = getSatellitePositions();
if (!satPoints?.visible || !Array.isArray(satPositions) || satPositions.length === 0) {
return [];
}
const rect = renderer.domElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const shaderSize = satPoints.material?.uniforms?.size?.value;
const dotSizePx = Number.isFinite(shaderSize)
? shaderSize / dpr
: SATELLITE_CONFIG.dotBaseSize;
const pickRadiusPx = Math.max(12, Math.min(30, dotSizePx * 4));
const pickRadiusSq = pickRadiusPx * pickRadiusPx;
const hits = [];
satPoints.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
for (let index = 0; index < satelliteCount; index++) {
const position = satPositions[index]?.current;
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
if (
scratchSatelliteScreenPosition.z < -1 ||
scratchSatelliteScreenPosition.z > 1
) {
continue;
}
const screenX =
rect.left + (scratchSatelliteScreenPosition.x * 0.5 + 0.5) * rect.width;
const screenY =
rect.top + (-scratchSatelliteScreenPosition.y * 0.5 + 0.5) * rect.height;
const dx = screenX - event.clientX;
const dy = screenY - event.clientY;
const distanceSq = dx * dx + dy * dy;
if (distanceSq <= pickRadiusSq) {
hits.push({ index, distanceSq });
}
}
hits.sort((a, b) => a.distanceSq - b.distanceSq);
return hits;
}
function buildLoadErrorMessage(errors) {
if (errors.length === 0) return "";
return errors
@@ -1922,6 +2007,7 @@ function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
satelliteHydrationToken += 1;
toggleSatellites(false);
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
@@ -2002,6 +2088,7 @@ export function init() {
initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
@@ -2023,15 +2110,28 @@ export function init() {
});
setCelestialDayNightEnabled(true);
createGridLines(scene, earthObj);
createCountryBoundaryLayer(earthObj);
createSatellites(scene, earthObj);
setupControls(camera, renderer, scene, earthObj);
setupEventListeners();
clock.start();
loadData();
animate();
registerGlobalApi();
setupControls(camera, renderer, scene, earthObj)
.catch((error) => {
console.error("初始化 Earth 控制项失败:", error);
void reportEarthClientLog({
level: "error",
category: "init",
module: "controls",
message: `初始化 Earth 控制项失败: ${error?.message || String(error)}`,
detail: error,
});
})
.finally(() => {
if (destroyed) return;
setupEventListeners();
clock.start();
loadData();
animate();
registerGlobalApi();
});
}
function registerGlobalApi() {
@@ -2162,6 +2262,7 @@ async function loadData() {
clearCableData(earth);
clearComputeCenterData(earth);
clearSatelliteData();
clearCountryBoundaryHover();
setLoadingMessage("正在初始化...");
setLoading(true);
@@ -2194,7 +2295,9 @@ async function loadData() {
updateComputeCenterHud,
updateBGPHud,
getShowComputeCenters,
getShowCountryBoundaries,
getShowBGP,
isEarthTextureVisible: () => getEarthTextureVisible(),
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
@@ -2247,6 +2350,7 @@ async function loadData() {
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
@@ -2338,6 +2442,102 @@ export async function setCablesEnabled(
}
}
export async function setCountryBoundariesEnabled(
enabled,
{ suppressStatus = false } = {},
) {
if (!enabled) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
if (!suppressStatus) {
showStatusMessage("国界已隐藏", "info");
}
return 0;
}
try {
const countryCount = await ensureCountryBoundariesReady();
const textureOn = getEarthTextureVisible();
toggleCountryBoundaries(true, {
showTint: !textureOn,
showLandFill: true,
suppressLandFill: false,
});
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
refreshLegend();
if (!suppressStatus) {
showStatusMessage("国界已显示", "info");
}
return countryCount;
} catch (error) {
toggleCountryBoundaries(false);
clearCountryBoundaryHover();
const message = `国界加载失败: ${error?.message || String(error)}`;
void reportEarthClientLog({
level: "error",
category: "layer-toggle",
module: "country-boundaries",
message,
detail: error,
});
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
}
}
let _dayNightBeforeTextureOff = null;
let _terrainBeforeTextureOff = null;
export function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) {
setEarthTextureVisible(enabled);
setSurfaceTintEnabled(!enabled);
setLandFillEnabled(true);
setLandFillSuppressed(false);
setTerrainLayerInteractable(enabled);
setDayNightInteractable(enabled);
if (!enabled) {
if (_terrainBeforeTextureOff === null) {
_terrainBeforeTextureOff = getShowTerrain();
}
toggleTerrain(false);
if (_dayNightBeforeTextureOff === null) {
_dayNightBeforeTextureOff = getDayNightEnabled();
}
setDayNightEnabledExternal(false, { persist: false });
} else {
if (_terrainBeforeTextureOff !== null) {
toggleTerrain(_terrainBeforeTextureOff);
_terrainBeforeTextureOff = null;
}
if (_dayNightBeforeTextureOff !== null) {
setDayNightEnabledExternal(_dayNightBeforeTextureOff, { persist: false });
_dayNightBeforeTextureOff = null;
}
}
if (!suppressStatus) {
showStatusMessage(enabled ? "高清材质已启用" : "高清材质已隐藏", "info");
}
return enabled;
}
export function getHighResTextureEnabled() {
return getEarthTextureVisible();
}
export function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) {
toggleClouds(enabled);
if (!suppressStatus) {
showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info");
}
return enabled;
}
export function getAtmosphereCloudsEnabled() {
return getShowClouds();
}
export async function setSatellitesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
@@ -2561,6 +2761,7 @@ function onMouseMove(event) {
inertialVelocity.y = rotationDeltaY;
inertialVelocity.x = rotationDeltaX;
previousMousePosition = { x: event.clientX, y: event.clientY };
clearCountryBoundaryHover();
hideTooltip();
return;
}
@@ -2590,18 +2791,11 @@ function onMouseMove(event) {
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
if (getShowSatellites()) {
const satPoints = getSatellitePoints();
if (satPoints) {
const satIntersects = interactionRaycaster.intersectObject(satPoints);
if (satIntersects.length > 0) {
const satIndex = satIntersects[0].index;
if (isSatelliteFrontFacing(satIndex, camera)) {
hoveredSatIndexFromIntersect = satIndex;
hoveredSat = selectSatellite(satIndex);
}
}
}
const satIntersects = getSatellitePointerIntersections(event);
if (satIntersects.length > 0) {
const satIndex = satIntersects[0].index;
hoveredSatIndexFromIntersect = satIndex;
hoveredSat = selectSatellite(satIndex);
}
const hoveredBGPMarker = getPrimaryBGPHoverTarget(
@@ -2706,7 +2900,7 @@ function onMouseMove(event) {
event.clientX,
event.clientY,
camera,
earth,
getEarthSurfacePickTarget() || earth,
document.body,
interactionRaycaster,
interactionMouse,
@@ -2714,6 +2908,18 @@ function onMouseMove(event) {
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
const hoveredCountry = getShowCountryBoundaries()
? updateCountryBoundaryHover(coords)
: null;
if (hoveredCountry) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getCountryBoundaryBriefHtml(hoveredCountry),
);
return;
}
clearCountryBoundaryHover();
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
@@ -2726,8 +2932,11 @@ function onMouseMove(event) {
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
);
} else {
clearCountryBoundaryHover();
hideTooltip();
}
} else {
clearCountryBoundaryHover();
}
}
@@ -2864,6 +3073,7 @@ function onPointerUp(event) {
}
function onMouseLeave() {
clearCountryBoundaryHover();
hideTooltip();
}
@@ -2898,9 +3108,7 @@ function onClick(event) {
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
)
: [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
const satIntersects = getSatellitePointerIntersections(event);
const clickedBGPMarker = getShowBGP()
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
@@ -2996,9 +3204,7 @@ function onClick(event) {
const clickX = event.clientX;
const clickY = event.clientY;
const frontFacingSats = satIntersects.filter((sat) =>
isSatelliteFrontFacing(sat.index, camera),
);
const frontFacingSats = satIntersects;
if (frontFacingSats.length === 0) return;
let selectedIndex = frontFacingSats[0].index;
@@ -3191,6 +3397,7 @@ export function destroy() {
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());
clearCountryBoundaryData();
resetSatelliteState();
clearUiState();
disposeCelestialLayer();

View File

@@ -42,6 +42,8 @@ let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
const SATELLITE_FOOTPRINT_POLICIES = Object.freeze({
NONE: "none",
STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
@@ -1560,7 +1562,7 @@ function createGroundFootprintMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: false,
depthTest: true,
depthWrite: false,
uniforms: {
uColor: { value: new THREE.Color(0xffffff) },
@@ -2126,6 +2128,7 @@ function showGroundFootprintStyle(position) {
createGroundFootprintMaterial(),
);
fill.name = "footprint-fill";
fill.renderOrder = GROUND_FOOTPRINT_RENDER_ORDER;
lockedGroundFootprintMesh.add(fill);
earthObjRef.add(lockedGroundFootprintMesh);
updateGroundFootprintTransform(position);