Files
planet/frontend/public/earth/js/bgp.js
2026-04-23 07:56:10 +08:00

1851 lines
57 KiB
JavaScript

import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
const bgpOverlayGroup = 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;
let collectorTextureCache = 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 collectorSurfaceNormal = new THREE.Vector3();
const collectorNorthPole = new THREE.Vector3(0, 1, 0);
const collectorFallbackForward = new THREE.Vector3(0, 0, 1);
const collectorNorthTangent = new THREE.Vector3();
const collectorEastTangent = new THREE.Vector3();
const collectorOrientationMatrix = new THREE.Matrix4();
const colorScratchA = new THREE.Color();
const colorScratchB = new THREE.Color();
const COLLECTOR_SCAN_SPEED_RAD = 0.00018;
const COLLECTOR_SCAN_REBUILD_MS = 80;
const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2";
function getMarkerTexture() {
if (textureCache) return textureCache;
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 getCollectorTexture() {
if (collectorTextureCache) return collectorTextureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
collectorTextureCache = new THREE.Texture(canvas);
return collectorTextureCache;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = BGP_CONFIG.collectorIcon.ringStroke;
context.lineWidth = BGP_CONFIG.collectorIcon.ringLineWidth;
context.beginPath();
context.arc(64, 64, BGP_CONFIG.collectorIcon.ringRadius, 0, Math.PI * 2);
context.stroke();
context.save();
context.translate(16, 16);
context.scale(4, 4);
const path = new Path2D(MATERIAL_ACCESS_POINT_PATH);
context.lineJoin = "round";
context.lineCap = "round";
context.lineWidth = BGP_CONFIG.collectorIcon.pathLineWidth;
context.strokeStyle = BGP_CONFIG.collectorIcon.pathStroke;
context.stroke(path);
context.fillStyle = BGP_CONFIG.collectorIcon.pathFill;
context.shadowBlur = 0;
context.fill(path);
context.fillStyle = BGP_CONFIG.collectorIcon.centerFill;
context.beginPath();
context.arc(12, 12, BGP_CONFIG.collectorIcon.centerRadius, 0, Math.PI * 2);
context.fill();
context.restore();
collectorTextureCache = new THREE.CanvasTexture(canvas);
return collectorTextureCache;
}
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 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_CONFIG.collectorAltitudeOffset,
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 orientCollectorMarkerToSurface(marker, position) {
collectorSurfaceNormal.copy(position).normalize();
collectorNorthTangent
.copy(collectorNorthPole)
.projectOnPlane(collectorSurfaceNormal);
if (collectorNorthTangent.lengthSq() < 1e-6) {
collectorNorthTangent
.copy(collectorFallbackForward)
.projectOnPlane(collectorSurfaceNormal);
}
collectorNorthTangent.normalize();
collectorEastTangent
.copy(collectorNorthTangent)
.cross(collectorSurfaceNormal)
.normalize();
collectorOrientationMatrix.makeBasis(
collectorEastTangent,
collectorNorthTangent,
collectorSurfaceNormal,
);
marker.quaternion.setFromRotationMatrix(collectorOrientationMatrix);
}
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.material?.dispose();
}
marker.material?.dispose();
bgpGroup.remove(marker);
}
}
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 createCollectorMarker(markerData) {
const activity = getCollectorActivityProfile(markerData);
const baseColor = activity.color;
const idleColor = blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
baseColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
const marker = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial({
map: getCollectorTexture(),
color: idleColor,
transparent: true,
opacity: BGP_CONFIG.collectorIcon.idleOpacity,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
}),
);
const position = latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset,
);
marker.position.copy(position);
marker.scale.set(BGP_CONFIG.marker.collectorBaseScale * 0.88 * activity.scaleBoost, BGP_CONFIG.marker.collectorBaseScale * 1.08 * activity.scaleBoost, 1);
marker.renderOrder = 3;
marker.visible = showBGP;
orientCollectorMarkerToSurface(marker, position);
const heatHalo = createOverlaySprite({
color: activity.color,
opacity: 0.0,
scale: activity.haloScale * 0.58,
});
heatHalo.renderOrder = 1;
marker.add(heatHalo);
const pulseHalo = createOverlaySprite({
color: activity.color,
opacity: 0.0,
scale: activity.pulseHaloScale * 0.48,
});
pulseHalo.renderOrder = 0;
marker.add(pulseHalo);
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.position.set(0, 0, 0.02);
statusCore.renderOrder = 4;
marker.add(statusCore);
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.add(coverageHalo);
marker.userData = {
type: "bgp_collector",
state: "normal",
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
baseColor,
idleColor,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
heatHalo,
pulseHalo,
statusCore,
coverageHalo,
...markerData,
};
collectorMarkers.push(marker);
bgpGroup.add(marker);
}
function createAnomalyMarker(markerData) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventTexture(markerData.incident_type || markerData.anomaly_type),
color: getSeverityColor(markerData.severity),
transparent: true,
opacity: BGP_CONFIG.opacity.normal,
depthWrite: false,
depthTest: true,
blending: THREE.NormalBlending,
}),
);
const position = latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
);
const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity);
sprite.position.copy(position);
sprite.scale.setScalar(baseScale);
sprite.renderOrder = 5;
sprite.visible = showBGP;
sprite.userData = {
type: "bgp",
state: "normal",
baseScale,
baseColor: getSeverityColor(markerData.severity),
pulseOffset: Math.random() * Math.PI * 2,
...markerData,
};
const ringA = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventRingTexture(),
color: getSeverityColor(markerData.severity),
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA);
ringA.position.set(0, 0, -0.01);
sprite.add(ringA);
const ringB = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventRingTexture(),
color: getSeverityColor(markerData.severity),
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
ringB.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleB);
ringB.position.set(0, 0, -0.02);
sprite.add(ringB);
sprite.userData.ringA = ringA;
sprite.userData.ringB = ringB;
anomalyMarkers.push(sprite);
bgpGroup.add(sprite);
}
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();
spreadCollectorPositions(
collectorFeatures
.map(buildCollectorFeatureData)
.filter(Boolean),
).forEach(createCollectorMarker);
if (selectedEventData.mode === "incident") {
dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker);
} else {
dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker);
}
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 opacity = BGP_CONFIG.collectorIcon.idleOpacity;
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;
opacity = 0.96;
haloOpacity = 0.05;
pulseOpacity = 0.024;
coverageOpacity = 0.036;
markerColor = 0xcff2ff;
} else if (isHovered) {
scale *= 1.08;
opacity = 0.88;
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;
opacity = BGP_CONFIG.collectorIcon.idleOpacity;
haloOpacity = 0.0;
pulseOpacity = 0.0;
coverageOpacity = 0.0;
markerColor = marker.userData.idleColor || markerColor;
} else {
scale *= 1 + 0.05 * pulse;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
if (marker.userData.heatHalo) {
marker.userData.heatHalo.material.opacity = haloOpacity;
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.heatHalo.scale.setScalar(
marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01),
);
}
if (marker.userData.pulseHalo) {
marker.userData.pulseHalo.material.opacity = pulseOpacity;
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.pulseHalo.scale.setScalar(
marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02),
);
}
if (marker.userData.statusCore) {
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.material.opacity = coverageOpacity;
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012),
1,
);
}
});
anomalyMarkers.forEach((marker) => {
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 opacity = BGP_CONFIG.opacity.normal;
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;
opacity = 0.9 + 0.1 * pulse;
markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2;
} else if (isCruise) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
ringBaseOpacity *= 1.2;
} else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale;
opacity = 0.9;
ringBaseOpacity *= 1.05;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale;
opacity = 0.22;
markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02;
} else {
scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse;
opacity = isIncidentMarker ? 0.7 : 0.62;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
marker.renderOrder = isActive ? 7 : 3;
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 =
iconAnchorScale * (minScale + progress * (maxScale - minScale));
const parentScale = Math.max(scale, 0.0001);
const localRingScale = desiredWorldScale / parentScale;
const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14));
const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut;
ring.scale.setScalar(localRingScale);
ring.material.color.setHex(markerColor);
ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0;
ring.visible = showBGP;
};
applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA);
if (marker.userData.ringB) {
marker.userData.ringB.material.opacity = 0;
marker.userData.ringB.visible = false;
}
});
}
export function setBGPMarkerState(marker, state = "normal") {
if (!marker?.userData) return;
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
return;
}
marker.userData.state = state;
}
export function clearBGPSelection() {
collectorMarkers.forEach((marker) => {
marker.userData.state = "normal";
});
anomalyMarkers.forEach((marker) => {
marker.userData.state = "normal";
});
clearBGPEventOverlay();
}
export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers);
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;
collectorMarkers.forEach((marker) => {
marker.visible = showBGP;
});
anomalyMarkers.forEach((marker) => {
marker.visible = showBGP;
});
}
export function getShowBGP() {
return showBGP;
}
export function getBGPMarkers() {
return [...anomalyMarkers, ...collectorMarkers];
}
export function getBGPAnomalyMarkers() {
return anomalyMarkers;
}
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_CONFIG.collectorAltitudeOffset - 0.1,
),
);
halo.renderOrder = 2;
bgpOverlayGroup.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_CONFIG.collectorAltitudeOffset - 0.15,
),
);
halo.renderOrder = 2;
bgpOverlayGroup.add(halo);
const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
opacity: 0.065,
scale: pulseHaloScale * 0.82,
});
pulseHalo.position.copy(halo.position);
pulseHalo.renderOrder = 1;
bgpOverlayGroup.add(pulseHalo);
const innerRing = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
opacity: 0.12,
scale: Math.max(haloScale * 0.34, 5.5),
});
innerRing.position.copy(halo.position);
innerRing.renderOrder = 3;
bgpOverlayGroup.add(innerRing);
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_CONFIG.collectorAltitudeOffset + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 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;
bgpOverlayGroup.add(sectorFill);
overlayItems.push(sectorFill);
const outerArc = createCoverageBoundaryLine(
outerArcPoints,
coverageColor,
0.9,
);
outerArc.renderOrder = 3;
bgpOverlayGroup.add(outerArc);
overlayItems.push(outerArc);
const leftBoundary = createCoverageBoundaryLine(
leftBoundaryPoints,
coverageColor,
0.76,
);
leftBoundary.renderOrder = 3;
bgpOverlayGroup.add(leftBoundary);
overlayItems.push(leftBoundary);
const rightBoundary = createCoverageBoundaryLine(
rightBoundaryPoints,
coverageColor,
0.76,
);
rightBoundary.renderOrder = 3;
bgpOverlayGroup.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(bgpOverlayGroup);
}
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: "低危事件" },
];
}