import * as THREE from "three"; import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; import { createInteractableLayer } from "./interactable.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js"; const bgpGroup = new THREE.Group(); const bgpOverlayGroup = new THREE.Group(); const bgpEventOverlayGroup = new THREE.Group(); const bgpCollectorRadarGroup = new THREE.Group(); const collectorMarkers = []; const anomalyMarkers = []; const activeEventCountByCollector = new Map(); let showBGP = true; let totalAnomalyCount = 0; let totalIncidentCount = 0; let textureCache = null; let eventRingTextureCache = null; const eventTextureCache = new Map(); let activeEventOverlay = null; let activeCollectorOverlayContext = null; const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto", }); const collectorWorldPosition = new THREE.Vector3(); const colorScratchA = new THREE.Color(); const colorScratchB = new THREE.Color(); const COLLECTOR_SCAN_SPEED_RAD = 0.00018; const COLLECTOR_SCAN_REBUILD_MS = 80; const BGP_EVENT_RENDER_ORDER = 4.5; const BGP_EVENT_POINT_SIZE = 34; const BGP_EVENT_SYMBOL_SIZE = 60; const BGP_COLLECTOR_RENDER_ORDER = 4.4; const BGP_COLLECTOR_ALTITUDE_OFFSET = BGP_CONFIG.collectorAltitudeOffset; const BGP_COLLECTOR_POINT_SIZE = 36; const BGP_COLLECTOR_ICON_FIT_SIZE = 60; const BGP_COLLECTOR_ICON_SOURCE = "/earth/assets/icons/bgp-broadcast-pin.svg"; const BGP_COLLECTOR_HOVER_SCALE = 1.08; const BGP_COLLECTOR_LOCKED_SCALE = 1.12; const BGP_COLLECTOR_PULSE_AMPLITUDE = 0.14; bgpOverlayGroup.name = "bgp-overlay-root"; bgpEventOverlayGroup.name = "bgp-event-overlay-layer"; bgpCollectorRadarGroup.name = "bgp-collector-radar-layer"; bgpOverlayGroup.add(bgpEventOverlayGroup); bgpOverlayGroup.add(bgpCollectorRadarGroup); function getMarkerTexture() { if (textureCache) return textureCache; const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { textureCache = new THREE.Texture(canvas); return textureCache; } const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 56); gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(0.24, "rgba(255,255,255,0.92)"); gradient.addColorStop(0.58, "rgba(255,255,255,0.35)"); gradient.addColorStop(1, "rgba(255,255,255,0)"); context.fillStyle = gradient; context.beginPath(); context.arc(64, 64, 56, 0, Math.PI * 2); context.fill(); textureCache = new THREE.CanvasTexture(canvas); return textureCache; } function getEventRingTexture() { if (eventRingTextureCache) return eventRingTextureCache; const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { eventRingTextureCache = new THREE.Texture(canvas); return eventRingTextureCache; } context.clearRect(0, 0, 128, 128); context.strokeStyle = "rgba(255,255,255,0.98)"; context.lineWidth = 6; context.beginPath(); context.arc(64, 64, 44, 0, Math.PI * 2); context.stroke(); eventRingTextureCache = new THREE.CanvasTexture(canvas); return eventRingTextureCache; } function getEventSymbolKind(anomalyType) { const value = String(anomalyType || "").toLowerCase(); if (value.includes("origin")) return "triangle"; if (value.includes("withdraw")) return "exclamation"; if (value.includes("specific") || value.includes("burst")) return "burst"; if (value.includes("flap")) return "wave"; if (value.includes("leak")) return "leak"; return "dot"; } function drawTriangleSymbol(context) { context.beginPath(); context.moveTo(64, 18); context.lineTo(110, 106); context.lineTo(18, 106); context.closePath(); context.fill(); } function drawExclamationSymbol(context) { context.beginPath(); context.roundRect(52, 22, 24, 62, 12); context.fill(); context.beginPath(); context.arc(64, 102, 10, 0, Math.PI * 2); context.fill(); } function drawWaveSymbol(context) { context.beginPath(); context.moveTo(14, 100); context.lineTo(38, 26); context.lineTo(64, 100); context.lineTo(90, 26); context.lineTo(114, 100); context.closePath(); context.fill(); } function drawBurstSymbol(context) { context.lineWidth = 10; context.lineCap = "round"; for (let index = 0; index < 6; index += 1) { const angle = (Math.PI * 2 * index) / 6; const inner = 26; const outer = 48; context.beginPath(); context.moveTo(64 + Math.cos(angle) * inner, 64 + Math.sin(angle) * inner); context.lineTo(64 + Math.cos(angle) * outer, 64 + Math.sin(angle) * outer); context.stroke(); } context.beginPath(); context.arc(64, 64, 16, 0, Math.PI * 2); context.fill(); } function drawLeakSymbol(context) { context.lineWidth = 10; context.lineCap = "round"; context.beginPath(); context.moveTo(28, 96); context.lineTo(64, 28); context.lineTo(100, 96); context.stroke(); context.beginPath(); context.moveTo(40, 82); context.lineTo(64, 54); context.lineTo(88, 82); context.stroke(); } function drawDotSymbol(context) { context.beginPath(); context.arc(64, 64, 28, 0, Math.PI * 2); context.fill(); } function getEventTexture(anomalyType) { const kind = getEventSymbolKind(anomalyType); if (eventTextureCache.has(kind)) return eventTextureCache.get(kind); const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { const fallback = new THREE.Texture(canvas); eventTextureCache.set(kind, fallback); return fallback; } context.clearRect(0, 0, 128, 128); context.fillStyle = "rgba(255,255,255,0.96)"; context.strokeStyle = "rgba(255,255,255,0.96)"; context.shadowBlur = 0; context.lineJoin = "round"; if (kind === "triangle") { drawTriangleSymbol(context); } else if (kind === "exclamation") { drawExclamationSymbol(context); } else if (kind === "wave") { drawWaveSymbol(context); } else if (kind === "burst") { drawBurstSymbol(context); } else if (kind === "leak") { drawLeakSymbol(context); } else { drawDotSymbol(context); } const texture = new THREE.CanvasTexture(canvas); eventTextureCache.set(kind, texture); return texture; } function normalizeSeverity(severity) { const value = String(severity || "").trim().toLowerCase(); if (value === "critical") return "critical"; if (value === "high" || value === "major") return "high"; if (value === "medium" || value === "moderate" || value === "warning") { return "medium"; } if (value === "low" || value === "info" || value === "informational") { return "low"; } return "medium"; } function getSeverityColor(severity) { return BGP_CONFIG.severityColors[normalizeSeverity(severity)]; } function getSeverityScale(severity) { return BGP_CONFIG.severityScales[normalizeSeverity(severity)]; } function severityColorHex(severity) { return `#${getSeverityColor(severity).toString(16).padStart(6, "0")}`; } function colorNumberHex(colorNumber) { return `#${Number(colorNumber || 0xffffff).toString(16).padStart(6, "0")}`; } function drawBGPEventIcon(context, { marker, color = "#ffffff", glow = false }) { const kind = getEventSymbolKind( marker?.userData?.incident_type || marker?.userData?.anomaly_type, ); context.save(); context.fillStyle = color; context.strokeStyle = color; context.lineJoin = "round"; context.lineCap = "round"; context.shadowColor = color; context.shadowBlur = glow ? 14 : 0; const inset = (128 - BGP_EVENT_SYMBOL_SIZE) / 2; context.translate(inset, inset); context.scale(BGP_EVENT_SYMBOL_SIZE / 128, BGP_EVENT_SYMBOL_SIZE / 128); if (kind === "triangle") { drawTriangleSymbol(context); } else if (kind === "exclamation") { drawExclamationSymbol(context); } else if (kind === "wave") { drawWaveSymbol(context); } else if (kind === "burst") { drawBurstSymbol(context); } else if (kind === "leak") { drawLeakSymbol(context); } else { drawDotSymbol(context); } context.restore(); } const bgpEventIconLayer = createInteractableLayer({ id: "bgp-events", objectType: "bgp", renderOrder: BGP_EVENT_RENDER_ORDER, altitudeOffset: BGP_CONFIG.altitudeOffset, pointSize: BGP_EVENT_POINT_SIZE, colors: { byKind: Object.fromEntries( Object.keys(BGP_CONFIG.severityColors).map((severity) => [ severity, severityColorHex(severity), ]), ), normal: severityColorHex("medium"), }, opacity: { normal: BGP_CONFIG.opacity.normal, hover: BGP_CONFIG.opacity.hover, locked: BGP_CONFIG.opacity.lockedMax, dimmed: BGP_CONFIG.opacity.dimmed, }, stateScale: { hover: BGP_CONFIG.marker.hoverScale, locked: BGP_CONFIG.marker.hoverScale, dimmed: BGP_CONFIG.marker.dimmedScale, }, pulse: { enabled: true, speed: BGP_CONFIG.pulse.eventSpeed, amplitude: BGP_CONFIG.pulse.lockedAmplitude, }, icon: { coordinates: "canvas", draw: drawBGPEventIcon, }, getPosition: (item) => ({ latitude: item.latitude, longitude: item.longitude, }), getKind: (item) => normalizeSeverity(item.severity), getBucketKey: (marker) => [ getEventSymbolKind(marker.userData?.incident_type || marker.userData?.anomaly_type), normalizeSeverity(marker.userData?.severity), ].join(":"), getPointSizeMultiplier: (marker) => getSeverityScale(marker.userData?.severity), getUserData: (item) => ({ ...item, baseScale: BGP_CONFIG.marker.eventBaseScale * getSeverityScale(item.severity), baseColor: getSeverityColor(item.severity), pulseOffset: Math.random() * Math.PI * 2, }), }); const bgpCollectorIconLayer = createInteractableLayer({ id: "bgp-collectors", objectType: "bgp_collector", renderOrder: BGP_COLLECTOR_RENDER_ORDER, altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET, pointSize: BGP_COLLECTOR_POINT_SIZE, colors: { byKind: Object.fromEntries( Object.entries(BGP_CONFIG.collectorHeatColors).map(([tier, color]) => [ tier, colorNumberHex(color), ]), ), normal: colorNumberHex(BGP_CONFIG.collectorColor), }, opacity: { normal: BGP_CONFIG.collectorIcon.idleOpacity, hover: 0.98, locked: 1, dimmed: BGP_CONFIG.opacity.dimmed, }, stateScale: { hover: BGP_COLLECTOR_HOVER_SCALE, locked: BGP_COLLECTOR_LOCKED_SCALE, dimmed: BGP_CONFIG.marker.dimmedScale, }, pulse: { enabled: true, speed: BGP_CONFIG.pulse.collectorSpeed, amplitude: BGP_COLLECTOR_PULSE_AMPLITUDE, }, icon: { coordinates: "canvas", fitSize: BGP_COLLECTOR_ICON_FIT_SIZE, glowBlur: 16, source: BGP_COLLECTOR_ICON_SOURCE, }, getPosition: (item) => ({ latitude: item.displayLatitude, longitude: item.displayLongitude, }), getKind: (item) => getCollectorActivityProfile(item).tier, getBucketKey: (marker) => { const scaleBoost = marker.userData?.activity?.scaleBoost ?? 1; return `${marker.userData?.activity?.tier || "idle"}:${scaleBoost.toFixed(2)}`; }, getPointSizeMultiplier: (marker) => marker.userData?.activity?.scaleBoost ?? 1, getUserData: (item) => { const activity = getCollectorActivityProfile(item); const baseColor = activity.color; const idleColor = blendHexColors( BGP_CONFIG.collectorIcon.idleBaseColor, baseColor, BGP_CONFIG.collectorIcon.idleBlend, ); return { ...item, baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost, baseColor, idleColor, pulseOffset: Math.random() * Math.PI * 2, anomaly_count: 0, activity, }; }, }); 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_COLLECTOR_ALTITUDE_OFFSET, referenceFov: 75, min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6), max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9), }); } function getEventDistanceScale(marker, camera) { if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; return getSurfaceMarkerCameraScale(camera, { altitudeOffset: BGP_CONFIG.altitudeOffset, referenceFov: 75, min: Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7), max: Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9), }); } function getCollectorActivityProfile(markerData) { const recent24h = Number(markerData?.recent_24h_observation_count || 0); const recent7d = Number(markerData?.recent_7d_observation_count || 0); const prefixes = Number(markerData?.prefix_count || 0); const origins = Number(markerData?.origin_asn_count || 0); const activityScore = recent24h * 1.7 + recent7d * 0.3 + prefixes * 0.16 + origins * 0.12; let tier = "idle"; if (activityScore >= 50 || recent24h >= 24) tier = "hot"; else if (activityScore >= 20 || recent24h >= 10) tier = "high"; else if (activityScore >= 8 || recent24h >= 4) tier = "medium"; else if (activityScore > 0) tier = "low"; const scaleBoost = clamp(1 + Math.log2(activityScore + 1) * 0.12, 1, 1.55); const haloScale = BGP_CONFIG.halo.collectorScale + clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12); const pulseHaloScale = BGP_CONFIG.halo.collectorPulseScale + clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14); const coverageHaloScale = BGP_CONFIG.halo.collectorCoverageScale + clamp(Math.log2(prefixes + origins + 1) * 3.4, 0, 18); return { tier, color: BGP_CONFIG.collectorHeatColors[tier] || BGP_CONFIG.collectorColor, scaleBoost, haloScale, pulseHaloScale, coverageHaloScale, activityScore, }; } function formatLocalDateTime(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; } function toDate(value) { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return date; } function formatRelativeTime(value) { const date = toDate(value); if (!date) return null; const diffMs = date.getTime() - Date.now(); const absMs = Math.abs(diffMs); if (absMs < 60 * 1000) { return relativeTimeFormatter.format(Math.round(diffMs / 1000), "second"); } if (absMs < 60 * 60 * 1000) { return relativeTimeFormatter.format(Math.round(diffMs / (60 * 1000)), "minute"); } if (absMs < 24 * 60 * 60 * 1000) { return relativeTimeFormatter.format(Math.round(diffMs / (60 * 60 * 1000)), "hour"); } return relativeTimeFormatter.format( Math.round(diffMs / (24 * 60 * 60 * 1000)), "day", ); } export function formatBGPSeverityLabel(severity) { const normalized = normalizeSeverity(severity); switch (normalized) { case "critical": return "严重"; case "high": return "高"; case "medium": return "中"; case "low": return "低"; default: return "中"; } } export function formatBGPAnomalyTypeLabel(type) { const value = String(type || "").trim().toLowerCase(); if (!value) return "-"; if (value.includes("hijack")) return "前缀劫持"; if (value.includes("leak")) return "路由泄露"; if (value.includes("withdraw")) return "大规模撤销"; if (value.includes("subprefix") || value.includes("more_specific")) { return "更具体前缀异常"; } if (value.includes("path")) return "路径突变"; if (value.includes("flap")) return "路由抖动"; return String(type); } export function formatBGPStatusLabel(status) { const value = String(status || "").trim().toLowerCase(); if (!value) return "-"; if (value === "active") return "活跃"; if (value === "resolved") return "已恢复"; if (value === "suppressed") return "已抑制"; return String(status); } export function formatBGPCollectorStatus(status) { const value = String(status || "").trim().toLowerCase(); if (!value) return "在线"; if (value === "online") return "在线"; if (value === "offline") return "离线"; return String(status); } export function formatBGPConfidence(value) { if (value === null || value === undefined || value === "") return "-"; const number = Number(value); if (!Number.isFinite(number)) return String(value); if (number >= 0 && number <= 1) { return `${Math.round(number * 100)}%`; } return `${Math.round(number)}%`; } export function formatBGPLocation(city, country) { const cityText = city || ""; const countryText = country || ""; if (cityText && countryText) return `${cityText}, ${countryText}`; return cityText || countryText || "-"; } export function formatBGPRouteChange(originAsn, newOriginAsn) { const from = originAsn ?? "-"; const to = newOriginAsn ?? "-"; if ((from === "-" || from === "" || from === null) && (to === "-" || to === "" || to === null)) { return "-"; } if (to === "-" || to === "" || to === null) { return `AS${from}`; } return `AS${from} -> AS${to}`; } export function formatBGPObservedTime(value) { const absolute = formatLocalDateTime(value); const relative = formatRelativeTime(value); if (!relative || absolute === "-") return absolute; return `${relative} (${absolute})`; } export function formatBGPASPath(asPath) { if (!Array.isArray(asPath) || asPath.length === 0) return "-"; return asPath.map((asn) => `AS${asn}`).join(" -> "); } export function formatBGPObservedBy(collectors) { if (!Array.isArray(collectors) || collectors.length === 0) return "-"; const preview = collectors.slice(0, 3).join(", "); if (collectors.length <= 3) { return `${collectors.length}个观测站 (${preview})`; } return `${collectors.length}个观测站 (${preview} 等)`; } export function formatBGPImpactedScope(regions) { if (!Array.isArray(regions) || regions.length === 0) return "-"; const labels = regions .map((region) => { const city = region?.city || ""; const country = region?.country || ""; return city && country ? `${city}, ${country}` : city || country || ""; }) .filter(Boolean); if (labels.length === 0) return "-"; if (labels.length <= 3) return labels.join(" / "); return `${labels.slice(0, 3).join(" / ")} 等${labels.length}地`; } export function formatBGPRelatedCables(items) { if (!Array.isArray(items) || items.length === 0) return "-"; const labels = items .slice(0, 3) .map((item) => { const landing = item?.landing_point || ""; const cables = Array.isArray(item?.cable_names) ? item.cable_names : []; const cableText = cables.length > 0 ? cables.slice(0, 2).join(", ") : "附近登陆点"; const distance = item?.distance_km !== undefined ? ` ${item.distance_km}km` : ""; return `${landing || cableText} (${cableText}${distance})`; }) .filter(Boolean); if (labels.length === 0) return "-"; if (items.length <= 3) return labels.join(" / "); return `${labels.join(" / ")} 等${items.length}处`; } export function formatBGPScope(scope) { const countries = Array.isArray(scope?.countries) ? scope.countries : []; const cities = Array.isArray(scope?.cities) ? scope.cities : []; const cityText = cities.slice(0, 3).join(" / "); const countryText = countries.slice(0, 3).join(" / "); if (cityText && countryText) { return `${cityText} | ${countryText}`; } return cityText || countryText || "-"; } export function formatBGPTopEventTypes(items) { if (!Array.isArray(items) || items.length === 0) return "-"; return items .slice(0, 3) .map((item) => `${item?.event_type || "-"} x${item?.count || 0}`) .join(" / "); } export function formatBGPCollectorCoverageHalo(markerData) { const prefixes = Number( markerData?.recent_24h_prefix_count || markerData?.recent_7d_prefix_count || markerData?.prefix_count || 0, ); const observations = Number( markerData?.recent_24h_observation_count || markerData?.recent_7d_observation_count || markerData?.observation_count || 0, ); if (prefixes <= 0 && observations <= 0) return "静态观测站"; return `近24h ${observations}条事件 / ${prefixes}个前缀`; } function buildCollectorFeatureData(feature) { const coordinates = feature?.geometry?.coordinates || []; const [longitude, latitude] = coordinates; if ( typeof latitude !== "number" || typeof longitude !== "number" || Number.isNaN(latitude) || Number.isNaN(longitude) ) { return null; } const properties = feature?.properties || {}; return { latitude, longitude, collector: properties.collector || "-", city: properties.city || "-", country: properties.country || "-", status: properties.status || "online", observation_count: properties.observation_count || 0, recent_24h_observation_count: properties.recent_24h_observation_count || 0, recent_7d_observation_count: properties.recent_7d_observation_count || 0, prefix_count: properties.prefix_count || 0, recent_24h_prefix_count: properties.recent_24h_prefix_count || 0, recent_7d_prefix_count: properties.recent_7d_prefix_count || 0, origin_asn_count: properties.origin_asn_count || 0, latest_observed_at: properties.latest_observed_at || null, latest_event_type: properties.latest_event_type || null, top_event_types: Array.isArray(properties.top_event_types) ? properties.top_event_types : [], baseline_scope: properties.baseline_scope || { countries: [], cities: [] }, }; } function spreadCollectorPositions(markers) { const groups = new Map(); markers.forEach((marker) => { const key = `${marker.latitude.toFixed(4)}|${marker.longitude.toFixed(4)}`; if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(marker); }); groups.forEach((group) => { if (group.length <= 1) return; const radius = 1.4; group.forEach((marker, index) => { const angle = (Math.PI * 2 * index) / group.length; marker.displayLatitude = marker.latitude + Math.sin(angle) * radius * 0.28; marker.displayLongitude = marker.longitude + Math.cos(angle) * radius * 0.28; marker.isSpread = true; marker.groupSize = group.length; }); }); markers.forEach((marker) => { if (marker.displayLatitude === undefined) { marker.displayLatitude = marker.latitude; marker.displayLongitude = marker.longitude; marker.isSpread = false; marker.groupSize = 1; } }); return markers; } function buildAnomalyFeatureData(feature) { const point = extractFeaturePoint(feature); if (!point) return null; const { latitude, longitude } = point; const properties = feature?.properties || {}; const meta = extractFeatureMeta(properties, properties.created_at || null); return { latitude, longitude, rawSeverity: meta.rawSeverity, severity: meta.severity, collector: properties.collector || "-", city: properties.city || "-", country: properties.country || "-", source: properties.source || "-", anomaly_type: properties.anomaly_type || "-", status: properties.status || "-", prefix: properties.prefix || "-", origin_asn: properties.origin_asn ?? "-", new_origin_asn: properties.new_origin_asn ?? "-", as_path: Array.isArray(properties.as_path) ? properties.as_path : [], collectors: Array.isArray(properties.collectors) ? properties.collectors : [], collector_count: properties.collector_count ?? 1, impacted_regions: Array.isArray(properties.impacted_regions) ? properties.impacted_regions : [], confidence: properties.confidence ?? "-", summary: properties.summary || "-", created_at: meta.createdAt, created_at_raw: meta.createdAtRaw, id: properties.id || `${properties.collector || "unknown"}-${latitude}-${longitude}`, }; } function buildIncidentFeatureData(feature) { const point = extractFeaturePoint(feature); if (!point) return null; const { latitude, longitude } = point; const properties = feature?.properties || {}; const startedAt = properties.started_at || properties.created_at || null; const meta = extractFeatureMeta(properties, startedAt); const affectedPrefixes = Array.isArray(properties.affected_prefixes) ? properties.affected_prefixes : []; const affectedAsns = Array.isArray(properties.affected_asns) ? properties.affected_asns : []; const affectedCollectors = Array.isArray(properties.affected_collectors) ? properties.affected_collectors : []; const affectedRegions = Array.isArray(properties.affected_regions) ? properties.affected_regions : []; const primaryRegion = affectedRegions[0] || {}; return { latitude, longitude, rawSeverity: meta.rawSeverity, severity: meta.severity, collector: affectedCollectors[0] || primaryRegion.collector || "-", city: primaryRegion.city || "-", country: primaryRegion.country || "-", source: "bgp_incident", anomaly_type: properties.incident_type || properties.title || "-", incident_type: properties.incident_type || "-", incident_key: properties.incident_key || "-", status: properties.status || "-", prefix: affectedPrefixes[0] || "-", prefixes: affectedPrefixes, origin_asn: affectedAsns[0] ?? "-", new_origin_asn: affectedAsns[1] ?? "-", affected_asns: affectedAsns, as_path: [], collectors: affectedCollectors, collector_count: affectedCollectors.length || 1, impacted_regions: affectedRegions, related_cables: Array.isArray(properties.related_cables) ? properties.related_cables : [], related_ixps: Array.isArray(properties.related_ixps) ? properties.related_ixps : [], confidence: properties.confidence ?? "-", summary: properties.summary || properties.title || "-", created_at: meta.createdAt, created_at_raw: meta.createdAtRaw, route_change: affectedAsns.length > 1 ? affectedAsns.slice(0, 2).map((asn) => `AS${asn}`).join(" -> ") : affectedPrefixes.length > 1 ? `${affectedPrefixes.length}个前缀簇` : properties.incident_type || "-", observed_by: formatBGPObservedBy(affectedCollectors), impacted_scope: formatBGPImpactedScope(affectedRegions), location: formatBGPLocation(primaryRegion.city, primaryRegion.country), id: properties.id || properties.incident_key || `${properties.incident_type || "incident"}-${latitude}-${longitude}`, }; } function extractFeaturePoint(feature) { const coordinates = feature?.geometry?.coordinates || []; const [longitude, latitude] = coordinates; if ( typeof latitude !== "number" || typeof longitude !== "number" || Number.isNaN(latitude) || Number.isNaN(longitude) ) { return null; } return { latitude, longitude }; } function extractFeatureMeta(properties, createdAtRaw) { const severity = normalizeSeverity(properties.severity); return { rawSeverity: properties.severity || severity, severity, createdAt: formatLocalDateTime(createdAtRaw), createdAtRaw: createdAtRaw, }; } function clearMarkerArray(markers) { while (markers.length > 0) { const marker = markers.pop(); while (marker.children.length > 0) { const child = marker.children.pop(); child.geometry?.dispose?.(); child.material?.dispose(); } disposeCollectorEffectSprites(marker); disposeEventRingSprite(marker.userData?.ringA); disposeEventRingSprite(marker.userData?.ringB); marker.material?.dispose(); marker.parent?.remove?.(marker); } } function disposeAnomalyRingSprites() { anomalyMarkers.forEach((marker) => { disposeEventRingSprite(marker.userData?.ringA); disposeEventRingSprite(marker.userData?.ringB); delete marker.userData.ringA; delete marker.userData.ringB; }); } function clearGroup(group) { while (group.children.length > 0) { const child = group.children[group.children.length - 1]; group.remove(child); if (child.geometry) child.geometry.dispose(); if (child.material) child.material.dispose(); } } function createSpriteMaterial({ color, opacity }) { return new THREE.SpriteMaterial({ map: getMarkerTexture(), color, transparent: true, opacity, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, }); } function createOverlaySprite({ color, opacity, scale }) { const sprite = new THREE.Sprite(createSpriteMaterial({ color, opacity })); sprite.scale.setScalar(scale); return sprite; } function projectLatLon(lat, lon, bearingDeg, distanceDeg) { const latRad = (lat * Math.PI) / 180; const lonRad = (lon * Math.PI) / 180; const bearing = (bearingDeg * Math.PI) / 180; const angularDistance = (distanceDeg * Math.PI) / 180; const targetLat = Math.asin( Math.sin(latRad) * Math.cos(angularDistance) + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing), ); const targetLon = lonRad + Math.atan2( Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad), Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(targetLat), ); return { latitude: (targetLat * 180) / Math.PI, longitude: ((((targetLon * 180) / Math.PI) + 540) % 360) - 180, }; } function createCoverageBoundaryLine(points, color, opacity = 0.3) { const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthWrite: false, blending: THREE.AdditiveBlending, }); return new THREE.Line(geometry, material); } function createCoverageSector(points, color, opacity = 0.12) { const center = points[0]; const positions = []; for (let index = 1; index < points.length - 1; index += 1) { const current = points[index]; const next = points[index + 1]; positions.push( center.x, center.y, center.z, current.x, current.y, current.z, next.x, next.y, next.z, ); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending, }); return new THREE.Mesh(geometry, material); } function createCoverageSectorMesh( anchorLatitude, anchorLongitude, startBearingDeg, endBearingDeg, reachDeg, altitude, color, opacity = 0.12, radialSegments = 8, angularSegments = 28, ) { const positions = []; for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) { const innerDistance = (reachDeg * radialIndex) / radialSegments; const outerDistance = (reachDeg * (radialIndex + 1)) / radialSegments; for (let angularIndex = 0; angularIndex < angularSegments; angularIndex += 1) { const startProgress = angularIndex / angularSegments; const endProgress = (angularIndex + 1) / angularSegments; const startBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * startProgress; const endBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * endProgress; const innerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, innerDistance); const innerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, innerDistance); const outerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, outerDistance); const outerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, outerDistance); const innerStartVector = latLonToVector3(innerStart.latitude, innerStart.longitude, altitude); const innerEndVector = latLonToVector3(innerEnd.latitude, innerEnd.longitude, altitude); const outerStartVector = latLonToVector3(outerStart.latitude, outerStart.longitude, altitude); const outerEndVector = latLonToVector3(outerEnd.latitude, outerEnd.longitude, altitude); positions.push( innerStartVector.x, innerStartVector.y, innerStartVector.z, outerStartVector.x, outerStartVector.y, outerStartVector.z, outerEndVector.x, outerEndVector.y, outerEndVector.z, ); positions.push( innerStartVector.x, innerStartVector.y, innerStartVector.z, outerEndVector.x, outerEndVector.y, outerEndVector.z, innerEndVector.x, innerEndVector.y, innerEndVector.z, ); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, blending: THREE.AdditiveBlending, }); return new THREE.Mesh(geometry, material); } function createRadialBoundaryPoints( anchorLatitude, anchorLongitude, bearingDeg, reachDeg, altitude, segments = 18, ) { const points = []; for (let step = 0; step <= segments; step += 1) { const progress = step / segments; const projected = projectLatLon( anchorLatitude, anchorLongitude, bearingDeg, reachDeg * progress, ); points.push( latLonToVector3( projected.latitude, projected.longitude, altitude, ), ); } return points; } function attachCollectorEffectSprites(marker) { const activity = marker.userData.activity; const heatHalo = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: activity.haloScale * 0.58, }); heatHalo.renderOrder = 1; const pulseHalo = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: activity.pulseHaloScale * 0.48, }); pulseHalo.renderOrder = 0; const statusCore = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: Math.max( BGP_CONFIG.marker.collectorBaseScale * BGP_CONFIG.marker.collectorStatusCoreBaseScale, BGP_CONFIG.marker.collectorStatusCoreMinScale, ), }); statusCore.renderOrder = 4; const coverageHalo = createOverlaySprite({ color: 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.userData.heatHalo = heatHalo; marker.userData.pulseHalo = pulseHalo; marker.userData.statusCore = statusCore; marker.userData.coverageHalo = coverageHalo; [heatHalo, pulseHalo, statusCore, coverageHalo].forEach((sprite) => { sprite.position.copy(marker.position); sprite.visible = showBGP; bgpGroup.add(sprite); }); } function disposeCollectorEffectSprites(marker) { [ marker?.userData?.heatHalo, marker?.userData?.pulseHalo, marker?.userData?.statusCore, marker?.userData?.coverageHalo, ].forEach((sprite) => { if (!sprite) return; sprite.parent?.remove?.(sprite); sprite.material?.dispose?.(); sprite.geometry?.dispose?.(); }); } async function setCollectorMarkers(markerData, earth) { collectorMarkers.forEach(disposeCollectorEffectSprites); collectorMarkers.length = 0; await bgpCollectorIconLayer.preloadAssets(markerData); bgpCollectorIconLayer.setData(markerData); bgpCollectorIconLayer.attach(earth); bgpCollectorIconLayer.setVisible(showBGP); bgpCollectorIconLayer.getMarkers().forEach((marker) => { attachCollectorEffectSprites(marker); collectorMarkers.push(marker); }); } function createEventRingSprite(marker) { const ring = new THREE.Sprite( new THREE.SpriteMaterial({ map: getEventRingTexture(), color: marker.userData.baseColor || getSeverityColor(marker.userData.severity), transparent: true, opacity: 0, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, }), ); ring.position.copy(marker.position); ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05; ring.visible = showBGP; return ring; } function disposeEventRingSprite(ring) { if (!ring) return; ring.parent?.remove?.(ring); ring.material?.dispose?.(); ring.geometry?.dispose?.(); } function attachAnomalyRingSprites(marker) { const ringA = createEventRingSprite(marker); const ringB = createEventRingSprite(marker); ringB.visible = false; marker.userData.ringA = ringA; marker.userData.ringB = ringB; bgpGroup.add(ringA); bgpGroup.add(ringB); } function setAnomalyMarkers(markerData, earth) { disposeAnomalyRingSprites(); anomalyMarkers.length = 0; bgpEventIconLayer.setData(markerData); bgpEventIconLayer.attach(earth); bgpEventIconLayer.setVisible(showBGP); bgpEventIconLayer.getMarkers().forEach((marker) => { attachAnomalyRingSprites(marker); anomalyMarkers.push(marker); }); } function dedupeAnomalies(features) { const latestByLocation = new Map(); features.forEach((feature) => { const data = buildAnomalyFeatureData(feature); if (!data) return; activeEventCountByCollector.set( data.collector, (activeEventCountByCollector.get(data.collector) || 0) + 1, ); const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`; const previous = latestByLocation.get(dedupeKey); const currentTime = data.created_at_raw ? new Date(data.created_at_raw).getTime() : 0; const previousTime = previous?.created_at_raw ? new Date(previous.created_at_raw).getTime() : 0; const currentSeverity = getSeverityScale(data.severity); const previousSeverity = previous ? getSeverityScale(previous.severity) : 0; if ( !previous || currentSeverity > previousSeverity || (currentSeverity === previousSeverity && currentTime >= previousTime) ) { latestByLocation.set(dedupeKey, data); } }); return Array.from(latestByLocation.values()) .sort((a, b) => { const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity); if (severityDiff !== 0) return severityDiff; const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0; const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0; return timeB - timeA; }) .slice(0, BGP_CONFIG.maxRenderedMarkers); } function dedupeIncidents(features) { const latestByLocation = new Map(); features.forEach((feature) => { const data = buildIncidentFeatureData(feature); if (!data) return; data.collectors.forEach((collector) => { activeEventCountByCollector.set( collector, (activeEventCountByCollector.get(collector) || 0) + 1, ); }); const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`; const previous = latestByLocation.get(dedupeKey); const currentTime = data.created_at_raw ? new Date(data.created_at_raw).getTime() : 0; const previousTime = previous?.created_at_raw ? new Date(previous.created_at_raw).getTime() : 0; const currentSeverity = getSeverityScale(data.severity); const previousSeverity = previous ? getSeverityScale(previous.severity) : 0; if ( !previous || currentSeverity > previousSeverity || (currentSeverity === previousSeverity && currentTime >= previousTime) ) { latestByLocation.set(dedupeKey, data); } }); return Array.from(latestByLocation.values()) .sort((a, b) => { const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity); if (severityDiff !== 0) return severityDiff; const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0; const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0; return timeB - timeA; }) .slice(0, BGP_CONFIG.maxRenderedMarkers); } function applyCollectorCounts() { collectorMarkers.forEach((marker) => { marker.userData.anomaly_count = activeEventCountByCollector.get(marker.userData.collector) || 0; }); } async function fetchGeoJSONWithTimeout(url, timeoutMs, warningMessage, fallbackPayload) { try { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } catch (error) { console.warn(warningMessage, error); return fallbackPayload; } } function selectBGPEventFeatures(incidentPayload, anomalyPayload) { const incidentFeatures = Array.isArray(incidentPayload?.features) ? incidentPayload.features : []; if (incidentFeatures.length > 0) { return { features: incidentFeatures, totalIncidentCount: incidentPayload?.count ?? incidentFeatures.length, totalAnomalyCount: anomalyPayload?.count ?? 0, mode: "incident", }; } const anomalyFeatures = Array.isArray(anomalyPayload?.features) ? anomalyPayload.features : []; return { features: anomalyFeatures, totalIncidentCount: 0, totalAnomalyCount: anomalyPayload?.count ?? anomalyFeatures.length, mode: "anomaly", }; } export async function loadBGPAnomalies(scene, earth) { const collectorsResponse = await fetch(PATHS.bgpCollectorsApi); if (!collectorsResponse.ok) { throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`); } const emptyPayload = { type: "FeatureCollection", features: [], count: 0 }; const anomaliesPayload = await fetchGeoJSONWithTimeout( `${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`, 5000, "BGP anomalies unavailable, falling back to collectors only:", emptyPayload, ); const incidentsPayload = await fetchGeoJSONWithTimeout( `${PATHS.bgpIncidentsApi}?limit=${BGP_CONFIG.defaultFetchLimit}`, 5000, "BGP incidents unavailable, falling back to anomalies:", emptyPayload, ); const collectorsPayload = await collectorsResponse.json(); const collectorFeatures = Array.isArray(collectorsPayload?.features) ? collectorsPayload.features : []; const selectedEventData = selectBGPEventFeatures(incidentsPayload, anomaliesPayload); clearBGPData(earth); totalAnomalyCount = selectedEventData.totalAnomalyCount; totalIncidentCount = selectedEventData.totalIncidentCount; activeEventCountByCollector.clear(); const collectorMarkersData = spreadCollectorPositions( collectorFeatures .map(buildCollectorFeatureData) .filter(Boolean), ); await setCollectorMarkers(collectorMarkersData, earth); const eventMarkers = selectedEventData.mode === "incident" ? dedupeIncidents(selectedEventData.features) : dedupeAnomalies(selectedEventData.features); setAnomalyMarkers(eventMarkers, earth); applyCollectorCounts(); if (!bgpGroup.parent) { earth.add(bgpGroup); } if (!bgpOverlayGroup.parent) { earth.add(bgpOverlayGroup); } bgpGroup.visible = showBGP; bgpOverlayGroup.visible = showBGP; if (scene && !scene.children.includes(earth)) { scene.add(earth); } return { totalCount: totalIncidentCount, anomalyCount: totalAnomalyCount, renderedCount: anomalyMarkers.length, collectorCount: collectorMarkers.length, }; } export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) { const now = performance.now(); updateCollectorOverlayScan(lockedObjectType, lockedObject); const hasLockedLayer = Boolean( lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType), ); collectorMarkers.forEach((marker) => { const isLocked = (lockedObjectType === "bgp_collector" || lockedObjectType === "bgp") && lockedObject?.userData?.collector === marker.userData.collector; const isHovered = marker.userData.state === "hover" || marker.userData.state === "linked"; const pulse = 0.5 + 0.5 * Math.sin( now * BGP_CONFIG.pulse.collectorSpeed + marker.userData.pulseOffset, ); let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera); let haloOpacity = 0.0; let pulseOpacity = 0.0; let coverageOpacity = 0.0; let markerColor = marker.userData.idleColor || blendHexColors( BGP_CONFIG.collectorIcon.idleBaseColor, marker.userData.baseColor || BGP_CONFIG.collectorColor, BGP_CONFIG.collectorIcon.idleBlend, ); if (isLocked) { scale *= 1.1 + 0.14 * pulse; haloOpacity = 0.05; pulseOpacity = 0.024; coverageOpacity = 0.036; markerColor = 0xcff2ff; } else if (isHovered) { scale *= 1.08; haloOpacity = 0.03; pulseOpacity = 0.014; coverageOpacity = 0.02; markerColor = blendHexColors( BGP_CONFIG.collectorIcon.hoverNeutralColor, marker.userData.baseColor || BGP_CONFIG.collectorColor, BGP_CONFIG.collectorIcon.hoverBlend, ); } else if (hasLockedLayer) { scale *= 0.98; haloOpacity = 0.0; pulseOpacity = 0.0; coverageOpacity = 0.0; markerColor = marker.userData.idleColor || markerColor; } else { scale *= 1 + 0.05 * pulse; } if (marker.userData.heatHalo) { marker.userData.heatHalo.position.copy(marker.position); 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.position.copy(marker.position); 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.position.copy(marker.position); marker.userData.statusCore.material.opacity = isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18; marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.statusCore.scale.setScalar( Math.max( BGP_CONFIG.marker.collectorBaseScale * BGP_CONFIG.marker.collectorStatusCoreBaseScale, BGP_CONFIG.marker.collectorStatusCoreMinScale, ) * (isLocked ? 1.08 : isHovered ? 1.04 : 0.92), ); } if (marker.userData.coverageHalo) { marker.userData.coverageHalo.position.copy(marker.position); marker.userData.coverageHalo.material.opacity = coverageOpacity; 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, ); } }); const focusedCollector = lockedObjectType === "bgp" ? collectorMarkers.find( (marker) => marker.userData.collector === lockedObject?.userData?.collector, ) : lockedObjectType === "bgp_collector" ? lockedObject : null; bgpCollectorIconLayer.updateVisualState( focusedCollector ? "bgp_collector" : lockedObjectType, focusedCollector || lockedObject, camera, ); anomalyMarkers.forEach((marker) => { const isLocked = lockedObjectType === "bgp" && lockedObject === marker; const isLinkedCollectorLocked = lockedObjectType === "bgp_collector" && lockedObject?.userData?.collector === marker.userData.collector; const isCruise = !isLocked && !isLinkedCollectorLocked && cruiseMarker != null && marker === cruiseMarker; const hasFocusedMarker = hasLockedLayer || cruiseMarker != null; const isOtherLocked = hasFocusedMarker && !isLocked && !isLinkedCollectorLocked && !isCruise; const isActive = isLocked || isLinkedCollectorLocked || isCruise; const isHovered = marker.userData.state === "hover"; const pulse = 0.5 + 0.5 * Math.sin(now * BGP_CONFIG.pulse.eventSpeed + marker.userData.pulseOffset); const iconAnchorScale = marker.userData.baseScale * getEventDistanceScale(marker, camera); let scale = iconAnchorScale; let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity); const isIncidentMarker = marker.userData.source === "bgp_incident"; let ringBaseOpacity = isIncidentMarker ? BGP_CONFIG.ring.opacity : BGP_CONFIG.ring.opacity * 0.45; if (isLocked || isLinkedCollectorLocked) { scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; markerColor = 0xfff1a8; ringBaseOpacity *= 1.2; } else if (isCruise) { scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; ringBaseOpacity *= 1.2; } else if (isHovered) { scale *= BGP_CONFIG.marker.hoverScale; ringBaseOpacity *= 1.05; } else if (isOtherLocked) { scale *= BGP_CONFIG.marker.dimmedScale; markerColor = 0x7d8ca3; ringBaseOpacity = 0.02; } else { scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse; } const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1; const applyRingState = (ring, phase, maxScale) => { if (!ring) return; const progress = Math.max(0, Math.min(1, phase)); const minScale = 1.28; const desiredWorldScale = scale * (minScale + progress * (maxScale - minScale)); const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14)); const fadeOut = 1 - progress; const visibility = fadeIn * fadeOut; ring.position.copy(marker.position); ring.scale.setScalar(desiredWorldScale); ring.material.color.setHex(markerColor); ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0; ring.visible = showBGP; ring.renderOrder = isActive ? BGP_EVENT_RENDER_ORDER + 0.15 : BGP_EVENT_RENDER_ORDER - 0.05; }; applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA); if (marker.userData.ringB) { marker.userData.ringB.material.opacity = 0; marker.userData.ringB.visible = false; } }); bgpEventIconLayer.updateVisualState(lockedObjectType, lockedObject, camera); } export function setBGPMarkerState(marker, state = "normal") { if (!marker?.userData) return; if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") { return; } if (marker.userData.type === "bgp") { bgpEventIconLayer.setMarkerState(marker, state); return; } bgpCollectorIconLayer.setMarkerState(marker, state); } export function clearBGPSelection() { collectorMarkers.forEach((marker) => { bgpCollectorIconLayer.setMarkerState(marker, "normal"); }); anomalyMarkers.forEach((marker) => { bgpEventIconLayer.setMarkerState(marker, "normal"); }); clearBGPEventOverlay(); } export function clearBGPData(earth) { clearMarkerArray(collectorMarkers); clearMarkerArray(anomalyMarkers); bgpCollectorIconLayer.clearData(earth); bgpEventIconLayer.clearData(earth); clearBGPEventOverlay(); activeEventCountByCollector.clear(); totalAnomalyCount = 0; totalIncidentCount = 0; if (earth && bgpGroup.parent === earth) { earth.remove(bgpGroup); } if (earth && bgpOverlayGroup.parent === earth) { earth.remove(bgpOverlayGroup); } } export function toggleBGP(show) { showBGP = Boolean(show); bgpGroup.visible = showBGP; bgpOverlayGroup.visible = showBGP; bgpCollectorIconLayer.setVisible(showBGP); bgpEventIconLayer.setVisible(showBGP); collectorMarkers.forEach((marker) => { [ marker.userData.heatHalo, marker.userData.pulseHalo, marker.userData.statusCore, marker.userData.coverageHalo, ].forEach((sprite) => { if (sprite) sprite.visible = showBGP; }); }); anomalyMarkers.forEach((marker) => { marker.userData.ringA && (marker.userData.ringA.visible = showBGP); marker.userData.ringB && (marker.userData.ringB.visible = false); }); } export function getShowBGP() { return showBGP; } export function getBGPMarkers() { return [...anomalyMarkers, ...collectorMarkers]; } export function getBGPAnomalyMarkers() { return anomalyMarkers; } export function getBGPAnomalyPointerIntersections(options = {}) { return bgpEventIconLayer.getPointerIntersections(options); } export function getBGPCollectorPointerIntersections(options = {}) { return bgpCollectorIconLayer.getPointerIntersections(options); } export function getBGPCollectorMarkers() { return collectorMarkers; } export function getBGPCount() { return totalIncidentCount; } export function getBGPCollectorCount() { return collectorMarkers.length; } export function getBGPStatusSummary() { if (totalIncidentCount > 0 && totalAnomalyCount > 0) { return `${totalIncidentCount} 起活跃事件 / ${totalAnomalyCount} 条异常`; } if (totalIncidentCount > 0) { return `${totalIncidentCount} 起活跃事件`; } if (totalAnomalyCount > 0) { return `${totalAnomalyCount} 条活跃异常`; } if (collectorMarkers.length > 0) { return "当前无活跃事件"; } return "暂无观测数据"; } export function showBGPEventOverlay(marker, earth) { if (!marker?.userData || marker.userData.type !== "bgp" || !earth) return; clearBGPEventOverlay(); const impactedRegions = Array.isArray(marker.userData.impacted_regions) && marker.userData.impacted_regions.length > 0 ? marker.userData.impacted_regions : [ { collector: marker.userData.collector, city: marker.userData.city, country: marker.userData.country, latitude: marker.userData.latitude, longitude: marker.userData.longitude, }, ]; const validRegions = impactedRegions.filter( (region) => typeof region?.latitude === "number" && typeof region?.longitude === "number", ); if (validRegions.length === 0) return; const overlayItems = []; validRegions.forEach((region) => { const halo = createOverlaySprite({ color: BGP_CONFIG.regionColor, opacity: 0.24, scale: BGP_CONFIG.regionScale, }); halo.position.copy( latLonToVector3( region.latitude, region.longitude, CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1, ), ); halo.renderOrder = 2; bgpEventOverlayGroup.add(halo); overlayItems.push(halo); }); activeEventOverlay = overlayItems; activeCollectorOverlayContext = null; bgpOverlayGroup.visible = showBGP; } export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { if (!marker?.userData || marker.userData.type !== "bgp_collector" || !earth) return; clearBGPEventOverlay(); const prefixCount = Number( marker.userData.recent_24h_prefix_count || marker.userData.recent_7d_prefix_count || marker.userData.prefix_count || 0, ); const observationCount = Number( marker.userData.recent_24h_observation_count || marker.userData.recent_7d_observation_count || marker.userData.observation_count || 0, ); const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8); const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost; const pulseHaloScale = haloScale * 1.32; const 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_COLLECTOR_ALTITUDE_OFFSET - 0.15, ), ); halo.renderOrder = 2; bgpCollectorRadarGroup.add(halo); const pulseHalo = createOverlaySprite({ color: BGP_CONFIG.collectorColor, opacity: 0.065, scale: pulseHaloScale * 0.82, }); pulseHalo.position.copy(halo.position); pulseHalo.renderOrder = 1; bgpCollectorRadarGroup.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; bgpCollectorRadarGroup.add(innerRing); const overlayItems = [halo, pulseHalo, innerRing]; const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude; const anchorLongitude = marker.userData.displayLongitude ?? marker.userData.longitude; const activityMagnitude = Math.log2(prefixCount + observationCount + 1); const corridorReach = Math.min(30, 12 + activityMagnitude * 3.2); const orientationSeed = Array.from(marker.userData.collector || "") .reduce((sum, char) => sum + char.charCodeAt(0), 0); const baseRotation = ((orientationSeed % 140) - 70) * (Math.PI / 180); const sectorRotation = baseRotation + (options.rotationOffsetRad || 0); const sectorHalfWidth = Math.PI * 0.22; const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI); const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI); const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor; const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44; const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4; const leftBoundaryPoints = createRadialBoundaryPoints( anchorLatitude, anchorLongitude, startBearing, corridorReach, boundaryAltitude, ); const rightBoundaryPoints = createRadialBoundaryPoints( anchorLatitude, anchorLongitude, endBearing, corridorReach, boundaryAltitude, ); const outerArcPoints = []; const outerArcSteps = 28; for (let step = 0; step <= outerArcSteps; step += 1) { const progress = step / outerArcSteps; const bearingDeg = startBearing + (endBearing - startBearing) * progress; const projected = projectLatLon(anchorLatitude, anchorLongitude, bearingDeg, corridorReach); outerArcPoints.push( latLonToVector3( projected.latitude, projected.longitude, boundaryAltitude, ), ); } const sectorFill = createCoverageSectorMesh( anchorLatitude, anchorLongitude, startBearing, endBearing, corridorReach, fillAltitude, coverageColor, 0.12, ); sectorFill.renderOrder = 2; bgpCollectorRadarGroup.add(sectorFill); overlayItems.push(sectorFill); const outerArc = createCoverageBoundaryLine( outerArcPoints, coverageColor, 0.9, ); outerArc.renderOrder = 3; bgpCollectorRadarGroup.add(outerArc); overlayItems.push(outerArc); const leftBoundary = createCoverageBoundaryLine( leftBoundaryPoints, coverageColor, 0.76, ); leftBoundary.renderOrder = 3; bgpCollectorRadarGroup.add(leftBoundary); overlayItems.push(leftBoundary); const rightBoundary = createCoverageBoundaryLine( rightBoundaryPoints, coverageColor, 0.76, ); rightBoundary.renderOrder = 3; bgpCollectorRadarGroup.add(rightBoundary); overlayItems.push(rightBoundary); activeEventOverlay = overlayItems; activeCollectorOverlayContext = { marker, earth, baseRotation, lastRebuildAt: performance.now(), }; bgpOverlayGroup.visible = showBGP; } export function clearBGPEventOverlay() { activeEventOverlay = null; activeCollectorOverlayContext = null; clearGroup(bgpEventOverlayGroup); clearGroup(bgpCollectorRadarGroup); } function updateCollectorOverlayScan(lockedObjectType, lockedObject) { if ( lockedObjectType !== "bgp_collector" || !lockedObject || !activeCollectorOverlayContext || activeCollectorOverlayContext.marker !== lockedObject ) { return; } const now = performance.now(); if (now - activeCollectorOverlayContext.lastRebuildAt < COLLECTOR_SCAN_REBUILD_MS) { return; } const rotationOffsetRad = now * COLLECTOR_SCAN_SPEED_RAD; showBGPCollectorCoverageOverlay( activeCollectorOverlayContext.marker, activeCollectorOverlayContext.earth, { rotationOffsetRad }, ); } export function getBGPLegendItems() { return [ { color: "#6db7ff", label: "静态观测站" }, { color: "#fbbf24", label: "中活跃观测站" }, { color: "#ff5f57", label: "高活跃观测站" }, { color: "#6db7ff", label: "观测范围示意" }, { color: "#8af5ff", label: "事件连线 / 枢纽" }, { color: "#2dd4bf", label: "影响区域" }, { color: "#ff4d4f", label: "严重事件" }, { color: "#ff9f43", label: "高危事件" }, { color: "#ffd166", label: "中危事件" }, { color: "#4dabf7", label: "低危事件" }, ]; }