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 anomalyCountByCollector = new Map(); let showBGP = true; let totalAnomalyCount = 0; let textureCache = null; let activeEventOverlay = null; const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto", }); 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 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 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}地`; } 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", }; } 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 = 0.9; group.forEach((marker, index) => { const angle = (Math.PI * 2 * index) / group.length; marker.displayLatitude = marker.latitude + Math.sin(angle) * radius * 0.18; marker.displayLongitude = marker.longitude + Math.cos(angle) * radius * 0.18; 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 clearMarkerArray(markers) { while (markers.length > 0) { const marker = markers.pop(); 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) { const midpoint = start .clone() .add(end) .multiplyScalar(0.5) .normalize() .multiplyScalar(CONFIG.earthRadius + BGP_CONFIG.eventHubAltitudeOffset * 0.8); const curve = new THREE.QuadraticBezierCurve3(start, midpoint, end); const points = curve.getPoints(32); const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.82, depthWrite: false, blending: THREE.AdditiveBlending, }); return new THREE.Line(geometry, material); } function createCollectorMarker(markerData) { const sprite = new THREE.Sprite( createSpriteMaterial({ color: BGP_CONFIG.collectorColor, opacity: BGP_CONFIG.opacity.collector, }), ); const position = latLonToVector3( markerData.displayLatitude, markerData.displayLongitude, CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset, ); sprite.position.copy(position); sprite.scale.setScalar(BGP_CONFIG.collectorScale); sprite.renderOrder = 3; sprite.visible = showBGP; sprite.userData = { type: "bgp_collector", state: "normal", baseScale: BGP_CONFIG.collectorScale, pulseOffset: Math.random() * Math.PI * 2, anomaly_count: 0, ...markerData, }; collectorMarkers.push(sprite); bgpGroup.add(sprite); } 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, 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; anomalyCountByCollector.set( data.collector, (anomalyCountByCollector.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 applyCollectorCounts() { collectorMarkers.forEach((marker) => { marker.userData.anomaly_count = anomalyCountByCollector.get(marker.userData.collector) || 0; }); } export async function loadBGPAnomalies(scene, earth) { clearBGPData(earth); const [collectorsResponse, anomaliesResponse] = await Promise.all([ fetch(PATHS.bgpCollectorsApi), fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`), ]); if (!collectorsResponse.ok) { throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`); } if (!anomaliesResponse.ok) { throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`); } const collectorsPayload = await collectorsResponse.json(); const anomaliesPayload = await anomaliesResponse.json(); const collectorFeatures = Array.isArray(collectorsPayload?.features) ? collectorsPayload.features : []; const anomalyFeatures = Array.isArray(anomaliesPayload?.features) ? anomaliesPayload.features : []; totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length; anomalyCountByCollector.clear(); spreadCollectorPositions( collectorFeatures .map(buildCollectorFeatureData) .filter(Boolean), ).forEach(createCollectorMarker); 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: totalAnomalyCount, renderedCount: anomalyMarkers.length, collectorCount: collectorMarkers.length, }; } export function updateBGPVisualState(lockedObjectType, lockedObject) { const now = performance.now(); 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; let opacity = BGP_CONFIG.opacity.collector; if (isLocked) { scale *= 1.1 + 0.14 * pulse; opacity = BGP_CONFIG.opacity.collectorHover; } else if (isHovered) { scale *= 1.08; opacity = BGP_CONFIG.opacity.collectorHover; } else if (hasLockedLayer) { scale *= BGP_CONFIG.dimmedScale; opacity = BGP_CONFIG.opacity.dimmed; } else { scale *= 1 + 0.05 * pulse; } marker.scale.setScalar(scale); marker.material.opacity = opacity; marker.visible = showBGP; }); 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; 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 = BGP_CONFIG.opacity.dimmed; } else { scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse; opacity = BGP_CONFIG.opacity.normal; } marker.scale.setScalar(scale); 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(); anomalyCountByCollector.clear(); totalAnomalyCount = 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 totalAnomalyCount; } 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; bgpOverlayGroup.visible = showBGP; } export function clearBGPEventOverlay() { activeEventOverlay = null; clearGroup(bgpOverlayGroup); } export function getBGPLegendItems() { return [ { color: "#6db7ff", label: "观测站" }, { color: "#8af5ff", label: "事件连线 / 枢纽" }, { color: "#2dd4bf", label: "影响区域" }, { color: "#ff4d4f", label: "严重异常" }, { color: "#ff9f43", label: "高危异常" }, { color: "#ffd166", label: "中危异常" }, { color: "#4dabf7", label: "低危异常" }, ]; }