视角距离:
300 km
@@ -207,7 +220,7 @@
正在初始化全球态势数据...
-
同步卫星、海底光缆与登陆点数据
+
同步卫星、海底光缆、登陆点与BGP异常数据
diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js
index baf6c647..8276eb4c 100644
--- a/frontend/public/earth/js/cables.js
+++ b/frontend/public/earth/js/cables.js
@@ -6,7 +6,7 @@ import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import { updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js";
-import { setLegendMode } from "./legend.js";
+import { setLegendItems, setLegendMode } from "./legend.js";
export let cableLines = [];
export let landingPoints = [];
@@ -57,7 +57,11 @@ function getCableColor(properties) {
}
const cableName =
- properties.Name || properties.cableName || properties.shortname || "";
+ properties.Name ||
+ properties.name ||
+ properties.cableName ||
+ properties.shortname ||
+ "";
if (cableName.includes("Americas II")) {
return CABLE_COLORS["Americas II"];
}
@@ -91,11 +95,17 @@ function createCableLine(points, color, properties) {
properties.cable_id ||
properties.id ||
properties.Name ||
+ properties.name ||
Math.random().toString(36);
cableLine.userData = {
type: "cable",
cableId,
- name: properties.Name || properties.cableName || "Unknown",
+ name:
+ properties.Name ||
+ properties.name ||
+ properties.cableName ||
+ properties.shortname ||
+ "Unknown",
owner: properties.owner || properties.owners || "-",
status: properties.status || "-",
length: properties.length || "-",
@@ -382,6 +392,7 @@ export async function loadLandingPoints(scene, earthObj) {
export function handleCableClick(cable) {
lockedCable = cable;
+ setLegendItems("cables", getCableLegendItems());
const data = cable.userData;
setLegendMode("cables");
@@ -399,12 +410,51 @@ export function handleCableClick(cable) {
export function clearCableSelection() {
lockedCable = null;
+ setLegendItems("cables", getCableLegendItems());
}
export function getCableLines() {
return cableLines;
}
+export function getCableLegendItems() {
+ const legendMap = new Map();
+
+ cableLines.forEach((cable) => {
+ const color = cable.userData?.originalColor;
+ const label = cable.userData?.name || "未知线缆";
+
+ if (typeof color === "number" && !legendMap.has(label)) {
+ legendMap.set(label, {
+ label,
+ color: `#${color.toString(16).padStart(6, "0")}`,
+ });
+ }
+ });
+
+ if (legendMap.size === 0) {
+ return [{ label: "其他电缆", color: "#ffff44" }];
+ }
+
+ const items = Array.from(legendMap.values()).sort((a, b) =>
+ a.label.localeCompare(b.label, "zh-CN"),
+ );
+
+ const selectedName = lockedCable?.userData?.name;
+ if (!selectedName) {
+ return items;
+ }
+
+ const selectedIndex = items.findIndex((item) => item.label === selectedName);
+ if (selectedIndex <= 0) {
+ return items;
+ }
+
+ const [selectedItem] = items.splice(selectedIndex, 1);
+ items.unshift(selectedItem);
+ return items;
+}
+
export function getCablesById(cableId) {
return cableIdMap.get(cableId) || [];
}
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js
index 5cee455d..935facb7 100644
--- a/frontend/public/earth/js/constants.js
+++ b/frontend/public/earth/js/constants.js
@@ -26,6 +26,7 @@ export const EARTH_CONFIG = {
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
+ bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
geoJSON: './geo.json',
landingPointsStatic: './landing-point-geo.geojson',
};
@@ -69,6 +70,37 @@ export const SATELLITE_CONFIG = {
dotOpacityMax: 1.0
};
+export const BGP_CONFIG = {
+ defaultFetchLimit: 200,
+ maxRenderedMarkers: 200,
+ altitudeOffset: 1.2,
+ baseScale: 6.2,
+ hoverScale: 1.16,
+ dimmedScale: 0.92,
+ pulseSpeed: 0.0045,
+ normalPulseAmplitude: 0.08,
+ lockedPulseAmplitude: 0.28,
+ opacity: {
+ normal: 0.78,
+ hover: 1.0,
+ dimmed: 0.24,
+ lockedMin: 0.65,
+ lockedMax: 1.0
+ },
+ severityColors: {
+ critical: 0xff4d4f,
+ high: 0xff9f43,
+ medium: 0xffd166,
+ low: 0x4dabf7
+ },
+ severityScales: {
+ critical: 1.18,
+ high: 1.08,
+ medium: 1.0,
+ low: 0.94
+ }
+};
+
export const PREDICTED_ORBIT_CONFIG = {
sampleInterval: 10,
opacity: 0.8
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js
index 07bdaafd..3f69576b 100644
--- a/frontend/public/earth/js/controls.js
+++ b/frontend/public/earth/js/controls.js
@@ -11,6 +11,7 @@ import {
getSatelliteCount,
} from "./satellites.js";
import { toggleCables, getShowCables } from "./cables.js";
+import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -293,6 +294,7 @@ function setupTerrainControls() {
const infoTrigger = document.getElementById("info-trigger");
const terrainBtn = document.getElementById("toggle-terrain");
const satellitesBtn = document.getElementById("toggle-satellites");
+ const bgpBtn = document.getElementById("toggle-bgp");
const trailsBtn = document.getElementById("toggle-trails");
const cablesBtn = document.getElementById("toggle-cables");
const layoutBtn = document.getElementById("layout-toggle");
@@ -337,6 +339,22 @@ function setupTerrainControls() {
showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info");
});
+ bindListener(bgpBtn, "click", function () {
+ const showNextBGP = !getShowBGP();
+ if (!showNextBGP) {
+ clearLockedObject();
+ }
+ toggleBGP(showNextBGP);
+ this.classList.toggle("active", showNextBGP);
+ const tooltip = this.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = showNextBGP ? "隐藏BGP观测" : "显示BGP观测";
+ const bgpCountEl = document.getElementById("bgp-anomaly-count");
+ if (bgpCountEl) {
+ bgpCountEl.textContent = `${getBGPCount()} 条`;
+ }
+ showStatusMessage(showNextBGP ? "BGP观测已显示" : "BGP观测已隐藏", "info");
+ });
+
bindListener(trailsBtn, "click", function () {
const isActive = this.classList.contains("active");
const nextShowTrails = !isActive;
diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js
index 6be5cee8..b8a2b8b6 100644
--- a/frontend/public/earth/js/info-card.js
+++ b/frontend/public/earth/js/info-card.js
@@ -30,6 +30,25 @@ const CARD_CONFIG = {
{ key: 'apogee', label: '远地点', unit: 'km' }
]
},
+ bgp: {
+ icon: '📡',
+ title: 'BGP异常详情',
+ className: 'bgp',
+ fields: [
+ { key: 'anomaly_type', label: '异常类型' },
+ { key: 'severity', label: '严重度' },
+ { key: 'status', label: '状态' },
+ { key: 'prefix', label: '前缀' },
+ { key: 'origin_asn', label: '原始 ASN' },
+ { key: 'new_origin_asn', label: '新 ASN' },
+ { key: 'confidence', label: '置信度' },
+ { key: 'collector', label: '采集器' },
+ { key: 'country', label: '国家' },
+ { key: 'city', label: '城市' },
+ { key: 'created_at', label: '创建时间' },
+ { key: 'summary', label: '摘要' }
+ ]
+ },
supercomputer: {
icon: '🖥️',
title: '超算详情',
diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js
index 18eac298..94e44535 100644
--- a/frontend/public/earth/js/legend.js
+++ b/frontend/public/earth/js/legend.js
@@ -1,25 +1,21 @@
const LEGEND_MODES = {
cables: {
title: "线缆图例",
- items: [
- { color: "#ff4444", label: "Americas II" },
- { color: "#44ff44", label: "AU Aleutian A" },
- { color: "#4444ff", label: "AU Aleutian B" },
- { color: "#ffff44", label: "其他电缆" },
- ],
},
satellites: {
title: "卫星图例",
- items: [
- { color: "#4db8ff", label: "卫星本体" },
- { color: "#9be7ff", label: "卫星轨迹" },
- { color: "#7dffb3", label: "悬停高亮" },
- { color: "#ffd166", label: "选中目标" },
- ],
+ },
+ bgp: {
+ title: "BGP观测图例",
},
};
let currentLegendMode = "cables";
+let legendItemsByMode = {
+ cables: [],
+ satellites: [],
+ bgp: [],
+};
export function initLegend() {
renderLegend(currentLegendMode);
@@ -27,7 +23,6 @@ export function initLegend() {
export function setLegendMode(mode) {
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
- if (nextMode === currentLegendMode) return;
currentLegendMode = nextMode;
renderLegend(currentLegendMode);
}
@@ -36,12 +31,25 @@ export function getLegendMode() {
return currentLegendMode;
}
+export function refreshLegend() {
+ renderLegend(currentLegendMode);
+}
+
+export function setLegendItems(mode, items) {
+ if (!LEGEND_MODES[mode]) return;
+ legendItemsByMode[mode] = Array.isArray(items) ? items : [];
+ if (mode === currentLegendMode) {
+ renderLegend(currentLegendMode);
+ }
+}
+
function renderLegend(mode) {
const legend = document.getElementById("legend");
if (!legend) return;
const config = LEGEND_MODES[mode] || LEGEND_MODES.cables;
- const itemsHtml = config.items
+ const items = legendItemsByMode[mode] || [];
+ const itemsHtml = items
.map(
(item) => `
diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js
index 7b9217f8..beb9e400 100644
--- a/frontend/public/earth/js/main.js
+++ b/frontend/public/earth/js/main.js
@@ -30,6 +30,7 @@ import {
handleCableClick,
clearCableSelection,
getCableLines,
+ getCableLegendItems,
getCableState,
setCableState,
clearAllCableStates,
@@ -44,6 +45,9 @@ import {
updateSatellitePositions,
toggleSatellites,
getShowSatellites,
+ getSatelliteLegendItems,
+ setSelectedSatelliteLegend,
+ clearSelectedSatelliteLegend,
getSatelliteCount,
selectSatellite,
getSatellitePoints,
@@ -60,6 +64,18 @@ import {
resetSatelliteState,
clearSatelliteData,
} from "./satellites.js";
+import {
+ loadBGPAnomalies,
+ getBGPMarkers,
+ getBGPLegendItems,
+ getBGPCount,
+ getShowBGP,
+ clearBGPSelection,
+ setBGPMarkerState,
+ updateBGPVisualState,
+ clearBGPData,
+ toggleBGP,
+} from "./bgp.js";
import {
setupControls,
getAutoRotate,
@@ -74,7 +90,12 @@ import {
hideInfoCard,
setInfoCardNoBorder,
} from "./info-card.js";
-import { initLegend, setLegendMode } from "./legend.js";
+import {
+ initLegend,
+ setLegendMode,
+ refreshLegend,
+ setLegendItems,
+} from "./legend.js";
export let scene;
export let camera;
@@ -86,6 +107,7 @@ 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;
@@ -110,6 +132,7 @@ 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 cleanupFns = [];
const DRAG_ROTATION_FACTOR = 0.005;
@@ -169,6 +192,7 @@ function disposeSceneObject(object) {
function clearRuntimeSelection() {
hoveredCable = null;
+ hoveredBGP = null;
hoveredSatellite = null;
hoveredSatelliteIndex = null;
lockedObject = null;
@@ -176,14 +200,17 @@ function clearRuntimeSelection() {
lockedSatellite = null;
lockedSatelliteIndex = null;
setLockedSatelliteIndex(null);
+ clearSelectedSatelliteLegend();
}
export function clearLockedObject() {
hidePredictedOrbit();
clearAllCableStates();
clearCableSelection();
+ clearBGPSelection();
setSatelliteRingState(null, "none", null);
clearRuntimeSelection();
+ setLegendItems("satellites", getSatelliteLegendItems());
}
function isSameCable(cable1, cable2) {
@@ -213,6 +240,8 @@ function showSatelliteInfo(props) {
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 || "-",
@@ -224,6 +253,24 @@ function showSatelliteInfo(props) {
});
}
+function showBGPInfo(marker) {
+ setLegendMode("bgp");
+ showInfoCard("bgp", {
+ anomaly_type: marker.userData.anomaly_type,
+ severity: marker.userData.rawSeverity || marker.userData.severity,
+ status: marker.userData.status,
+ prefix: marker.userData.prefix,
+ origin_asn: marker.userData.origin_asn,
+ new_origin_asn: marker.userData.new_origin_asn,
+ confidence: marker.userData.confidence,
+ collector: marker.userData.collector,
+ country: marker.userData.country,
+ city: marker.userData.city,
+ created_at: marker.userData.created_at,
+ summary: marker.userData.summary,
+ });
+}
+
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
@@ -248,7 +295,8 @@ function applyCableVisualState() {
default:
if (
(lockedObjectType === "cable" && lockedObject) ||
- (lockedObjectType === "satellite" && lockedSatellite)
+ (lockedObjectType === "satellite" && lockedSatellite) ||
+ (lockedObjectType === "bgp" && lockedObject)
) {
cable.material.opacity = CABLE_CONFIG.otherOpacity;
const origColor = cable.userData.originalColor;
@@ -289,6 +337,7 @@ function updateStatsSummary() {
cableCount: getCableLines().length,
landingPointCount:
document.getElementById("landing-point-count")?.textContent || 0,
+ bgpAnomalyCount: `${getBGPCount()} 条`,
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
@@ -337,6 +386,9 @@ export function init() {
addLights();
initInfoCard();
initLegend();
+ setLegendItems("cables", getCableLegendItems());
+ setLegendItems("satellites", getSatelliteLegendItems());
+ setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
targetRotation = {
x: earthObj.rotation.x,
@@ -400,8 +452,8 @@ async function loadData(showWhiteSphere = false) {
setLoadingMessage(
showWhiteSphere ? "正在刷新全球态势数据..." : "正在初始化全球态势数据...",
showWhiteSphere
- ? "重新同步卫星、海底光缆与登陆点数据"
- : "同步卫星、海底光缆与登陆点数据",
+ ? "重新同步卫星、海底光缆、登陆点与BGP异常数据"
+ : "同步卫星、海底光缆、登陆点与BGP异常数据",
);
setLoading(true);
clearLockedObject();
@@ -434,6 +486,22 @@ async function loadData(showWhiteSphere = false) {
}
return satelliteCount;
})(),
+ (async () => {
+ clearBGPData(earth);
+ const bgpResult = await loadBGPAnomalies(scene, earth);
+ toggleBGP(true);
+ const bgpBtn = document.getElementById("toggle-bgp");
+ if (bgpBtn) {
+ bgpBtn.classList.add("active");
+ const tooltip = bgpBtn.querySelector(".tooltip");
+ if (tooltip) tooltip.textContent = "隐藏BGP观测";
+ }
+ const bgpCountEl = document.getElementById("bgp-anomaly-count");
+ if (bgpCountEl) {
+ bgpCountEl.textContent = `${bgpResult.totalCount} 条`;
+ }
+ return bgpResult;
+ })(),
]);
if (loadToken !== currentLoadToken) {
@@ -451,6 +519,9 @@ async function loadData(showWhiteSphere = false) {
if (results[2].status === "rejected") {
errors.push({ label: "卫星", reason: results[2].reason });
}
+ if (results[3].status === "rejected") {
+ errors.push({ label: "BGP异常", reason: results[3].reason });
+ }
if (errors.length > 0) {
const errorMessage = buildLoadErrorMessage(errors);
@@ -462,6 +533,10 @@ async function loadData(showWhiteSphere = false) {
}
updateStatsSummary();
+ setLegendItems("cables", getCableLegendItems());
+ setLegendItems("satellites", getSatelliteLegendItems());
+ setLegendItems("bgp", getBGPLegendItems());
+ refreshLegend();
setLoading(false);
isDataLoading = false;
@@ -524,6 +599,18 @@ function getFrontFacingCables(cableLines) {
});
}
+function getFrontFacingBGPMarkers(markers) {
+ const earth = getEarth();
+ if (!earth) return markers;
+
+ scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
+
+ return markers.filter((marker) => {
+ scratchBGPDirection.copy(marker.position).normalize();
+ return scratchCameraToEarth.dot(scratchBGPDirection) > 0;
+ });
+}
+
function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
@@ -551,6 +638,10 @@ function onMouseMove(event) {
const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
+ const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
+ const bgpIntersects = getShowBGP()
+ ? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
+ : [];
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
@@ -568,6 +659,16 @@ function onMouseMove(event) {
}
}
+ if (
+ hoveredBGP &&
+ (!bgpIntersects.length || bgpIntersects[0]?.object !== hoveredBGP)
+ ) {
+ if (hoveredBGP !== lockedObject) {
+ setBGPMarkerState(hoveredBGP, "normal");
+ }
+ hoveredBGP = null;
+ }
+
if (
hoveredCable &&
(!cableIntersects.length ||
@@ -589,7 +690,16 @@ function onMouseMove(event) {
hoveredSatelliteIndex = null;
}
- if (cableIntersects.length > 0 && getShowCables()) {
+ if (bgpIntersects.length > 0 && getShowBGP()) {
+ const marker = bgpIntersects[0].object;
+ hoveredBGP = marker;
+ if (marker !== lockedObject) {
+ setBGPMarkerState(marker, "hover");
+ }
+ showBGPInfo(marker);
+ setInfoCardNoBorder(true);
+ hideTooltip();
+ } else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
if (!isSameCable(cable, lockedObject)) {
@@ -613,6 +723,8 @@ function onMouseMove(event) {
}
showSatelliteInfo(hoveredSat.properties);
setInfoCardNoBorder(true);
+ } else if (lockedObjectType === "bgp" && lockedObject) {
+ showBGPInfo(lockedObject);
} else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject);
} else if (lockedObjectType === "satellite" && lockedSatellite) {
@@ -686,10 +798,31 @@ function onClick(event) {
const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()),
);
+ const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
+ const bgpIntersects = getShowBGP()
+ ? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
+ : [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
+ if (bgpIntersects.length > 0 && getShowBGP()) {
+ clearLockedObject();
+
+ const clickedMarker = bgpIntersects[0].object;
+ setBGPMarkerState(clickedMarker, "locked");
+
+ lockedObject = clickedMarker;
+ lockedObjectType = "bgp";
+ setAutoRotate(false);
+ showBGPInfo(clickedMarker);
+ showStatusMessage(
+ `已选择BGP异常: ${clickedMarker.userData.collector}`,
+ "info",
+ );
+ return;
+ }
+
if (cableIntersects.length > 0 && getShowCables()) {
clearLockedObject();
@@ -816,10 +949,15 @@ function animate() {
}
applyCableVisualState();
+ updateBGPVisualState(lockedObjectType, lockedObject);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false);
- } else if (lockedObjectType === "satellite" && lockedSatellite) {
+ } else if (
+ lockedObjectType === "satellite" && lockedSatellite
+ ) {
+ applyLandingPointVisualState(null, true);
+ } else if (lockedObjectType === "bgp" && lockedObject) {
applyLandingPointVisualState(null, true);
} else {
resetLandingPointVisualState();
@@ -864,6 +1002,7 @@ export function destroy() {
clearLockedObject();
clearCableData(getEarth());
+ clearBGPData(getEarth());
resetSatelliteState();
clearUiState();
diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js
index a28f21eb..3435565b 100644
--- a/frontend/public/earth/js/satellites.js
+++ b/frontend/public/earth/js/satellites.js
@@ -22,6 +22,7 @@ let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
+let selectedSatelliteLegendKey = null;
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
@@ -33,10 +34,95 @@ const scratchToSatellite = new THREE.Vector3();
export let breathingPhase = 0;
+const SATELLITE_LEGEND_RULES = [
+ {
+ key: "starlink",
+ label: "Starlink",
+ color: "#00e6ff",
+ match: (props) => (props?.name || "").includes("STARLINK"),
+ },
+ {
+ key: "geo",
+ label: "GEO / 倾角 20-30",
+ color: "#ffcc00",
+ match: (props) => {
+ const inclination = props?.inclination || 53;
+ return inclination > 20 && inclination < 30;
+ },
+ },
+ {
+ key: "iridium",
+ label: "Iridium",
+ color: "#ff8000",
+ match: (props) => (props?.name || "").includes("IRIDIUM"),
+ },
+ {
+ key: "mid-inclination",
+ label: "倾角 50-70",
+ color: "#00ff4d",
+ match: (props) => {
+ const inclination = props?.inclination || 53;
+ return inclination > 50 && inclination < 70;
+ },
+ },
+ {
+ key: "other",
+ label: "其他卫星",
+ color: "#ffffff",
+ match: () => true,
+ },
+];
+
export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
+export function getSatelliteLegendItems() {
+ const presentKeys = new Set();
+
+ satelliteData.forEach((satellite) => {
+ const props = satellite?.properties || {};
+ const rule = SATELLITE_LEGEND_RULES.find((item) => item.match(props));
+ if (rule) {
+ presentKeys.add(rule.key);
+ }
+ });
+
+ if (presentKeys.size === 0) {
+ return SATELLITE_LEGEND_RULES.map(({ label, color }) => ({ label, color }));
+ }
+
+ const items = SATELLITE_LEGEND_RULES
+ .filter((item) => presentKeys.has(item.key))
+ .map(({ key, label, color }) => ({ key, label, color }));
+
+ if (!selectedSatelliteLegendKey) {
+ return items.map(({ label, color }) => ({ label, color }));
+ }
+
+ const selectedIndex = items.findIndex(
+ (item) => item.key === selectedSatelliteLegendKey,
+ );
+
+ if (selectedIndex > 0) {
+ const [selectedItem] = items.splice(selectedIndex, 1);
+ items.unshift(selectedItem);
+ }
+
+ return items.map(({ label, color }) => ({ label, color }));
+}
+
+export function setSelectedSatelliteLegend(props) {
+ const rule = SATELLITE_LEGEND_RULES.find((item) =>
+ item.match(props || {}),
+ );
+ selectedSatelliteLegendKey = rule?.key || null;
+}
+
+export function clearSelectedSatelliteLegend() {
+ selectedSatelliteLegendKey = null;
+}
+
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
diff --git a/frontend/public/earth/js/ui.js b/frontend/public/earth/js/ui.js
index 672150e8..fa93357e 100644
--- a/frontend/public/earth/js/ui.js
+++ b/frontend/public/earth/js/ui.js
@@ -85,12 +85,15 @@ export function updateZoomDisplay(zoomLevel, distance) {
export function updateEarthStats(stats) {
const cableCountEl = document.getElementById("cable-count");
const landingPointCountEl = document.getElementById("landing-point-count");
+ const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count");
const terrainStatusEl = document.getElementById("terrain-status");
const textureQualityEl = document.getElementById("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl)
landingPointCountEl.textContent = stats.landingPointCount || 0;
+ if (bgpAnomalyCountEl)
+ bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl)