feat: expand bgp observability surfaces

This commit is contained in:
linkong
2026-03-31 14:07:28 +08:00
parent ac63bba2a2
commit 552e49bde0
16 changed files with 1319 additions and 47 deletions

View File

@@ -13,10 +13,12 @@ let showBGP = true;
let totalAnomalyCount = 0;
let totalIncidentCount = 0;
let textureCache = null;
let collectorTextureCache = null;
let activeEventOverlay = null;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto",
});
const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2";
function getMarkerTexture() {
if (textureCache) return textureCache;
@@ -46,6 +48,42 @@ function getMarkerTexture() {
return textureCache;
}
function getCollectorTexture() {
if (collectorTextureCache) return collectorTextureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
collectorTextureCache = new THREE.Texture(canvas);
return collectorTextureCache;
}
context.clearRect(0, 0, 128, 128);
const glow = context.createRadialGradient(64, 64, 8, 64, 64, 34);
glow.addColorStop(0, "rgba(255,255,255,0.36)");
glow.addColorStop(0.55, "rgba(255,255,255,0.12)");
glow.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = glow;
context.beginPath();
context.arc(64, 64, 34, 0, Math.PI * 2);
context.fill();
context.save();
context.translate(16, 16);
context.scale(4, 4);
context.fillStyle = "rgba(255,255,255,0.98)";
context.shadowColor = "rgba(255,255,255,0.3)";
context.shadowBlur = 3;
context.fill(new Path2D(MATERIAL_ACCESS_POINT_PATH));
context.restore();
collectorTextureCache = new THREE.CanvasTexture(canvas);
return collectorTextureCache;
}
function normalizeSeverity(severity) {
const value = String(severity || "").trim().toLowerCase();
@@ -69,6 +107,50 @@ function getSeverityScale(severity) {
return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getCollectorActivityProfile(markerData) {
const recent24h = Number(markerData?.recent_24h_observation_count || 0);
const recent7d = Number(markerData?.recent_7d_observation_count || 0);
const prefixes = Number(markerData?.prefix_count || 0);
const origins = Number(markerData?.origin_asn_count || 0);
const activityScore =
recent24h * 1.7 +
recent7d * 0.3 +
prefixes * 0.16 +
origins * 0.12;
let tier = "idle";
if (activityScore >= 50 || recent24h >= 24) tier = "hot";
else if (activityScore >= 20 || recent24h >= 10) tier = "high";
else if (activityScore >= 8 || recent24h >= 4) tier = "medium";
else if (activityScore > 0) tier = "low";
const scaleBoost = clamp(1 + Math.log2(activityScore + 1) * 0.12, 1, 1.55);
const haloScale =
BGP_CONFIG.collectorHaloScale +
clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12);
const pulseHaloScale =
BGP_CONFIG.collectorPulseHaloScale +
clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14);
const coverageHaloScale =
BGP_CONFIG.collectorCoverageHaloScale +
clamp(Math.log2(prefixes + origins + 1) * 3.4, 0, 18);
return {
tier,
color: BGP_CONFIG.collectorHeatColors[tier] || BGP_CONFIG.collectorColor,
scaleBoost,
haloScale,
pulseHaloScale,
coverageHaloScale,
activityScore,
};
}
function formatLocalDateTime(value) {
if (!value) return "-";
@@ -222,6 +304,62 @@ export function formatBGPImpactedScope(regions) {
return `${labels.slice(0, 3).join(" / ")}${labels.length}`;
}
export function formatBGPRelatedCables(items) {
if (!Array.isArray(items) || items.length === 0) return "-";
const labels = items
.slice(0, 3)
.map((item) => {
const landing = item?.landing_point || "";
const cables = Array.isArray(item?.cable_names) ? item.cable_names : [];
const cableText = cables.length > 0 ? cables.slice(0, 2).join(", ") : "附近登陆点";
const distance = item?.distance_km !== undefined ? ` ${item.distance_km}km` : "";
return `${landing || cableText} (${cableText}${distance})`;
})
.filter(Boolean);
if (labels.length === 0) return "-";
if (items.length <= 3) return labels.join(" / ");
return `${labels.join(" / ")}${items.length}`;
}
export function formatBGPScope(scope) {
const countries = Array.isArray(scope?.countries) ? scope.countries : [];
const cities = Array.isArray(scope?.cities) ? scope.cities : [];
const cityText = cities.slice(0, 3).join(" / ");
const countryText = countries.slice(0, 3).join(" / ");
if (cityText && countryText) {
return `${cityText} | ${countryText}`;
}
return cityText || countryText || "-";
}
export function formatBGPTopEventTypes(items) {
if (!Array.isArray(items) || items.length === 0) return "-";
return items
.slice(0, 3)
.map((item) => `${item?.event_type || "-"} x${item?.count || 0}`)
.join(" / ");
}
export function formatBGPCollectorCoverageHalo(markerData) {
const prefixes = Number(
markerData?.recent_24h_prefix_count ||
markerData?.recent_7d_prefix_count ||
markerData?.prefix_count ||
0,
);
const observations = Number(
markerData?.recent_24h_observation_count ||
markerData?.recent_7d_observation_count ||
markerData?.observation_count ||
0,
);
if (prefixes <= 0 && observations <= 0) return "静态观测站";
return `近24h ${observations}条事件 / ${prefixes}个前缀`;
}
function buildCollectorFeatureData(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
@@ -242,6 +380,19 @@ function buildCollectorFeatureData(feature) {
city: properties.city || "-",
country: properties.country || "-",
status: properties.status || "online",
observation_count: properties.observation_count || 0,
recent_24h_observation_count: properties.recent_24h_observation_count || 0,
recent_7d_observation_count: properties.recent_7d_observation_count || 0,
prefix_count: properties.prefix_count || 0,
recent_24h_prefix_count: properties.recent_24h_prefix_count || 0,
recent_7d_prefix_count: properties.recent_7d_prefix_count || 0,
origin_asn_count: properties.origin_asn_count || 0,
latest_observed_at: properties.latest_observed_at || null,
latest_event_type: properties.latest_event_type || null,
top_event_types: Array.isArray(properties.top_event_types)
? properties.top_event_types
: [],
baseline_scope: properties.baseline_scope || { countries: [], cities: [] },
};
}
@@ -381,6 +532,12 @@ function buildIncidentFeatureData(feature) {
collectors: affectedCollectors,
collector_count: affectedCollectors.length || 1,
impacted_regions: affectedRegions,
related_cables: Array.isArray(properties.related_cables)
? properties.related_cables
: [],
related_ixps: Array.isArray(properties.related_ixps)
? properties.related_ixps
: [],
confidence: properties.confidence ?? "-",
summary: properties.summary || properties.title || "-",
created_at: formatLocalDateTime(startedAt),
@@ -404,6 +561,10 @@ function buildIncidentFeatureData(feature) {
function clearMarkerArray(markers) {
while (markers.length > 0) {
const marker = markers.pop();
while (marker.children.length > 0) {
const child = marker.children.pop();
child.material?.dispose();
}
marker.material?.dispose();
bgpGroup.remove(marker);
}
@@ -459,10 +620,15 @@ function createArcLine(start, end, color) {
}
function createCollectorMarker(markerData) {
const activity = getCollectorActivityProfile(markerData);
const sprite = new THREE.Sprite(
createSpriteMaterial({
color: BGP_CONFIG.collectorColor,
new THREE.SpriteMaterial({
map: getCollectorTexture(),
color: activity.color,
transparent: true,
opacity: BGP_CONFIG.opacity.collector,
depthWrite: false,
depthTest: true,
}),
);
@@ -473,15 +639,46 @@ function createCollectorMarker(markerData) {
);
sprite.position.copy(position);
sprite.scale.setScalar(BGP_CONFIG.collectorScale);
sprite.scale.set(BGP_CONFIG.collectorScale * 0.88 * activity.scaleBoost, BGP_CONFIG.collectorScale * 1.08 * activity.scaleBoost, 1);
sprite.renderOrder = 3;
sprite.visible = showBGP;
const heatHalo = createOverlaySprite({
color: activity.color,
opacity: 0.018,
scale: activity.haloScale * 0.78,
});
heatHalo.renderOrder = 1;
sprite.add(heatHalo);
const pulseHalo = createOverlaySprite({
color: activity.color,
opacity: 0.008,
scale: activity.pulseHaloScale * 0.72,
});
pulseHalo.renderOrder = 0;
sprite.add(pulseHalo);
const coverageHalo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
opacity: 0.01,
scale: activity.coverageHaloScale * 0.7,
});
coverageHalo.renderOrder = 0;
coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1);
sprite.add(coverageHalo);
sprite.userData = {
type: "bgp_collector",
state: "normal",
baseScale: BGP_CONFIG.collectorScale,
baseScale: BGP_CONFIG.collectorScale * activity.scaleBoost,
baseColor: activity.color,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
heatHalo,
pulseHalo,
coverageHalo,
...markerData,
};
@@ -512,6 +709,7 @@ function createAnomalyMarker(markerData) {
type: "bgp",
state: "normal",
baseScale,
baseColor: getSeverityColor(markerData.severity),
pulseOffset: Math.random() * Math.PI * 2,
...markerData,
};
@@ -692,23 +890,61 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) {
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.collector;
let haloOpacity = 0.018;
let pulseOpacity = 0.008;
let coverageOpacity = 0.01;
let markerColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
if (isLocked) {
scale *= 1.1 + 0.14 * pulse;
opacity = BGP_CONFIG.opacity.collectorHover;
haloOpacity = 0.07;
pulseOpacity = 0.04;
coverageOpacity = 0.035;
} else if (isHovered) {
scale *= 1.08;
opacity = BGP_CONFIG.opacity.collectorHover;
haloOpacity = 0.05;
pulseOpacity = 0.03;
coverageOpacity = 0.028;
} else if (hasLockedLayer) {
scale *= BGP_CONFIG.dimmedScale;
opacity = BGP_CONFIG.opacity.dimmed;
opacity = 0.12;
haloOpacity = 0.0;
pulseOpacity = 0.0;
coverageOpacity = 0.0;
markerColor = 0x7d8ca3;
} else {
scale *= 1 + 0.05 * pulse;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
if (marker.userData.heatHalo) {
marker.userData.heatHalo.material.opacity = haloOpacity;
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.heatHalo.scale.setScalar(
marker.userData.activity?.haloScale * 0.78 * (1 + pulse * 0.03),
);
}
if (marker.userData.pulseHalo) {
marker.userData.pulseHalo.material.opacity = pulseOpacity;
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.pulseHalo.scale.setScalar(
marker.userData.activity?.pulseHaloScale * 0.72 * (1 + pulse * 0.05),
);
}
if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.material.opacity = coverageOpacity;
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012),
1,
);
}
});
anomalyMarkers.forEach((marker) => {
@@ -724,6 +960,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) {
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.normal;
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse;
@@ -735,13 +972,15 @@ export function updateBGPVisualState(lockedObjectType, lockedObject) {
opacity = BGP_CONFIG.opacity.hover;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.dimmedScale;
opacity = BGP_CONFIG.opacity.dimmed;
opacity = 0.1;
markerColor = 0x7d8ca3;
} else {
scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse;
opacity = BGP_CONFIG.opacity.normal;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
});
@@ -915,6 +1154,63 @@ export function showBGPEventOverlay(marker, earth) {
bgpOverlayGroup.visible = showBGP;
}
export function showBGPCollectorCoverageOverlay(marker, earth) {
if (!marker?.userData || marker.userData.type !== "bgp_collector" || !earth) return;
clearBGPEventOverlay();
const prefixCount = Number(
marker.userData.recent_24h_prefix_count ||
marker.userData.recent_7d_prefix_count ||
marker.userData.prefix_count ||
0,
);
const observationCount = Number(
marker.userData.recent_24h_observation_count ||
marker.userData.recent_7d_observation_count ||
marker.userData.observation_count ||
0,
);
const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8);
const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost;
const pulseHaloScale = haloScale * 1.32;
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
opacity: 0.11,
scale: haloScale * 0.78,
});
halo.position.copy(
latLonToVector3(
marker.userData.displayLatitude ?? marker.userData.latitude,
marker.userData.displayLongitude ?? marker.userData.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15,
),
);
halo.renderOrder = 2;
bgpOverlayGroup.add(halo);
const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
opacity: 0.065,
scale: pulseHaloScale * 0.82,
});
pulseHalo.position.copy(halo.position);
pulseHalo.renderOrder = 1;
bgpOverlayGroup.add(pulseHalo);
const innerRing = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
opacity: 0.12,
scale: Math.max(haloScale * 0.34, 5.5),
});
innerRing.position.copy(halo.position);
innerRing.renderOrder = 3;
bgpOverlayGroup.add(innerRing);
activeEventOverlay = [halo, pulseHalo, innerRing];
bgpOverlayGroup.visible = showBGP;
}
export function clearBGPEventOverlay() {
activeEventOverlay = null;
clearGroup(bgpOverlayGroup);
@@ -922,7 +1218,9 @@ export function clearBGPEventOverlay() {
export function getBGPLegendItems() {
return [
{ color: "#6db7ff", label: "观测站" },
{ color: "#6db7ff", label: "静态观测站" },
{ color: "#fbbf24", label: "中活跃观测站" },
{ color: "#ff5f57", label: "高活跃观测站" },
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
{ color: "#2dd4bf", label: "影响区域" },
{ color: "#ff4d4f", label: "严重事件" },

View File

@@ -496,10 +496,17 @@ export function getAllLandingPoints() {
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
const brightness = 0.3;
const relatedNames = Array.isArray(lockedCableName)
? lockedCableName.filter(Boolean)
: lockedCableName
? [lockedCableName]
: [];
landingPoints.forEach((lp) => {
const isRelated =
!dimAll && lp.userData.cableNames?.includes(lockedCableName);
!dimAll &&
Array.isArray(lp.userData.cableNames) &&
lp.userData.cableNames.some((name) => relatedNames.includes(name));
if (isRelated) {
lp.material.color.setHex(0xffaa00);

View File

@@ -110,9 +110,19 @@ export const BGP_CONFIG = {
low: 0.94
},
collectorColor: 0x6db7ff,
collectorHeatColors: {
idle: 0x6db7ff,
low: 0x60a5fa,
medium: 0xfbbf24,
high: 0xfb923c,
hot: 0xff5f57
},
eventHubColor: 0x8af5ff,
linkColor: 0x54d2ff,
regionColor: 0x2dd4bf
regionColor: 0x2dd4bf,
collectorHaloScale: 11.5,
collectorPulseHaloScale: 16.5,
collectorCoverageHaloScale: 22.5
};
export const PREDICTED_ORBIT_CONFIG = {

View File

@@ -47,6 +47,8 @@ const CARD_CONFIG = {
{ key: 'collector', label: '主观测站' },
{ key: 'observed_by', label: '观测范围' },
{ key: 'impacted_scope', label: '影响区域' },
{ key: 'related_cables', label: '附近基础设施' },
{ key: 'related_satellites', label: '附近卫星' },
{ key: 'location', label: '观测位置' },
{ key: 'created_at', label: '事件时间' },
{ key: 'summary', label: '摘要' }
@@ -60,6 +62,17 @@ const CARD_CONFIG = {
{ key: 'collector', label: '采集器' },
{ key: 'location', label: '观测位置' },
{ key: 'anomaly_count', label: '当前事件数' },
{ key: 'observation_count', label: '观测事件数' },
{ key: 'recent_24h_observation_count', label: '近24h事件数' },
{ key: 'recent_7d_observation_count', label: '近7d事件数' },
{ key: 'prefix_count', label: '观测前缀数' },
{ key: 'origin_asn_count', label: '观测 ASN 数' },
{ key: 'top_event_types', label: '主要事件类型' },
{ key: 'coverage_halo', label: '日常活跃度' },
{ key: 'related_satellites', label: '附近卫星' },
{ key: 'latest_event_type', label: '最近事件类型' },
{ key: 'latest_observed_at', label: '最近活跃时间' },
{ key: 'baseline_scope', label: '日常覆盖范围' },
{ key: 'status', label: '状态' }
]
},
@@ -89,8 +102,36 @@ const CARD_CONFIG = {
};
export function initInfoCard() {
const card = document.getElementById('info-card');
const content = document.getElementById('info-card-content');
if (!content || content.dataset.copyBound === 'true') return;
if (!card || !content) return;
if (card.dataset.interactionBound !== 'true') {
const stopEvent = (event) => {
event.stopPropagation();
};
[
'mousemove',
'mousedown',
'mouseup',
'click',
'dblclick',
'wheel',
'pointerdown',
'pointerup',
'pointermove',
'touchstart',
'touchmove',
'touchend',
].forEach((eventName) => {
card.addEventListener(eventName, stopEvent, { passive: false });
});
card.dataset.interactionBound = 'true';
}
if (content.dataset.copyBound === 'true') return;
content.addEventListener('click', async (event) => {
const label = event.target.closest('.info-card-label');

View File

@@ -59,6 +59,10 @@ import {
getSatellitePositions,
showPredictedOrbit,
hidePredictedOrbit,
highlightRelatedSatellites,
clearRelatedSatelliteHighlights,
getRelatedSatelliteIndicesForRegions,
updateRelatedSatelliteHighlights,
updateBreathingPhase,
isSatelliteFrontFacing,
setSatelliteCamera,
@@ -88,10 +92,15 @@ import {
formatBGPLocation,
formatBGPObservedTime,
formatBGPObservedBy,
formatBGPRelatedCables,
formatBGPRouteChange,
formatBGPTopEventTypes,
formatBGPScope,
formatBGPCollectorCoverageHalo,
formatBGPSeverityLabel,
formatBGPStatusLabel,
showBGPEventOverlay,
showBGPCollectorCoverageOverlay,
} from "./bgp.js";
import {
setupControls,
@@ -165,6 +174,18 @@ const DRAG_ROTATION_FACTOR = 0.005;
const DRAG_SMOOTHING_FACTOR = 0.18;
const INERTIA_DAMPING = 0.92;
const INERTIA_MIN_VELOCITY = 0.00008;
const HUD_INTERACTIVE_SELECTORS = [
"#info-panel",
"#info-panel *",
"#right-toolbar-group",
"#right-toolbar-group *",
"#coordinates-display",
"#coordinates-display *",
"#legend",
"#legend *",
"#earth-stats",
"#earth-stats *",
];
function bindListener(target, eventName, handler, options) {
if (!target) return;
@@ -174,6 +195,12 @@ function bindListener(target, eventName, handler, options) {
);
}
function isEventOnHud(event) {
const target = event?.target;
if (!(target instanceof Element)) return false;
return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector));
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
@@ -234,11 +261,18 @@ export function clearLockedObject() {
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;
@@ -281,6 +315,22 @@ function resetTransientBGPStates() {
});
}
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) {
@@ -419,6 +469,11 @@ function showBGPInfo(marker) {
marker.userData.observed_by ||
formatBGPObservedBy(marker.userData.collectors),
impacted_scope: formatBGPImpactedScope(impactedRegions),
related_cables: formatBGPRelatedCables(marker.userData.related_cables),
related_satellites:
marker.userData.related_satellite_count > 0
? `${marker.userData.related_satellite_count}颗附近卫星`
: "-",
location:
marker.userData.location ||
formatBGPLocation(marker.userData.city, marker.userData.country),
@@ -433,10 +488,65 @@ function showBGPCollectorInfo(marker) {
collector: marker.userData.collector,
location: formatBGPLocation(marker.userData.city, marker.userData.country),
anomaly_count: marker.userData.anomaly_count ?? 0,
observation_count: marker.userData.observation_count ?? 0,
recent_24h_observation_count: marker.userData.recent_24h_observation_count ?? 0,
recent_7d_observation_count: marker.userData.recent_7d_observation_count ?? 0,
prefix_count: marker.userData.prefix_count ?? 0,
origin_asn_count: marker.userData.origin_asn_count ?? 0,
top_event_types: formatBGPTopEventTypes(marker.userData.top_event_types),
coverage_halo: formatBGPCollectorCoverageHalo(marker.userData),
related_satellites:
marker.userData.related_satellite_count > 0
? `${marker.userData.related_satellite_count}颗附近卫星`
: "-",
latest_event_type: marker.userData.latest_event_type || "-",
latest_observed_at: formatBGPObservedTime(marker.userData.latest_observed_at),
baseline_scope: formatBGPScope(marker.userData.baseline_scope),
status: formatBGPCollectorStatus(marker.userData.status || "online"),
});
}
function getBGPRelatedCableNames(marker) {
const items = Array.isArray(marker?.userData?.related_cables)
? marker.userData.related_cables
: [];
const names = [];
items.forEach((item) => {
const cableNames = Array.isArray(item?.cable_names) ? item.cable_names : [];
cableNames.forEach((name) => {
if (name && !names.includes(name)) {
names.push(name);
}
});
});
return names;
}
function getBGPRelatedRegions(marker) {
if (Array.isArray(marker?.userData?.impacted_regions) && marker.userData.impacted_regions.length > 0) {
return marker.userData.impacted_regions;
}
if (
marker?.userData?.type === "bgp_collector" &&
typeof marker.userData.latitude === "number" &&
typeof marker.userData.longitude === "number"
) {
return [
{
collector: marker.userData.collector,
city: marker.userData.city,
country: marker.userData.country,
latitude: marker.userData.latitude,
longitude: marker.userData.longitude,
},
];
}
return [];
}
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
@@ -462,7 +572,8 @@ function applyCableVisualState() {
if (
(lockedObjectType === "cable" && lockedObject) ||
(lockedObjectType === "satellite" && lockedSatellite) ||
(lockedObjectType === "bgp" && lockedObject)
(lockedObjectType === "bgp" && lockedObject) ||
(lockedObjectType === "bgp_collector" && lockedObject)
) {
cable.material.opacity = CABLE_CONFIG.otherOpacity;
const origColor = cable.userData.originalColor;
@@ -972,6 +1083,26 @@ function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) {
clearTransientHoverState();
if (lockedObjectType === "bgp" && lockedObject) {
applyBGPHoverState(lockedObject);
showBGPInfo(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
showBGPCollectorInfo(lockedObject);
} else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject);
} else if (lockedObjectType === "satellite" && lockedSatellite) {
showSatelliteInfo(lockedSatellite.properties);
} else {
hideInfoCard();
}
hideTooltip();
return;
}
if (isDragging) {
if (Date.now() - dragStartTime > 500) {
isLongDrag = true;
@@ -1029,12 +1160,8 @@ function onMouseMove(event) {
bgpCollectorIntersects,
);
if (
hoveredBGP &&
!isSameBGPMarker(hoveredBGP, hoveredBGPMarker)
) {
resetTransientBGPStates();
hoveredBGP = null;
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
clearTransientHoverState();
}
if (
@@ -1042,20 +1169,14 @@ function onMouseMove(event) {
(!cableIntersects.length ||
!isSameCable(cableIntersects[0]?.object, hoveredCable))
) {
if (!isSameCable(hoveredCable, lockedObject)) {
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
}
hoveredCable = null;
clearTransientHoverState();
}
if (
hoveredSatelliteIndex !== null &&
hoveredSatelliteIndex !== hoveredSatIndexFromIntersect
) {
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
setSatelliteRingState(hoveredSatelliteIndex, "none", null);
}
hoveredSatelliteIndex = null;
clearTransientHoverState();
}
if (
@@ -1142,6 +1263,10 @@ function onMouseMove(event) {
}
function onMouseDown(event) {
if (isEventOnHud(event)) {
return;
}
const earth = getEarth();
isDragging = true;
dragStartTime = Date.now();
@@ -1170,6 +1295,7 @@ function onMouseLeave() {
function onClick(event) {
const earth = getEarth();
if (!earth) return;
if (isEventOnHud(event)) return;
updatePointerFromEvent(event);
@@ -1210,6 +1336,14 @@ function onClick(event) {
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPEventOverlay(clickedMarker, earth);
{
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
getBGPRelatedRegions(clickedMarker),
{ limit: 6, maxAngleDeg: 20 },
);
clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
highlightRelatedSatellites(relatedSatelliteIndices, "#7dd3fc");
}
showBGPInfo(clickedMarker);
showStatusMessage(
`已选择BGP事件: ${clickedMarker.userData.collector}`,
@@ -1231,6 +1365,15 @@ function onClick(event) {
lastBGPClickType = "bgp_collector";
lastBGPClickPos = { x: event.clientX, y: event.clientY };
setAutoRotate(false);
showBGPCollectorCoverageOverlay(clickedMarker, earth);
{
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
getBGPRelatedRegions(clickedMarker),
{ limit: 4, maxAngleDeg: 18 },
);
clickedMarker.userData.related_satellite_count = relatedSatelliteIndices.length;
highlightRelatedSatellites(relatedSatelliteIndices, "#93c5fd");
}
showBGPCollectorInfo(clickedMarker);
showStatusMessage(
`已选择观测站: ${clickedMarker.userData.collector}`,
@@ -1374,6 +1517,21 @@ function animate() {
) {
applyLandingPointVisualState(null, true);
} else if (lockedObjectType === "bgp" && lockedObject) {
const relatedCableNames = getBGPRelatedCableNames(lockedObject);
clearAllCableStates();
relatedCableNames.forEach((name) => {
getCableLines().forEach((cable) => {
if (cable.userData?.name === name) {
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
}
});
});
applyLandingPointVisualState(
relatedCableNames.length > 0 ? relatedCableNames : null,
relatedCableNames.length === 0,
);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
clearAllCableStates();
applyLandingPointVisualState(null, true);
} else {
resetLandingPointVisualState();
@@ -1381,6 +1539,7 @@ function animate() {
updateSatellitePositions(deltaTime);
updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights();
const satPositions = getSatellitePositions();
if (

View File

@@ -3,6 +3,7 @@
import * as THREE from "three";
import { twoline2satrec, propagate } from "satellite.js";
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
let satellitePoints = null;
let satelliteTrails = null;
@@ -15,6 +16,7 @@ let hoverRingSprite = null;
let lockedRingSprite = null;
let lockedDotSprite = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
let earthObjRef = null;
let sceneRef = null;
let cameraRef = null;
@@ -759,6 +761,25 @@ function createRingSprite(position, isLocked = false) {
return sprite;
}
function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
if (!earthObjRef) return null;
const ringTexture = createRingTexture(7, 11, color);
const spriteMaterial = new THREE.SpriteMaterial({
map: ringTexture,
transparent: true,
opacity: 0.55,
depthTest: false,
sizeAttenuation: false,
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
earthObjRef.add(sprite);
return sprite;
}
export function showHoverRing(position, isLocked = false) {
if (!earthObjRef || !position) return null;
@@ -866,6 +887,82 @@ export function setSatelliteRingState(index, state, position) {
}
}
export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => {
if (item.sprite) {
disposeObject3D(item.sprite);
}
});
relatedSatelliteSprites = [];
}
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return;
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
const sprite = createRelatedSatelliteSprite(pos, color);
if (!sprite) return;
relatedSatelliteSprites.push({ index, sprite, color });
});
}
export function updateRelatedSatelliteHighlights() {
if (relatedSatelliteSprites.length === 0) return;
relatedSatelliteSprites = relatedSatelliteSprites.filter((item) => {
const pos = satellitePositions?.[item.index]?.current;
if (!pos || !item.sprite) return false;
item.sprite.position.copy(pos);
return true;
});
}
export function getRelatedSatelliteIndicesForRegions(
regions,
{ limit = 6, maxAngleDeg = 22 } = {},
) {
if (!Array.isArray(regions) || regions.length === 0 || satellitePositions.length === 0) {
return [];
}
const regionVectors = regions
.filter(
(region) =>
typeof region?.latitude === "number" &&
typeof region?.longitude === "number",
)
.map((region) =>
latLonToVector3(region.latitude, region.longitude, CONFIG.earthRadius + 1)
.clone()
.normalize(),
);
if (regionVectors.length === 0) return [];
const threshold = Math.cos((maxAngleDeg * Math.PI) / 180);
const ranked = [];
satellitePositions.forEach((item, index) => {
const current = item?.current;
if (!current || current.lengthSq() === 0) return;
const satVector = current.clone().normalize();
let bestDot = -1;
regionVectors.forEach((regionVector) => {
bestDot = Math.max(bestDot, satVector.dot(regionVector));
});
if (bestDot >= threshold) {
ranked.push({ index, score: bestDot });
}
});
return ranked
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.index);
}
export function initSatelliteScene(scene, earth) {
sceneRef = scene;
earthObjRef = earth;
@@ -1000,6 +1097,7 @@ export function clearSatelliteData() {
hideHoverRings();
hideLockedRing();
hidePredictedOrbit();
clearRelatedSatelliteHighlights();
}
export function resetSatelliteState() {