import * as THREE from "three"; import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; import { latLonToVector3 } from "./utils.js"; const bgpGroup = new THREE.Group(); const collectorMarkers = []; const anomalyMarkers = []; const anomalyCountByCollector = new Map(); let showBGP = true; let totalAnomalyCount = 0; let textureCache = null; 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 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 ?? "-", 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 createSpriteMaterial({ color, opacity }) { return new THREE.SpriteMaterial({ map: getMarkerTexture(), color, transparent: true, opacity, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, }); } 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); } bgpGroup.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"; }); } export function clearBGPData(earth) { clearMarkerArray(collectorMarkers); clearMarkerArray(anomalyMarkers); anomalyCountByCollector.clear(); totalAnomalyCount = 0; if (earth && bgpGroup.parent === earth) { earth.remove(bgpGroup); } } export function toggleBGP(show) { showBGP = Boolean(show); bgpGroup.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 getBGPLegendItems() { return [ { color: "#6db7ff", label: "观测站" }, { color: "#ff4d4f", label: "Critical 异常" }, { color: "#ff9f43", label: "High / Major 异常" }, { color: "#ffd166", label: "Medium 异常" }, { color: "#4dabf7", label: "Low / Info 异常" }, ]; }