2666 lines
76 KiB
JavaScript
2666 lines
76 KiB
JavaScript
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);
|