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