Files
planet/frontend/public/earth/js/bgp.js
2026-03-31 17:30:53 +08:00

1620 lines
50 KiB
JavaScript

import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { 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 collectorTextureCache = null;
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 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 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) return 1;
marker.getWorldPosition(collectorWorldPosition);
const distanceToCamera = camera.position.distanceTo(collectorWorldPosition);
const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset;
return clamp(distanceToCamera / referenceDistance, 0.6, 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.collectorHaloScale +
clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12);
const pulseHaloScale =
BGP_CONFIG.collectorPulseHaloScale +
clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14);
const coverageHaloScale =
BGP_CONFIG.collectorCoverageHaloScale +
clamp(Math.log2(prefixes + origins + 1) * 3.4, 0, 18);
return {
tier,
color: BGP_CONFIG.collectorHeatColors[tier] || BGP_CONFIG.collectorColor,
scaleBoost,
haloScale,
pulseHaloScale,
coverageHaloScale,
activityScore,
};
}
function formatLocalDateTime(value) {
if (!value) return "-";
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 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 || {};
const severity = normalizeSeverity(properties.severity);
const createdAt = properties.created_at || null;
return {
latitude,
longitude,
rawSeverity: properties.severity || severity,
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: formatLocalDateTime(createdAt),
created_at_raw: createdAt,
id:
properties.id ||
`${properties.collector || "unknown"}-${latitude}-${longitude}`,
};
}
function buildIncidentFeatureData(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 || {};
const severity = normalizeSeverity(properties.severity);
const startedAt = properties.started_at || properties.created_at || null;
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: properties.severity || severity,
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: formatLocalDateTime(startedAt),
created_at_raw: startedAt,
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 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 createArcLine(start, end, color, opacity = 0.82) {
const points = createArcPoints(start, end);
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 createArcPoints(start, end, heightOffset = BGP_CONFIG.eventHubAltitudeOffset * 0.8, segments = 32) {
const midpoint = start
.clone()
.add(end)
.multiplyScalar(0.5)
.normalize()
.multiplyScalar(CONFIG.earthRadius + heightOffset);
const curve = new THREE.QuadraticBezierCurve3(start, midpoint, end);
return curve.getPoints(segments);
}
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.collectorScale * 0.88 * activity.scaleBoost, BGP_CONFIG.collectorScale * 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.collectorScale * 0.12, 1.2),
});
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.collectorScale * 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(
createSpriteMaterial({
color: getSeverityColor(markerData.severity),
opacity: BGP_CONFIG.opacity.normal,
}),
);
const position = latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
);
const baseScale = BGP_CONFIG.baseScale * 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,
};
anomalyMarkers.push(sprite);
bgpGroup.add(sprite);
}
function dedupeAnomalies(features) {
const latestByCollector = 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.collector}|${data.latitude.toFixed(4)}|${data.longitude.toFixed(4)}`;
const previous = latestByCollector.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;
if (!previous || currentTime >= previousTime) {
latestByCollector.set(dedupeKey, data);
}
});
return Array.from(latestByCollector.values())
.sort((a, b) => {
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 latestByKey = 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 = String(data.incident_key || data.id);
const previous = latestByKey.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;
if (!previous || currentTime >= previousTime) {
latestByKey.set(dedupeKey, data);
}
});
return Array.from(latestByKey.values())
.sort((a, b) => {
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;
});
}
export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const [collectorsResponse, incidentsResponse, anomaliesResponse] = await Promise.all([
fetch(PATHS.bgpCollectorsApi),
fetch(`${PATHS.bgpIncidentsApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
]);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
}
if (!incidentsResponse.ok) {
throw new Error(`BGP incidents HTTP ${incidentsResponse.status}`);
}
if (!anomaliesResponse.ok) {
throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`);
}
const collectorsPayload = await collectorsResponse.json();
const incidentsPayload = await incidentsResponse.json();
const anomaliesPayload = await anomaliesResponse.json();
const collectorFeatures = Array.isArray(collectorsPayload?.features)
? collectorsPayload.features
: [];
const incidentFeatures = Array.isArray(incidentsPayload?.features)
? incidentsPayload.features
: [];
const anomalyFeatures = Array.isArray(anomaliesPayload?.features)
? anomaliesPayload.features
: [];
totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length;
totalIncidentCount = incidentsPayload?.count ?? incidentFeatures.length;
activeEventCountByCollector.clear();
spreadCollectorPositions(
collectorFeatures
.map(buildCollectorFeatureData)
.filter(Boolean),
).forEach(createCollectorMarker);
const incidentMarkers = dedupeIncidents(incidentFeatures);
if (incidentMarkers.length > 0) {
incidentMarkers.forEach(createAnomalyMarker);
} else {
dedupeAnomalies(anomalyFeatures).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) {
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.collectorPulseSpeed + 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 = BGP_CONFIG.opacity.collectorHover;
haloOpacity = 0.022;
pulseOpacity = 0.012;
coverageOpacity = 0.02;
markerColor = blendHexColors(
BGP_CONFIG.collectorIcon.lockedNeutralColor,
marker.userData.baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.collectorIcon.lockedBlend,
);
} else if (isHovered) {
scale *= 1.08;
opacity = BGP_CONFIG.opacity.collectorHover;
haloOpacity = 0.016;
pulseOpacity = 0.008;
coverageOpacity = 0.014;
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.collectorScale * 0.12, 1.2) * (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 isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
const isHovered = marker.userData.state === "hover";
const pulse =
0.5 +
0.5 * Math.sin(now * BGP_CONFIG.pulseSpeed + marker.userData.pulseOffset);
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.normal;
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse;
opacity =
BGP_CONFIG.opacity.lockedMin +
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
} else if (isHovered) {
scale *= BGP_CONFIG.hoverScale;
opacity = BGP_CONFIG.opacity.hover;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.dimmedScale;
opacity = 0.1;
markerColor = 0x7d8ca3;
} else {
scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse;
opacity = BGP_CONFIG.opacity.normal;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
});
}
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 averageLatitude =
validRegions.reduce((sum, region) => sum + region.latitude, 0) /
validRegions.length;
const averageLongitude =
validRegions.reduce((sum, region) => sum + region.longitude, 0) /
validRegions.length;
const hubPosition = latLonToVector3(
averageLatitude,
averageLongitude,
CONFIG.earthRadius + BGP_CONFIG.eventHubAltitudeOffset,
);
const hub = createOverlaySprite({
color: BGP_CONFIG.eventHubColor,
opacity: 0.95,
scale: BGP_CONFIG.eventHubScale,
});
hub.position.copy(hubPosition);
hub.renderOrder = 6;
bgpOverlayGroup.add(hub);
const overlayItems = [hub];
validRegions.forEach((region) => {
const regionPosition = latLonToVector3(
region.latitude,
region.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.3,
);
const link = createArcLine(regionPosition, hubPosition, BGP_CONFIG.linkColor);
link.renderOrder = 4;
bgpOverlayGroup.add(link);
overlayItems.push(link);
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: "低危事件" },
];
}