import * as THREE from "three";
import {
CONFIG,
HUD_CONFIG,
CABLE_CONFIG,
CABLE_STATE,
SATELLITE_CONFIG,
PATHS,
BGP_CONFIG,
CRUISE_CONFIG,
ROTATION_MODE,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
queueStatusMessage,
updateCoordinatesDisplay,
updateZoomDisplay,
updateEarthStats,
setEarthStatValue,
setLoading,
setLoadingMessage,
showTooltip,
hideTooltip,
showError,
hideError,
clearUiState,
} from "./ui.js";
import {
createEarth,
createClouds,
createTerrain,
createGridLines,
getEarth,
loadEarthTexture,
clearEarthTexture,
setEarthSunDirection,
} from "./earth.js";
import { registerTerrainMesh, clearTerrainData, sampleElevationAt } from "./terrain.js";
import {
initCelestialLayer,
updateCelestialLayer,
disposeCelestialLayer,
getCelestialDebugState,
getSunDirection,
setCelestialOrientation,
setCelestialFollow,
setCelestialDayNightEnabled,
} from "./celestial.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,
getSatelliteData,
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 {
clearComputeCenterData,
clearComputeCenterSelection,
formatComputeCenterCapacity,
formatComputeCenterLocationPrecision,
formatComputeCenterTypeLabel,
formatComputeCenterUpdatedAt,
getComputeCenterCount,
getComputeCenterLegendItems,
getComputeCenterMarkers,
getShowComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
toggleComputeCenters,
updateComputeCenterVisualState,
} from "./compute-centers.js";
import {
setupControls,
getAutoRotate,
getRotationMode,
getShowTerrain,
getStartupLoadLayers,
setAutoRotate,
applyImmediateView,
focusEarthView,
getZoomLevel,
setZoomLevel,
teardownControls,
} from "./controls.js";
import {
createLayerStartupTaskMap,
resolveStartupMessage,
} from "./layer-startup-tasks.js";
import {
setLayerButtonState,
} from "./layer-button-state.js";
import { CalloutConnector } from "./callout-connector.js";
import { CruiseSequencer } from "./cruise-sequencer.js";
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
import {
initInfoCard,
showInfoCard,
hideInfoCard,
} from "./info-card.js";
import {
initLegend,
setLegendMode,
refreshLegend,
setLegendItems,
} from "./legend.js";
import { mountBrand } from "./brand.js";
import { initTVPanel } from "./tv.js";
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
import { initSearchPanel } from "./search.js";
export let scene;
export let camera;
export let renderer;
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 hoveredComputeCenter = 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;
let satelliteHydrationToken = 0;
let sceneLights = null;
let cruisePollTimerId = null;
let cruiseConnector = null;
let cruiseBGPAdapter = null;
let cruiseSequencer = null;
let activeDragPointerId = null;
let activeTouchPoints = new Map();
let pinchGesture = null;
let pointerDragDistance = 0;
let suppressNextClick = false;
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 scratchComputeCenterDirection = new THREE.Vector3();
const scratchComputeCenterWorldPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = [];
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
const CRUISE_TRANSITION_GAP_MS = 24;
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
"#info-panel",
"#info-panel *",
"#right-toolbar-group",
"#right-toolbar-group *",
"#legend",
"#legend *",
"#earth-stats",
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#mobile-drawer-shell",
"#mobile-drawer-shell *",
];
function bindListener(target, eventName, handler, options) {
if (!target) return;
target.addEventListener(eventName, handler, options);
cleanupFns.push(() =>
target.removeEventListener(eventName, handler, options),
);
}
function getViewportAspect() {
return window.innerWidth / window.innerHeight;
}
function syncRendererViewport() {
if (!camera || !renderer) return;
camera.aspect = getViewportAspect();
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function isEventOnHud(event) {
const target = event?.target;
if (!(target instanceof Element)) return false;
return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector));
}
function clearDocumentSelection() {
const selection = window.getSelection?.();
if (!selection || selection.rangeCount === 0) return;
selection.removeAllRanges();
}
function setGlobeDraggingUiState(active) {
document.body.classList.toggle(GLOBE_DRAGGING_CLASS, active);
document.documentElement.classList.toggle(GLOBE_DRAGGING_CLASS, active);
}
function getDragRotationFactor() {
const zoom = Math.max(getZoomLevel(), 0.01);
const scale = THREE.MathUtils.clamp(
1 / zoom,
CONFIG.dragRotationScaleMin,
CONFIG.dragRotationScaleMax,
);
return CONFIG.dragRotationFactorBase * scale;
}
function getTouchDistance(firstPoint, secondPoint) {
return Math.hypot(
secondPoint.clientX - firstPoint.clientX,
secondPoint.clientY - firstPoint.clientY,
);
}
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;
hoveredComputeCenter = null;
hoveredSatellite = null;
hoveredSatelliteIndex = null;
lockedObject = null;
lockedObjectType = null;
lockedSatellite = null;
lockedSatelliteIndex = null;
setLockedSatelliteIndex(null);
clearSelectedSatelliteLegend();
}
export function clearLockedObject() {
hidePredictedOrbit();
clearAllCableStates();
clearCableSelection();
clearBGPSelection();
clearComputeCenterSelection();
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 isSameComputeCenter(marker1, marker2) {
if (!marker1 || !marker2) return false;
if (marker1.userData?.type !== "compute_center" || marker2.userData?.type !== "compute_center") {
return false;
}
return marker1.userData?.id === marker2.userData?.id;
}
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 resetTransientComputeCenterStates() {
getComputeCenterMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setComputeCenterMarkerState(marker, "normal");
}
});
}
function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
hoveredBGP = null;
hoveredComputeCenter = 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 applyComputeCenterHoverState(marker) {
resetTransientComputeCenterStates();
if (!marker) {
hoveredComputeCenter = null;
return;
}
hoveredComputeCenter = marker;
if (marker !== lockedObject) {
setComputeCenterMarkerState(marker, "hover");
}
}
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, coords) {
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,
}, coords);
}
function getCableBriefHtml(cable) {
const name = cable.userData.name || "未知海缆";
const status = cable.userData.status || "";
return `${name}${status ? `
${status}` : ""}`;
}
function showSatelliteInfo(props, coords) {
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,
}, coords);
}
function getSatelliteBriefHtml(props) {
const name = props?.name || "未知卫星";
const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : "";
return `${name}${id ? `
${id}` : ""}`;
}
function showComputeCenterInfo(marker, coords) {
const siteType = marker.userData?.site_type === "supercomputer"
? "supercomputer"
: "gpu_cluster";
setLegendMode("computeCenters");
showInfoCard(siteType, {
name: marker.userData?.name || "-",
site_type_label: formatComputeCenterTypeLabel(siteType),
rank: marker.userData?.rank ?? "-",
capacity: formatComputeCenterCapacity(marker.userData),
vendor: marker.userData?.vendor || "-",
operator: marker.userData?.operator || "-",
gpu_count: marker.userData?.gpu_count ?? "-",
gpu_type: marker.userData?.gpu_type || "-",
cores: marker.userData?.cores ?? "-",
power: marker.userData?.power ?? "-",
country: marker.userData?.country || "-",
city: marker.userData?.city || "-",
location_precision_label: formatComputeCenterLocationPrecision(marker.userData),
source: marker.userData?.source || "-",
updated_at: formatComputeCenterUpdatedAt(marker.userData?.updated_at),
}, coords);
}
function getComputeCenterBriefHtml(marker) {
const name = marker.userData?.name || "算力中心";
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
const location = [marker.userData?.city, marker.userData?.country]
.filter(Boolean)
.join(", ");
const precision = marker.userData?.is_estimated ? " · 估算位置" : "";
return `${name}
${type}${location ? ` · ${location}` : ""}${precision}`;
}
function showBGPInfo(marker, coords) {
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,
},
];
const observedBy =
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors);
const impactedScope = formatBGPImpactedScope(impactedRegions);
const relatedCables = formatBGPRelatedCables(marker.userData.related_cables);
const narrative =
marker.userData.summary && marker.userData.summary !== "-"
? marker.userData.summary
: buildBGPIncidentNarrative(marker, impactedRegions);
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: observedBy,
impacted_scope: impactedScope,
related_cables: relatedCables,
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: narrative,
}, coords);
}
function getBGPBriefHtml(marker) {
const type = formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
);
const collector = marker.userData.collector || "";
return `${type}${collector ? `
${collector}` : ""}`;
}
function showBGPCollectorInfo(marker, coords) {
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: "-",
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"),
}, coords);
}
function getBGPCollectorBriefHtml(marker) {
const name = marker.userData.collector || "观测站";
const count = marker.userData.anomaly_count ?? 0;
return `${name}
${count} 条事件`;
}
function getSearchCardCoords() {
return {
x: Math.round(window.innerWidth * SEARCH_CARD_X_RATIO),
y: Math.round(window.innerHeight * SEARCH_CARD_Y_RATIO),
absolute: true,
};
}
function normalizeSearchString(...parts) {
return parts
.flat()
.filter((part) => part !== undefined && part !== null && part !== false)
.map((part) => String(part).trim())
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function computeSearchScore(query, ...parts) {
const text = normalizeSearchString(...parts);
if (!text) return -1;
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return -1;
if (text === normalizedQuery) return 240;
if (text.startsWith(normalizedQuery)) return 180;
if (text.includes(normalizedQuery)) return 120;
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return -1;
let score = 0;
for (const token of tokens) {
if (text.startsWith(token)) {
score += 60;
continue;
}
if (text.includes(token)) {
score += 36;
continue;
}
return -1;
}
return score;
}
function getCableFocusCoords(cable) {
if (!cable?.userData?.localCenter) return null;
return vector3ToLatLon(cable.userData.localCenter);
}
function getLandingPointFocusCoords(point) {
if (!point?.position) return null;
return vector3ToLatLon(point.position);
}
function getSatelliteFocusCoords(index) {
const positions = getSatellitePositions();
const vector = positions?.[index]?.current;
if (!vector) return null;
return vector3ToLatLon(vector);
}
function getBGPFocusCoords(marker) {
const lat = marker?.userData?.displayLatitude ?? marker?.userData?.latitude;
const lon = marker?.userData?.displayLongitude ?? marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
function getComputeCenterFocusCoords(marker) {
const lat = marker?.userData?.latitude;
const lon = marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
if (!coords || !camera) return;
await focusEarthView(camera, {
lat: coords.lat,
lon: coords.lon,
zoom,
duration: 950,
suppressStatus: true,
});
}
function showLandingPointInfo(point, coords) {
const cableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
setLegendMode("cables");
showInfoCard(
"landing_point",
{
name: point?.userData?.name || "-",
country: point?.userData?.country || "-",
status: point?.userData?.status || "-",
cable_count: cableNames.length,
cables: cableNames.length > 0 ? cableNames.join(" / ") : "-",
},
coords,
);
}
async function focusSearchCable(cable) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getCableFocusCoords(cable);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.14));
}
const cableId = cable?.userData?.cableId;
if (cableId !== undefined) {
setCableState(cableId, CABLE_STATE.LOCKED);
}
lockedObject = cable;
lockedObjectType = "cable";
handleCableClick(cable);
showCableInfo(cable, getSearchCardCoords());
}
async function focusSearchLandingPoint(point) {
await setCablesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getLandingPointFocusCoords(point);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.22));
}
const relatedCableNames = Array.isArray(point?.userData?.cableNames)
? point.userData.cableNames
: [];
clearAllCableStates();
getCableLines().forEach((cable) => {
if (relatedCableNames.includes(cable.userData?.name)) {
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
}
});
applyLandingPointVisualState(relatedCableNames, relatedCableNames.length === 0, camera);
showLandingPointInfo(point, getSearchCardCoords());
showStatusMessage(`已定位登陆点:${point.userData?.name || "未知登陆点"}`, "info");
}
async function focusSearchSatellite(index) {
await setSatellitesEnabled(true, {
suppressStatus: true,
suppressLoadingUi: true,
});
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const sat = selectSatellite(index);
if (!sat?.properties) return;
const coords = getSatelliteFocusCoords(index);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.18));
}
lockedObject = sat;
lockedObjectType = "satellite";
lockedSatellite = sat;
lockedSatelliteIndex = index;
setLockedSatelliteIndex(index);
showPredictedOrbit(sat);
const satPositions = getSatellitePositions();
if (satPositions?.[index]) {
setSatelliteRingState(index, "locked", satPositions[index].current);
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
}
async function focusSearchBGPMarker(marker) {
if (!getShowBGP()) {
toggleBGP(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getBGPFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.2));
}
const earth = getEarth();
if (marker?.userData?.type === "bgp") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp";
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
showBGPInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位 BGP 事件:${marker.userData?.collector || "未知观测站"}`, "info");
return;
}
if (marker?.userData?.type === "bgp_collector") {
setBGPMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "bgp_collector";
showBGPCollectorCoverageOverlay(marker, earth);
showBGPCollectorInfo(marker, getSearchCardCoords());
showStatusMessage(`已定位观测站:${marker.userData?.collector || "未知观测站"}`, "info");
}
}
async function focusSearchComputeCenter(marker) {
if (!getShowComputeCenters()) {
toggleComputeCenters(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getComputeCenterFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.16));
}
setComputeCenterMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "compute_center";
showComputeCenterInfo(marker, getSearchCardCoords());
showStatusMessage(
`已定位算力中心:${marker.userData?.name || "未知节点"}`,
"info",
);
}
function resolveEarthSearchResults(query) {
const results = [];
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return results;
getCableLines().forEach((cable) => {
const score = computeSearchScore(
normalizedQuery,
cable.userData?.name,
cable.userData?.owner,
cable.userData?.status,
cable.userData?.length,
"海缆 电缆 cable",
);
if (score < 0) return;
results.push({
id: `cable:${cable.userData?.cableId || cable.uuid}`,
kind: "cable",
icon: "cable",
typeLabel: "海缆",
title: cable.userData?.name || "未知海缆",
subtitle: [cable.userData?.owner, cable.userData?.status].filter(Boolean).join(" · ") || "海底光缆系统",
score,
entity: cable,
});
});
getLandingPoints().forEach((point, index) => {
const score = computeSearchScore(
normalizedQuery,
point.userData?.name,
point.userData?.country,
point.userData?.status,
point.userData?.cableNames,
"登陆点 landing point",
);
if (score < 0) return;
results.push({
id: `landing:${point.uuid || index}`,
kind: "landing_point",
icon: "location_on",
typeLabel: "登陆点",
title: point.userData?.name || "未知登陆点",
subtitle:
[point.userData?.country, Array.isArray(point.userData?.cableNames) ? `${point.userData.cableNames.length} 条海缆` : ""]
.filter(Boolean)
.join(" · ") || "海缆登陆点",
score,
entity: point,
});
});
getSatelliteData().forEach((satellite, index) => {
const props = satellite?.properties;
const score = computeSearchScore(
normalizedQuery,
props?.name,
props?.norad_cat_id,
props?.inclination,
"卫星 satellite norad",
);
if (score < 0) return;
results.push({
id: `sat:${props?.norad_cat_id || index}`,
kind: "satellite",
icon: "satellite_alt",
typeLabel: "卫星",
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
score,
entity: { index },
});
});
getBGPAnomalyMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.prefix,
marker.userData?.city,
marker.userData?.country,
marker.userData?.anomaly_type,
marker.userData?.incident_type,
marker.userData?.origin_asn,
marker.userData?.new_origin_asn,
"bgp 事件 anomaly prefix asn",
);
if (score < 0) return;
results.push({
id: `bgp:${marker.userData?.id || marker.uuid}`,
kind: "bgp",
icon: "hub",
typeLabel: "BGP事件",
title:
formatBGPAnomalyTypeLabel(
marker.userData?.incident_type || marker.userData?.anomaly_type,
) || "BGP 事件",
subtitle:
[
marker.userData?.collector,
marker.userData?.prefix,
formatBGPLocation(marker.userData?.city, marker.userData?.country),
]
.filter(Boolean)
.join(" · ") || "BGP 异常事件",
score,
entity: marker,
});
});
getBGPCollectorMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.collector,
marker.userData?.city,
marker.userData?.country,
marker.userData?.status,
"bgp collector 观测站",
);
if (score < 0) return;
results.push({
id: `collector:${marker.userData?.collector || marker.uuid}`,
kind: "bgp_collector",
icon: "travel_explore",
typeLabel: "观测站",
title: marker.userData?.collector || "未知观测站",
subtitle:
[
formatBGPLocation(marker.userData?.city, marker.userData?.country),
formatBGPCollectorStatus(marker.userData?.status || "online"),
]
.filter(Boolean)
.join(" · ") || "BGP 观测站",
score,
entity: marker,
});
});
getComputeCenterMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.name,
marker.userData?.country,
marker.userData?.city,
marker.userData?.operator,
marker.userData?.vendor,
marker.userData?.site_type,
"算力中心 compute center gpu 超算",
);
if (score < 0) return;
results.push({
id: `compute:${marker.userData?.id || marker.uuid}`,
kind: "compute_center",
icon: "memory",
typeLabel: "算力中心",
title: marker.userData?.name || "未知算力中心",
subtitle: [
formatComputeCenterTypeLabel(marker.userData?.site_type),
marker.userData?.city,
marker.userData?.country,
].filter(Boolean).join(" · ") || "算力基础设施",
score,
entity: marker,
});
});
return results
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.title.localeCompare(right.title, "zh-CN");
})
.slice(0, SEARCH_RESULT_LIMIT);
}
async function handleSearchSelection(result) {
if (!result) return;
if (result.kind === "cable") {
await focusSearchCable(result.entity);
return;
}
if (result.kind === "landing_point") {
await focusSearchLandingPoint(result.entity);
return;
}
if (result.kind === "satellite") {
await focusSearchSatellite(result.entity.index);
return;
}
if (result.kind === "bgp" || result.kind === "bgp_collector") {
await focusSearchBGPMarker(result.entity);
return;
}
if (result.kind === "compute_center") {
await focusSearchComputeCenter(result.entity);
}
}
function getBGPStatusText(bgpResult) {
if (bgpResult.totalCount > 0) {
return `${bgpResult.totalCount} 起活跃事件`;
}
if (bgpResult.anomalyCount > 0) {
return `${bgpResult.anomalyCount} 条活跃异常`;
}
return "当前无活跃事件";
}
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 = "隐藏算力中心";
}
}
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount} 个`);
}
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;
}
}
setEarthStatValue("bgp-anomaly-count", `${bgpResult.totalCount} 起`);
setEarthStatValue("bgp-collector-count", `${bgpResult.collectorCount} 个`);
setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult));
}
function ensureCruiseConnector() {
if (!cruiseConnector) {
cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" });
}
return cruiseConnector;
}
function ensureBGPCruiseAdapter() {
if (cruiseBGPAdapter) return cruiseBGPAdapter;
cruiseBGPAdapter = createBGPCruiseAdapter({
camera,
getMarkers: () => getBGPAnomalyMarkers(),
connector: ensureCruiseConnector(),
focusView: (options) => focusEarthView(camera, options),
setMarkerLocked: (marker) => {
setLegendMode("bgp");
setBGPMarkerState(marker, "locked");
},
clearMarkerState: (marker) => setBGPMarkerState(marker, "normal"),
showMarkerOverlay: (marker) => {
const earth = getEarth();
if (!marker || !earth) return;
showBGPEventOverlay(marker, earth);
},
applySatelliteHighlights: (marker) => {
if (!marker) return;
applyBGPEventSatelliteHighlights(marker);
},
showMarkerInfo: showBGPInfo,
hideInfo: hideInfoCard,
isInfoVisible: () =>
document.getElementById("info-panel")?.classList.contains("is-visible") === true,
getLockedObject: () => lockedObject,
refreshMarkers: async () => {
const bgpResult = await loadBGPAnomalies(scene, getEarth());
updateBGPHud(bgpResult);
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
},
});
return cruiseBGPAdapter;
}
function isCruisePresentationPinned() {
return cruiseSequencer?.isPresentationPinned() === true;
}
function setCruisePresentationVisible(visible) {
if (cruiseSequencer) {
cruiseSequencer.setPresentationVisible(visible);
}
if (!visible) {
ensureBGPCruiseAdapter().resetPresentation();
}
}
function clearCruiseMarkerHighlight() {
ensureBGPCruiseAdapter().clearCurrentHighlight();
}
function getCruiseMarkersSorted() {
return ensureBGPCruiseAdapter().getSortedMarkers();
}
function repositionCruiseConnector() {
if (!isCruisePresentationPinned()) return;
const marker = cruiseSequencer?.getCurrentItem() ?? null;
ensureBGPCruiseAdapter().repositionConnector(marker);
}
function isCruiseModeActive() {
return getRotationMode() === ROTATION_MODE.CRUISE;
}
function ensureCruiseSequencer() {
if (cruiseSequencer) return cruiseSequencer;
cruiseSequencer = new CruiseSequencer({
isActive: () => isCruiseModeActive() && getAutoRotate(),
getItems: () => getCruiseMarkersSorted(),
getItemId: (marker) => marker?.userData?.id || null,
dwellMs: CRUISE_CONFIG.dwellMs,
transitionGapMs: CRUISE_TRANSITION_GAP_MS,
clearCurrent: () => {
clearCruiseMarkerHighlight();
clearLockedObject();
hideInfoCard();
setCruisePresentationVisible(false);
},
onStop: ({ preservePresentation }) => {
clearBGPSelection();
if (!preservePresentation && !lockedObject) {
hideInfoCard();
}
},
focusItem: async (marker, { interrupt }) =>
ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }),
presentItem: async (marker, { context }) => {
setCruisePresentationVisible(true);
const presented = await ensureBGPCruiseAdapter().presentMarker(marker, {
context,
});
if (!presented) {
setCruisePresentationVisible(false);
}
return presented;
},
hideItem: async (_marker, { context }) => {
await ensureBGPCruiseAdapter().hidePresentation({ context });
setCruisePresentationVisible(false);
},
});
return cruiseSequencer;
}
function interruptCruisePresentation({ resetLoop = false } = {}) {
ensureCruiseSequencer().interruptPresentation({ resetLoop });
setCruisePresentationVisible(false);
}
function stopCruiseMode({ preserveCard = false } = {}) {
ensureCruiseSequencer().stop({ preservePresentation: preserveCard });
if (!preserveCard) {
setCruisePresentationVisible(false);
}
}
async function advanceCruiseEvent({ interrupt = false } = {}) {
if (!isCruiseModeActive() || !getAutoRotate()) return;
await ensureCruiseSequencer().advance({ interrupt });
}
async function pollCruiseEventsIfNeeded() {
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
try {
const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
if (newIds.length === 0) return;
ensureCruiseSequencer().enqueue(newIds);
if (!ensureCruiseSequencer().isBusy()) {
await advanceCruiseEvent({ interrupt: true });
}
} catch (error) {
console.warn("巡航模式轮询 BGP 事件失败:", error);
}
}
function ensureCruisePolling() {
if (cruisePollTimerId) return;
cruisePollTimerId = window.setInterval(() => {
pollCruiseEventsIfNeeded().catch((error) => {
console.warn("巡航轮询失败:", error);
});
}, CRUISE_CONFIG.pollIntervalMs);
cleanupFns.push(() => {
if (cruisePollTimerId) {
clearInterval(cruisePollTimerId);
cruisePollTimerId = null;
}
});
}
function handleRotationModeChange(event) {
const detailMode = event?.detail?.mode || getRotationMode();
const detailActive =
typeof event?.detail?.active === "boolean"
? event.detail.active
: getAutoRotate();
if (detailMode !== ROTATION_MODE.CRUISE) {
stopCruiseMode();
return;
}
ensureCruisePolling();
ensureBGPCruiseAdapter().syncKnownEventIds();
if (!detailActive) {
stopCruiseMode({ preserveCard: true });
return;
}
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("启动巡航模式失败:", error);
});
}
function clearSelectionAndInfo() {
clearLockedObject();
interruptCruisePresentation();
hideInfoCard();
}
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 getBGPInfrastructureSummary(marker) {
return {
cableCount: getBGPRelatedCableNames(marker).length,
regionCount: getBGPRelatedRegions(marker).length,
};
}
function buildBGPIncidentNarrative(marker, impactedRegions) {
const eventLabel = formatBGPAnomalyTypeLabel(
marker.userData.incident_type || marker.userData.anomaly_type,
);
const severityLabel = formatBGPSeverityLabel(
marker.userData.rawSeverity || marker.userData.severity,
);
const observedBy =
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors);
const routeLabel =
marker.userData.route_change ||
formatBGPRouteChange(
marker.userData.origin_asn,
marker.userData.new_origin_asn,
);
const cableCount = getBGPRelatedCableNames(marker).length;
const regionCount = impactedRegions.length;
return `${eventLabel},${severityLabel};${observedBy},影响${regionCount}个区域,关联${cableCount}条海缆线索${routeLabel && routeLabel !== "-" ? `,特征 ${routeLabel}` : ""}。`;
}
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 applyBGPEventSatelliteHighlights(marker) {
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
getBGPRelatedRegions(marker),
{ limit: 6, maxAngleDeg: 20 },
);
marker.userData.related_satellite_count = relatedSatelliteIndices.length;
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
}
function applyBGPRelatedCablesAndLandingPoints(marker, camera) {
const relatedCableNames = getBGPRelatedCableNames(marker);
clearAllCableStates();
relatedCableNames.forEach((name) => {
getCableLines().forEach((cable) => {
if (cable.userData?.name === name) {
setCableState(cable.userData.cableId, CABLE_STATE.LOCKED);
}
});
});
applyLandingPointVisualState(
relatedCableNames.length > 0 ? relatedCableNames : null,
relatedCableNames.length === 0,
camera,
);
}
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);
const hasFocus =
(lockedObjectType === "cable" && lockedObject) ||
(lockedObjectType === "satellite" && lockedSatellite) ||
(lockedObjectType === "bgp" && lockedObject) ||
(isCruiseModeActive() && isCruisePresentationPinned());
switch (state) {
case CABLE_STATE.LOCKED:
case CABLE_STATE.HOVERED:
cable.material.opacity = 1;
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.NORMAL:
default:
if (hasFocus) {
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) {
setLayerButtonState(satBtn, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏卫星" : "显示卫星",
});
}
setEarthStatValue("satellite-count", `${satelliteCount} 颗`);
}
function updateCableToggleUi(enabled) {
const cableBtn = document.getElementById("toggle-cables");
if (cableBtn) {
setLayerButtonState(cableBtn, {
active: enabled,
loading: false,
tooltip: enabled ? "隐藏线缆" : "显示线缆",
});
}
setEarthStatValue("cable-count", `${getCableLines().length}个`);
setEarthStatValue("landing-point-count", `${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);
// Load landing points first so they appear before cable lines
await loadLandingPoints(scene, earth, { silent: true });
const cableCount = await loadGeoJSONFromPath(scene, earth, {
silent: true,
});
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 loadResult = await loadSatellites({
limit: getInitialSatelliteLoadLimit(),
});
if (
requestToken !== satelliteToggleToken ||
!satellitesEnabled ||
destroyed
) {
resetSatelliteState();
return 0;
}
updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
scheduleSatellitePositionWarmup(() => {
if (
requestToken === satelliteToggleToken &&
satellitesEnabled &&
!destroyed
) {
toggleSatellites(true);
}
});
if (shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = ++satelliteHydrationToken;
hydrateAllSatellitesInBackground(
() =>
hydrationToken === satelliteHydrationToken &&
requestToken === satelliteToggleToken &&
satellitesEnabled &&
!destroyed,
);
}
return loadResult.count;
}
function disableSatellites() {
satellitesEnabled = false;
satelliteToggleToken += 1;
satelliteHydrationToken += 1;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
}
function updateStatsSummary() {
updateEarthStats({
cableCount: getCableLines().length,
landingPointCount: getLandingPoints().length,
computeCenterCount: `${getComputeCenterCount()} 个`,
bgpAnomalyCount: `${getBGPCount()} 条`,
bgpCollectorCount: `${getBGPCollectorCount()} 个`,
bgpStatusSummary: getBGPStatusSummary(),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
}
function getCurrentViewCenterCoords() {
const earth = getEarth();
if (!earth || !camera) return null;
scratchViewCenterWorld
.copy(camera.position)
.sub(earth.position)
.normalize()
.multiplyScalar(CONFIG.earthRadius);
earth.worldToLocal(scratchViewCenterWorld);
return vector3ToLatLon(scratchViewCenterWorld);
}
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;
updateHudScale();
const brandRoot = document.getElementById("brand-root");
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
initTVPanel();
initNewsPanel();
initSearchPanel({
resolveResults: resolveEarthSearchResults,
onSelectResult: handleSearchSelection,
});
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
75,
getViewportAspect(),
0.1,
5000,
);
camera.position.z = CONFIG.defaultCameraZ;
setSatelliteCamera(camera);
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
syncRendererViewport();
renderer.setClearColor(0x02040a, 1);
renderer.setPixelRatio(window.devicePixelRatio);
const container = document.getElementById("container");
if (container) {
container.querySelector("canvas")?.remove();
container.appendChild(renderer.domElement);
}
sceneLights = addLights();
initInfoCard();
initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
applyImmediateView(earthObj, camera);
targetRotation = {
x: earthObj.rotation.x,
y: earthObj.rotation.y,
};
inertialVelocity = { x: 0, y: 0 };
createClouds(scene, earthObj);
registerTerrainMesh(createTerrain(earthObj));
initCelestialLayer(scene, {
camera,
ambientLight: sceneLights?.ambientLight ?? null,
sunLight: sceneLights?.sunLight ?? null,
backLight: sceneLights?.backLight ?? null,
pointLight: sceneLights?.pointLight ?? null,
earth: earthObj,
});
setCelestialDayNightEnabled(true);
createGridLines(scene, earthObj);
createSatellites(scene, earthObj);
setupControls(camera, renderer, scene, earthObj);
setupEventListeners();
clock.start();
loadData();
animate();
registerGlobalApi();
}
function registerGlobalApi() {
window.__planetEarth = {
reloadData,
clearSelection: () => {
hideInfoCard();
clearLockedObject();
},
celestial: {
getState: () => getCelestialDebugState(),
setOrientation: (nextEuler) => setCelestialOrientation(nextEuler),
setFollow: (nextFollow) => setCelestialFollow(nextFollow),
},
destroy,
init,
};
}
function addLights() {
const ambientLight = new THREE.AmbientLight(SCENE_LIGHT_CONFIG.ambient.color);
ambientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(
SCENE_LIGHT_CONFIG.sun.color,
SCENE_LIGHT_CONFIG.sun.intensity,
);
sunLight.position.set(
SCENE_LIGHT_CONFIG.sun.position.x,
SCENE_LIGHT_CONFIG.sun.position.y,
SCENE_LIGHT_CONFIG.sun.position.z,
);
sunLight.target.position.set(0, 0, 0);
scene.add(sunLight);
scene.add(sunLight.target);
const backLight = new THREE.DirectionalLight(
SCENE_LIGHT_CONFIG.back.color,
SCENE_LIGHT_CONFIG.back.intensity,
);
backLight.position.set(
SCENE_LIGHT_CONFIG.back.position.x,
SCENE_LIGHT_CONFIG.back.position.y,
SCENE_LIGHT_CONFIG.back.position.z,
);
scene.add(backLight);
const pointLight = new THREE.PointLight(
SCENE_LIGHT_CONFIG.point.color,
SCENE_LIGHT_CONFIG.point.intensity,
);
pointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
scene.add(pointLight);
return {
ambientLight,
sunLight,
backLight,
pointLight,
};
}
// Yield control to the browser so the renderer can paint a frame before the next step
const yieldFrame = (ms = 24) => new Promise((r) => setTimeout(r, ms));
function scheduleSatellitePositionWarmup(onReady) {
window.requestAnimationFrame(() => {
updateSatellitePositions(POSITION_UPDATE_FORCE_DELTA, true);
if (typeof onReady === "function") {
onReady();
}
});
}
function getInitialSatelliteLoadLimit() {
const configuredInitial = Number.isFinite(SATELLITE_CONFIG.initialLoadCount)
? Math.max(1, Math.floor(SATELLITE_CONFIG.initialLoadCount))
: null;
if (SATELLITE_CONFIG.maxCount > 0) {
return configuredInitial === null
? SATELLITE_CONFIG.maxCount
: Math.min(configuredInitial, SATELLITE_CONFIG.maxCount);
}
return configuredInitial;
}
function shouldHydrateFullSatelliteSet(loadResult) {
if (!SATELLITE_CONFIG.hydrateFullAfterInitialLoad) return false;
if (!loadResult || loadResult.requestedLimit === null) return false;
return loadResult.count >= loadResult.requestedLimit;
}
async function hydrateAllSatellitesInBackground(guardFn) {
try {
const loadResult = await loadSatellites({ limit: null });
if (!guardFn()) return;
updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
scheduleSatellitePositionWarmup();
} catch (error) {
console.warn("后台补全卫星全量数据失败:", error);
}
}
async function loadData() {
if (!scene || !camera || !renderer) return;
if (isDataLoading) return;
const earth = getEarth();
if (!earth) return;
const loadToken = ++currentLoadToken;
isDataLoading = true;
hideError();
clearSelectionAndInfo();
// Always begin as a white sphere so every layer appears explicitly
clearEarthTexture();
clearBGPData(earth);
clearCableData(earth);
clearComputeCenterData(earth);
clearSatelliteData();
setLoadingMessage("正在初始化...");
setLoading(true);
await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
const errors = [];
// Step 1 — Earth texture
setLoadingMessage("正在加载地球纹理...");
await yieldFrame(12);
try {
await loadEarthTexture();
} catch (err) {
// texture failure is non-fatal
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
const startupLoaders = createLayerStartupTaskMap({
scene,
earth,
setLoadingMessage,
yieldFrame,
refreshLegend,
setLegendItems,
getComputeCenterLegendItems,
updateCableToggleUi,
updateSatelliteToggleUi,
updateComputeCenterHud,
updateBGPHud,
getShowComputeCenters,
getShowBGP,
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
hydrateAllSatellitesInBackground,
syncBGPKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(),
isCancelled: () => loadToken !== currentLoadToken || destroyed,
isCablesEnabled: () => cablesEnabled,
isSatellitesEnabled: () => satellitesEnabled,
nextSatelliteHydrationToken: () => ++satelliteHydrationToken,
getSatelliteHydrationToken: () => satelliteHydrationToken,
reportError: (label, reason) => {
errors.push({ label, reason });
},
});
const startupLayers = getStartupLoadLayers();
const startupLoadQueue = startupLayers
.map((layer) => ({
layer,
run: startupLoaders[layer.id],
}))
.filter((entry) => typeof entry.run === "function");
for (const entry of startupLoadQueue) {
await entry.run(entry.layer);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
}
// Step 6 — Terrain (if enabled)
if (getShowTerrain()) {
const terrainLayer = startupLayers.find((layer) => layer.id === "terrain");
const terrainMessage = resolveStartupMessage(
terrainLayer,
"load",
"正在渲染地形...",
);
setLoadingMessage(terrainMessage);
await yieldFrame(24);
}
updateStatsSummary();
updateCableToggleUi(cablesEnabled);
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
setLoading(false);
isDataLoading = false;
if (getRotationMode() === ROTATION_MODE.CRUISE && getAutoRotate()) {
advanceCruiseEvent({ interrupt: true }).catch((error) => {
console.warn("初始化后启动巡航失败:", error);
});
}
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
showError(errorMessage);
queueStatusMessage(errorMessage, "error");
} else {
hideError();
queueStatusMessage("数据已加载", "success");
}
}
const POSITION_UPDATE_FORCE_DELTA = 250;
const SEARCH_RESULT_LIMIT = 28;
const SEARCH_CARD_X_RATIO = 0.68;
const SEARCH_CARD_Y_RATIO = 0.18;
export async function reloadData() {
await loadData();
}
export function getSatellitesEnabled() {
return satellitesEnabled;
}
export async function setCablesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === cablesEnabled) {
updateCableToggleUi(enabled);
return getCableLines().length;
}
if (!enabled) {
clearSelectionAndInfo();
disableCables();
if (!suppressStatus) {
showStatusMessage("线缆已隐藏", "info");
}
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage("正在加载线缆数据...");
setLoading(true);
hideError();
}
try {
const cableCount = await ensureCablesEnabled();
if (!suppressStatus) {
showStatusMessage("线缆已显示", "info");
}
return cableCount;
} catch (error) {
cablesEnabled = false;
clearCableData(getEarth());
updateCableToggleUi(false);
const message = `线缆加载失败: ${error?.message || String(error)}`;
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
export async function setSatellitesEnabled(
enabled,
{ suppressStatus = false, suppressLoadingUi = false } = {},
) {
if (enabled === satellitesEnabled) {
updateSatelliteToggleUi(enabled);
return getSatelliteCount();
}
if (!enabled) {
clearSelectionAndInfo();
disableSatellites();
return 0;
}
if (!suppressLoadingUi) {
setLoadingMessage("正在加载卫星数据...");
setLoading(true);
hideError();
}
try {
const satelliteCount = await ensureSatellitesEnabled();
if (!suppressStatus) {
showStatusMessage("卫星已显示", "info");
}
return satelliteCount;
} catch (error) {
satellitesEnabled = false;
resetSatelliteState();
updateSatelliteToggleUi(false, 0);
const message = `卫星加载失败: ${error?.message || String(error)}`;
if (!suppressLoadingUi) {
showError(message);
}
if (!suppressStatus) {
showStatusMessage(message, "error");
}
throw error;
} finally {
if (!suppressLoadingUi) {
setLoading(false);
}
}
}
function setupEventListeners() {
const handleResize = () => onWindowResize();
const handlePointerMove = (event) => onPointerMove(event);
const handlePointerDown = (event) => onPointerDown(event);
const handlePointerUp = (event) => onPointerUp(event);
const handleMouseLeave = () => onMouseLeave();
const handleClick = (event) => onClick(event);
const handlePageHide = () => destroy();
const handleRotationMode = (event) => handleRotationModeChange(event);
bindListener(window, "resize", handleResize);
bindListener(window, "pagehide", handlePageHide);
bindListener(window, "beforeunload", handlePageHide);
bindListener(window, "earth:rotation-mode-change", handleRotationMode);
bindListener(renderer.domElement, "pointerdown", handlePointerDown);
bindListener(window, "pointermove", handlePointerMove);
bindListener(window, "pointerup", handlePointerUp);
bindListener(window, "pointercancel", handlePointerUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
if (renderer?.domElement) {
renderer.domElement.style.touchAction = "none";
}
}
function updateHudScale() {
const widthScale = window.innerWidth / HUD_CONFIG.scaleReferenceWidth;
const heightScale = window.innerHeight / HUD_CONFIG.scaleReferenceHeight;
const nextScale = THREE.MathUtils.clamp(
Math.min(widthScale, heightScale),
HUD_CONFIG.minScale,
HUD_CONFIG.maxScale,
);
document.documentElement.style.setProperty(
"--hud-scale",
nextScale.toFixed(3),
);
}
function onWindowResize() {
updateHudScale();
syncRendererViewport();
repositionCruiseConnector();
}
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) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
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) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function getFrontFacingComputeCenterMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchComputeCenterWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchComputeCenterWorldPosition);
scratchComputeCenterDirection
.subVectors(scratchComputeCenterWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchComputeCenterDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) {
clearTransientHoverState();
// Info card stays visible at its click position; just maintain BGP visual state
if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) {
hideInfoCard();
}
hideTooltip();
return;
}
if (isDragging) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
}
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
isLongDrag = true;
}
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
const dragRotationFactor = getDragRotationFactor();
const rotationDeltaY = deltaX * dragRotationFactor;
const rotationDeltaX = deltaY * dragRotationFactor;
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)
: [];
const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers(
getComputeCenterMarkers(),
);
const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers)
: [];
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();
}
const hoveredComputeCenterMarker =
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
if (
hoveredComputeCenter &&
!isSameComputeCenter(hoveredComputeCenter, hoveredComputeCenterMarker)
) {
clearTransientHoverState();
}
if (
hoveredCable &&
(!cableIntersects.length ||
!isSameCable(cableIntersects[0]?.object, hoveredCable))
) {
clearTransientHoverState();
}
if (
hoveredSatelliteIndex !== null &&
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
) {
clearTransientHoverState();
}
let objectTooltipShown = false;
if (
hoveredBGPMarker &&
getShowBGP() &&
lockedObjectType !== "bgp" &&
lockedObjectType !== "bgp_collector"
) {
applyBGPHoverState(hoveredBGPMarker);
if (hoveredBGPMarker.userData?.type === "bgp") {
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPBriefHtml(hoveredBGPMarker));
} else {
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPCollectorBriefHtml(hoveredBGPMarker));
}
objectTooltipShown = true;
} else if (
hoveredComputeCenterMarker &&
getShowComputeCenters() &&
lockedObjectType !== "compute_center"
) {
applyComputeCenterHoverState(hoveredComputeCenterMarker);
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getComputeCenterBriefHtml(hoveredComputeCenterMarker),
);
objectTooltipShown = true;
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) {
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
}
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getCableBriefHtml(cable));
objectTooltipShown = true;
} else if (hoveredSat?.properties) {
hoveredSatellite = hoveredSat;
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
const satPositions = getSatellitePositions();
if (satPositions && satPositions[hoveredSatelliteIndex]) {
setSatelliteRingState(
hoveredSatelliteIndex,
"hover",
satPositions[hoveredSatelliteIndex].current,
);
}
}
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
objectTooltipShown = true;
} else if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "compute_center" && lockedObject) {
applyComputeCenterHoverState(lockedObject);
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
resetTransientBGPStates();
resetTransientComputeCenterStates();
hideInfoCard();
}
if (!objectTooltipShown) {
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);
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
`纬度: ${coords.lat}°
经度: ${coords.lon}°
海拔: ${elevText}`,
);
} 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,
};
}
clearDocumentSelection();
setGlobeDraggingUiState(true);
document.getElementById("container")?.classList.add("dragging");
hideTooltip();
}
function onMouseUp() {
isDragging = false;
setGlobeDraggingUiState(false);
clearDocumentSelection();
document.getElementById("container")?.classList.remove("dragging");
}
function onPointerDown(event) {
if (isEventOnHud(event)) return;
if (event.pointerType !== "touch" && event.button !== 0) return;
renderer?.domElement?.setPointerCapture?.(event.pointerId);
clearDocumentSelection();
if (event.pointerType === "touch") {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
renderer?.domElement?.setPointerCapture?.(event.pointerId);
if (activeTouchPoints.size === 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
pinchGesture = {
distance: getTouchDistance(firstPoint, secondPoint),
startZoom: getZoomLevel(),
};
activeDragPointerId = null;
onMouseUp();
return;
}
}
activeDragPointerId = event.pointerId;
pointerDragDistance = 0;
suppressNextClick = false;
onMouseDown(event);
}
function onPointerMove(event) {
if (activeDragPointerId === event.pointerId && isDragging) {
clearDocumentSelection();
}
if (event.pointerType === "touch") {
if (activeTouchPoints.has(event.pointerId)) {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
});
}
if (pinchGesture && activeTouchPoints.size >= 2) {
const [firstPoint, secondPoint] = Array.from(activeTouchPoints.values());
const nextDistance = getTouchDistance(firstPoint, secondPoint);
if (pinchGesture.distance > 0) {
const scale = nextDistance / pinchGesture.distance;
setZoomLevel(pinchGesture.startZoom * scale, camera);
suppressNextClick = true;
hideTooltip();
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
onMouseMove(event);
return;
}
return;
}
if (activeDragPointerId === event.pointerId && isDragging) {
const deltaX = event.clientX - previousMousePosition.x;
const deltaY = event.clientY - previousMousePosition.y;
pointerDragDistance = Math.max(
pointerDragDistance,
Math.hypot(deltaX, deltaY),
);
}
onMouseMove(event);
}
function onPointerUp(event) {
if (event.pointerType === "touch") {
activeTouchPoints.delete(event.pointerId);
if (activeTouchPoints.size < 2) {
pinchGesture = null;
}
}
if (activeDragPointerId === event.pointerId) {
if (pointerDragDistance > DRAG_POINTER_THRESHOLD_PX) {
suppressNextClick = true;
isLongDrag = true;
}
renderer?.domElement?.releasePointerCapture?.(event.pointerId);
activeDragPointerId = null;
pointerDragDistance = 0;
onMouseUp();
}
}
function onMouseLeave() {
hideTooltip();
}
function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
if (suppressNextClick) {
suppressNextClick = false;
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 computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
)
: [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
const clickedBGPMarker = getShowBGP()
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
: null;
const clickedComputeCenterMarker = computeCenterIntersects.length > 0
? computeCenterIntersects[0].object
: null;
if (clickedBGPMarker?.userData?.type === "bgp") {
interruptCruisePresentation();
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);
applyBGPEventSatelliteHighlights(clickedMarker);
const incidentSummary = getBGPInfrastructureSummary(clickedMarker);
showBGPInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择BGP事件: ${clickedMarker.userData.collector} · ${incidentSummary.regionCount}个区域 / ${incidentSummary.cableCount}条相关海缆`,
"info",
);
return;
}
if (clickedBGPMarker?.userData?.type === "bgp_collector") {
interruptCruisePresentation();
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);
clickedMarker.userData.related_satellite_count = 0;
showBGPCollectorInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择观测站: ${clickedMarker.userData.collector}`,
"info",
);
return;
}
if (clickedComputeCenterMarker?.userData?.type === "compute_center") {
interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedComputeCenterMarker;
setComputeCenterMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "compute_center";
setAutoRotate(false);
showComputeCenterInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择算力中心: ${clickedMarker.userData?.name || "未知节点"}`,
"info",
);
return;
}
if (cableIntersects.length > 0 && getShowCables()) {
interruptCruisePresentation();
clearLockedObject();
const clickedCable = cableIntersects[0].object;
const cableId = clickedCable.userData.cableId;
setCableState(cableId, CABLE_STATE.LOCKED);
lockedObject = clickedCable;
lockedObjectType = "cable";
setAutoRotate(false);
{
const cableLandingRegions = getLandingPoints()
.filter((lp) => lp.userData.cableNames?.includes(clickedCable.userData.name))
.map((lp) => {
const { lat, lon } = vector3ToLatLon(lp.position);
return { latitude: lat, longitude: lon };
});
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
cableLandingRegions,
{ limit: 6, maxAngleDeg: 20 },
);
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
}
handleCableClick(clickedCable);
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
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;
interruptCruisePresentation();
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, { x: event.clientX, y: event.clientY });
showStatusMessage("已选择: " + sat.properties.name, "info");
return;
}
if (!isLongDrag) {
if (isCruiseModeActive()) {
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
hideInfoCard();
setAutoRotate(true);
return;
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
hideInfoCard();
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() && getRotationMode() === ROTATION_MODE.ROTATE && 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();
const activeCruiseMarker =
isCruiseModeActive() && isCruisePresentationPinned()
? cruiseSequencer?.getCurrentItem() ?? null
: null;
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
} else if (
lockedObjectType === "satellite" && lockedSatellite
) {
applyLandingPointVisualState(null, true, camera);
} else if (lockedObjectType === "bgp" && lockedObject) {
applyBGPRelatedCablesAndLandingPoints(lockedObject, camera);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
clearAllCableStates();
resetLandingPointVisualState(camera);
} else if (activeCruiseMarker) {
applyBGPRelatedCablesAndLandingPoints(activeCruiseMarker, camera);
} else {
resetLandingPointVisualState(camera);
}
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
setEarthSunDirection(getSunDirection());
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (
lockedObjectType === "satellite" &&
lockedSatelliteIndex !== null &&
satPositions?.[lockedSatelliteIndex]
) {
updateLockedRingPosition(satPositions[lockedSatelliteIndex].current);
} else if (
hoveredSatelliteIndex !== null &&
satPositions?.[hoveredSatelliteIndex]
) {
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
}
repositionCruiseConnector();
renderer.render(scene, camera);
}
export function destroy() {
if (destroyed) return;
destroyed = true;
currentLoadToken += 1;
isDataLoading = false;
setGlobeDraggingUiState(false);
clearDocumentSelection();
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
teardownControls();
while (cleanupFns.length) {
const cleanup = cleanupFns.pop();
cleanup?.();
}
clearLockedObject();
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());
resetSatelliteState();
clearUiState();
disposeCelestialLayer();
clearTerrainData();
if (scene) {
disposeSceneObject(scene);
}
if (renderer) {
renderer.dispose();
if (typeof renderer.forceContextLoss === "function") {
renderer.forceContextLoss();
}
renderer.domElement?.remove();
}
scene = null;
camera = null;
renderer = null;
sceneLights = null;
initialized = false;
delete window.__planetEarth;
}
document.addEventListener("DOMContentLoaded", init);