Files
planet/frontend/public/earth/js/bgp.js
linkong e1984c7a35 release: bump version to 0.49.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:42:27 +08:00

1970 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import {
createInteractableLayer,
SURFACE_AVOIDANCE_PROFILES,
} from "./interactable.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group();
const bgpEventOverlayGroup = new THREE.Group();
const bgpCollectorRadarGroup = new THREE.Group();
const collectorMarkers = [];
const anomalyMarkers = [];
const activeEventCountByCollector = new Map();
let showBGP = true;
let totalAnomalyCount = 0;
let totalIncidentCount = 0;
let textureCache = null;
let eventRingTextureCache = null;
const eventTextureCache = new Map();
let activeEventOverlay = null;
let activeCollectorOverlayContext = null;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto",
});
const collectorWorldPosition = new THREE.Vector3();
const colorScratchA = new THREE.Color();
const colorScratchB = new THREE.Color();
const COLLECTOR_SCAN_SPEED_RAD = 0.00018;
const COLLECTOR_SCAN_REBUILD_MS = 80;
const BGP_EVENT_RENDER_ORDER = 4.5;
const BGP_EVENT_POINT_SIZE = 34;
const BGP_EVENT_SYMBOL_SIZE = 60;
const BGP_COLLECTOR_RENDER_ORDER = 4.4;
const BGP_COLLECTOR_ALTITUDE_OFFSET = BGP_CONFIG.collectorAltitudeOffset;
const BGP_COLLECTOR_POINT_SIZE = 36;
const BGP_COLLECTOR_ICON_FIT_SIZE = 60;
const BGP_COLLECTOR_ICON_SOURCE = "/earth/assets/icons/bgp-broadcast-pin.svg";
const BGP_COLLECTOR_HOVER_SCALE = 1.08;
const BGP_COLLECTOR_LOCKED_SCALE = 1.12;
const BGP_COLLECTOR_PULSE_AMPLITUDE = 0.14;
bgpOverlayGroup.name = "bgp-overlay-root";
bgpEventOverlayGroup.name = "bgp-event-overlay-layer";
bgpCollectorRadarGroup.name = "bgp-collector-radar-layer";
bgpOverlayGroup.add(bgpEventOverlayGroup);
bgpOverlayGroup.add(bgpCollectorRadarGroup);
function getMarkerTexture() {
if (textureCache) return textureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
textureCache = new THREE.Texture(canvas);
return textureCache;
}
const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 56);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.24, "rgba(255,255,255,0.92)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.35)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = gradient;
context.beginPath();
context.arc(64, 64, 56, 0, Math.PI * 2);
context.fill();
textureCache = new THREE.CanvasTexture(canvas);
return textureCache;
}
function getEventRingTexture() {
if (eventRingTextureCache) return eventRingTextureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
eventRingTextureCache = new THREE.Texture(canvas);
return eventRingTextureCache;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = "rgba(255,255,255,0.98)";
context.lineWidth = 6;
context.beginPath();
context.arc(64, 64, 44, 0, Math.PI * 2);
context.stroke();
eventRingTextureCache = new THREE.CanvasTexture(canvas);
return eventRingTextureCache;
}
function getEventSymbolKind(anomalyType) {
const value = String(anomalyType || "").toLowerCase();
if (value.includes("origin")) return "triangle";
if (value.includes("withdraw")) return "exclamation";
if (value.includes("specific") || value.includes("burst")) return "burst";
if (value.includes("flap")) return "wave";
if (value.includes("leak")) return "leak";
return "dot";
}
function drawTriangleSymbol(context) {
context.beginPath();
context.moveTo(64, 18);
context.lineTo(110, 106);
context.lineTo(18, 106);
context.closePath();
context.fill();
}
function drawExclamationSymbol(context) {
context.beginPath();
context.roundRect(52, 22, 24, 62, 12);
context.fill();
context.beginPath();
context.arc(64, 102, 10, 0, Math.PI * 2);
context.fill();
}
function drawWaveSymbol(context) {
context.beginPath();
context.moveTo(14, 100);
context.lineTo(38, 26);
context.lineTo(64, 100);
context.lineTo(90, 26);
context.lineTo(114, 100);
context.closePath();
context.fill();
}
function drawBurstSymbol(context) {
context.lineWidth = 10;
context.lineCap = "round";
for (let index = 0; index < 6; index += 1) {
const angle = (Math.PI * 2 * index) / 6;
const inner = 26;
const outer = 48;
context.beginPath();
context.moveTo(64 + Math.cos(angle) * inner, 64 + Math.sin(angle) * inner);
context.lineTo(64 + Math.cos(angle) * outer, 64 + Math.sin(angle) * outer);
context.stroke();
}
context.beginPath();
context.arc(64, 64, 16, 0, Math.PI * 2);
context.fill();
}
function drawLeakSymbol(context) {
context.lineWidth = 10;
context.lineCap = "round";
context.beginPath();
context.moveTo(28, 96);
context.lineTo(64, 28);
context.lineTo(100, 96);
context.stroke();
context.beginPath();
context.moveTo(40, 82);
context.lineTo(64, 54);
context.lineTo(88, 82);
context.stroke();
}
function drawDotSymbol(context) {
context.beginPath();
context.arc(64, 64, 28, 0, Math.PI * 2);
context.fill();
}
function getEventTexture(anomalyType) {
const kind = getEventSymbolKind(anomalyType);
if (eventTextureCache.has(kind)) return eventTextureCache.get(kind);
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
const fallback = new THREE.Texture(canvas);
eventTextureCache.set(kind, fallback);
return fallback;
}
context.clearRect(0, 0, 128, 128);
context.fillStyle = "rgba(255,255,255,0.96)";
context.strokeStyle = "rgba(255,255,255,0.96)";
context.shadowBlur = 0;
context.lineJoin = "round";
if (kind === "triangle") {
drawTriangleSymbol(context);
} else if (kind === "exclamation") {
drawExclamationSymbol(context);
} else if (kind === "wave") {
drawWaveSymbol(context);
} else if (kind === "burst") {
drawBurstSymbol(context);
} else if (kind === "leak") {
drawLeakSymbol(context);
} else {
drawDotSymbol(context);
}
const texture = new THREE.CanvasTexture(canvas);
eventTextureCache.set(kind, texture);
return texture;
}
function normalizeSeverity(severity) {
const value = String(severity || "").trim().toLowerCase();
if (value === "critical") return "critical";
if (value === "high" || value === "major") return "high";
if (value === "medium" || value === "moderate" || value === "warning") {
return "medium";
}
if (value === "low" || value === "info" || value === "informational") {
return "low";
}
return "medium";
}
function getSeverityColor(severity) {
return BGP_CONFIG.severityColors[normalizeSeverity(severity)];
}
function getSeverityScale(severity) {
return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
}
function severityColorHex(severity) {
return `#${getSeverityColor(severity).toString(16).padStart(6, "0")}`;
}
function colorNumberHex(colorNumber) {
return `#${Number(colorNumber || 0xffffff).toString(16).padStart(6, "0")}`;
}
function drawBGPEventIcon(context, { marker, color = "#ffffff", glow = false }) {
const kind = getEventSymbolKind(
marker?.userData?.incident_type || marker?.userData?.anomaly_type,
);
context.save();
context.fillStyle = color;
context.strokeStyle = color;
context.lineJoin = "round";
context.lineCap = "round";
context.shadowColor = color;
context.shadowBlur = glow ? 14 : 0;
const inset = (128 - BGP_EVENT_SYMBOL_SIZE) / 2;
context.translate(inset, inset);
context.scale(BGP_EVENT_SYMBOL_SIZE / 128, BGP_EVENT_SYMBOL_SIZE / 128);
if (kind === "triangle") {
drawTriangleSymbol(context);
} else if (kind === "exclamation") {
drawExclamationSymbol(context);
} else if (kind === "wave") {
drawWaveSymbol(context);
} else if (kind === "burst") {
drawBurstSymbol(context);
} else if (kind === "leak") {
drawLeakSymbol(context);
} else {
drawDotSymbol(context);
}
context.restore();
}
const bgpEventIconLayer = createInteractableLayer({
id: "bgp-events",
objectType: "bgp",
renderOrder: BGP_EVENT_RENDER_ORDER,
altitudeOffset: BGP_CONFIG.altitudeOffset,
pointSize: BGP_EVENT_POINT_SIZE,
colors: {
byKind: Object.fromEntries(
Object.keys(BGP_CONFIG.severityColors).map((severity) => [
severity,
severityColorHex(severity),
]),
),
normal: severityColorHex("medium"),
},
opacity: {
normal: BGP_CONFIG.opacity.normal,
hover: BGP_CONFIG.opacity.hover,
locked: BGP_CONFIG.opacity.lockedMax,
dimmed: BGP_CONFIG.opacity.dimmed,
},
stateScale: {
hover: BGP_CONFIG.marker.hoverScale,
locked: BGP_CONFIG.marker.hoverScale,
dimmed: BGP_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: BGP_CONFIG.pulse.eventSpeed,
amplitude: BGP_CONFIG.pulse.lockedAmplitude,
},
icon: {
coordinates: "canvas",
draw: drawBGPEventIcon,
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => normalizeSeverity(item.severity),
getBucketKey: (marker) => [
getEventSymbolKind(marker.userData?.incident_type || marker.userData?.anomaly_type),
normalizeSeverity(marker.userData?.severity),
].join(":"),
getPointSizeMultiplier: (marker) => getSeverityScale(marker.userData?.severity),
getUserData: (item) => ({
...item,
baseScale: BGP_CONFIG.marker.eventBaseScale * getSeverityScale(item.severity),
baseColor: getSeverityColor(item.severity),
pulseOffset: Math.random() * Math.PI * 2,
}),
});
const bgpCollectorIconLayer = createInteractableLayer({
id: "bgp-collectors",
objectType: "bgp_collector",
renderOrder: BGP_COLLECTOR_RENDER_ORDER,
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
pointSize: BGP_COLLECTOR_POINT_SIZE,
colors: {
byKind: Object.fromEntries(
Object.entries(BGP_CONFIG.collectorHeatColors).map(([tier, color]) => [
tier,
colorNumberHex(color),
]),
),
normal: colorNumberHex(BGP_CONFIG.collectorColor),
},
opacity: {
normal: BGP_CONFIG.collectorIcon.idleOpacity,
hover: 0.98,
locked: 1,
dimmed: BGP_CONFIG.opacity.dimmed,
},
stateScale: {
hover: BGP_COLLECTOR_HOVER_SCALE,
locked: BGP_COLLECTOR_LOCKED_SCALE,
dimmed: BGP_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: BGP_CONFIG.pulse.collectorSpeed,
amplitude: BGP_COLLECTOR_PULSE_AMPLITUDE,
},
icon: {
coordinates: "canvas",
fitSize: BGP_COLLECTOR_ICON_FIT_SIZE,
glowBlur: 16,
source: BGP_COLLECTOR_ICON_SOURCE,
},
getPosition: (item) => ({
latitude: item.displayLatitude,
longitude: item.displayLongitude,
}),
getKind: (item) => getCollectorActivityProfile(item).tier,
getBucketKey: (marker) => {
const scaleBoost = marker.userData?.activity?.scaleBoost ?? 1;
return `${marker.userData?.activity?.tier || "idle"}:${scaleBoost.toFixed(2)}`;
},
getPointSizeMultiplier: (marker) => marker.userData?.activity?.scaleBoost ?? 1,
getUserData: (item) => {
const activity = getCollectorActivityProfile(item);
const baseColor = activity.color;
const idleColor = blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
baseColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
return {
...item,
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
baseColor,
idleColor,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
};
},
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
});
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function blendHexColors(fromHex, toHex, ratio) {
colorScratchA.setHex(fromHex);
colorScratchB.setHex(toHex);
colorScratchA.lerp(colorScratchB, clamp(ratio, 0, 1));
return colorScratchA.getHex();
}
function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
referenceFov: 75,
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
});
}
function getEventDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: BGP_CONFIG.altitudeOffset,
referenceFov: 75,
min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7),
max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9),
});
}
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.halo.collectorScale +
clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12);
const pulseHaloScale =
BGP_CONFIG.halo.collectorPulseScale +
clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14);
const coverageHaloScale =
BGP_CONFIG.halo.collectorCoverageScale +
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 "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
}
function toDate(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatRelativeTime(value) {
const date = toDate(value);
if (!date) return null;
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 60 * 1000) {
return relativeTimeFormatter.format(Math.round(diffMs / 1000), "second");
}
if (absMs < 60 * 60 * 1000) {
return relativeTimeFormatter.format(Math.round(diffMs / (60 * 1000)), "minute");
}
if (absMs < 24 * 60 * 60 * 1000) {
return relativeTimeFormatter.format(Math.round(diffMs / (60 * 60 * 1000)), "hour");
}
return relativeTimeFormatter.format(
Math.round(diffMs / (24 * 60 * 60 * 1000)),
"day",
);
}
export function formatBGPSeverityLabel(severity) {
const normalized = normalizeSeverity(severity);
switch (normalized) {
case "critical":
return "严重";
case "high":
return "高";
case "medium":
return "中";
case "low":
return "低";
default:
return "中";
}
}
export function formatBGPAnomalyTypeLabel(type) {
const value = String(type || "").trim().toLowerCase();
if (!value) return "-";
if (value.includes("hijack")) return "前缀劫持";
if (value.includes("leak")) return "路由泄露";
if (value.includes("withdraw")) return "大规模撤销";
if (value.includes("subprefix") || value.includes("more_specific")) {
return "更具体前缀异常";
}
if (value.includes("path")) return "路径突变";
if (value.includes("flap")) return "路由抖动";
return String(type);
}
export function formatBGPStatusLabel(status) {
const value = String(status || "").trim().toLowerCase();
if (!value) return "-";
if (value === "active") return "活跃";
if (value === "resolved") return "已恢复";
if (value === "suppressed") return "已抑制";
return String(status);
}
export function formatBGPCollectorStatus(status) {
const value = String(status || "").trim().toLowerCase();
if (!value) return "在线";
if (value === "online") return "在线";
if (value === "offline") return "离线";
return String(status);
}
export function formatBGPConfidence(value) {
if (value === null || value === undefined || value === "") return "-";
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (number >= 0 && number <= 1) {
return `${Math.round(number * 100)}%`;
}
return `${Math.round(number)}%`;
}
export function formatBGPLocation(city, country) {
const cityText = city || "";
const countryText = country || "";
if (cityText && countryText) return `${cityText}, ${countryText}`;
return cityText || countryText || "-";
}
export function formatBGPRouteChange(originAsn, newOriginAsn) {
const from = originAsn ?? "-";
const to = newOriginAsn ?? "-";
if ((from === "-" || from === "" || from === null) && (to === "-" || to === "" || to === null)) {
return "-";
}
if (to === "-" || to === "" || to === null) {
return `AS${from}`;
}
return `AS${from} -> AS${to}`;
}
export function formatBGPObservedTime(value) {
const absolute = formatLocalDateTime(value);
const relative = formatRelativeTime(value);
if (!relative || absolute === "-") return absolute;
return `${relative} (${absolute})`;
}
export function formatBGPASPath(asPath) {
if (!Array.isArray(asPath) || asPath.length === 0) return "-";
return asPath.map((asn) => `AS${asn}`).join(" -> ");
}
export function formatBGPObservedBy(collectors) {
if (!Array.isArray(collectors) || collectors.length === 0) return "-";
const preview = collectors.slice(0, 3).join(", ");
if (collectors.length <= 3) {
return `${collectors.length}个观测站 (${preview})`;
}
return `${collectors.length}个观测站 (${preview} 等)`;
}
export function formatBGPImpactedScope(regions) {
if (!Array.isArray(regions) || regions.length === 0) return "-";
const labels = regions
.map((region) => {
const city = region?.city || "";
const country = region?.country || "";
return city && country ? `${city}, ${country}` : city || country || "";
})
.filter(Boolean);
if (labels.length === 0) return "-";
if (labels.length <= 3) return labels.join(" / ");
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;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
const properties = feature?.properties || {};
return {
latitude,
longitude,
collector: properties.collector || "-",
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: [] },
};
}
function spreadCollectorPositions(markers) {
const groups = new Map();
markers.forEach((marker) => {
const key = `${marker.latitude.toFixed(4)}|${marker.longitude.toFixed(4)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(marker);
});
groups.forEach((group) => {
if (group.length <= 1) return;
const radius = 1.4;
group.forEach((marker, index) => {
const angle = (Math.PI * 2 * index) / group.length;
marker.displayLatitude =
marker.latitude + Math.sin(angle) * radius * 0.28;
marker.displayLongitude =
marker.longitude + Math.cos(angle) * radius * 0.28;
marker.isSpread = true;
marker.groupSize = group.length;
});
});
markers.forEach((marker) => {
if (marker.displayLatitude === undefined) {
marker.displayLatitude = marker.latitude;
marker.displayLongitude = marker.longitude;
marker.isSpread = false;
marker.groupSize = 1;
}
});
return markers;
}
function buildAnomalyFeatureData(feature) {
const point = extractFeaturePoint(feature);
if (!point) return null;
const { latitude, longitude } = point;
const properties = feature?.properties || {};
const meta = extractFeatureMeta(properties, properties.created_at || null);
return {
latitude,
longitude,
rawSeverity: meta.rawSeverity,
severity: meta.severity,
collector: properties.collector || "-",
city: properties.city || "-",
country: properties.country || "-",
source: properties.source || "-",
anomaly_type: properties.anomaly_type || "-",
status: properties.status || "-",
prefix: properties.prefix || "-",
origin_asn: properties.origin_asn ?? "-",
new_origin_asn: properties.new_origin_asn ?? "-",
as_path: Array.isArray(properties.as_path) ? properties.as_path : [],
collectors: Array.isArray(properties.collectors) ? properties.collectors : [],
collector_count: properties.collector_count ?? 1,
impacted_regions: Array.isArray(properties.impacted_regions)
? properties.impacted_regions
: [],
confidence: properties.confidence ?? "-",
summary: properties.summary || "-",
created_at: meta.createdAt,
created_at_raw: meta.createdAtRaw,
id:
properties.id ||
`${properties.collector || "unknown"}-${latitude}-${longitude}`,
};
}
function buildIncidentFeatureData(feature) {
const point = extractFeaturePoint(feature);
if (!point) return null;
const { latitude, longitude } = point;
const properties = feature?.properties || {};
const startedAt = properties.started_at || properties.created_at || null;
const meta = extractFeatureMeta(properties, startedAt);
const affectedPrefixes = Array.isArray(properties.affected_prefixes)
? properties.affected_prefixes
: [];
const affectedAsns = Array.isArray(properties.affected_asns)
? properties.affected_asns
: [];
const affectedCollectors = Array.isArray(properties.affected_collectors)
? properties.affected_collectors
: [];
const affectedRegions = Array.isArray(properties.affected_regions)
? properties.affected_regions
: [];
const primaryRegion = affectedRegions[0] || {};
return {
latitude,
longitude,
rawSeverity: meta.rawSeverity,
severity: meta.severity,
collector: affectedCollectors[0] || primaryRegion.collector || "-",
city: primaryRegion.city || "-",
country: primaryRegion.country || "-",
source: "bgp_incident",
anomaly_type: properties.incident_type || properties.title || "-",
incident_type: properties.incident_type || "-",
incident_key: properties.incident_key || "-",
status: properties.status || "-",
prefix: affectedPrefixes[0] || "-",
prefixes: affectedPrefixes,
origin_asn: affectedAsns[0] ?? "-",
new_origin_asn: affectedAsns[1] ?? "-",
affected_asns: affectedAsns,
as_path: [],
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: meta.createdAt,
created_at_raw: meta.createdAtRaw,
route_change:
affectedAsns.length > 1
? affectedAsns.slice(0, 2).map((asn) => `AS${asn}`).join(" -> ")
: affectedPrefixes.length > 1
? `${affectedPrefixes.length}个前缀簇`
: properties.incident_type || "-",
observed_by: formatBGPObservedBy(affectedCollectors),
impacted_scope: formatBGPImpactedScope(affectedRegions),
location: formatBGPLocation(primaryRegion.city, primaryRegion.country),
id:
properties.id ||
properties.incident_key ||
`${properties.incident_type || "incident"}-${latitude}-${longitude}`,
};
}
function extractFeaturePoint(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
return { latitude, longitude };
}
function extractFeatureMeta(properties, createdAtRaw) {
const severity = normalizeSeverity(properties.severity);
return {
rawSeverity: properties.severity || severity,
severity,
createdAt: formatLocalDateTime(createdAtRaw),
createdAtRaw: createdAtRaw,
};
}
function clearMarkerArray(markers) {
while (markers.length > 0) {
const marker = markers.pop();
while (marker.children.length > 0) {
const child = marker.children.pop();
child.geometry?.dispose?.();
child.material?.dispose();
}
disposeCollectorEffectSprites(marker);
disposeEventRingSprite(marker.userData?.ringA);
disposeEventRingSprite(marker.userData?.ringB);
marker.material?.dispose();
marker.parent?.remove?.(marker);
}
}
function disposeAnomalyRingSprites() {
anomalyMarkers.forEach((marker) => {
disposeEventRingSprite(marker.userData?.ringA);
disposeEventRingSprite(marker.userData?.ringB);
delete marker.userData.ringA;
delete marker.userData.ringB;
});
}
function clearGroup(group) {
while (group.children.length > 0) {
const child = group.children[group.children.length - 1];
group.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
}
}
function createSpriteMaterial({ color, opacity }) {
return new THREE.SpriteMaterial({
map: getMarkerTexture(),
color,
transparent: true,
opacity,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
});
}
function createOverlaySprite({ color, opacity, scale }) {
const sprite = new THREE.Sprite(createSpriteMaterial({ color, opacity }));
sprite.scale.setScalar(scale);
return sprite;
}
function projectLatLon(lat, lon, bearingDeg, distanceDeg) {
const latRad = (lat * Math.PI) / 180;
const lonRad = (lon * Math.PI) / 180;
const bearing = (bearingDeg * Math.PI) / 180;
const angularDistance = (distanceDeg * Math.PI) / 180;
const targetLat = Math.asin(
Math.sin(latRad) * Math.cos(angularDistance) +
Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing),
);
const targetLon =
lonRad +
Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad),
Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(targetLat),
);
return {
latitude: (targetLat * 180) / Math.PI,
longitude: ((((targetLon * 180) / Math.PI) + 540) % 360) - 180,
};
}
function createCoverageBoundaryLine(points, color, opacity = 0.3) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
return new THREE.Line(geometry, material);
}
function createCoverageSector(points, color, opacity = 0.12) {
const center = points[0];
const positions = [];
for (let index = 1; index < points.length - 1; index += 1) {
const current = points[index];
const next = points[index + 1];
positions.push(
center.x, center.y, center.z,
current.x, current.y, current.z,
next.x, next.y, next.z,
);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.computeVertexNormals();
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
return new THREE.Mesh(geometry, material);
}
function createCoverageSectorMesh(
anchorLatitude,
anchorLongitude,
startBearingDeg,
endBearingDeg,
reachDeg,
altitude,
color,
opacity = 0.12,
radialSegments = 8,
angularSegments = 28,
) {
const positions = [];
for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) {
const innerDistance = (reachDeg * radialIndex) / radialSegments;
const outerDistance = (reachDeg * (radialIndex + 1)) / radialSegments;
for (let angularIndex = 0; angularIndex < angularSegments; angularIndex += 1) {
const startProgress = angularIndex / angularSegments;
const endProgress = (angularIndex + 1) / angularSegments;
const startBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * startProgress;
const endBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * endProgress;
const innerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, innerDistance);
const innerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, innerDistance);
const outerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, outerDistance);
const outerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, outerDistance);
const innerStartVector = latLonToVector3(innerStart.latitude, innerStart.longitude, altitude);
const innerEndVector = latLonToVector3(innerEnd.latitude, innerEnd.longitude, altitude);
const outerStartVector = latLonToVector3(outerStart.latitude, outerStart.longitude, altitude);
const outerEndVector = latLonToVector3(outerEnd.latitude, outerEnd.longitude, altitude);
positions.push(
innerStartVector.x, innerStartVector.y, innerStartVector.z,
outerStartVector.x, outerStartVector.y, outerStartVector.z,
outerEndVector.x, outerEndVector.y, outerEndVector.z,
);
positions.push(
innerStartVector.x, innerStartVector.y, innerStartVector.z,
outerEndVector.x, outerEndVector.y, outerEndVector.z,
innerEndVector.x, innerEndVector.y, innerEndVector.z,
);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.computeVertexNormals();
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
side: THREE.DoubleSide,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
blending: THREE.AdditiveBlending,
});
return new THREE.Mesh(geometry, material);
}
function createRadialBoundaryPoints(
anchorLatitude,
anchorLongitude,
bearingDeg,
reachDeg,
altitude,
segments = 18,
) {
const points = [];
for (let step = 0; step <= segments; step += 1) {
const progress = step / segments;
const projected = projectLatLon(
anchorLatitude,
anchorLongitude,
bearingDeg,
reachDeg * progress,
);
points.push(
latLonToVector3(
projected.latitude,
projected.longitude,
altitude,
),
);
}
return points;
}
function attachCollectorEffectSprites(marker) {
const activity = marker.userData.activity;
const heatHalo = createOverlaySprite({
color: activity.color,
opacity: 0.0,
scale: activity.haloScale * 0.58,
});
heatHalo.renderOrder = 1;
const pulseHalo = createOverlaySprite({
color: activity.color,
opacity: 0.0,
scale: activity.pulseHaloScale * 0.48,
});
pulseHalo.renderOrder = 0;
const statusCore = createOverlaySprite({
color: activity.color,
opacity: 0.0,
scale: Math.max(
BGP_CONFIG.marker.collectorBaseScale *
BGP_CONFIG.marker.collectorStatusCoreBaseScale,
BGP_CONFIG.marker.collectorStatusCoreMinScale,
),
});
statusCore.renderOrder = 4;
const coverageHalo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
opacity: 0.0,
scale: activity.coverageHaloScale * 0.7,
});
coverageHalo.renderOrder = 0;
coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1);
marker.userData.heatHalo = heatHalo;
marker.userData.pulseHalo = pulseHalo;
marker.userData.statusCore = statusCore;
marker.userData.coverageHalo = coverageHalo;
[heatHalo, pulseHalo, statusCore, coverageHalo].forEach((sprite) => {
sprite.position.copy(marker.position);
sprite.visible = showBGP;
bgpGroup.add(sprite);
});
}
function disposeCollectorEffectSprites(marker) {
[
marker?.userData?.heatHalo,
marker?.userData?.pulseHalo,
marker?.userData?.statusCore,
marker?.userData?.coverageHalo,
].forEach((sprite) => {
if (!sprite) return;
sprite.parent?.remove?.(sprite);
sprite.material?.dispose?.();
sprite.geometry?.dispose?.();
});
}
async function setCollectorMarkers(markerData, earth) {
collectorMarkers.forEach(disposeCollectorEffectSprites);
collectorMarkers.length = 0;
await bgpCollectorIconLayer.preloadAssets(markerData);
bgpCollectorIconLayer.setData(markerData);
bgpCollectorIconLayer.attach(earth);
bgpCollectorIconLayer.setVisible(showBGP);
bgpCollectorIconLayer.getMarkers().forEach((marker) => {
attachCollectorEffectSprites(marker);
collectorMarkers.push(marker);
});
}
function createEventRingSprite(marker) {
const ring = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventRingTexture(),
color: marker.userData.baseColor || getSeverityColor(marker.userData.severity),
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
ring.position.copy(marker.position);
ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05;
ring.visible = showBGP;
return ring;
}
function disposeEventRingSprite(ring) {
if (!ring) return;
ring.parent?.remove?.(ring);
ring.material?.dispose?.();
ring.geometry?.dispose?.();
}
function attachAnomalyRingSprites(marker) {
const ringA = createEventRingSprite(marker);
const ringB = createEventRingSprite(marker);
ringB.visible = false;
marker.userData.ringA = ringA;
marker.userData.ringB = ringB;
bgpGroup.add(ringA);
bgpGroup.add(ringB);
}
function setAnomalyMarkers(markerData, earth) {
disposeAnomalyRingSprites();
anomalyMarkers.length = 0;
bgpEventIconLayer.setData(markerData);
bgpEventIconLayer.attach(earth);
bgpEventIconLayer.setVisible(showBGP);
bgpEventIconLayer.getMarkers().forEach((marker) => {
attachAnomalyRingSprites(marker);
anomalyMarkers.push(marker);
});
}
function dedupeAnomalies(features) {
const latestByLocation = new Map();
features.forEach((feature) => {
const data = buildAnomalyFeatureData(feature);
if (!data) return;
activeEventCountByCollector.set(
data.collector,
(activeEventCountByCollector.get(data.collector) || 0) + 1,
);
const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`;
const previous = latestByLocation.get(dedupeKey);
const currentTime = data.created_at_raw
? new Date(data.created_at_raw).getTime()
: 0;
const previousTime = previous?.created_at_raw
? new Date(previous.created_at_raw).getTime()
: 0;
const currentSeverity = getSeverityScale(data.severity);
const previousSeverity = previous ? getSeverityScale(previous.severity) : 0;
if (
!previous ||
currentSeverity > previousSeverity ||
(currentSeverity === previousSeverity && currentTime >= previousTime)
) {
latestByLocation.set(dedupeKey, data);
}
});
return Array.from(latestByLocation.values())
.sort((a, b) => {
const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity);
if (severityDiff !== 0) return severityDiff;
const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0;
const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0;
return timeB - timeA;
})
.slice(0, BGP_CONFIG.maxRenderedMarkers);
}
function dedupeIncidents(features) {
const latestByLocation = new Map();
features.forEach((feature) => {
const data = buildIncidentFeatureData(feature);
if (!data) return;
data.collectors.forEach((collector) => {
activeEventCountByCollector.set(
collector,
(activeEventCountByCollector.get(collector) || 0) + 1,
);
});
const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`;
const previous = latestByLocation.get(dedupeKey);
const currentTime = data.created_at_raw
? new Date(data.created_at_raw).getTime()
: 0;
const previousTime = previous?.created_at_raw
? new Date(previous.created_at_raw).getTime()
: 0;
const currentSeverity = getSeverityScale(data.severity);
const previousSeverity = previous ? getSeverityScale(previous.severity) : 0;
if (
!previous ||
currentSeverity > previousSeverity ||
(currentSeverity === previousSeverity && currentTime >= previousTime)
) {
latestByLocation.set(dedupeKey, data);
}
});
return Array.from(latestByLocation.values())
.sort((a, b) => {
const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity);
if (severityDiff !== 0) return severityDiff;
const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0;
const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0;
return timeB - timeA;
})
.slice(0, BGP_CONFIG.maxRenderedMarkers);
}
function applyCollectorCounts() {
collectorMarkers.forEach((marker) => {
marker.userData.anomaly_count =
activeEventCountByCollector.get(marker.userData.collector) || 0;
});
}
async function fetchGeoJSONWithTimeout(url, timeoutMs, warningMessage, fallbackPayload) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn(warningMessage, error);
return fallbackPayload;
}
}
function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
const incidentFeatures = Array.isArray(incidentPayload?.features)
? incidentPayload.features
: [];
if (incidentFeatures.length > 0) {
return {
features: incidentFeatures,
totalIncidentCount: incidentPayload?.count ?? incidentFeatures.length,
totalAnomalyCount: anomalyPayload?.count ?? 0,
mode: "incident",
};
}
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
? anomalyPayload.features
: [];
return {
features: anomalyFeatures,
totalIncidentCount: 0,
totalAnomalyCount: anomalyPayload?.count ?? anomalyFeatures.length,
mode: "anomaly",
};
}
export async function loadBGPAnomalies(scene, earth) {
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
}
const emptyPayload = { type: "FeatureCollection", features: [], count: 0 };
const anomaliesPayload = await fetchGeoJSONWithTimeout(
`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`,
5000,
"BGP anomalies unavailable, falling back to collectors only:",
emptyPayload,
);
const incidentsPayload = await fetchGeoJSONWithTimeout(
`${PATHS.bgpIncidentsApi}?limit=${BGP_CONFIG.defaultFetchLimit}`,
5000,
"BGP incidents unavailable, falling back to anomalies:",
emptyPayload,
);
const collectorsPayload = await collectorsResponse.json();
const collectorFeatures = Array.isArray(collectorsPayload?.features)
? collectorsPayload.features
: [];
const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload);
clearBGPData(earth);
totalAnomalyCount = selectedEventData.totalAnomalyCount;
totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear();
const collectorMarkersData = spreadCollectorPositions(
collectorFeatures
.map(buildCollectorFeatureData)
.filter(Boolean),
);
await setCollectorMarkers(collectorMarkersData, earth);
const eventMarkers =
selectedEventData.mode === "incident"
? dedupeIncidents(selectedEventData.features)
: dedupeAnomalies(selectedEventData.features);
setAnomalyMarkers(eventMarkers, earth);
applyCollectorCounts();
if (!bgpGroup.parent) {
earth.add(bgpGroup);
}
if (!bgpOverlayGroup.parent) {
earth.add(bgpOverlayGroup);
}
bgpGroup.visible = showBGP;
bgpOverlayGroup.visible = showBGP;
if (scene && !scene.children.includes(earth)) {
scene.add(earth);
}
return {
totalCount: totalIncidentCount,
anomalyCount: totalAnomalyCount,
renderedCount: anomalyMarkers.length,
collectorCount: collectorMarkers.length,
};
}
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
const now = performance.now();
updateCollectorOverlayScan(lockedObjectType, lockedObject);
const hasLockedLayer = Boolean(
lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType),
);
collectorMarkers.forEach((marker) => {
const isLocked =
(lockedObjectType === "bgp_collector" || lockedObjectType === "bgp") &&
lockedObject?.userData?.collector === marker.userData.collector;
const isHovered =
marker.userData.state === "hover" || marker.userData.state === "linked";
const pulse =
0.5 +
0.5 *
Math.sin(
now * BGP_CONFIG.pulse.collectorSpeed + marker.userData.pulseOffset,
);
let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera);
let haloOpacity = 0.0;
let pulseOpacity = 0.0;
let coverageOpacity = 0.0;
let markerColor =
marker.userData.idleColor ||
blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
if (isLocked) {
scale *= 1.1 + 0.14 * pulse;
haloOpacity = 0.05;
pulseOpacity = 0.024;
coverageOpacity = 0.036;
markerColor = 0xcff2ff;
} else if (isHovered) {
scale *= 1.08;
haloOpacity = 0.03;
pulseOpacity = 0.014;
coverageOpacity = 0.02;
markerColor = blendHexColors(
BGP_CONFIG.collectorIcon.hoverNeutralColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.collectorIcon.hoverBlend,
);
} else if (hasLockedLayer) {
scale *= 0.98;
haloOpacity = 0.0;
pulseOpacity = 0.0;
coverageOpacity = 0.0;
markerColor = marker.userData.idleColor || markerColor;
} else {
scale *= 1 + 0.05 * pulse;
}
// When this collector shares a city-level avoidance bucket with another
// interactable layer (e.g. compute centers), the icon is fanned out by
// ~1.4u but the decorative halos extend 1140u and would still cover the
// neighbour. Shrink+fade them so the other layer remains visible.
const crossLayer = Boolean(marker.userData.icon_avoidance_cross_layer);
const haloOpacityMul = crossLayer && !isLocked && !isHovered ? 0.18 : 1;
const haloScaleMul = crossLayer && !isLocked && !isHovered ? 0.45 : 1;
if (marker.userData.heatHalo) {
marker.userData.heatHalo.position.copy(marker.position);
marker.userData.heatHalo.material.opacity = haloOpacity * haloOpacityMul;
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.heatHalo.scale.setScalar(
marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01) * haloScaleMul,
);
}
if (marker.userData.pulseHalo) {
marker.userData.pulseHalo.position.copy(marker.position);
marker.userData.pulseHalo.material.opacity = pulseOpacity * haloOpacityMul;
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.pulseHalo.scale.setScalar(
marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02) * haloScaleMul,
);
}
if (marker.userData.statusCore) {
marker.userData.statusCore.position.copy(marker.position);
marker.userData.statusCore.material.opacity =
isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18;
marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.statusCore.scale.setScalar(
Math.max(
BGP_CONFIG.marker.collectorBaseScale *
BGP_CONFIG.marker.collectorStatusCoreBaseScale,
BGP_CONFIG.marker.collectorStatusCoreMinScale,
) * (isLocked ? 1.08 : isHovered ? 1.04 : 0.92),
);
}
if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.position.copy(marker.position);
marker.userData.coverageHalo.material.opacity = coverageOpacity * haloOpacityMul;
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012) * haloScaleMul,
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012) * haloScaleMul,
1,
);
}
});
const focusedCollector =
lockedObjectType === "bgp"
? collectorMarkers.find(
(marker) => marker.userData.collector === lockedObject?.userData?.collector,
)
: lockedObjectType === "bgp_collector"
? lockedObject
: null;
bgpCollectorIconLayer.updateVisualState(
focusedCollector ? "bgp_collector" : lockedObjectType,
focusedCollector || lockedObject,
camera,
);
anomalyMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "bgp" && lockedObject === marker;
const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.collector;
const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker;
const hasFocusedMarker = hasLockedLayer || cruiseMarker != null;
const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise;
const isActive = isLocked || isLinkedCollectorLocked || isCruise;
const isHovered = marker.userData.state === "hover";
const pulse =
0.5 +
0.5 * Math.sin(now * BGP_CONFIG.pulse.eventSpeed + marker.userData.pulseOffset);
const iconAnchorScale =
marker.userData.baseScale * getEventDistanceScale(marker, camera);
let scale = iconAnchorScale;
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
const isIncidentMarker = marker.userData.source === "bgp_incident";
let ringBaseOpacity = isIncidentMarker
? BGP_CONFIG.ring.opacity
: BGP_CONFIG.ring.opacity * 0.45;
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2;
} else if (isCruise) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
ringBaseOpacity *= 1.2;
} else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale;
ringBaseOpacity *= 1.05;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale;
markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02;
} else {
scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse;
}
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => {
if (!ring) return;
const progress = Math.max(0, Math.min(1, phase));
const minScale = 1.28;
const desiredWorldScale =
scale * (minScale + progress * (maxScale - minScale));
const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14));
const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut;
ring.position.copy(marker.position);
ring.scale.setScalar(desiredWorldScale);
ring.material.color.setHex(markerColor);
ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0;
ring.visible = showBGP;
ring.renderOrder = isActive ? BGP_EVENT_RENDER_ORDER + 0.15 : BGP_EVENT_RENDER_ORDER - 0.05;
};
applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA);
if (marker.userData.ringB) {
marker.userData.ringB.material.opacity = 0;
marker.userData.ringB.visible = false;
}
});
bgpEventIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}
export function setBGPMarkerState(marker, state = "normal") {
if (!marker?.userData) return;
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
return;
}
if (marker.userData.type === "bgp") {
bgpEventIconLayer.setMarkerState(marker, state);
return;
}
bgpCollectorIconLayer.setMarkerState(marker, state);
}
export function clearBGPSelection() {
collectorMarkers.forEach((marker) => {
bgpCollectorIconLayer.setMarkerState(marker, "normal");
});
anomalyMarkers.forEach((marker) => {
bgpEventIconLayer.setMarkerState(marker, "normal");
});
clearBGPEventOverlay();
}
export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers);
bgpCollectorIconLayer.clearData(earth);
bgpEventIconLayer.clearData(earth);
clearBGPEventOverlay();
activeEventCountByCollector.clear();
totalAnomalyCount = 0;
totalIncidentCount = 0;
if (earth && bgpGroup.parent === earth) {
earth.remove(bgpGroup);
}
if (earth && bgpOverlayGroup.parent === earth) {
earth.remove(bgpOverlayGroup);
}
}
export function toggleBGP(show) {
showBGP = Boolean(show);
bgpGroup.visible = showBGP;
bgpOverlayGroup.visible = showBGP;
bgpCollectorIconLayer.setVisible(showBGP);
bgpEventIconLayer.setVisible(showBGP);
collectorMarkers.forEach((marker) => {
[
marker.userData.heatHalo,
marker.userData.pulseHalo,
marker.userData.statusCore,
marker.userData.coverageHalo,
].forEach((sprite) => {
if (sprite) sprite.visible = showBGP;
});
});
anomalyMarkers.forEach((marker) => {
marker.userData.ringA && (marker.userData.ringA.visible = showBGP);
marker.userData.ringB && (marker.userData.ringB.visible = false);
});
}
export function getShowBGP() {
return showBGP;
}
export function getBGPMarkers() {
return [...anomalyMarkers, ...collectorMarkers];
}
export function getBGPAnomalyMarkers() {
return anomalyMarkers;
}
export function getBGPAnomalyPointerIntersections(options = {}) {
return bgpEventIconLayer.getPointerIntersections(options);
}
export function getBGPCollectorPointerIntersections(options = {}) {
return bgpCollectorIconLayer.getPointerIntersections(options);
}
export function getBGPCollectorMarkers() {
return collectorMarkers;
}
export function getBGPCount() {
return totalIncidentCount;
}
export function getBGPCollectorCount() {
return collectorMarkers.length;
}
export function getBGPStatusSummary() {
if (totalIncidentCount > 0 && totalAnomalyCount > 0) {
return `${totalIncidentCount} 起活跃事件 / ${totalAnomalyCount} 条异常`;
}
if (totalIncidentCount > 0) {
return `${totalIncidentCount} 起活跃事件`;
}
if (totalAnomalyCount > 0) {
return `${totalAnomalyCount} 条活跃异常`;
}
if (collectorMarkers.length > 0) {
return "当前无活跃事件";
}
return "暂无观测数据";
}
export function showBGPEventOverlay(marker, earth) {
if (!marker?.userData || marker.userData.type !== "bgp" || !earth) return;
clearBGPEventOverlay();
const impactedRegions =
Array.isArray(marker.userData.impacted_regions) &&
marker.userData.impacted_regions.length > 0
? marker.userData.impacted_regions
: [
{
collector: marker.userData.collector,
city: marker.userData.city,
country: marker.userData.country,
latitude: marker.userData.latitude,
longitude: marker.userData.longitude,
},
];
const validRegions = impactedRegions.filter(
(region) =>
typeof region?.latitude === "number" &&
typeof region?.longitude === "number",
);
if (validRegions.length === 0) return;
const overlayItems = [];
validRegions.forEach((region) => {
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
opacity: 0.24,
scale: BGP_CONFIG.regionScale,
});
halo.position.copy(
latLonToVector3(
region.latitude,
region.longitude,
CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1,
),
);
halo.renderOrder = 2;
bgpEventOverlayGroup.add(halo);
overlayItems.push(halo);
});
activeEventOverlay = overlayItems;
activeCollectorOverlayContext = null;
bgpOverlayGroup.visible = showBGP;
}
export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
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_COLLECTOR_ALTITUDE_OFFSET - 0.15,
),
);
halo.renderOrder = 2;
bgpCollectorRadarGroup.add(halo);
const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
opacity: 0.065,
scale: pulseHaloScale * 0.82,
});
pulseHalo.position.copy(halo.position);
pulseHalo.renderOrder = 1;
bgpCollectorRadarGroup.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;
bgpCollectorRadarGroup.add(innerRing);
const overlayItems = [halo, pulseHalo, innerRing];
const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude;
const anchorLongitude = marker.userData.displayLongitude ?? marker.userData.longitude;
const activityMagnitude = Math.log2(prefixCount + observationCount + 1);
const corridorReach = Math.min(30, 12 + activityMagnitude * 3.2);
const orientationSeed = Array.from(marker.userData.collector || "")
.reduce((sum, char) => sum + char.charCodeAt(0), 0);
const baseRotation = ((orientationSeed % 140) - 70) * (Math.PI / 180);
const sectorRotation = baseRotation + (options.rotationOffsetRad || 0);
const sectorHalfWidth = Math.PI * 0.22;
const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI);
const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI);
const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4;
const leftBoundaryPoints = createRadialBoundaryPoints(
anchorLatitude,
anchorLongitude,
startBearing,
corridorReach,
boundaryAltitude,
);
const rightBoundaryPoints = createRadialBoundaryPoints(
anchorLatitude,
anchorLongitude,
endBearing,
corridorReach,
boundaryAltitude,
);
const outerArcPoints = [];
const outerArcSteps = 28;
for (let step = 0; step <= outerArcSteps; step += 1) {
const progress = step / outerArcSteps;
const bearingDeg = startBearing + (endBearing - startBearing) * progress;
const projected = projectLatLon(anchorLatitude, anchorLongitude, bearingDeg, corridorReach);
outerArcPoints.push(
latLonToVector3(
projected.latitude,
projected.longitude,
boundaryAltitude,
),
);
}
const sectorFill = createCoverageSectorMesh(
anchorLatitude,
anchorLongitude,
startBearing,
endBearing,
corridorReach,
fillAltitude,
coverageColor,
0.12,
);
sectorFill.renderOrder = 2;
bgpCollectorRadarGroup.add(sectorFill);
overlayItems.push(sectorFill);
const outerArc = createCoverageBoundaryLine(
outerArcPoints,
coverageColor,
0.9,
);
outerArc.renderOrder = 3;
bgpCollectorRadarGroup.add(outerArc);
overlayItems.push(outerArc);
const leftBoundary = createCoverageBoundaryLine(
leftBoundaryPoints,
coverageColor,
0.76,
);
leftBoundary.renderOrder = 3;
bgpCollectorRadarGroup.add(leftBoundary);
overlayItems.push(leftBoundary);
const rightBoundary = createCoverageBoundaryLine(
rightBoundaryPoints,
coverageColor,
0.76,
);
rightBoundary.renderOrder = 3;
bgpCollectorRadarGroup.add(rightBoundary);
overlayItems.push(rightBoundary);
activeEventOverlay = overlayItems;
activeCollectorOverlayContext = {
marker,
earth,
baseRotation,
lastRebuildAt: performance.now(),
};
bgpOverlayGroup.visible = showBGP;
}
export function clearBGPEventOverlay() {
activeEventOverlay = null;
activeCollectorOverlayContext = null;
clearGroup(bgpEventOverlayGroup);
clearGroup(bgpCollectorRadarGroup);
}
function updateCollectorOverlayScan(lockedObjectType, lockedObject) {
if (
lockedObjectType !== "bgp_collector" ||
!lockedObject ||
!activeCollectorOverlayContext ||
activeCollectorOverlayContext.marker !== lockedObject
) {
return;
}
const now = performance.now();
if (now - activeCollectorOverlayContext.lastRebuildAt < COLLECTOR_SCAN_REBUILD_MS) {
return;
}
const rotationOffsetRad = now * COLLECTOR_SCAN_SPEED_RAD;
showBGPCollectorCoverageOverlay(
activeCollectorOverlayContext.marker,
activeCollectorOverlayContext.earth,
{ rotationOffsetRad },
);
}
export function getBGPLegendItems() {
return [
{ color: "#6db7ff", label: "静态观测站" },
{ color: "#fbbf24", label: "中活跃观测站" },
{ color: "#ff5f57", label: "高活跃观测站" },
{ color: "#6db7ff", label: "观测范围示意" },
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
{ color: "#2dd4bf", label: "影响区域" },
{ color: "#ff4d4f", label: "严重事件" },
{ color: "#ff9f43", label: "高危事件" },
{ color: "#ffd166", label: "中危事件" },
{ color: "#4dabf7", label: "低危事件" },
];
}