Files
planet/frontend/public/earth/js/main.js
2026-04-21 22:49:39 +08:00

2666 lines
76 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 {
CONFIG,
HUD_CONFIG,
CABLE_CONFIG,
CABLE_STATE,
SATELLITE_CONFIG,
PATHS,
BGP_CONFIG,
CRUISE_CONFIG,
ROTATION_MODE,
} from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
showStatusMessage,
queueStatusMessage,
updateCoordinatesDisplay,
updateZoomDisplay,
updateEarthStats,
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 } from "./terrain.js";
import {
initCelestialLayer,
updateCelestialLayer,
disposeCelestialLayer,
getCelestialDebugState,
getSunDirection,
setCelestialOrientation,
setCelestialFollow,
} 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,
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,
getRotationMode,
getShowTerrain,
setAutoRotate,
applyImmediateView,
focusEarthView,
getZoomLevel,
teardownControls,
} from "./controls.js";
import {
setLayerButtonState,
} from "./layer-button-state.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";
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 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 cruiseTimerId = null;
let cruiseHideCardTimerId = null;
let cruisePollTimerId = null;
let cruiseCurrentMarkerId = null;
let cruiseCurrentIndex = -1;
let cruiseQueuedMarkerIds = [];
let cruiseKnownEventIds = new Set();
let cruiseCardPinned = false;
let cruiseConnectorEl = null;
let cruiseConnectorPolyline = null;
let cruiseConnectorStartpoint = null;
let cruiseConnectorEndpoint = null;
let cruiseConnectorNeedsAnimation = false;
let cruiseCardPlacement = null;
let cruiseSequenceToken = 0;
let cruisePresentationPhase = "hidden";
let cruiseAdvanceInFlight = false;
let cruiseAdvanceQueued = false;
let cruiseAdvanceInterrupt = false;
let cruiseCancelNotifier = null;
function createCruisePresentationTimer() {
let endsAt = 0;
return {
start(durationMs) {
endsAt = Date.now() + Math.max(0, durationMs);
},
stop() {
endsAt = 0;
},
isActive() {
return endsAt > 0 && Date.now() < endsAt;
},
};
}
const cruisePresentationTimer = createCruisePresentationTimer();
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 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_CONNECTOR_DRAW_MS = 420;
const CRUISE_TRANSITION_GAP_MS = 24;
const CRUISE_PRESENTATION_HIDE_MS = 220;
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
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 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 *",
];
function bindListener(target, eventName, handler, options) {
if (!target) return;
target.addEventListener(eventName, handler, options);
cleanupFns.push(() =>
target.removeEventListener(eventName, handler, options),
);
}
function waitForCruiseDelay(durationMs, sequenceToken, { useHideTimer = false } = {}) {
return new Promise((resolve) => {
let settled = false;
const settle = (ok) => {
if (settled) return;
settled = true;
if (cruiseCancelNotifier === onCancel) cruiseCancelNotifier = null;
resolve(ok);
};
const onCancel = () => settle(false);
const timerId = window.setTimeout(() => {
if (useHideTimer) {
if (cruiseHideCardTimerId === timerId) cruiseHideCardTimerId = null;
} else if (cruiseTimerId === timerId) {
cruiseTimerId = null;
}
settle(sequenceToken === cruiseSequenceToken);
}, durationMs);
if (useHideTimer) {
cruiseHideCardTimerId = timerId;
} else {
cruiseTimerId = timerId;
}
cruiseCancelNotifier = onCancel;
});
}
async function waitForCruiseConnectorReady(sequenceToken) {
const startedAt = performance.now();
while (sequenceToken === cruiseSequenceToken) {
if (!isCruiseModeActive() || !getAutoRotate()) {
return false;
}
const ready = updateCruiseConnector();
if (ready) {
return true;
}
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
return false;
}
await nextAnimationFrame();
}
return false;
}
function getViewportAspect() {
return window.innerWidth / window.innerHeight;
}
function nextAnimationFrame() {
return new Promise((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
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 getDragRotationFactor() {
const zoom = Math.max(getZoomLevel(), 0.01);
const scale = THREE.MathUtils.clamp(
1 / zoom,
CONFIG.dragRotationScaleMin,
CONFIG.dragRotationScaleMax,
);
return CONFIG.dragRotationFactorBase * scale;
}
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, 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 `<strong>${name}</strong>${status ? `<br>${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 `<strong>${name}</strong>${id ? `<br>${id}` : ""}`;
}
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 `<strong>${type}</strong>${collector ? `<br>${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 `<strong>${name}</strong><br>${count} 条事件`;
}
function getBGPStatusText(bgpResult) {
if (bgpResult.totalCount > 0) {
return `${bgpResult.totalCount} 起活跃事件`;
}
if (bgpResult.anomalyCount > 0) {
return `${bgpResult.anomalyCount} 条活跃异常`;
}
return "当前无活跃事件";
}
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;
}
}
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 = getBGPStatusText(bgpResult);
}
}
function clearCruiseTimers() {
if (cruiseTimerId) {
clearTimeout(cruiseTimerId);
cruiseTimerId = null;
}
if (cruiseHideCardTimerId) {
clearTimeout(cruiseHideCardTimerId);
cruiseHideCardTimerId = null;
}
}
function ensureCruiseConnector() {
if (cruiseConnectorEl instanceof SVGSVGElement) {
return cruiseConnectorEl;
}
const container = document.getElementById("container");
if (!(container instanceof HTMLElement)) return null;
const connector = document.createElementNS("http://www.w3.org/2000/svg", "svg");
connector.id = "info-card-cruise-link";
connector.setAttribute("class", "info-card-cruise-link");
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
connector.setAttribute("preserveAspectRatio", "none");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
const startpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle");
const endpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle");
startpoint.setAttribute("r", "4");
endpoint.setAttribute("r", "4");
connector.appendChild(startpoint);
connector.appendChild(polyline);
connector.appendChild(endpoint);
container.appendChild(connector);
cruiseConnectorEl = connector;
cruiseConnectorPolyline = polyline;
cruiseConnectorStartpoint = startpoint;
cruiseConnectorEndpoint = endpoint;
connector.addEventListener("animationend", (event) => {
if (
event.animationName === "cruiseConnectorDraw" &&
cruiseConnectorEl?.classList.contains("is-visible")
) {
if (cruiseConnectorPolyline) {
cruiseConnectorPolyline.style.strokeDashoffset = "0";
}
cruiseConnectorEl.classList.remove("is-animating");
}
});
return connector;
}
function hideCruiseConnector() {
const connector = ensureCruiseConnector();
if (!connector) return;
connector.classList.remove("is-visible");
connector.classList.remove("is-animating");
cruiseCardPlacement = null;
cruisePresentationPhase = "hidden";
}
function hideCruiseConnectorVisual() {
const connector = ensureCruiseConnector();
if (!connector) return;
connector.classList.remove("is-visible");
connector.classList.remove("is-animating");
}
function showCruiseConnectorVisual() {
const connector = ensureCruiseConnector();
if (!connector) return;
connector.classList.add("is-visible");
}
function setCruiseCardPinned(pinned) {
cruiseCardPinned = pinned;
if (!pinned) {
cruisePresentationTimer.stop();
hideCruiseConnector();
}
}
function interruptCruisePresentation() {
++cruiseSequenceToken;
clearCruiseTimers();
const notifier = cruiseCancelNotifier;
cruiseCancelNotifier = null;
notifier?.();
setCruiseCardPinned(false);
}
function beginCruisePresentationHide() {
if (!lockedObject) {
hideInfoCard();
}
cruisePresentationPhase = "hidden";
const connector = ensureCruiseConnector();
connector?.classList.remove("is-visible");
connector?.classList.remove("is-animating");
}
function scheduleCruiseCardHide() {
if (cruiseHideCardTimerId) {
clearTimeout(cruiseHideCardTimerId);
cruiseHideCardTimerId = null;
}
}
function clearCruiseMarkerHighlight() {
if (!cruiseCurrentMarkerId) return;
const marker = getBGPAnomalyMarkers().find(
(item) => item.userData?.id === cruiseCurrentMarkerId,
);
if (marker && lockedObject !== marker) {
setBGPMarkerState(marker, "normal");
}
cruiseCurrentMarkerId = null;
}
function getBGPMarkerTimestamp(marker) {
const rawValue = marker?.userData?.created_at_raw;
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
function getCruiseMarkersSorted() {
return getBGPAnomalyMarkers()
.slice()
.sort((a, b) => getBGPMarkerTimestamp(b) - getBGPMarkerTimestamp(a));
}
function getCruiseMarkerScreenCoords(marker) {
if (!marker || !camera) return null;
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
const projected = scratchBGPWorldPosition.clone().project(camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
return {
x: ((projected.x + 1) * 0.5) * window.innerWidth,
y: ((1 - projected.y) * 0.5) * window.innerHeight,
};
}
function getCruiseCardScreenCoords(marker) {
const markerCoords = getCruiseMarkerScreenCoords(marker);
if (!markerCoords) return null;
const hudScale =
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
) || 1;
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
const x =
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
const y =
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
const margin = 12;
const clampedX = Math.min(
Math.max(margin, x),
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
);
const clampedY = Math.min(
Math.max(margin, y),
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
);
const anchorY = clampedY + Math.max(18 * hudScale, estimatedCardHeight * 0.18);
return {
x: clampedX,
y: clampedY,
width: estimatedCardWidth,
height: estimatedCardHeight,
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
anchorY,
};
}
function showCruiseEventCard(marker) {
const coords = cruiseCardPlacement || getCruiseCardScreenCoords(marker);
if (!coords) return;
showBGPInfo(marker, {
x: coords.x,
y: coords.y,
absolute: true,
});
}
function isCruiseInfoCardVisible() {
return document.getElementById("info-panel")?.classList.contains("is-visible") === true;
}
function computeCruiseConnectorPoints(marker) {
const markerCoords = getCruiseMarkerScreenCoords(marker);
if (!markerCoords) return null;
const targetCardCoords = cruiseCardPlacement || getCruiseCardScreenCoords(marker);
if (!targetCardCoords) return null;
const panelAnchorX = targetCardCoords.anchorX;
const panelAnchorY = targetCardCoords.anchorY;
const horizontalDirection = markerCoords.x <= panelAnchorX ? 1 : -1;
const startX = markerCoords.x + horizontalDirection * CRUISE_CONFIG.linkMarkerGapPx;
const startY = markerCoords.y;
const elbowX =
panelAnchorX -
horizontalDirection * (CRUISE_CONFIG.linkElbowOffsetPx + CRUISE_CONFIG.linkPanelGapPx);
const elbowY = Math.min(startY, panelAnchorY) + CRUISE_CONFIG.linkElbowDropPx;
if (Math.abs(panelAnchorX - startX) < 8 && Math.abs(panelAnchorY - startY) < 8) {
return null;
}
return { startX, startY, elbowX, elbowY, panelAnchorX, panelAnchorY };
}
function applyCruiseConnectorPoints(pts) {
const polyline = cruiseConnectorPolyline;
const startpoint = cruiseConnectorStartpoint;
const endpoint = cruiseConnectorEndpoint;
if (!polyline || !startpoint || !endpoint) return 0;
polyline.setAttribute(
"points",
`${pts.startX.toFixed(2)},${pts.startY.toFixed(2)} ` +
`${pts.elbowX.toFixed(2)},${pts.elbowY.toFixed(2)} ` +
`${pts.panelAnchorX.toFixed(2)},${pts.panelAnchorY.toFixed(2)}`,
);
startpoint.setAttribute("cx", pts.startX.toFixed(2));
startpoint.setAttribute("cy", pts.startY.toFixed(2));
endpoint.setAttribute("cx", pts.panelAnchorX.toFixed(2));
endpoint.setAttribute("cy", pts.panelAnchorY.toFixed(2));
const totalLength =
typeof polyline.getTotalLength === "function" ? polyline.getTotalLength() : 0;
return totalLength;
}
function updateCruiseConnector() {
const connector = ensureCruiseConnector();
if (
!connector ||
!cruiseConnectorPolyline ||
!cruiseConnectorStartpoint ||
!cruiseConnectorEndpoint ||
!cruiseCardPinned ||
cruisePresentationPhase === "hidden" ||
!cruiseCurrentMarkerId
) {
return false;
}
const marker = getBGPAnomalyMarkers().find(
(item) => item.userData?.id === cruiseCurrentMarkerId,
);
if (!marker) return false;
const pts = computeCruiseConnectorPoints(marker);
if (!pts) return false;
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
const totalLength = applyCruiseConnectorPoints(pts);
const polyline = cruiseConnectorPolyline;
polyline.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
polyline.style.strokeDashoffset =
totalLength > 0 ? `${cruiseConnectorNeedsAnimation ? totalLength : 0}` : "";
connector.style.setProperty(
"--connector-length",
totalLength > 0 ? `${totalLength}` : "0px",
);
connector.classList.add("is-visible");
if (cruiseConnectorNeedsAnimation && totalLength > 0) {
connector.classList.remove("is-animating");
void connector.getBoundingClientRect();
polyline.style.strokeDashoffset = `${totalLength}`;
connector.classList.add("is-animating");
cruiseConnectorNeedsAnimation = false;
}
return totalLength > 0;
}
function repositionCruiseConnector() {
if (!cruiseCardPinned || cruisePresentationPhase === "hidden" || !cruiseCurrentMarkerId) return;
const connector = cruiseConnectorEl;
if (!connector || !connector.classList.contains("is-visible")) return;
const marker = getBGPAnomalyMarkers().find(
(item) => item.userData?.id === cruiseCurrentMarkerId,
);
if (!marker) return;
const pts = computeCruiseConnectorPoints(marker);
if (!pts) return;
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
applyCruiseConnectorPoints(pts);
}
function syncCruiseKnownEventIds() {
cruiseKnownEventIds = new Set(
getBGPAnomalyMarkers()
.map((marker) => marker.userData?.id)
.filter(Boolean),
);
}
function isCruiseModeActive() {
return getRotationMode() === ROTATION_MODE.CRUISE;
}
function stopCruiseMode({ preserveCard = false } = {}) {
clearCruiseTimers();
clearCruiseMarkerHighlight();
clearBGPSelection();
cruiseCurrentIndex = -1;
cruiseQueuedMarkerIds = [];
cruiseAdvanceQueued = false;
cruiseAdvanceInterrupt = false;
if (!preserveCard) {
setCruiseCardPinned(false);
}
if (!preserveCard && !lockedObject) {
hideInfoCard();
}
}
async function focusCruiseMarker(marker, { interrupt = false } = {}) {
const earth = getEarth();
if (!marker || !earth || !isCruiseModeActive() || !getAutoRotate()) return;
const sequenceToken = ++cruiseSequenceToken;
const abortPresentation = () => {
hideInfoCard();
setCruiseCardPinned(false);
};
const shouldAbortSequence = () =>
sequenceToken !== cruiseSequenceToken ||
!isCruiseModeActive() ||
!getAutoRotate();
clearCruiseTimers();
clearCruiseMarkerHighlight();
clearLockedObject();
hideInfoCard();
setCruiseCardPinned(false);
cruiseCurrentMarkerId = marker.userData?.id || null;
cruiseConnectorNeedsAnimation = true;
cruiseCardPlacement = getCruiseCardScreenCoords(marker);
cruiseCurrentIndex = getCruiseMarkersSorted().findIndex(
(item) => item.userData?.id === cruiseCurrentMarkerId,
);
setLegendMode("bgp");
setBGPMarkerState(marker, "locked");
showBGPEventOverlay(marker, earth);
applyBGPEventSatelliteHighlights(marker);
await focusEarthView(camera, {
lat: marker.userData?.latitude ?? 0,
lon: marker.userData?.longitude ?? 0,
rotLon: (marker.userData?.longitude ?? 0) - 270,
zoom: 1.0,
duration: interrupt ? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78) : CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
if (
shouldAbortSequence()
) {
abortPresentation();
return;
}
setCruiseCardPinned(true);
cruisePresentationPhase = "connector";
showCruiseConnectorVisual();
const connectorReady = await waitForCruiseConnectorReady(sequenceToken);
if (
!connectorReady ||
shouldAbortSequence()
) {
abortPresentation();
return;
}
const connectorDelayCompleted = await waitForCruiseDelay(
CRUISE_CONNECTOR_DRAW_MS,
sequenceToken,
);
if (
!connectorDelayCompleted ||
shouldAbortSequence()
) {
abortPresentation();
return;
}
cruisePresentationPhase = "card";
showCruiseEventCard(marker);
await nextAnimationFrame();
if (!isCruiseInfoCardVisible()) {
showCruiseEventCard(marker);
await nextAnimationFrame();
}
if (!isCruiseInfoCardVisible() || shouldAbortSequence()) {
abortPresentation();
return;
}
cruisePresentationTimer.start(CRUISE_CONFIG.dwellMs);
const dwellDelayCompleted = await waitForCruiseDelay(
CRUISE_CONFIG.dwellMs,
sequenceToken,
);
if (
!dwellDelayCompleted ||
shouldAbortSequence()
) {
abortPresentation();
return;
}
beginCruisePresentationHide();
const hideDelayCompleted = await waitForCruiseDelay(
CRUISE_PRESENTATION_HIDE_MS,
sequenceToken,
{ useHideTimer: true },
);
if (
!hideDelayCompleted ||
shouldAbortSequence()
) {
abortPresentation();
return;
}
setCruiseCardPinned(false);
const transitionGapCompleted = await waitForCruiseDelay(
CRUISE_TRANSITION_GAP_MS,
sequenceToken,
);
if (
!transitionGapCompleted ||
shouldAbortSequence()
) {
abortPresentation();
return;
}
void advanceCruiseEvent();
}
async function performCruiseAdvance({ interrupt = false } = {}) {
if (!isCruiseModeActive() || !getAutoRotate()) return;
const markers = getCruiseMarkersSorted();
if (markers.length === 0) return;
let targetMarker = null;
while (cruiseQueuedMarkerIds.length > 0 && !targetMarker) {
const queuedId = cruiseQueuedMarkerIds.shift();
targetMarker = markers.find((marker) => marker.userData?.id === queuedId) || null;
}
if (!targetMarker) {
const nextIndex =
cruiseCurrentIndex >= 0
? (cruiseCurrentIndex + 1) % markers.length
: 0;
targetMarker = markers[nextIndex] || markers[0];
}
await focusCruiseMarker(targetMarker, { interrupt });
}
async function advanceCruiseEvent({ interrupt = false } = {}) {
if (!isCruiseModeActive() || !getAutoRotate()) return;
cruiseAdvanceQueued = true;
cruiseAdvanceInterrupt = cruiseAdvanceInterrupt || interrupt;
if (cruiseAdvanceInFlight) {
return;
}
cruiseAdvanceInFlight = true;
try {
while (cruiseAdvanceQueued && isCruiseModeActive() && getAutoRotate()) {
const nextInterrupt = cruiseAdvanceInterrupt;
cruiseAdvanceQueued = false;
cruiseAdvanceInterrupt = false;
await performCruiseAdvance({ interrupt: nextInterrupt });
}
} finally {
cruiseAdvanceInFlight = false;
}
}
async function pollCruiseEventsIfNeeded() {
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
try {
const [incidentResponse, anomalyResponse] = await Promise.all([
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
]);
if (!incidentResponse.ok || !anomalyResponse.ok) return;
const [incidentPayload, anomalyPayload] = await Promise.all([
incidentResponse.json(),
anomalyResponse.json(),
]);
const incidentFeatures = Array.isArray(incidentPayload?.features)
? incidentPayload.features
: [];
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
? anomalyPayload.features
: [];
const selectedFeatures =
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
const nextIds = selectedFeatures
.map((feature) => {
const properties = feature?.properties || {};
const coords = feature?.geometry?.coordinates || [];
return (
properties.id ||
properties.incident_key ||
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
);
})
.filter(Boolean);
const newIds = nextIds.filter((id) => !cruiseKnownEventIds.has(id));
if (newIds.length === 0) return;
const bgpResult = await loadBGPAnomalies(scene, getEarth());
updateBGPHud(bgpResult);
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
syncCruiseKnownEventIds();
cruiseQueuedMarkerIds = Array.from(
new Set([...newIds, ...cruiseQueuedMarkerIds]),
);
if (
cruisePresentationPhase === "hidden" &&
!cruiseCardPinned &&
!cruisePresentationTimer.isActive()
) {
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();
syncCruiseKnownEventIds();
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() && cruiseCardPinned);
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 ? "隐藏卫星" : "显示卫星",
});
}
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${satelliteCount}`;
}
}
function updateCableToggleUi(enabled) {
const cableBtn = document.getElementById("toggle-cables");
if (cableBtn) {
setLayerButtonState(cableBtn, {
active: enabled,
loading: false,
tooltip: 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);
// 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:
document.getElementById("landing-point-count")?.textContent || 0,
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();
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("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,
sunLight: sceneLights?.sunLight ?? null,
backLight: sceneLights?.backLight ?? null,
earth: earthObj,
});
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(0x404060);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
sunLight.position.set(5, 3, 5);
sunLight.target.position.set(0, 0, 0);
scene.add(sunLight);
scene.add(sunLight.target);
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);
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);
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);
// Step 2 — Landing points
if (cablesEnabled) {
setLoadingMessage("正在加载登陆点...");
await yieldFrame(12);
try {
await loadLandingPoints(scene, earth, { silent: true });
} catch (err) {
errors.push({ label: "登陆点", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
}
// Step 3 — Cables
if (cablesEnabled) {
setLoadingMessage("正在加载海缆...");
await yieldFrame(12);
try {
const cableCount = await loadGeoJSONFromPath(scene, earth, {
silent: true,
});
if (loadToken === currentLoadToken && cablesEnabled) {
toggleCables(true);
updateCableToggleUi(true);
setLegendItems("cables", getCableLegendItems());
refreshLegend();
}
} catch (err) {
errors.push({ label: "海缆", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
}
// Step 4 — Satellites
if (satellitesEnabled) {
setLoadingMessage("正在加载卫星...");
await yieldFrame(12);
try {
clearSatelliteData();
const loadResult = await loadSatellites({
limit: getInitialSatelliteLoadLimit(),
});
if (loadToken === currentLoadToken && satellitesEnabled) {
updateSatelliteToggleUi(true, loadResult.count);
setLegendItems("satellites", getSatelliteLegendItems());
refreshLegend();
scheduleSatellitePositionWarmup(() => {
if (
loadToken === currentLoadToken &&
satellitesEnabled &&
!destroyed
) {
toggleSatellites(true);
}
});
if (shouldHydrateFullSatelliteSet(loadResult)) {
const hydrationToken = ++satelliteHydrationToken;
hydrateAllSatellitesInBackground(
() =>
hydrationToken === satelliteHydrationToken &&
loadToken === currentLoadToken &&
satellitesEnabled &&
!destroyed,
);
}
}
} catch (err) {
errors.push({ label: "卫星", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
}
// Step 5 — BGP
setLoadingMessage("正在加载BGP态势...");
await yieldFrame(12);
try {
const bgpResult = await loadBGPAnomalies(scene, earth);
if (loadToken === currentLoadToken) {
toggleBGP(true);
updateBGPHud(bgpResult);
syncCruiseKnownEventIds();
}
} catch (err) {
errors.push({ label: "BGP态势", reason: err });
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
// Step 6 — Terrain (if enabled)
if (getShowTerrain()) {
setLoadingMessage("正在渲染地形...");
await yieldFrame(24);
}
updateStatsSummary();
updateCableToggleUi(cablesEnabled);
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
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;
export async function reloadData() {
await loadData();
}
export async function setCablesEnabled(enabled) {
if (enabled === cablesEnabled) {
updateCableToggleUi(enabled);
return getCableLines().length;
}
if (!enabled) {
clearSelectionAndInfo();
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) {
clearSelectionAndInfo();
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();
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(window, "mousemove", handleMouseMove);
bindListener(renderer.domElement, "mousedown", handleMouseDown);
bindListener(window, "mouseup", handleMouseUp);
bindListener(renderer.domElement, "mouseleave", handleMouseLeave);
bindListener(renderer.domElement, "click", handleClick);
}
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) > 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();
// 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 && !cruiseCardPinned) {
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 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)
: [];
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();
}
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 (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 && !cruiseCardPinned) {
resetTransientBGPStates();
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);
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
`纬度: ${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") {
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 (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) {
interruptCruisePresentation();
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() && cruiseCardPinned && cruiseCurrentMarkerId)
? getBGPAnomalyMarkers().find((m) => m.userData?.id === cruiseCurrentMarkerId) ?? null
: null;
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
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;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
teardownControls();
while (cleanupFns.length) {
const cleanup = cleanupFns.pop();
cleanup?.();
}
clearLockedObject();
clearCableData(getEarth());
clearBGPData(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);