fix: refine treemap sizing and add earth bgp collectors

This commit is contained in:
linkong
2026-03-27 16:35:40 +08:00
parent 62f2d9f403
commit 7a3ca6e1b3
11 changed files with 741 additions and 49 deletions

View File

@@ -0,0 +1,496 @@
import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
const collectorMarkers = [];
const anomalyMarkers = [];
const anomalyCountByCollector = new Map();
let showBGP = true;
let totalAnomalyCount = 0;
let textureCache = null;
function getMarkerTexture() {
if (textureCache) return textureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
textureCache = new THREE.Texture(canvas);
return textureCache;
}
const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 56);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.24, "rgba(255,255,255,0.92)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.35)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = gradient;
context.beginPath();
context.arc(64, 64, 56, 0, Math.PI * 2);
context.fill();
textureCache = new THREE.CanvasTexture(canvas);
return textureCache;
}
function normalizeSeverity(severity) {
const value = String(severity || "").trim().toLowerCase();
if (value === "critical") return "critical";
if (value === "high" || value === "major") return "high";
if (value === "medium" || value === "moderate" || value === "warning") {
return "medium";
}
if (value === "low" || value === "info" || value === "informational") {
return "low";
}
return "medium";
}
function getSeverityColor(severity) {
return BGP_CONFIG.severityColors[normalizeSeverity(severity)];
}
function getSeverityScale(severity) {
return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
}
function formatLocalDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
}
function buildCollectorFeatureData(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
const properties = feature?.properties || {};
return {
latitude,
longitude,
collector: properties.collector || "-",
city: properties.city || "-",
country: properties.country || "-",
status: properties.status || "online",
};
}
function spreadCollectorPositions(markers) {
const groups = new Map();
markers.forEach((marker) => {
const key = `${marker.latitude.toFixed(4)}|${marker.longitude.toFixed(4)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(marker);
});
groups.forEach((group) => {
if (group.length <= 1) return;
const radius = 0.9;
group.forEach((marker, index) => {
const angle = (Math.PI * 2 * index) / group.length;
marker.displayLatitude =
marker.latitude + Math.sin(angle) * radius * 0.18;
marker.displayLongitude =
marker.longitude + Math.cos(angle) * radius * 0.18;
marker.isSpread = true;
marker.groupSize = group.length;
});
});
markers.forEach((marker) => {
if (marker.displayLatitude === undefined) {
marker.displayLatitude = marker.latitude;
marker.displayLongitude = marker.longitude;
marker.isSpread = false;
marker.groupSize = 1;
}
});
return markers;
}
function buildAnomalyFeatureData(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
const properties = feature?.properties || {};
const severity = normalizeSeverity(properties.severity);
const createdAt = properties.created_at || null;
return {
latitude,
longitude,
rawSeverity: properties.severity || severity,
severity,
collector: properties.collector || "-",
city: properties.city || "-",
country: properties.country || "-",
source: properties.source || "-",
anomaly_type: properties.anomaly_type || "-",
status: properties.status || "-",
prefix: properties.prefix || "-",
origin_asn: properties.origin_asn ?? "-",
new_origin_asn: properties.new_origin_asn ?? "-",
confidence: properties.confidence ?? "-",
summary: properties.summary || "-",
created_at: formatLocalDateTime(createdAt),
created_at_raw: createdAt,
id:
properties.id ||
`${properties.collector || "unknown"}-${latitude}-${longitude}`,
};
}
function clearMarkerArray(markers) {
while (markers.length > 0) {
const marker = markers.pop();
marker.material?.dispose();
bgpGroup.remove(marker);
}
}
function createSpriteMaterial({ color, opacity }) {
return new THREE.SpriteMaterial({
map: getMarkerTexture(),
color,
transparent: true,
opacity,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
});
}
function createCollectorMarker(markerData) {
const sprite = new THREE.Sprite(
createSpriteMaterial({
color: BGP_CONFIG.collectorColor,
opacity: BGP_CONFIG.opacity.collector,
}),
);
const position = latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset,
);
sprite.position.copy(position);
sprite.scale.setScalar(BGP_CONFIG.collectorScale);
sprite.renderOrder = 3;
sprite.visible = showBGP;
sprite.userData = {
type: "bgp_collector",
state: "normal",
baseScale: BGP_CONFIG.collectorScale,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
...markerData,
};
collectorMarkers.push(sprite);
bgpGroup.add(sprite);
}
function createAnomalyMarker(markerData) {
const sprite = new THREE.Sprite(
createSpriteMaterial({
color: getSeverityColor(markerData.severity),
opacity: BGP_CONFIG.opacity.normal,
}),
);
const position = latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
);
const baseScale = BGP_CONFIG.baseScale * getSeverityScale(markerData.severity);
sprite.position.copy(position);
sprite.scale.setScalar(baseScale);
sprite.renderOrder = 5;
sprite.visible = showBGP;
sprite.userData = {
type: "bgp",
state: "normal",
baseScale,
pulseOffset: Math.random() * Math.PI * 2,
...markerData,
};
anomalyMarkers.push(sprite);
bgpGroup.add(sprite);
}
function dedupeAnomalies(features) {
const latestByCollector = new Map();
features.forEach((feature) => {
const data = buildAnomalyFeatureData(feature);
if (!data) return;
anomalyCountByCollector.set(
data.collector,
(anomalyCountByCollector.get(data.collector) || 0) + 1,
);
const dedupeKey = `${data.collector}|${data.latitude.toFixed(4)}|${data.longitude.toFixed(4)}`;
const previous = latestByCollector.get(dedupeKey);
const currentTime = data.created_at_raw
? new Date(data.created_at_raw).getTime()
: 0;
const previousTime = previous?.created_at_raw
? new Date(previous.created_at_raw).getTime()
: 0;
if (!previous || currentTime >= previousTime) {
latestByCollector.set(dedupeKey, data);
}
});
return Array.from(latestByCollector.values())
.sort((a, b) => {
const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0;
const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0;
return timeB - timeA;
})
.slice(0, BGP_CONFIG.maxRenderedMarkers);
}
function applyCollectorCounts() {
collectorMarkers.forEach((marker) => {
marker.userData.anomaly_count =
anomalyCountByCollector.get(marker.userData.collector) || 0;
});
}
export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const [collectorsResponse, anomaliesResponse] = await Promise.all([
fetch(PATHS.bgpCollectorsApi),
fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
]);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
}
if (!anomaliesResponse.ok) {
throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`);
}
const collectorsPayload = await collectorsResponse.json();
const anomaliesPayload = await anomaliesResponse.json();
const collectorFeatures = Array.isArray(collectorsPayload?.features)
? collectorsPayload.features
: [];
const anomalyFeatures = Array.isArray(anomaliesPayload?.features)
? anomaliesPayload.features
: [];
totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length;
anomalyCountByCollector.clear();
spreadCollectorPositions(
collectorFeatures
.map(buildCollectorFeatureData)
.filter(Boolean),
).forEach(createCollectorMarker);
dedupeAnomalies(anomalyFeatures).forEach(createAnomalyMarker);
applyCollectorCounts();
if (!bgpGroup.parent) {
earth.add(bgpGroup);
}
bgpGroup.visible = showBGP;
if (scene && !scene.children.includes(earth)) {
scene.add(earth);
}
return {
totalCount: totalAnomalyCount,
renderedCount: anomalyMarkers.length,
collectorCount: collectorMarkers.length,
};
}
export function updateBGPVisualState(lockedObjectType, lockedObject) {
const now = performance.now();
const hasLockedLayer = Boolean(
lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType),
);
collectorMarkers.forEach((marker) => {
const isLocked =
(lockedObjectType === "bgp_collector" || lockedObjectType === "bgp") &&
lockedObject?.userData?.collector === marker.userData.collector;
const isHovered =
marker.userData.state === "hover" || marker.userData.state === "linked";
const pulse =
0.5 +
0.5 *
Math.sin(
now * BGP_CONFIG.collectorPulseSpeed + marker.userData.pulseOffset,
);
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.collector;
if (isLocked) {
scale *= 1.1 + 0.14 * pulse;
opacity = BGP_CONFIG.opacity.collectorHover;
} else if (isHovered) {
scale *= 1.08;
opacity = BGP_CONFIG.opacity.collectorHover;
} else if (hasLockedLayer) {
scale *= BGP_CONFIG.dimmedScale;
opacity = BGP_CONFIG.opacity.dimmed;
} else {
scale *= 1 + 0.05 * pulse;
}
marker.scale.setScalar(scale);
marker.material.opacity = opacity;
marker.visible = showBGP;
});
anomalyMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "bgp" && lockedObject === marker;
const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.collector;
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
const isHovered = marker.userData.state === "hover";
const pulse =
0.5 +
0.5 * Math.sin(now * BGP_CONFIG.pulseSpeed + marker.userData.pulseOffset);
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.normal;
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse;
opacity =
BGP_CONFIG.opacity.lockedMin +
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
} else if (isHovered) {
scale *= BGP_CONFIG.hoverScale;
opacity = BGP_CONFIG.opacity.hover;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.dimmedScale;
opacity = BGP_CONFIG.opacity.dimmed;
} else {
scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse;
opacity = BGP_CONFIG.opacity.normal;
}
marker.scale.setScalar(scale);
marker.material.opacity = opacity;
marker.visible = showBGP;
});
}
export function setBGPMarkerState(marker, state = "normal") {
if (!marker?.userData) return;
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
return;
}
marker.userData.state = state;
}
export function clearBGPSelection() {
collectorMarkers.forEach((marker) => {
marker.userData.state = "normal";
});
anomalyMarkers.forEach((marker) => {
marker.userData.state = "normal";
});
}
export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers);
anomalyCountByCollector.clear();
totalAnomalyCount = 0;
if (earth && bgpGroup.parent === earth) {
earth.remove(bgpGroup);
}
}
export function toggleBGP(show) {
showBGP = Boolean(show);
bgpGroup.visible = showBGP;
collectorMarkers.forEach((marker) => {
marker.visible = showBGP;
});
anomalyMarkers.forEach((marker) => {
marker.visible = showBGP;
});
}
export function getShowBGP() {
return showBGP;
}
export function getBGPMarkers() {
return [...anomalyMarkers, ...collectorMarkers];
}
export function getBGPAnomalyMarkers() {
return anomalyMarkers;
}
export function getBGPCollectorMarkers() {
return collectorMarkers;
}
export function getBGPCount() {
return totalAnomalyCount;
}
export function getBGPLegendItems() {
return [
{ color: "#6db7ff", label: "观测站" },
{ color: "#ff4d4f", label: "Critical 异常" },
{ color: "#ff9f43", label: "High / Major 异常" },
{ color: "#ffd166", label: "Medium 异常" },
{ color: "#4dabf7", label: "Low / Info 异常" },
];
}

View File

@@ -27,6 +27,7 @@ export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
geoJSON: './geo.json',
landingPointsStatic: './landing-point-geo.geojson',
};
@@ -73,17 +74,22 @@ export const SATELLITE_CONFIG = {
export const BGP_CONFIG = {
defaultFetchLimit: 200,
maxRenderedMarkers: 200,
altitudeOffset: 1.2,
altitudeOffset: 2.1,
collectorAltitudeOffset: 1.6,
baseScale: 6.2,
collectorScale: 7.4,
hoverScale: 1.16,
dimmedScale: 0.92,
pulseSpeed: 0.0045,
collectorPulseSpeed: 0.0024,
normalPulseAmplitude: 0.08,
lockedPulseAmplitude: 0.28,
opacity: {
normal: 0.78,
hover: 1.0,
dimmed: 0.24,
collector: 0.82,
collectorHover: 1.0,
lockedMin: 0.65,
lockedMax: 1.0
},
@@ -98,7 +104,8 @@ export const BGP_CONFIG = {
high: 1.08,
medium: 1.0,
low: 0.94
}
},
collectorColor: 0x6db7ff
};
export const PREDICTED_ORBIT_CONFIG = {

View File

@@ -49,6 +49,18 @@ const CARD_CONFIG = {
{ key: 'summary', label: '摘要' }
]
},
bgp_collector: {
icon: '📍',
title: 'BGP观测站详情',
className: 'bgp',
fields: [
{ key: 'collector', label: '采集器' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' },
{ key: 'anomaly_count', label: '当前异常数' },
{ key: 'status', label: '状态' }
]
},
supercomputer: {
icon: '🖥️',
title: '超算详情',

View File

@@ -66,7 +66,8 @@ import {
} from "./satellites.js";
import {
loadBGPAnomalies,
getBGPMarkers,
getBGPAnomalyMarkers,
getBGPCollectorMarkers,
getBGPLegendItems,
getBGPCount,
getShowBGP,
@@ -271,6 +272,17 @@ function showBGPInfo(marker) {
});
}
function showBGPCollectorInfo(marker) {
setLegendMode("bgp");
showInfoCard("bgp_collector", {
collector: marker.userData.collector,
country: marker.userData.country,
city: marker.userData.city,
anomaly_count: marker.userData.anomaly_count ?? 0,
status: marker.userData.status || "online",
});
}
function applyCableVisualState() {
const allCables = getCableLines();
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
@@ -638,9 +650,17 @@ function onMouseMove(event) {
const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
const bgpIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
: [];
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
: [];
let hoveredSat = null;
@@ -661,7 +681,10 @@ function onMouseMove(event) {
if (
hoveredBGP &&
(!bgpIntersects.length || bgpIntersects[0]?.object !== hoveredBGP)
(!bgpAnomalyIntersects.length ||
bgpAnomalyIntersects[0]?.object !== hoveredBGP) &&
(!bgpCollectorIntersects.length ||
bgpCollectorIntersects[0]?.object !== hoveredBGP)
) {
if (hoveredBGP !== lockedObject) {
setBGPMarkerState(hoveredBGP, "normal");
@@ -690,8 +713,8 @@ function onMouseMove(event) {
hoveredSatelliteIndex = null;
}
if (bgpIntersects.length > 0 && getShowBGP()) {
const marker = bgpIntersects[0].object;
if (bgpAnomalyIntersects.length > 0 && getShowBGP()) {
const marker = bgpAnomalyIntersects[0].object;
hoveredBGP = marker;
if (marker !== lockedObject) {
setBGPMarkerState(marker, "hover");
@@ -699,6 +722,15 @@ function onMouseMove(event) {
showBGPInfo(marker);
setInfoCardNoBorder(true);
hideTooltip();
} else if (bgpCollectorIntersects.length > 0 && getShowBGP()) {
const marker = bgpCollectorIntersects[0].object;
hoveredBGP = marker;
if (marker !== lockedObject) {
setBGPMarkerState(marker, "hover");
}
showBGPCollectorInfo(marker);
setInfoCardNoBorder(true);
hideTooltip();
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
@@ -725,6 +757,8 @@ function onMouseMove(event) {
setInfoCardNoBorder(true);
} else if (lockedObjectType === "bgp" && lockedObject) {
showBGPInfo(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
showBGPCollectorInfo(lockedObject);
} else if (lockedObjectType === "cable" && lockedObject) {
showCableInfo(lockedObject);
} else if (lockedObjectType === "satellite" && lockedSatellite) {
@@ -798,18 +832,26 @@ function onClick(event) {
const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()),
);
const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
const bgpIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
: [];
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
: [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
if (bgpIntersects.length > 0 && getShowBGP()) {
if (bgpAnomalyIntersects.length > 0 && getShowBGP()) {
clearLockedObject();
const clickedMarker = bgpIntersects[0].object;
const clickedMarker = bgpAnomalyIntersects[0].object;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
@@ -823,6 +865,23 @@ function onClick(event) {
return;
}
if (bgpCollectorIntersects.length > 0 && getShowBGP()) {
clearLockedObject();
const clickedMarker = bgpCollectorIntersects[0].object;
setBGPMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "bgp_collector";
setAutoRotate(false);
showBGPCollectorInfo(clickedMarker);
showStatusMessage(
`已选择观测站: ${clickedMarker.userData.collector}`,
"info",
);
return;
}
if (cableIntersects.length > 0 && getShowCables()) {
clearLockedObject();