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 eventRingTextureCache = null; let collectorTextureCache = null; const eventTextureCache = new Map(); let activeEventOverlay = null; let activeCollectorOverlayContext = null; const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto", }); const collectorWorldPosition = new THREE.Vector3(); const collectorSurfaceNormal = new THREE.Vector3(); const collectorNorthPole = new THREE.Vector3(0, 1, 0); const collectorFallbackForward = new THREE.Vector3(0, 0, 1); const collectorNorthTangent = new THREE.Vector3(); const collectorEastTangent = new THREE.Vector3(); const collectorOrientationMatrix = new THREE.Matrix4(); const colorScratchA = new THREE.Color(); const colorScratchB = new THREE.Color(); const COLLECTOR_SCAN_SPEED_RAD = 0.00018; const COLLECTOR_SCAN_REBUILD_MS = 80; const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"; function getMarkerTexture() { if (textureCache) return textureCache; const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { textureCache = new THREE.Texture(canvas); return textureCache; } const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 56); gradient.addColorStop(0, "rgba(255,255,255,1)"); gradient.addColorStop(0.24, "rgba(255,255,255,0.92)"); gradient.addColorStop(0.58, "rgba(255,255,255,0.35)"); gradient.addColorStop(1, "rgba(255,255,255,0)"); context.fillStyle = gradient; context.beginPath(); context.arc(64, 64, 56, 0, Math.PI * 2); context.fill(); textureCache = new THREE.CanvasTexture(canvas); return textureCache; } function getEventRingTexture() { if (eventRingTextureCache) return eventRingTextureCache; const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { eventRingTextureCache = new THREE.Texture(canvas); return eventRingTextureCache; } context.clearRect(0, 0, 128, 128); context.strokeStyle = "rgba(255,255,255,0.98)"; context.lineWidth = 6; context.beginPath(); context.arc(64, 64, 44, 0, Math.PI * 2); context.stroke(); eventRingTextureCache = new THREE.CanvasTexture(canvas); return eventRingTextureCache; } function getCollectorTexture() { if (collectorTextureCache) return collectorTextureCache; const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { collectorTextureCache = new THREE.Texture(canvas); return collectorTextureCache; } context.clearRect(0, 0, 128, 128); context.strokeStyle = BGP_CONFIG.collectorIcon.ringStroke; context.lineWidth = BGP_CONFIG.collectorIcon.ringLineWidth; context.beginPath(); context.arc(64, 64, BGP_CONFIG.collectorIcon.ringRadius, 0, Math.PI * 2); context.stroke(); context.save(); context.translate(16, 16); context.scale(4, 4); const path = new Path2D(MATERIAL_ACCESS_POINT_PATH); context.lineJoin = "round"; context.lineCap = "round"; context.lineWidth = BGP_CONFIG.collectorIcon.pathLineWidth; context.strokeStyle = BGP_CONFIG.collectorIcon.pathStroke; context.stroke(path); context.fillStyle = BGP_CONFIG.collectorIcon.pathFill; context.shadowBlur = 0; context.fill(path); context.fillStyle = BGP_CONFIG.collectorIcon.centerFill; context.beginPath(); context.arc(12, 12, BGP_CONFIG.collectorIcon.centerRadius, 0, Math.PI * 2); context.fill(); context.restore(); collectorTextureCache = new THREE.CanvasTexture(canvas); return collectorTextureCache; } function getEventSymbolKind(anomalyType) { const value = String(anomalyType || "").toLowerCase(); if (value.includes("origin")) return "triangle"; if (value.includes("withdraw")) return "exclamation"; if (value.includes("specific") || value.includes("burst")) return "burst"; if (value.includes("flap")) return "wave"; if (value.includes("leak")) return "leak"; return "dot"; } function drawTriangleSymbol(context) { context.beginPath(); context.moveTo(64, 18); context.lineTo(110, 106); context.lineTo(18, 106); context.closePath(); context.fill(); } function drawExclamationSymbol(context) { context.beginPath(); context.roundRect(52, 22, 24, 62, 12); context.fill(); context.beginPath(); context.arc(64, 102, 10, 0, Math.PI * 2); context.fill(); } function drawWaveSymbol(context) { context.lineWidth = 12; context.lineCap = "round"; context.beginPath(); context.moveTo(18, 76); context.bezierCurveTo(34, 46, 46, 46, 64, 76); context.bezierCurveTo(80, 106, 94, 106, 110, 76); context.stroke(); } function drawBurstSymbol(context) { context.lineWidth = 10; context.lineCap = "round"; for (let index = 0; index < 6; index += 1) { const angle = (Math.PI * 2 * index) / 6; const inner = 26; const outer = 48; context.beginPath(); context.moveTo(64 + Math.cos(angle) * inner, 64 + Math.sin(angle) * inner); context.lineTo(64 + Math.cos(angle) * outer, 64 + Math.sin(angle) * outer); context.stroke(); } context.beginPath(); context.arc(64, 64, 16, 0, Math.PI * 2); context.fill(); } function drawLeakSymbol(context) { context.lineWidth = 10; context.lineCap = "round"; context.beginPath(); context.moveTo(28, 96); context.lineTo(64, 28); context.lineTo(100, 96); context.stroke(); context.beginPath(); context.moveTo(40, 82); context.lineTo(64, 54); context.lineTo(88, 82); context.stroke(); } function drawDotSymbol(context) { context.beginPath(); context.arc(64, 64, 28, 0, Math.PI * 2); context.fill(); } function getEventTexture(anomalyType) { const kind = getEventSymbolKind(anomalyType); if (eventTextureCache.has(kind)) return eventTextureCache.get(kind); const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; const context = canvas.getContext("2d"); if (!context) { const fallback = new THREE.Texture(canvas); eventTextureCache.set(kind, fallback); return fallback; } context.clearRect(0, 0, 128, 128); context.fillStyle = "rgba(255,255,255,0.96)"; context.strokeStyle = "rgba(255,255,255,0.96)"; context.shadowBlur = 0; context.lineJoin = "round"; if (kind === "triangle") { drawTriangleSymbol(context); } else if (kind === "exclamation") { drawExclamationSymbol(context); } else if (kind === "wave") { drawWaveSymbol(context); } else if (kind === "burst") { drawBurstSymbol(context); } else if (kind === "leak") { drawLeakSymbol(context); } else { drawDotSymbol(context); } const texture = new THREE.CanvasTexture(canvas); eventTextureCache.set(kind, texture); return texture; } function normalizeSeverity(severity) { const value = String(severity || "").trim().toLowerCase(); if (value === "critical") return "critical"; if (value === "high" || value === "major") return "high"; if (value === "medium" || value === "moderate" || value === "warning") { return "medium"; } if (value === "low" || value === "info" || value === "informational") { return "low"; } return "medium"; } function getSeverityColor(severity) { return BGP_CONFIG.severityColors[normalizeSeverity(severity)]; } function getSeverityScale(severity) { return BGP_CONFIG.severityScales[normalizeSeverity(severity)]; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function blendHexColors(fromHex, toHex, ratio) { colorScratchA.setHex(fromHex); colorScratchB.setHex(toHex); colorScratchA.lerp(colorScratchB, clamp(ratio, 0, 1)); return colorScratchA.getHex(); } function getCollectorDistanceScale(marker, camera) { if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; marker.getWorldPosition(collectorWorldPosition); const distanceToCamera = camera.position.distanceTo(collectorWorldPosition); const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset; const referenceFovRad = (75 * Math.PI) / 180; const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180; const min = Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6); const max = Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9); const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2); const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2); return clamp(worldPerPixel / referenceWorldPerPixel, min, max); } function getEventDistanceScale(marker, camera) { if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; marker.getWorldPosition(collectorWorldPosition); const distanceToCamera = camera.position.distanceTo(collectorWorldPosition); const referenceDistance = CONFIG.defaultCameraZ - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset; const referenceFovRad = (75 * Math.PI) / 180; const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180; const min = Number(BGP_CONFIG.sizeStabilization?.eventMin ?? 0.7); const max = Number(BGP_CONFIG.sizeStabilization?.eventMax ?? 1.9); const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2); const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2); return clamp(worldPerPixel / referenceWorldPerPixel, min, max); } function orientCollectorMarkerToSurface(marker, position) { collectorSurfaceNormal.copy(position).normalize(); collectorNorthTangent .copy(collectorNorthPole) .projectOnPlane(collectorSurfaceNormal); if (collectorNorthTangent.lengthSq() < 1e-6) { collectorNorthTangent .copy(collectorFallbackForward) .projectOnPlane(collectorSurfaceNormal); } collectorNorthTangent.normalize(); collectorEastTangent .copy(collectorNorthTangent) .cross(collectorSurfaceNormal) .normalize(); collectorOrientationMatrix.makeBasis( collectorEastTangent, collectorNorthTangent, collectorSurfaceNormal, ); marker.quaternion.setFromRotationMatrix(collectorOrientationMatrix); } function getCollectorActivityProfile(markerData) { const recent24h = Number(markerData?.recent_24h_observation_count || 0); const recent7d = Number(markerData?.recent_7d_observation_count || 0); const prefixes = Number(markerData?.prefix_count || 0); const origins = Number(markerData?.origin_asn_count || 0); const activityScore = recent24h * 1.7 + recent7d * 0.3 + prefixes * 0.16 + origins * 0.12; let tier = "idle"; if (activityScore >= 50 || recent24h >= 24) tier = "hot"; else if (activityScore >= 20 || recent24h >= 10) tier = "high"; else if (activityScore >= 8 || recent24h >= 4) tier = "medium"; else if (activityScore > 0) tier = "low"; const scaleBoost = clamp(1 + Math.log2(activityScore + 1) * 0.12, 1, 1.55); const haloScale = BGP_CONFIG.halo.collectorScale + clamp(Math.log2(recent24h + prefixes + 1) * 2.2, 0, 12); const pulseHaloScale = BGP_CONFIG.halo.collectorPulseScale + clamp(Math.log2(recent24h + recent7d + 1) * 2.8, 0, 14); const coverageHaloScale = BGP_CONFIG.halo.collectorCoverageScale + clamp(Math.log2(prefixes + origins + 1) * 3.4, 0, 18); return { tier, color: BGP_CONFIG.collectorHeatColors[tier] || BGP_CONFIG.collectorColor, scaleBoost, haloScale, pulseHaloScale, coverageHaloScale, activityScore, }; } function formatLocalDateTime(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`; } function toDate(value) { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return date; } function formatRelativeTime(value) { const date = toDate(value); if (!date) return null; const diffMs = date.getTime() - Date.now(); const absMs = Math.abs(diffMs); if (absMs < 60 * 1000) { return relativeTimeFormatter.format(Math.round(diffMs / 1000), "second"); } if (absMs < 60 * 60 * 1000) { return relativeTimeFormatter.format(Math.round(diffMs / (60 * 1000)), "minute"); } if (absMs < 24 * 60 * 60 * 1000) { return relativeTimeFormatter.format(Math.round(diffMs / (60 * 60 * 1000)), "hour"); } return relativeTimeFormatter.format( Math.round(diffMs / (24 * 60 * 60 * 1000)), "day", ); } export function formatBGPSeverityLabel(severity) { const normalized = normalizeSeverity(severity); switch (normalized) { case "critical": return "严重"; case "high": return "高"; case "medium": return "中"; case "low": return "低"; default: return "中"; } } export function formatBGPAnomalyTypeLabel(type) { const value = String(type || "").trim().toLowerCase(); if (!value) return "-"; if (value.includes("hijack")) return "前缀劫持"; if (value.includes("leak")) return "路由泄露"; if (value.includes("withdraw")) return "大规模撤销"; if (value.includes("subprefix") || value.includes("more_specific")) { return "更具体前缀异常"; } if (value.includes("path")) return "路径突变"; if (value.includes("flap")) return "路由抖动"; return String(type); } export function formatBGPStatusLabel(status) { const value = String(status || "").trim().toLowerCase(); if (!value) return "-"; if (value === "active") return "活跃"; if (value === "resolved") return "已恢复"; if (value === "suppressed") return "已抑制"; return String(status); } export function formatBGPCollectorStatus(status) { const value = String(status || "").trim().toLowerCase(); if (!value) return "在线"; if (value === "online") return "在线"; if (value === "offline") return "离线"; return String(status); } export function formatBGPConfidence(value) { if (value === null || value === undefined || value === "") return "-"; const number = Number(value); if (!Number.isFinite(number)) return String(value); if (number >= 0 && number <= 1) { return `${Math.round(number * 100)}%`; } return `${Math.round(number)}%`; } export function formatBGPLocation(city, country) { const cityText = city || ""; const countryText = country || ""; if (cityText && countryText) return `${cityText}, ${countryText}`; return cityText || countryText || "-"; } export function formatBGPRouteChange(originAsn, newOriginAsn) { const from = originAsn ?? "-"; const to = newOriginAsn ?? "-"; if ((from === "-" || from === "" || from === null) && (to === "-" || to === "" || to === null)) { return "-"; } if (to === "-" || to === "" || to === null) { return `AS${from}`; } return `AS${from} -> AS${to}`; } export function formatBGPObservedTime(value) { const absolute = formatLocalDateTime(value); const relative = formatRelativeTime(value); if (!relative || absolute === "-") return absolute; return `${relative} (${absolute})`; } export function formatBGPASPath(asPath) { if (!Array.isArray(asPath) || asPath.length === 0) return "-"; return asPath.map((asn) => `AS${asn}`).join(" -> "); } export function formatBGPObservedBy(collectors) { if (!Array.isArray(collectors) || collectors.length === 0) return "-"; const preview = collectors.slice(0, 3).join(", "); if (collectors.length <= 3) { return `${collectors.length}个观测站 (${preview})`; } return `${collectors.length}个观测站 (${preview} 等)`; } export function formatBGPImpactedScope(regions) { if (!Array.isArray(regions) || regions.length === 0) return "-"; const labels = regions .map((region) => { const city = region?.city || ""; const country = region?.country || ""; return city && country ? `${city}, ${country}` : city || country || ""; }) .filter(Boolean); if (labels.length === 0) return "-"; if (labels.length <= 3) return labels.join(" / "); return `${labels.slice(0, 3).join(" / ")} 等${labels.length}地`; } export function formatBGPRelatedCables(items) { if (!Array.isArray(items) || items.length === 0) return "-"; const labels = items .slice(0, 3) .map((item) => { const landing = item?.landing_point || ""; const cables = Array.isArray(item?.cable_names) ? item.cable_names : []; const cableText = cables.length > 0 ? cables.slice(0, 2).join(", ") : "附近登陆点"; const distance = item?.distance_km !== undefined ? ` ${item.distance_km}km` : ""; return `${landing || cableText} (${cableText}${distance})`; }) .filter(Boolean); if (labels.length === 0) return "-"; if (items.length <= 3) return labels.join(" / "); return `${labels.join(" / ")} 等${items.length}处`; } export function formatBGPScope(scope) { const countries = Array.isArray(scope?.countries) ? scope.countries : []; const cities = Array.isArray(scope?.cities) ? scope.cities : []; const cityText = cities.slice(0, 3).join(" / "); const countryText = countries.slice(0, 3).join(" / "); if (cityText && countryText) { return `${cityText} | ${countryText}`; } return cityText || countryText || "-"; } export function formatBGPTopEventTypes(items) { if (!Array.isArray(items) || items.length === 0) return "-"; return items .slice(0, 3) .map((item) => `${item?.event_type || "-"} x${item?.count || 0}`) .join(" / "); } export function formatBGPCollectorCoverageHalo(markerData) { const prefixes = Number( markerData?.recent_24h_prefix_count || markerData?.recent_7d_prefix_count || markerData?.prefix_count || 0, ); const observations = Number( markerData?.recent_24h_observation_count || markerData?.recent_7d_observation_count || markerData?.observation_count || 0, ); if (prefixes <= 0 && observations <= 0) return "静态观测站"; return `近24h ${observations}条事件 / ${prefixes}个前缀`; } function buildCollectorFeatureData(feature) { const coordinates = feature?.geometry?.coordinates || []; const [longitude, latitude] = coordinates; if ( typeof latitude !== "number" || typeof longitude !== "number" || Number.isNaN(latitude) || Number.isNaN(longitude) ) { return null; } const properties = feature?.properties || {}; return { latitude, longitude, collector: properties.collector || "-", city: properties.city || "-", country: properties.country || "-", status: properties.status || "online", observation_count: properties.observation_count || 0, recent_24h_observation_count: properties.recent_24h_observation_count || 0, recent_7d_observation_count: properties.recent_7d_observation_count || 0, prefix_count: properties.prefix_count || 0, recent_24h_prefix_count: properties.recent_24h_prefix_count || 0, recent_7d_prefix_count: properties.recent_7d_prefix_count || 0, origin_asn_count: properties.origin_asn_count || 0, latest_observed_at: properties.latest_observed_at || null, latest_event_type: properties.latest_event_type || null, top_event_types: Array.isArray(properties.top_event_types) ? properties.top_event_types : [], baseline_scope: properties.baseline_scope || { countries: [], cities: [] }, }; } function spreadCollectorPositions(markers) { const groups = new Map(); markers.forEach((marker) => { const key = `${marker.latitude.toFixed(4)}|${marker.longitude.toFixed(4)}`; if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(marker); }); groups.forEach((group) => { if (group.length <= 1) return; const radius = 1.4; group.forEach((marker, index) => { const angle = (Math.PI * 2 * index) / group.length; marker.displayLatitude = marker.latitude + Math.sin(angle) * radius * 0.28; marker.displayLongitude = marker.longitude + Math.cos(angle) * radius * 0.28; marker.isSpread = true; marker.groupSize = group.length; }); }); markers.forEach((marker) => { if (marker.displayLatitude === undefined) { marker.displayLatitude = marker.latitude; marker.displayLongitude = marker.longitude; marker.isSpread = false; marker.groupSize = 1; } }); return markers; } function buildAnomalyFeatureData(feature) { const point = extractFeaturePoint(feature); if (!point) return null; const { latitude, longitude } = point; const properties = feature?.properties || {}; const meta = extractFeatureMeta(properties, properties.created_at || null); return { latitude, longitude, rawSeverity: meta.rawSeverity, severity: meta.severity, collector: properties.collector || "-", city: properties.city || "-", country: properties.country || "-", source: properties.source || "-", anomaly_type: properties.anomaly_type || "-", status: properties.status || "-", prefix: properties.prefix || "-", origin_asn: properties.origin_asn ?? "-", new_origin_asn: properties.new_origin_asn ?? "-", as_path: Array.isArray(properties.as_path) ? properties.as_path : [], collectors: Array.isArray(properties.collectors) ? properties.collectors : [], collector_count: properties.collector_count ?? 1, impacted_regions: Array.isArray(properties.impacted_regions) ? properties.impacted_regions : [], confidence: properties.confidence ?? "-", summary: properties.summary || "-", created_at: meta.createdAt, created_at_raw: meta.createdAtRaw, id: properties.id || `${properties.collector || "unknown"}-${latitude}-${longitude}`, }; } function buildIncidentFeatureData(feature) { const point = extractFeaturePoint(feature); if (!point) return null; const { latitude, longitude } = point; const properties = feature?.properties || {}; const startedAt = properties.started_at || properties.created_at || null; const meta = extractFeatureMeta(properties, startedAt); const affectedPrefixes = Array.isArray(properties.affected_prefixes) ? properties.affected_prefixes : []; const affectedAsns = Array.isArray(properties.affected_asns) ? properties.affected_asns : []; const affectedCollectors = Array.isArray(properties.affected_collectors) ? properties.affected_collectors : []; const affectedRegions = Array.isArray(properties.affected_regions) ? properties.affected_regions : []; const primaryRegion = affectedRegions[0] || {}; return { latitude, longitude, rawSeverity: meta.rawSeverity, severity: meta.severity, collector: affectedCollectors[0] || primaryRegion.collector || "-", city: primaryRegion.city || "-", country: primaryRegion.country || "-", source: "bgp_incident", anomaly_type: properties.incident_type || properties.title || "-", incident_type: properties.incident_type || "-", incident_key: properties.incident_key || "-", status: properties.status || "-", prefix: affectedPrefixes[0] || "-", prefixes: affectedPrefixes, origin_asn: affectedAsns[0] ?? "-", new_origin_asn: affectedAsns[1] ?? "-", affected_asns: affectedAsns, as_path: [], collectors: affectedCollectors, collector_count: affectedCollectors.length || 1, impacted_regions: affectedRegions, related_cables: Array.isArray(properties.related_cables) ? properties.related_cables : [], related_ixps: Array.isArray(properties.related_ixps) ? properties.related_ixps : [], confidence: properties.confidence ?? "-", summary: properties.summary || properties.title || "-", created_at: meta.createdAt, created_at_raw: meta.createdAtRaw, route_change: affectedAsns.length > 1 ? affectedAsns.slice(0, 2).map((asn) => `AS${asn}`).join(" -> ") : affectedPrefixes.length > 1 ? `${affectedPrefixes.length}个前缀簇` : properties.incident_type || "-", observed_by: formatBGPObservedBy(affectedCollectors), impacted_scope: formatBGPImpactedScope(affectedRegions), location: formatBGPLocation(primaryRegion.city, primaryRegion.country), id: properties.id || properties.incident_key || `${properties.incident_type || "incident"}-${latitude}-${longitude}`, }; } function extractFeaturePoint(feature) { const coordinates = feature?.geometry?.coordinates || []; const [longitude, latitude] = coordinates; if ( typeof latitude !== "number" || typeof longitude !== "number" || Number.isNaN(latitude) || Number.isNaN(longitude) ) { return null; } return { latitude, longitude }; } function extractFeatureMeta(properties, createdAtRaw) { const severity = normalizeSeverity(properties.severity); return { rawSeverity: properties.severity || severity, severity, createdAt: formatLocalDateTime(createdAtRaw), createdAtRaw: createdAtRaw, }; } function clearMarkerArray(markers) { while (markers.length > 0) { const marker = markers.pop(); while (marker.children.length > 0) { const child = marker.children.pop(); child.material?.dispose(); } marker.material?.dispose(); bgpGroup.remove(marker); } } function clearGroup(group) { while (group.children.length > 0) { const child = group.children[group.children.length - 1]; group.remove(child); if (child.geometry) child.geometry.dispose(); if (child.material) child.material.dispose(); } } function createSpriteMaterial({ color, opacity }) { return new THREE.SpriteMaterial({ map: getMarkerTexture(), color, transparent: true, opacity, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, }); } function createOverlaySprite({ color, opacity, scale }) { const sprite = new THREE.Sprite(createSpriteMaterial({ color, opacity })); sprite.scale.setScalar(scale); return sprite; } function projectLatLon(lat, lon, bearingDeg, distanceDeg) { const latRad = (lat * Math.PI) / 180; const lonRad = (lon * Math.PI) / 180; const bearing = (bearingDeg * Math.PI) / 180; const angularDistance = (distanceDeg * Math.PI) / 180; const targetLat = Math.asin( Math.sin(latRad) * Math.cos(angularDistance) + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing), ); const targetLon = lonRad + Math.atan2( Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad), Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(targetLat), ); return { latitude: (targetLat * 180) / Math.PI, longitude: ((((targetLon * 180) / Math.PI) + 540) % 360) - 180, }; } function createCoverageBoundaryLine(points, color, opacity = 0.3) { const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity, depthWrite: false, blending: THREE.AdditiveBlending, }); return new THREE.Line(geometry, material); } function createCoverageSector(points, color, opacity = 0.12) { const center = points[0]; const positions = []; for (let index = 1; index < points.length - 1; index += 1) { const current = points[index]; const next = points[index + 1]; positions.push( center.x, center.y, center.z, current.x, current.y, current.z, next.x, next.y, next.z, ); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending, }); return new THREE.Mesh(geometry, material); } function createCoverageSectorMesh( anchorLatitude, anchorLongitude, startBearingDeg, endBearingDeg, reachDeg, altitude, color, opacity = 0.12, radialSegments = 8, angularSegments = 28, ) { const positions = []; for (let radialIndex = 0; radialIndex < radialSegments; radialIndex += 1) { const innerDistance = (reachDeg * radialIndex) / radialSegments; const outerDistance = (reachDeg * (radialIndex + 1)) / radialSegments; for (let angularIndex = 0; angularIndex < angularSegments; angularIndex += 1) { const startProgress = angularIndex / angularSegments; const endProgress = (angularIndex + 1) / angularSegments; const startBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * startProgress; const endBearing = startBearingDeg + (endBearingDeg - startBearingDeg) * endProgress; const innerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, innerDistance); const innerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, innerDistance); const outerStart = projectLatLon(anchorLatitude, anchorLongitude, startBearing, outerDistance); const outerEnd = projectLatLon(anchorLatitude, anchorLongitude, endBearing, outerDistance); const innerStartVector = latLonToVector3(innerStart.latitude, innerStart.longitude, altitude); const innerEndVector = latLonToVector3(innerEnd.latitude, innerEnd.longitude, altitude); const outerStartVector = latLonToVector3(outerStart.latitude, outerStart.longitude, altitude); const outerEndVector = latLonToVector3(outerEnd.latitude, outerEnd.longitude, altitude); positions.push( innerStartVector.x, innerStartVector.y, innerStartVector.z, outerStartVector.x, outerStartVector.y, outerStartVector.z, outerEndVector.x, outerEndVector.y, outerEndVector.z, ); positions.push( innerStartVector.x, innerStartVector.y, innerStartVector.z, outerEndVector.x, outerEndVector.y, outerEndVector.z, innerEndVector.x, innerEndVector.y, innerEndVector.z, ); } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1, blending: THREE.AdditiveBlending, }); return new THREE.Mesh(geometry, material); } function createRadialBoundaryPoints( anchorLatitude, anchorLongitude, bearingDeg, reachDeg, altitude, segments = 18, ) { const points = []; for (let step = 0; step <= segments; step += 1) { const progress = step / segments; const projected = projectLatLon( anchorLatitude, anchorLongitude, bearingDeg, reachDeg * progress, ); points.push( latLonToVector3( projected.latitude, projected.longitude, altitude, ), ); } return points; } function createCollectorMarker(markerData) { const activity = getCollectorActivityProfile(markerData); const baseColor = activity.color; const idleColor = blendHexColors( BGP_CONFIG.collectorIcon.idleBaseColor, baseColor, BGP_CONFIG.collectorIcon.idleBlend, ); const marker = new THREE.Mesh( new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: getCollectorTexture(), color: idleColor, transparent: true, opacity: BGP_CONFIG.collectorIcon.idleOpacity, depthWrite: false, depthTest: true, side: THREE.DoubleSide, }), ); const position = latLonToVector3( markerData.displayLatitude, markerData.displayLongitude, CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset, ); marker.position.copy(position); marker.scale.set(BGP_CONFIG.marker.collectorBaseScale * 0.88 * activity.scaleBoost, BGP_CONFIG.marker.collectorBaseScale * 1.08 * activity.scaleBoost, 1); marker.renderOrder = 3; marker.visible = showBGP; orientCollectorMarkerToSurface(marker, position); const heatHalo = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: activity.haloScale * 0.58, }); heatHalo.renderOrder = 1; marker.add(heatHalo); const pulseHalo = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: activity.pulseHaloScale * 0.48, }); pulseHalo.renderOrder = 0; marker.add(pulseHalo); const statusCore = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: Math.max( BGP_CONFIG.marker.collectorBaseScale * BGP_CONFIG.marker.collectorStatusCoreBaseScale, BGP_CONFIG.marker.collectorStatusCoreMinScale, ), }); statusCore.position.set(0, 0, 0.02); statusCore.renderOrder = 4; marker.add(statusCore); const coverageHalo = createOverlaySprite({ color: BGP_CONFIG.regionColor, opacity: 0.0, scale: activity.coverageHaloScale * 0.7, }); coverageHalo.renderOrder = 0; coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1); marker.add(coverageHalo); marker.userData = { type: "bgp_collector", state: "normal", baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost, baseColor, idleColor, pulseOffset: Math.random() * Math.PI * 2, anomaly_count: 0, activity, heatHalo, pulseHalo, statusCore, coverageHalo, ...markerData, }; collectorMarkers.push(marker); bgpGroup.add(marker); } function createAnomalyMarker(markerData) { const sprite = new THREE.Sprite( new THREE.SpriteMaterial({ map: getEventTexture(markerData.incident_type || markerData.anomaly_type), color: getSeverityColor(markerData.severity), transparent: true, opacity: BGP_CONFIG.opacity.normal, depthWrite: false, depthTest: true, blending: THREE.NormalBlending, }), ); const position = latLonToVector3( markerData.latitude, markerData.longitude, CONFIG.earthRadius + BGP_CONFIG.altitudeOffset, ); const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity); sprite.position.copy(position); sprite.scale.setScalar(baseScale); sprite.renderOrder = 5; sprite.visible = showBGP; sprite.userData = { type: "bgp", state: "normal", baseScale, baseColor: getSeverityColor(markerData.severity), pulseOffset: Math.random() * Math.PI * 2, ...markerData, }; const ringA = new THREE.Sprite( new THREE.SpriteMaterial({ map: getEventRingTexture(), color: getSeverityColor(markerData.severity), transparent: true, opacity: 0, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, }), ); ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA); ringA.position.set(0, 0, -0.01); sprite.add(ringA); const ringB = new THREE.Sprite( new THREE.SpriteMaterial({ map: getEventRingTexture(), color: getSeverityColor(markerData.severity), transparent: true, opacity: 0, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, }), ); ringB.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleB); ringB.position.set(0, 0, -0.02); sprite.add(ringB); sprite.userData.ringA = ringA; sprite.userData.ringB = ringB; anomalyMarkers.push(sprite); bgpGroup.add(sprite); } function dedupeAnomalies(features) { const latestByLocation = new Map(); features.forEach((feature) => { const data = buildAnomalyFeatureData(feature); if (!data) return; activeEventCountByCollector.set( data.collector, (activeEventCountByCollector.get(data.collector) || 0) + 1, ); const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`; const previous = latestByLocation.get(dedupeKey); const currentTime = data.created_at_raw ? new Date(data.created_at_raw).getTime() : 0; const previousTime = previous?.created_at_raw ? new Date(previous.created_at_raw).getTime() : 0; const currentSeverity = getSeverityScale(data.severity); const previousSeverity = previous ? getSeverityScale(previous.severity) : 0; if ( !previous || currentSeverity > previousSeverity || (currentSeverity === previousSeverity && currentTime >= previousTime) ) { latestByLocation.set(dedupeKey, data); } }); return Array.from(latestByLocation.values()) .sort((a, b) => { const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity); if (severityDiff !== 0) return severityDiff; const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0; const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0; return timeB - timeA; }) .slice(0, BGP_CONFIG.maxRenderedMarkers); } function dedupeIncidents(features) { const latestByLocation = new Map(); features.forEach((feature) => { const data = buildIncidentFeatureData(feature); if (!data) return; data.collectors.forEach((collector) => { activeEventCountByCollector.set( collector, (activeEventCountByCollector.get(collector) || 0) + 1, ); }); const dedupeKey = `${data.latitude.toFixed(3)}|${data.longitude.toFixed(3)}`; const previous = latestByLocation.get(dedupeKey); const currentTime = data.created_at_raw ? new Date(data.created_at_raw).getTime() : 0; const previousTime = previous?.created_at_raw ? new Date(previous.created_at_raw).getTime() : 0; const currentSeverity = getSeverityScale(data.severity); const previousSeverity = previous ? getSeverityScale(previous.severity) : 0; if ( !previous || currentSeverity > previousSeverity || (currentSeverity === previousSeverity && currentTime >= previousTime) ) { latestByLocation.set(dedupeKey, data); } }); return Array.from(latestByLocation.values()) .sort((a, b) => { const severityDiff = getSeverityScale(b.severity) - getSeverityScale(a.severity); if (severityDiff !== 0) return severityDiff; const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0; const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0; return timeB - timeA; }) .slice(0, BGP_CONFIG.maxRenderedMarkers); } function applyCollectorCounts() { collectorMarkers.forEach((marker) => { marker.userData.anomaly_count = activeEventCountByCollector.get(marker.userData.collector) || 0; }); } async function fetchGeoJSONWithTimeout(url, timeoutMs, warningMessage, fallbackPayload) { try { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } catch (error) { console.warn(warningMessage, error); return fallbackPayload; } } function selectBGPEventFeatures(incidentPayload, anomalyPayload) { const incidentFeatures = Array.isArray(incidentPayload?.features) ? incidentPayload.features : []; if (incidentFeatures.length > 0) { return { features: incidentFeatures, totalIncidentCount: incidentPayload?.count ?? incidentFeatures.length, totalAnomalyCount: anomalyPayload?.count ?? 0, mode: "incident", }; } const anomalyFeatures = Array.isArray(anomalyPayload?.features) ? anomalyPayload.features : []; return { features: anomalyFeatures, totalIncidentCount: 0, totalAnomalyCount: anomalyPayload?.count ?? anomalyFeatures.length, mode: "anomaly", }; } export async function loadBGPAnomalies(scene, earth) { clearBGPData(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); totalAnomalyCount = selectedEventData.totalAnomalyCount; totalIncidentCount = selectedEventData.totalIncidentCount; activeEventCountByCollector.clear(); spreadCollectorPositions( collectorFeatures .map(buildCollectorFeatureData) .filter(Boolean), ).forEach(createCollectorMarker); if (selectedEventData.mode === "incident") { dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker); } else { dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker); } applyCollectorCounts(); if (!bgpGroup.parent) { earth.add(bgpGroup); } if (!bgpOverlayGroup.parent) { earth.add(bgpOverlayGroup); } bgpGroup.visible = showBGP; bgpOverlayGroup.visible = showBGP; if (scene && !scene.children.includes(earth)) { scene.add(earth); } return { totalCount: totalIncidentCount, anomalyCount: totalAnomalyCount, renderedCount: anomalyMarkers.length, collectorCount: collectorMarkers.length, }; } export function updateBGPVisualState(lockedObjectType, lockedObject, camera) { const now = performance.now(); updateCollectorOverlayScan(lockedObjectType, lockedObject); const hasLockedLayer = Boolean( lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType), ); collectorMarkers.forEach((marker) => { const isLocked = (lockedObjectType === "bgp_collector" || lockedObjectType === "bgp") && lockedObject?.userData?.collector === marker.userData.collector; const isHovered = marker.userData.state === "hover" || marker.userData.state === "linked"; const pulse = 0.5 + 0.5 * Math.sin( now * BGP_CONFIG.pulse.collectorSpeed + marker.userData.pulseOffset, ); let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera); let opacity = BGP_CONFIG.collectorIcon.idleOpacity; let haloOpacity = 0.0; let pulseOpacity = 0.0; let coverageOpacity = 0.0; let markerColor = marker.userData.idleColor || blendHexColors( BGP_CONFIG.collectorIcon.idleBaseColor, marker.userData.baseColor || BGP_CONFIG.collectorColor, BGP_CONFIG.collectorIcon.idleBlend, ); if (isLocked) { scale *= 1.1 + 0.14 * pulse; opacity = 0.96; haloOpacity = 0.05; pulseOpacity = 0.024; coverageOpacity = 0.036; markerColor = 0xcff2ff; } else if (isHovered) { scale *= 1.08; opacity = 0.88; haloOpacity = 0.03; pulseOpacity = 0.014; coverageOpacity = 0.02; markerColor = blendHexColors( BGP_CONFIG.collectorIcon.hoverNeutralColor, marker.userData.baseColor || BGP_CONFIG.collectorColor, BGP_CONFIG.collectorIcon.hoverBlend, ); } else if (hasLockedLayer) { scale *= 0.98; opacity = BGP_CONFIG.collectorIcon.idleOpacity; haloOpacity = 0.0; pulseOpacity = 0.0; coverageOpacity = 0.0; markerColor = marker.userData.idleColor || markerColor; } else { scale *= 1 + 0.05 * pulse; } marker.scale.setScalar(scale); marker.material.color.setHex(markerColor); marker.material.opacity = opacity; marker.visible = showBGP; if (marker.userData.heatHalo) { marker.userData.heatHalo.material.opacity = haloOpacity; marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.heatHalo.scale.setScalar( marker.userData.activity?.haloScale * 0.58 * (1 + pulse * 0.01), ); } if (marker.userData.pulseHalo) { marker.userData.pulseHalo.material.opacity = pulseOpacity; marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.pulseHalo.scale.setScalar( marker.userData.activity?.pulseHaloScale * 0.48 * (1 + pulse * 0.02), ); } if (marker.userData.statusCore) { marker.userData.statusCore.material.opacity = isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18; marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.statusCore.scale.setScalar( Math.max( BGP_CONFIG.marker.collectorBaseScale * BGP_CONFIG.marker.collectorStatusCoreBaseScale, BGP_CONFIG.marker.collectorStatusCoreMinScale, ) * (isLocked ? 1.08 : isHovered ? 1.04 : 0.92), ); } if (marker.userData.coverageHalo) { marker.userData.coverageHalo.material.opacity = coverageOpacity; marker.userData.coverageHalo.scale.set( marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012), marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012), 1, ); } }); anomalyMarkers.forEach((marker) => { const isLocked = lockedObjectType === "bgp" && lockedObject === marker; const isLinkedCollectorLocked = lockedObjectType === "bgp_collector" && lockedObject?.userData?.collector === marker.userData.collector; const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked; const isHovered = marker.userData.state === "hover"; const pulse = 0.5 + 0.5 * Math.sin(now * BGP_CONFIG.pulse.eventSpeed + marker.userData.pulseOffset); const iconAnchorScale = marker.userData.baseScale * getEventDistanceScale(marker, camera); let scale = iconAnchorScale; let opacity = BGP_CONFIG.opacity.normal; let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity); const isIncidentMarker = marker.userData.source === "bgp_incident"; let ringBaseOpacity = isIncidentMarker ? BGP_CONFIG.ring.opacity : BGP_CONFIG.ring.opacity * 0.45; if (isLocked || isLinkedCollectorLocked) { scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; opacity = 0.9 + 0.1 * pulse; markerColor = 0xfff1a8; ringBaseOpacity *= 1.2; } else if (isHovered) { scale *= BGP_CONFIG.marker.hoverScale; opacity = 0.9; ringBaseOpacity *= 1.05; } else if (isOtherLocked) { scale *= BGP_CONFIG.marker.dimmedScale; opacity = 0.1; markerColor = 0x7d8ca3; ringBaseOpacity = 0.02; } else { scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse; opacity = isIncidentMarker ? 0.7 : 0.62; } marker.scale.setScalar(scale); marker.material.color.setHex(markerColor); marker.material.opacity = opacity; marker.visible = showBGP; const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1; const applyRingState = (ring, phase, maxScale) => { if (!ring) return; const progress = Math.max(0, Math.min(1, phase)); const minScale = 1.28; const desiredWorldScale = iconAnchorScale * (minScale + progress * (maxScale - minScale)); const parentScale = Math.max(scale, 0.0001); const localRingScale = desiredWorldScale / parentScale; const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14)); const fadeOut = 1 - progress; const visibility = fadeIn * fadeOut; ring.scale.setScalar(localRingScale); ring.material.color.setHex(markerColor); ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0; ring.visible = showBGP; }; applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA); if (marker.userData.ringB) { marker.userData.ringB.material.opacity = 0; marker.userData.ringB.visible = false; } }); } export function setBGPMarkerState(marker, state = "normal") { if (!marker?.userData) return; if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") { return; } marker.userData.state = state; } export function clearBGPSelection() { collectorMarkers.forEach((marker) => { marker.userData.state = "normal"; }); anomalyMarkers.forEach((marker) => { marker.userData.state = "normal"; }); clearBGPEventOverlay(); } export function clearBGPData(earth) { clearMarkerArray(collectorMarkers); clearMarkerArray(anomalyMarkers); clearBGPEventOverlay(); activeEventCountByCollector.clear(); totalAnomalyCount = 0; totalIncidentCount = 0; if (earth && bgpGroup.parent === earth) { earth.remove(bgpGroup); } if (earth && bgpOverlayGroup.parent === earth) { earth.remove(bgpOverlayGroup); } } export function toggleBGP(show) { showBGP = Boolean(show); bgpGroup.visible = showBGP; bgpOverlayGroup.visible = showBGP; collectorMarkers.forEach((marker) => { marker.visible = showBGP; }); anomalyMarkers.forEach((marker) => { marker.visible = showBGP; }); } export function getShowBGP() { return showBGP; } export function getBGPMarkers() { return [...anomalyMarkers, ...collectorMarkers]; } export function getBGPAnomalyMarkers() { return anomalyMarkers; } export function getBGPCollectorMarkers() { return collectorMarkers; } export function getBGPCount() { return totalIncidentCount; } export function getBGPCollectorCount() { return collectorMarkers.length; } export function getBGPStatusSummary() { if (totalIncidentCount > 0 && totalAnomalyCount > 0) { return `${totalIncidentCount} 起活跃事件 / ${totalAnomalyCount} 条异常`; } if (totalIncidentCount > 0) { return `${totalIncidentCount} 起活跃事件`; } if (totalAnomalyCount > 0) { return `${totalAnomalyCount} 条活跃异常`; } if (collectorMarkers.length > 0) { return "当前无活跃事件"; } return "暂无观测数据"; } export function showBGPEventOverlay(marker, earth) { if (!marker?.userData || marker.userData.type !== "bgp" || !earth) return; clearBGPEventOverlay(); const impactedRegions = Array.isArray(marker.userData.impacted_regions) && marker.userData.impacted_regions.length > 0 ? marker.userData.impacted_regions : [ { collector: marker.userData.collector, city: marker.userData.city, country: marker.userData.country, latitude: marker.userData.latitude, longitude: marker.userData.longitude, }, ]; const validRegions = impactedRegions.filter( (region) => typeof region?.latitude === "number" && typeof region?.longitude === "number", ); if (validRegions.length === 0) return; const overlayItems = []; validRegions.forEach((region) => { const halo = createOverlaySprite({ color: BGP_CONFIG.regionColor, opacity: 0.24, scale: BGP_CONFIG.regionScale, }); halo.position.copy( latLonToVector3( region.latitude, region.longitude, CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1, ), ); halo.renderOrder = 2; bgpOverlayGroup.add(halo); overlayItems.push(halo); }); activeEventOverlay = overlayItems; activeCollectorOverlayContext = null; bgpOverlayGroup.visible = showBGP; } export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { if (!marker?.userData || marker.userData.type !== "bgp_collector" || !earth) return; clearBGPEventOverlay(); const prefixCount = Number( marker.userData.recent_24h_prefix_count || marker.userData.recent_7d_prefix_count || marker.userData.prefix_count || 0, ); const observationCount = Number( marker.userData.recent_24h_observation_count || marker.userData.recent_7d_observation_count || marker.userData.observation_count || 0, ); const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8); const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost; const pulseHaloScale = haloScale * 1.32; const halo = createOverlaySprite({ color: BGP_CONFIG.regionColor, opacity: 0.11, scale: haloScale * 0.78, }); halo.position.copy( latLonToVector3( marker.userData.displayLatitude ?? marker.userData.latitude, marker.userData.displayLongitude ?? marker.userData.longitude, CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15, ), ); halo.renderOrder = 2; bgpOverlayGroup.add(halo); const pulseHalo = createOverlaySprite({ color: BGP_CONFIG.collectorColor, opacity: 0.065, scale: pulseHaloScale * 0.82, }); pulseHalo.position.copy(halo.position); pulseHalo.renderOrder = 1; bgpOverlayGroup.add(pulseHalo); const innerRing = createOverlaySprite({ color: BGP_CONFIG.collectorColor, opacity: 0.12, scale: Math.max(haloScale * 0.34, 5.5), }); innerRing.position.copy(halo.position); innerRing.renderOrder = 3; bgpOverlayGroup.add(innerRing); const overlayItems = [halo, pulseHalo, innerRing]; const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude; const anchorLongitude = marker.userData.displayLongitude ?? marker.userData.longitude; const activityMagnitude = Math.log2(prefixCount + observationCount + 1); const corridorReach = Math.min(30, 12 + activityMagnitude * 3.2); const orientationSeed = Array.from(marker.userData.collector || "") .reduce((sum, char) => sum + char.charCodeAt(0), 0); const baseRotation = ((orientationSeed % 140) - 70) * (Math.PI / 180); const sectorRotation = baseRotation + (options.rotationOffsetRad || 0); const sectorHalfWidth = Math.PI * 0.22; const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI); const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI); const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor; const boundaryAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.44; const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.4; const leftBoundaryPoints = createRadialBoundaryPoints( anchorLatitude, anchorLongitude, startBearing, corridorReach, boundaryAltitude, ); const rightBoundaryPoints = createRadialBoundaryPoints( anchorLatitude, anchorLongitude, endBearing, corridorReach, boundaryAltitude, ); const outerArcPoints = []; const outerArcSteps = 28; for (let step = 0; step <= outerArcSteps; step += 1) { const progress = step / outerArcSteps; const bearingDeg = startBearing + (endBearing - startBearing) * progress; const projected = projectLatLon(anchorLatitude, anchorLongitude, bearingDeg, corridorReach); outerArcPoints.push( latLonToVector3( projected.latitude, projected.longitude, boundaryAltitude, ), ); } const sectorFill = createCoverageSectorMesh( anchorLatitude, anchorLongitude, startBearing, endBearing, corridorReach, fillAltitude, coverageColor, 0.12, ); sectorFill.renderOrder = 2; bgpOverlayGroup.add(sectorFill); overlayItems.push(sectorFill); const outerArc = createCoverageBoundaryLine( outerArcPoints, coverageColor, 0.9, ); outerArc.renderOrder = 3; bgpOverlayGroup.add(outerArc); overlayItems.push(outerArc); const leftBoundary = createCoverageBoundaryLine( leftBoundaryPoints, coverageColor, 0.76, ); leftBoundary.renderOrder = 3; bgpOverlayGroup.add(leftBoundary); overlayItems.push(leftBoundary); const rightBoundary = createCoverageBoundaryLine( rightBoundaryPoints, coverageColor, 0.76, ); rightBoundary.renderOrder = 3; bgpOverlayGroup.add(rightBoundary); overlayItems.push(rightBoundary); activeEventOverlay = overlayItems; activeCollectorOverlayContext = { marker, earth, baseRotation, lastRebuildAt: performance.now(), }; bgpOverlayGroup.visible = showBGP; } export function clearBGPEventOverlay() { activeEventOverlay = null; activeCollectorOverlayContext = null; clearGroup(bgpOverlayGroup); } function updateCollectorOverlayScan(lockedObjectType, lockedObject) { if ( lockedObjectType !== "bgp_collector" || !lockedObject || !activeCollectorOverlayContext || activeCollectorOverlayContext.marker !== lockedObject ) { return; } const now = performance.now(); if (now - activeCollectorOverlayContext.lastRebuildAt < COLLECTOR_SCAN_REBUILD_MS) { return; } const rotationOffsetRad = now * COLLECTOR_SCAN_SPEED_RAD; showBGPCollectorCoverageOverlay( activeCollectorOverlayContext.marker, activeCollectorOverlayContext.earth, { rotationOffsetRad }, ); } export function getBGPLegendItems() { return [ { color: "#6db7ff", label: "静态观测站" }, { color: "#fbbf24", label: "中活跃观测站" }, { color: "#ff5f57", label: "高活跃观测站" }, { color: "#6db7ff", label: "观测范围示意" }, { color: "#8af5ff", label: "事件连线 / 枢纽" }, { color: "#2dd4bf", label: "影响区域" }, { color: "#ff4d4f", label: "严重事件" }, { color: "#ff9f43", label: "高危事件" }, { color: "#ffd166", label: "中危事件" }, { color: "#4dabf7", label: "低危事件" }, ]; }