1984 lines
62 KiB
JavaScript
1984 lines
62 KiB
JavaScript
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 = new URL("../assets/icons/bgp-broadcast-pin.svg", import.meta.url).href;
|
||
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 getHaloTintColor(baseColor) {
|
||
return blendHexColors(
|
||
BGP_CONFIG.halo.tintNeutralColor,
|
||
baseColor || BGP_CONFIG.collectorColor,
|
||
BGP_CONFIG.halo.tintBlend,
|
||
);
|
||
}
|
||
|
||
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: getHaloTintColor(activity.color),
|
||
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 11–40u 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.material.color.setHex(
|
||
getHaloTintColor(marker.userData.baseColor),
|
||
);
|
||
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 eventBaseColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
|
||
const halo = createOverlaySprite({
|
||
color: getHaloTintColor(eventBaseColor),
|
||
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 collectorBaseColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
|
||
const collectorHaloColor = getHaloTintColor(collectorBaseColor);
|
||
|
||
const halo = createOverlaySprite({
|
||
color: collectorHaloColor,
|
||
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: collectorHaloColor,
|
||
opacity: 0.065,
|
||
scale: pulseHaloScale * 0.82,
|
||
});
|
||
pulseHalo.position.copy(halo.position);
|
||
pulseHalo.renderOrder = 1;
|
||
bgpCollectorRadarGroup.add(pulseHalo);
|
||
const innerRing = createOverlaySprite({
|
||
color: collectorBaseColor,
|
||
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 = collectorBaseColor;
|
||
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: "低危事件" },
|
||
];
|
||
}
|