Files
planet/frontend/public/earth/js/main.js
2026-03-31 17:30:53 +08:00

1605 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from "three";
import { createNoise3D } from "simplex-noise";
import { CONFIG, CABLE_CONFIG, CABLE_STATE } from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
updateCoordinatesDisplay,
updateZoomDisplay,
updateEarthStats,
setLoading,
setLoadingMessage,
showTooltip,
hideTooltip,
showError,
hideError,
clearUiState,
} from "./ui.js";
import {
createEarth,
createClouds,
createTerrain,
createStars,
createGridLines,
getEarth,
} from "./earth.js";
import {
loadGeoJSONFromPath,
loadLandingPoints,
handleCableClick,
clearCableSelection,
getCableLines,
getCableLegendItems,
getCableState,
setCableState,
clearAllCableStates,
applyLandingPointVisualState,
resetLandingPointVisualState,
getShowCables,
clearCableData,
getLandingPoints,
toggleCables,
} from "./cables.js";
import {
createSatellites,
loadSatellites,
updateSatellitePositions,
toggleSatellites,
getShowSatellites,
getSatelliteLegendItems,
setSelectedSatelliteLegend,
clearSelectedSatelliteLegend,
getSatelliteCount,
selectSatellite,
getSatellitePoints,
setSatelliteRingState,
updateLockedRingPosition,
updateHoverRingPosition,
getSatellitePositions,
showPredictedOrbit,
hidePredictedOrbit,
highlightRelatedSatellites,
clearRelatedSatelliteHighlights,
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
isSatelliteFrontFacing,
setSatelliteCamera,
setLockedSatelliteIndex,
resetSatelliteState,
clearSatelliteData,
} from "./satellites.js";
import {
loadBGPAnomalies,
getBGPAnomalyMarkers,
getBGPCollectorMarkers,
getBGPLegendItems,
getBGPCount,
getBGPCollectorCount,
getBGPStatusSummary,
getShowBGP,
clearBGPSelection,
setBGPMarkerState,
updateBGPVisualState,
clearBGPData,
toggleBGP,
formatBGPAnomalyTypeLabel,
formatBGPASPath,
formatBGPCollectorStatus,
formatBGPConfidence,
formatBGPImpactedScope,
formatBGPLocation,
formatBGPObservedTime,
formatBGPObservedBy,
formatBGPRelatedCables,
formatBGPRouteChange,
formatBGPTopEventTypes,
formatBGPScope,
formatBGPCollectorCoverageHalo,
formatBGPSeverityLabel,
formatBGPStatusLabel,
showBGPEventOverlay,
showBGPCollectorCoverageOverlay,
} from "./bgp.js";
import {
setupControls,
getAutoRotate,
getShowTerrain,
setAutoRotate,
resetView,
teardownControls,
} from "./controls.js";
import {
initInfoCard,
showInfoCard,
hideInfoCard,
setInfoCardNoBorder,
} from "./info-card.js";
import {
initLegend,
setLegendMode,
refreshLegend,
setLegendItems,
} from "./legend.js";
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 };
let inertialVelocity = { x: 0, y: 0 };
let hoveredCable = null;
let hoveredBGP = null;
let hoveredSatellite = null;
let hoveredSatelliteIndex = null;
let lockedSatellite = null;
let lockedSatelliteIndex = null;
let lockedObject = null;
let lockedObjectType = null;
let dragStartTime = 0;
let isLongDrag = false;
let lastSatClickTime = 0;
let lastSatClickIndex = 0;
let lastSatClickPos = { x: 0, y: 0 };
let lastBGPClickTime = 0;
let lastBGPClickCollector = null;
let lastBGPClickType = null;
let lastBGPClickPos = { x: 0, y: 0 };
let earthTexture = null;
let animationFrameId = null;
let initialized = false;
let destroyed = false;
let isDataLoading = false;
let currentLoadToken = 0;
let cablesEnabled = true;
let satellitesEnabled = true;
let cableToggleToken = 0;
let satelliteToggleToken = 0;
const clock = new THREE.Clock();
const interactionRaycaster = new THREE.Raycaster();
const interactionMouse = new THREE.Vector2();
const scratchCameraToEarth = new THREE.Vector3();
const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const cleanupFns = [];
const DRAG_ROTATION_FACTOR = 0.005;
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
const HUD_INTERACTIVE_SELECTORS = [
"#info-panel",
"#info-panel *",
"#right-toolbar-group",
"#right-toolbar-group *",
"#coordinates-display",
"#coordinates-display *",
"#legend",
"#legend *",
"#earth-stats",
"#earth-stats *",
];
function bindListener(target, eventName, handler, options) {
if (!target) return;
target.addEventListener(eventName, handler, options);
cleanupFns.push(() =>
target.removeEventListener(eventName, handler, options),
);
}
function isEventOnHud(event) {
const target = event?.target;
if (!(target instanceof Element)) return false;
return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector));
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
if (material.map) material.map.dispose();
if (material.alphaMap) material.alphaMap.dispose();
if (material.aoMap) material.aoMap.dispose();
if (material.bumpMap) material.bumpMap.dispose();
if (material.displacementMap) material.displacementMap.dispose();
if (material.emissiveMap) material.emissiveMap.dispose();
if (material.envMap) material.envMap.dispose();
if (material.lightMap) material.lightMap.dispose();
if (material.metalnessMap) material.metalnessMap.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
if (material.specularMap) material.specularMap.dispose();
material.dispose();
}
function disposeSceneObject(object) {
if (!object) return;
for (let i = object.children.length - 1; i >= 0; i -= 1) {
disposeSceneObject(object.children[i]);
}
if (object.geometry) {
object.geometry.dispose();
}
if (object.material) {
disposeMaterial(object.material);
}
if (object.parent) {
object.parent.remove(object);
}
}
function clearRuntimeSelection() {
hoveredCable = null;
hoveredBGP = null;
hoveredSatellite = null;
hoveredSatelliteIndex = null;
lockedObject = null;
lockedObjectType = null;
lockedSatellite = null;
lockedSatelliteIndex = null;
setLockedSatelliteIndex(null);
clearSelectedSatelliteLegend();
}
export function clearLockedObject() {
hidePredictedOrbit();
clearAllCableStates();
clearCableSelection();
clearBGPSelection();
clearRelatedSatelliteHighlights();
setSatelliteRingState(null, "none", null);
clearRuntimeSelection();
setLegendItems("satellites", getSatelliteLegendItems());
}
export function clearLockedObjectAndInfo() {
clearLockedObject();
hideInfoCard();
hideTooltip();
}
function isSameCable(cable1, cable2) {
if (!cable1 || !cable2) return false;
const id1 = cable1.userData?.cableId;
const id2 = cable2.userData?.cableId;
if (id1 === undefined || id2 === undefined) return false;
return id1 === id2;
}
function isSameBGPMarker(marker1, marker2) {
if (!marker1 || !marker2) return false;
const type1 = marker1.userData?.type;
const type2 = marker2.userData?.type;
if (type1 !== type2) return false;
if (type1 === "bgp") {
return marker1.userData?.id === marker2.userData?.id;
}
if (type1 === "bgp_collector") {
return marker1.userData?.collector === marker2.userData?.collector;
}
return false;
}
function getBGPCollectorMarkerByName(collector) {
return getBGPCollectorMarkers().find(
(marker) => marker.userData?.collector === collector,
);
}
function resetTransientBGPStates() {
getBGPCollectorMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setBGPMarkerState(marker, "normal");
}
});
getBGPAnomalyMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setBGPMarkerState(marker, "normal");
}
});
}
function clearTransientHoverState() {
resetTransientBGPStates();
hoveredBGP = null;
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
}
hoveredCable = null;
if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== lockedSatelliteIndex) {
setSatelliteRingState(hoveredSatelliteIndex, "none", null);
}
hoveredSatellite = null;
hoveredSatelliteIndex = null;
}
function applyBGPHoverState(marker) {
resetTransientBGPStates();
if (!marker) {
hoveredBGP = null;
return;
}
hoveredBGP = marker;
if (marker !== lockedObject) {
setBGPMarkerState(marker, "hover");
}
const relatedCollector =
marker.userData?.type === "bgp_collector"
? marker
: getBGPCollectorMarkerByName(marker.userData?.collector);
if (relatedCollector && relatedCollector !== lockedObject && relatedCollector !== marker) {
setBGPMarkerState(relatedCollector, "linked");
}
}
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
if (bgpAnomalyIntersects.length > 0) {
return bgpAnomalyIntersects[0].object;
}
if (bgpCollectorIntersects.length > 0) {
return bgpCollectorIntersects[0].object;
}
return null;
}
function getPrimaryBGPClickTarget(
event,
bgpAnomalyIntersects,
bgpCollectorIntersects,
) {
const anomalyMarker = bgpAnomalyIntersects[0]?.object || null;
const collectorMarker = bgpCollectorIntersects[0]?.object || null;
if (!anomalyMarker && !collectorMarker) return null;
if (!anomalyMarker) return collectorMarker;
if (!collectorMarker) return anomalyMarker;
const clickCollector = anomalyMarker.userData?.collector || collectorMarker.userData?.collector;
const isRepeatedClick =
clickCollector &&
clickCollector === lastBGPClickCollector &&
Date.now() - lastBGPClickTime < 650 &&
Math.abs(event.clientX - lastBGPClickPos.x) < 28 &&
Math.abs(event.clientY - lastBGPClickPos.y) < 28;
if (isRepeatedClick) {
return lastBGPClickType === "bgp" ? collectorMarker : anomalyMarker;
}
return anomalyMarker;
}
function showCableInfo(cable) {
setLegendMode("cables");
showInfoCard("cable", {
name: cable.userData.name,
owner: cable.userData.owner,
status: cable.userData.status,
length: cable.userData.length,
coords: cable.userData.coords,
rfs: cable.userData.rfs,
});
}
function showSatelliteInfo(props) {
const meanMotion = props?.mean_motion || 0;
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
const ecc = props?.eccentricity || 0;
const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0);
setSelectedSatelliteLegend(props);
setLegendItems("satellites", getSatelliteLegendItems());
setLegendMode("satellites");
showInfoCard("satellite", {
name: props?.name || "-",
norad_id: props?.norad_cat_id,
inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
period,
perigee,
apogee,
});
}
function showBGPInfo(marker) {
setLegendMode("bgp");
const impactedRegions =
Array.isArray(marker.userData.impacted_regions) &&
marker.userData.impacted_regions.length > 0
? marker.userData.impacted_regions
: [
{
city: marker.userData.city,
country: marker.userData.country,
},
];
showInfoCard("bgp", {
anomaly_type: formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
),
severity: formatBGPSeverityLabel(
marker.userData.rawSeverity || marker.userData.severity,
),
status: formatBGPStatusLabel(marker.userData.status),
route_change:
marker.userData.route_change ||
formatBGPRouteChange(
marker.userData.origin_asn,
marker.userData.new_origin_asn,
),
prefix:
Array.isArray(marker.userData.prefixes) && marker.userData.prefixes.length > 1
? `${marker.userData.prefixes[0]}${marker.userData.prefixes.length}`
: marker.userData.prefix,
as_path_display:
Array.isArray(marker.userData.as_path) && marker.userData.as_path.length > 0
? formatBGPASPath(marker.userData.as_path)
: "-",
origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 0
? marker.userData.affected_asns.slice(0, 3).map((asn) => `AS${asn}`).join(", ")
: marker.userData.origin_asn,
new_origin_asn:
Array.isArray(marker.userData.affected_asns) && marker.userData.affected_asns.length > 3
? `${marker.userData.affected_asns.length}个ASN`
: marker.userData.new_origin_asn,
confidence: formatBGPConfidence(marker.userData.confidence),
collector: marker.userData.collector,
observed_by:
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors),
impacted_scope: formatBGPImpactedScope(impactedRegions),
related_cables: formatBGPRelatedCables(marker.userData.related_cables),
related_satellites:
marker.userData.related_satellite_count > 0
? `${marker.userData.related_satellite_count}颗附近卫星`
: "-",
location:
marker.userData.location ||
formatBGPLocation(marker.userData.city, marker.userData.country),
created_at: formatBGPObservedTime(marker.userData.created_at_raw),
summary: marker.userData.summary,
});
}
function showBGPCollectorInfo(marker) {
setLegendMode("bgp");
showInfoCard("bgp_collector", {
collector: marker.userData.collector,
location: formatBGPLocation(marker.userData.city, marker.userData.country),
anomaly_count: marker.userData.anomaly_count ?? 0,
observation_count: marker.userData.observation_count ?? 0,
recent_24h_observation_count: marker.userData.recent_24h_observation_count ?? 0,
recent_7d_observation_count: marker.userData.recent_7d_observation_count ?? 0,
prefix_count: marker.userData.prefix_count ?? 0,
origin_asn_count: marker.userData.origin_asn_count ?? 0,
top_event_types: formatBGPTopEventTypes(marker.userData.top_event_types),
coverage_halo: formatBGPCollectorCoverageHalo(marker.userData),
related_satellites:
marker.userData.related_satellite_count > 0
? `${marker.userData.related_satellite_count}颗附近卫星`
: "-",
latest_event_type: marker.userData.latest_event_type || "-",
latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at),
baseline_scope: formatBGPScope(marker.userData.baseline_scope),
status: formatBGPCollectorStatus(marker.userData.status || "online"),
});
}
function getBGPRelatedCableNames(marker) {
const items = Array.isArray(marker?.userData?.related_cables)
? marker.userData.related_cables
: [];
const names = [];
items.forEach((item) => {
const cableNames = Array.isArray(item?.cable_names) ? item.cable_names : [];
cableNames.forEach((name) => {
if (name && !names.includes(name)) {
names.push(name);
}
});
});
return names;
}
function getBGPRelatedRegions(marker) {
if (Array.isArray(marker?.userData?.impacted_regions) && marker.userData.impacted_regions.length > 0) {
return marker.userData.impacted_regions;
}
if (
marker?.userData?.type === "bgp_collector" &&
typeof marker.userData.latitude === "number" &&
typeof marker.userData.longitude === "number"
) {
return [
{
collector: marker.userData.collector,
city: marker.userData.city,
country: marker.userData.country,
latitude: marker.userData.latitude,
longitude: marker.userData.longitude,
},
];
}
return [];
}
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
allCables.forEach((cable) => {
const cableId = cable.userData.cableId;
const state = getCableState(cableId);
switch (state) {
case CABLE_STATE.LOCKED:
cable.material.opacity =
CABLE_CONFIG.lockedOpacityMin +
pulse *
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
cable.material.color.setRGB(1, 1, 1);
break;
case CABLE_STATE.HOVERED:
cable.material.opacity = 1;
cable.material.color.setRGB(1, 1, 1);
break;
case CABLE_STATE.NORMAL:
default:
if (
(lockedObjectType === "cable" && lockedObject) ||
(lockedObjectType === "satellite" && lockedSatellite) ||
(lockedObjectType === "bgp" && lockedObject) ||
(lockedObjectType === "bgp_collector" && lockedObject)
) {
cable.material.opacity = CABLE_CONFIG.otherOpacity;
const origColor = cable.userData.originalColor;
const brightness = CABLE_CONFIG.otherBrightness;
cable.material.color.setRGB(
(((origColor >> 16) & 255) / 255) * brightness,
(((origColor >> 8) & 255) / 255) * brightness,
((origColor & 255) / 255) * brightness,
);
} else {
cable.material.opacity = 1;
cable.material.color.setHex(cable.userData.originalColor);
}
}
});
}
function updatePointerFromEvent(event) {
interactionMouse.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
interactionRaycaster.setFromCamera(interactionMouse, camera);
}
function buildLoadErrorMessage(errors) {
if (errors.length === 0) return "";
return errors
.map(
({ label, reason }) =>
`${label}加载失败: ${reason?.message || String(reason)}`,
)
.join("");
}
function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount()) {
const satBtn = document.getElementById("toggle-satellites");
if (satBtn) {
satBtn.classList.toggle("active", enabled);
const tooltip = satBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = enabled ? "隐藏卫星" : "显示卫星";
}
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${satelliteCount}`;
}
}
function updateCableToggleUi(enabled) {
const cableBtn = document.getElementById("toggle-cables");
if (cableBtn) {
cableBtn.classList.toggle("active", enabled);
const tooltip = cableBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = enabled ? "隐藏线缆" : "显示线缆";
}
const cableCountEl = document.getElementById("cable-count");
if (cableCountEl) {
cableCountEl.textContent = `${getCableLines().length}`;
}
const landingPointCountEl = document.getElementById("landing-point-count");
if (landingPointCountEl) {
landingPointCountEl.textContent = `${getLandingPoints().length}`;
}
}
async function ensureCablesEnabled() {
if (!scene || !camera || !renderer || destroyed) {
return 0;
}
const earth = getEarth();
if (!earth) return 0;
cablesEnabled = true;
if (getCableLines().length > 0 || getLandingPoints().length > 0) {
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
return getCableLines().length;
}
const requestToken = ++cableToggleToken;
clearCableData(earth);
const [cableCount] = await Promise.all([
loadGeoJSONFromPath(scene, earth),
loadLandingPoints(scene, earth),
]);
if (requestToken !== cableToggleToken || !cablesEnabled || destroyed) {
clearCableData(earth);
return 0;
}
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
return cableCount;
}
function disableCables() {
cablesEnabled = false;
cableToggleToken += 1;
toggleCables(false);
updateCableToggleUi(false);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
}
async function ensureSatellitesEnabled() {
if (!scene || !camera || !renderer || destroyed) return 0;
const earth = getEarth();
if (!earth) return 0;
satellitesEnabled = true;
const requestToken = ++satelliteToggleToken;
if (!getSatellitePoints()) {
createSatellites(scene, earth);
}
clearSatelliteData();
const satelliteCount = await loadSatellites();
if (
requestToken !== satelliteToggleToken ||
!satellitesEnabled ||
destroyed
) {
resetSatelliteState();
return 0;
}
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
toggleSatellites(true);
updateSatelliteToggleUi(true, satelliteCount);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
return satelliteCount;
}
function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
}
function updateStatsSummary() {
updateEarthStats({
cableCount: getCableLines().length,
landingPointCount:
document.getElementById("landing-point-count")?.textContent || 0,
bgpAnomalyCount: `${getBGPCount()}`,
bgpCollectorCount: `${getBGPCollectorCount()}`,
bgpStatusSummary: getBGPStatusSummary(),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
}
window.addEventListener("error", (event) => {
console.error("全局错误:", event.error);
});
window.addEventListener("unhandledrejection", (event) => {
console.error("未处理的 Promise 错误:", event.reason);
});
export function init() {
if (initialized && !destroyed) return;
destroyed = false;
initialized = true;
simplex = createNoise3D();
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.z = CONFIG.defaultCameraZ;
setSatelliteCamera(camera);
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x0a0a1a, 1);
renderer.setPixelRatio(window.devicePixelRatio);
const container = document.getElementById("container");
if (container) {
container.querySelector("canvas")?.remove();
container.appendChild(renderer.domElement);
}
addLights();
initInfoCard();
initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
targetRotation = {
x: earthObj.rotation.x,
y: earthObj.rotation.y,
};
inertialVelocity = { x: 0, y: 0 };
createClouds(scene, earthObj);
createTerrain(scene, earthObj, simplex);
createStars(scene);
createGridLines(scene, earthObj);
createSatellites(scene, earthObj);
setupControls(camera, renderer, scene, earthObj);
resetView(camera);
setupEventListeners();
clock.start();
loadData();
animate();
registerGlobalApi();
}
function registerGlobalApi() {
window.__planetEarth = {
reloadData,
clearSelection: () => {
hideInfoCard();
clearLockedObject();
},
destroy,
init,
};
}
function addLights() {
scene.add(new THREE.AmbientLight(0x404060));
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(5, 3, 5);
scene.add(directionalLight);
const backLight = new THREE.DirectionalLight(0x446688, 0.3);
backLight.position.set(-5, 0, -5);
scene.add(backLight);
const pointLight = new THREE.PointLight(0xffffff, 0.4);
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
}
async function loadData(showWhiteSphere = false) {
if (!scene || !camera || !renderer) return;
if (isDataLoading) return;
const earth = getEarth();
if (!earth) return;
const loadToken = ++currentLoadToken;
isDataLoading = true;
hideError();
setLoadingMessage(
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
showWhiteSphere
? "重新同步卫星、海底光缆、登陆点与BGP态势数据"
: "同步卫星、海底光缆、登陆点与BGP态势数据",
);
setLoading(true);
clearLockedObject();
hideInfoCard();
if (showWhiteSphere && earth.material) {
earthTexture = earth.material.map;
earth.material.map = null;
earth.material.color.setHex(0xffffff);
earth.material.needsUpdate = true;
}
const results = await Promise.allSettled([
cablesEnabled ? ensureCablesEnabled() : Promise.resolve(0),
satellitesEnabled ? ensureSatellitesEnabled() : Promise.resolve(0),
(async () => {
clearBGPData(earth);
const bgpResult = await loadBGPAnomalies(scene, earth);
toggleBGP(true);
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
bgpBtn.classList.add("active");
const tooltip = bgpBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = "隐藏BGP观测";
}
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
bgpCountEl.textContent = `${bgpResult.totalCount}`;
}
const bgpCollectorEl = document.getElementById("bgp-collector-count");
if (bgpCollectorEl) {
bgpCollectorEl.textContent = `${bgpResult.collectorCount}`;
}
const bgpStatusEl = document.getElementById("bgp-status-summary");
if (bgpStatusEl) {
bgpStatusEl.textContent =
bgpResult.totalCount > 0
? `${bgpResult.totalCount} 起活跃事件`
: bgpResult.anomalyCount > 0
? `${bgpResult.anomalyCount} 条活跃异常`
: "当前无活跃事件";
}
return bgpResult;
})(),
]);
if (loadToken !== currentLoadToken) {
isDataLoading = false;
return;
}
const errors = [];
if (results[0].status === "rejected") {
errors.push({ label: "电缆", reason: results[0].reason });
}
if (results[1].status === "rejected") {
errors.push({ label: "卫星", reason: results[1].reason });
}
if (results[2].status === "rejected") {
errors.push({ label: "BGP态势", reason: results[2].reason });
}
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
showError(errorMessage);
showStatusMessage(errorMessage, "error");
} else {
hideError();
showStatusMessage("数据已重新加载", "success");
}
updateStatsSummary();
updateCableToggleUi(cablesEnabled);
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
setLoading(false);
isDataLoading = false;
if (showWhiteSphere && earth.material) {
earth.material.map = earthTexture;
earth.material.color.setHex(0xffffff);
earth.material.needsUpdate = true;
}
}
const POSITION_UPDATE_FORCE_DELTA = 250;
export async function reloadData() {
await loadData(true);
}
export async function setCablesEnabled(enabled) {
if (enabled === cablesEnabled) {
updateCableToggleUi(enabled);
return getCableLines().length;
}
if (!enabled) {
clearLockedObject();
hideInfoCard();
disableCables();
showStatusMessage("线缆已隐藏", "info");
return 0;
}
setLoadingMessage("正在加载线缆数据...", "重建海缆与登陆点对象");
setLoading(true);
hideError();
try {
const cableCount = await ensureCablesEnabled();
showStatusMessage("线缆已显示", "info");
return cableCount;
} catch (error) {
cablesEnabled = false;
clearCableData(getEarth());
updateCableToggleUi(false);
const message = `线缆加载失败: ${error?.message || String(error)}`;
showError(message);
showStatusMessage(message, "error");
throw error;
} finally {
setLoading(false);
}
}
export async function setSatellitesEnabled(enabled) {
if (enabled === satellitesEnabled) {
updateSatelliteToggleUi(enabled);
return getSatelliteCount();
}
if (!enabled) {
clearLockedObject();
hideInfoCard();
disableSatellites();
return 0;
}
setLoadingMessage("正在加载卫星数据...", "重建卫星点位与轨迹缓存");
setLoading(true);
hideError();
try {
const satelliteCount = await ensureSatellitesEnabled();
showStatusMessage("卫星已显示", "info");
return satelliteCount;
} catch (error) {
satellitesEnabled = false;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
const message = `卫星加载失败: ${error?.message || String(error)}`;
showError(message);
showStatusMessage(message, "error");
throw error;
} finally {
setLoading(false);
}
}
function setupEventListeners() {
const handleResize = () => onWindowResize();
const handleMouseMove = (event) => onMouseMove(event);
const handleMouseDown = (event) => onMouseDown(event);
const handleMouseUp = () => onMouseUp();
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
bindListener(window, "resize", handleResize);
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "mousemove", handleMouseMove);
bindListener(renderer.domElement, "mousedown", handleMouseDown);
bindListener(window, "mouseup", handleMouseUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
}
function onWindowResize() {
if (!camera || !renderer) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function getFrontFacingCables(cableLines) {
const earth = getEarth();
if (!earth) return cableLines;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return cableLines.filter((cable) => {
if (!cable.userData.localCenter) {
return true;
}
scratchCableCenter.copy(cable.userData.localCenter);
cable.localToWorld(scratchCableCenter);
scratchCableDirection
.subVectors(scratchCableCenter, earth.position)
.normalize();
return scratchCameraToEarth.dot(scratchCableDirection) > 0;
});
}
function getFrontFacingBGPMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
scratchBGPDirection
.subVectors(scratchBGPWorldPosition, earth.position)
.normalize();
return scratchCameraToEarth.dot(scratchBGPDirection) > 0;
});
}
function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) {
clearTransientHoverState();
if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
showBGPInfo(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
showBGPCollectorInfo(lockedObject);
} else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject);
} else if (lockedObjectType === "satellite" && lockedSatellite) {
showSatelliteInfo(lockedSatellite.properties);
} else {
hideInfoCard();
}
hideTooltip();
return;
}
if (isDragging) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
}
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
const rotationDeltaY = deltaX * DRAG_ROTATION_FACTOR;
const rotationDeltaX = deltaY * DRAG_ROTATION_FACTOR;
targetRotation.y += rotationDeltaY;
targetRotation.x += rotationDeltaX;
inertialVelocity.y = rotationDeltaY;
inertialVelocity.x = rotationDeltaX;
previousMousePosition = { x: event.clientX, y: event.clientY };
hideTooltip();
return;
}
updatePointerFromEvent(event);
const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
: [];
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
: [];
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 hoveredBGPMarker = getPrimaryBGPHoverTarget(
bgpAnomalyIntersects,
bgpCollectorIntersects,
);
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
clearTransientHoverState();
}
if (
hoveredCable &&
(!cableIntersects.length ||
!isSameCable(cableIntersects[0]?.object, hoveredCable))
) {
clearTransientHoverState();
}
if (
hoveredSatelliteIndex !== null &&
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
) {
clearTransientHoverState();
}
if (
hoveredBGPMarker &&
getShowBGP() &&
lockedObjectType !== "bgp" &&
lockedObjectType !== "bgp_collector"
) {
applyBGPHoverState(hoveredBGPMarker);
if (hoveredBGPMarker.userData?.type === "bgp") {
showBGPInfo(hoveredBGPMarker);
} else {
showBGPCollectorInfo(hoveredBGPMarker);
}
setInfoCardNoBorder(true);
hideTooltip();
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) {
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
}
showCableInfo(cable);
setInfoCardNoBorder(true);
hideTooltip();
} else if (hoveredSat?.properties) {
hoveredSatellite = hoveredSat;
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
const satPositions = getSatellitePositions();
if (satPositions && satPositions[hoveredSatelliteIndex]) {
setSatelliteRingState(
hoveredSatelliteIndex,
"hover",
satPositions[hoveredSatelliteIndex].current,
);
}
}
showSatelliteInfo(hoveredSat.properties);
setInfoCardNoBorder(true);
} else if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
showBGPInfo(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
showBGPCollectorInfo(lockedObject);
} else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject);
} else if (lockedObjectType === "satellite" && lockedSatellite) {
const satPositions = getSatellitePositions();
if (lockedSatelliteIndex !== null && satPositions?.[lockedSatelliteIndex]) {
setSatelliteRingState(
lockedSatelliteIndex,
"locked",
satPositions[lockedSatelliteIndex].current,
);
}
showSatelliteInfo(lockedSatellite.properties);
} else {
resetTransientBGPStates();
hideInfoCard();
}
const earthPoint = screenToEarthCoords(
event.clientX,
event.clientY,
camera,
earth,
document.body,
interactionRaycaster,
interactionMouse,
);
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
showTooltip(
event.clientX + 10,
event.clientY + 10,
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${coords.alt.toFixed(1)} km`,
);
} else {
hideTooltip();
}
}
function onMouseDown(event) {
if (isEventOnHud(event)) {
return;
}
const earth = getEarth();
isDragging = true;
dragStartTime = Date.now();
isLongDrag = false;
previousMousePosition = { x: event.clientX, y: event.clientY };
inertialVelocity = { x: 0, y: 0 };
if (earth) {
targetRotation = {
x: earth.rotation.x,
y: earth.rotation.y,
};
}
document.getElementById("container")?.classList.add("dragging");
hideTooltip();
}
function onMouseUp() {
isDragging = false;
document.getElementById("container")?.classList.remove("dragging");
}
function onMouseLeave() {
hideTooltip();
}
function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
updatePointerFromEvent(event);
const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()),
);
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
: [];
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
: [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
const clickedBGPMarker = getShowBGP()
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
: null;
if (clickedBGPMarker?.userData?.type === "bgp") {
clearLockedObject();
const clickedMarker = clickedBGPMarker;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "bgp";
lastBGPClickTime = Date.now();
lastBGPClickCollector = clickedMarker.userData?.collector || null;
lastBGPClickType = "bgp";
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPEventOverlay(clickedMarker, earth);
{
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
getBGPRelatedRegions(clickedMarker),
{ limit: 6, maxAngleDeg: 20 },
);
clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
}
showBGPInfo(clickedMarker);
showStatusMessage(
`已选择BGP事件: ${clickedMarker.userData.collector}`,
"info",
);
return;
}
if (clickedBGPMarker?.userData?.type === "bgp_collector") {
clearLockedObject();
const clickedMarker = clickedBGPMarker;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "bgp_collector";
lastBGPClickTime = Date.now();
lastBGPClickCollector = clickedMarker.userData?.collector || null;
lastBGPClickType = "bgp_collector";
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPCollectorCoverageOverlay(clickedMarker, earth);
{
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
getBGPRelatedRegions(clickedMarker),
{ limit: 4, maxAngleDeg: 18 },
);
clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
highlightRelatedSatellites(relatedSatelliteIndices, "#93c5fd");
}
showBGPCollectorInfo(clickedMarker);
showStatusMessage(
`已选择观测站: ${clickedMarker.userData.collector}`,
"info",
);
return;
}
if (cableIntersects.length > 0 && getShowCables()) {
clearLockedObject();
const clickedCable = cableIntersects[0].object;
const cableId = clickedCable.userData.cableId;
setCableState(cableId, CABLE_STATE.LOCKED);
lockedObject = clickedCable;
lockedObjectType = "cable";
setAutoRotate(false);
handleCableClick(clickedCable);
return;
}
if (satIntersects.length > 0) {
const now = Date.now();
const clickX = event.clientX;
const clickY = event.clientY;
const frontFacingSats = satIntersects.filter((sat) =>
isSatelliteFrontFacing(sat.index, camera),
);
if (frontFacingSats.length === 0) return;
let selectedIndex = frontFacingSats[0].index;
if (
frontFacingSats.length > 1 &&
now - lastSatClickTime < 500 &&
Math.abs(clickX - lastSatClickPos.x) < 30 &&
Math.abs(clickY - lastSatClickPos.y) < 30
) {
const currentIdx = frontFacingSats.findIndex(
(sat) => sat.index === lastSatClickIndex,
);
selectedIndex =
frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index;
}
lastSatClickTime = now;
lastSatClickIndex = selectedIndex;
lastSatClickPos = { x: clickX, y: clickY };
const sat = selectSatellite(selectedIndex);
if (!sat?.properties) return;
clearLockedObject();
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = selectedIndex;
setLockedSatelliteIndex(selectedIndex);
showPredictedOrbit(sat);
setAutoRotate(false);
const satPositions = getSatellitePositions();
if (satPositions?.[selectedIndex]) {
setSatelliteRingState(
selectedIndex,
"locked",
satPositions[selectedIndex].current,
);
}
showSatelliteInfo(sat.properties);
showStatusMessage("已选择: " + sat.properties.name, "info");
return;
}
if (!isLongDrag) {
clearLockedObject();
setAutoRotate(true);
}
}
function animate() {
if (destroyed) return;
animationFrameId = requestAnimationFrame(animate);
const earth = getEarth();
const deltaTime = clock.getDelta() * 1000;
const hasInertia =
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY;
if (getAutoRotate() && earth) {
earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16);
// Keep the drag target aligned with autorotation only when the user is not
// actively dragging and there is no residual inertial motion to preserve.
if (!isDragging && !hasInertia) {
targetRotation.y = earth.rotation.y;
targetRotation.x = earth.rotation.x;
}
}
if (earth) {
if (isDragging) {
// Smoothly follow the drag target to match the legacy interaction feel.
earth.rotation.x +=
(targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
earth.rotation.y +=
(targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
} else if (
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY
) {
// Continue rotating after release and gradually decay the motion.
targetRotation.x += inertialVelocity.x * (deltaTime / 16);
targetRotation.y += inertialVelocity.y * (deltaTime / 16);
earth.rotation.x +=
(targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
earth.rotation.y +=
(targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
inertialVelocity.x *= Math.pow(INERTIA_DAMPING, deltaTime / 16);
inertialVelocity.y *= Math.pow(INERTIA_DAMPING, deltaTime / 16);
} else {
inertialVelocity.x = 0;
inertialVelocity.y = 0;
targetRotation.x = earth.rotation.x;
targetRotation.y = earth.rotation.y;
}
}
applyCableVisualState();
updateBGPVisualState(lockedObjectType, lockedObject, camera);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false);
} else if (
lockedObjectType === "satellite" && lockedSatellite
) {
applyLandingPointVisualState(null, true);
} else if (lockedObjectType === "bgp" && lockedObject) {
const relatedCableNames = getBGPRelatedCableNames(lockedObject);
clearAllCableStates();
relatedCableNames.forEach((name) => {
getCableLines().forEach((cable) => {
if (cable.userData?.name === name) {
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
}
});
});
applyLandingPointVisualState(
relatedCableNames.length > 0 ? relatedCableNames : null,
relatedCableNames.length === 0,
);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
clearAllCableStates();
applyLandingPointVisualState(null, true);
} else {
resetLandingPointVisualState();
}
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights();
const satPositions = getSatellitePositions();
if (
lockedObjectType === "satellite" &&
lockedSatelliteIndex !== null &&
satPositions?.[lockedSatelliteIndex]
) {
updateLockedRingPosition(satPositions[lockedSatelliteIndex].current);
} else if (
hoveredSatelliteIndex !== null &&
satPositions?.[hoveredSatelliteIndex]
) {
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
}
renderer.render(scene, camera);
}
export function destroy() {
if (destroyed) return;
destroyed = true;
currentLoadToken += 1;
isDataLoading = false;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
teardownControls();
while (cleanupFns.length) {
const cleanup = cleanupFns.pop();
cleanup?.();
}
clearLockedObject();
clearCableData(getEarth());
clearBGPData(getEarth());
resetSatelliteState();
clearUiState();
if (scene) {
disposeSceneObject(scene);
}
if (renderer) {
renderer.dispose();
if (typeof renderer.forceContextLoss === "function") {
renderer.forceContextLoss();
}
renderer.domElement?.remove();
}
scene = null;
camera = null;
renderer = null;
initialized = false;
delete window.__planetEarth;
}
document.addEventListener("DOMContentLoaded", init);