diff --git a/VERSION b/VERSION index 78cfa5eb..aa53fc84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.6 +0.21.7 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 25de3c6f..f6a57686 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -328,6 +328,29 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any return {"type": "FeatureCollection", "features": features} +def convert_bgp_collectors_to_geojson() -> Dict[str, Any]: + features = [] + + for collector, location in sorted(RIPE_RIS_COLLECTOR_COORDS.items()): + features.append( + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [location["longitude"], location["latitude"]], + }, + "properties": { + "collector": collector, + "city": location.get("city"), + "country": location.get("country"), + "status": "online", + }, + } + ) + + return {"type": "FeatureCollection", "features": features} + + # ============== API Endpoints ============== @@ -553,6 +576,12 @@ async def get_bgp_anomalies_geojson( return {**geojson, "count": len(geojson.get("features", []))} +@router.get("/geo/bgp-collectors") +async def get_bgp_collectors_geojson(): + geojson = convert_bgp_collectors_to_geojson() + return {**geojson, "count": len(geojson.get("features", []))} + + @router.get("/all") async def get_all_visualization_data(db: AsyncSession = Depends(get_db)): """获取所有可视化数据的统一端点 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f863033d..6f450ac8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,32 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.21.7 + +Released: 2026-03-27 + +### Highlights + +- Added Earth-side BGP collector visualization support so anomaly markers and collector stations can be explored together. +- Refined the collected-data distribution treemap so square tiles better reflect relative volume while staying readable in dense layouts. + +### Added + +- Added `/api/v1/visualization/geo/bgp-collectors` to expose RIPE RIS collector locations as GeoJSON. +- Added dedicated Earth collector marker handling and BGP collector detail cards in the Earth runtime. +- Added collector-specific BGP visual tuning for altitude, opacity, scale, and pulse behavior. + +### Improved + +- Improved the collected-data distribution treemap with dynamic square-grid sizing, clearer area-based span rules, centered compact tiles, and tooltip coverage on both icons and labels. +- Improved compact treemap readability by hiding `1x1` labels, reducing `1x1` value font size, and centering icon/value content. +- Improved Earth BGP interactions so anomaly markers and collector markers can both participate in hover, lock, legend, and info-card flows. + +### Fixed + +- Fixed Earth BGP data loading gaps by adding the missing `bgp.js` runtime module required by the current control and visualization flow. +- Fixed treemap layout drift where compact tiles could appear oversized or visually inconsistent with the intended square-grid distribution. + ## 0.21.6 Released: 2026-03-27 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bc60ddc..25d317e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "planet-frontend", - "version": "0.21.5-dev", + "version": "0.21.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planet-frontend", - "version": "0.21.5-dev", + "version": "0.21.7", "dependencies": { "@ant-design/icons": "^5.2.6", "antd": "^5.12.5", diff --git a/frontend/package.json b/frontend/package.json index 76f8c812..20d02796 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.21.5-dev", + "version": "0.21.7", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js new file mode 100644 index 00000000..b20a8cf7 --- /dev/null +++ b/frontend/public/earth/js/bgp.js @@ -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 异常" }, + ]; +} diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 935facb7..6522c545 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -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 = { diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index b8a2b8b6..8026306c 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -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: '超算详情', diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index beb9e400..802ba48e 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -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(); diff --git a/frontend/src/index.css b/frontend/src/index.css index 8dbd5bed..d58896a4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -703,6 +703,22 @@ body { overflow: hidden; } +.data-list-treemap-tile--compact { + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; +} + +.data-list-treemap-tile--compact .data-list-treemap-head { + justify-content: center; +} + +.data-list-treemap-tile--compact .data-list-treemap-body { + margin-top: 0; + align-items: center; +} + .data-list-treemap-tile--ocean { background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%); } diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx index afb1d3d4..abedf157 100644 --- a/frontend/src/pages/DataList/DataList.tsx +++ b/frontend/src/pages/DataList/DataList.tsx @@ -242,6 +242,56 @@ function estimateTreemapRows( return Math.max(occupancy.length, 1) } +function getTreemapSpan(value: number, maxValue: number, columns: number) { + if (columns <= 1) return 1 + + const normalized = Math.log10(value + 1) / Math.log10(maxValue + 1) + + if (columns >= 4 && normalized >= 0.94) return 3 + if (normalized >= 0.62) return 2 + return 1 +} + +function isCompactTreemapItem(item: { colSpan: number; rowSpan: number }) { + return item.colSpan === 1 && item.rowSpan === 1 +} + +function getTreemapColumnCount( + width: number, + minCellSize: number, + gap: number, + isCompact: boolean +) { + const visualCap = isCompact ? 4 : 8 + if (width <= 0) return Math.min(visualCap, isCompact ? 2 : 4) + + const maxColumnsByWidth = Math.max(1, Math.floor((width + gap) / (minCellSize + gap))) + return Math.max(1, Math.min(maxColumnsByWidth, visualCap)) +} + +function getTreemapBaseSize(width: number, columns: number, gap: number, minCellSize: number) { + const fittedSize = Math.floor((Math.max(width, 0) - Math.max(0, columns - 1) * gap) / columns) + return Math.max(minCellSize, fittedSize || minCellSize) +} + +function getTreemapTypography(rowHeight: number) { + const tilePadding = rowHeight <= 72 ? 8 : rowHeight <= 84 ? 10 : 12 + const labelSize = rowHeight <= 72 ? 10 : rowHeight <= 84 ? 11 : 12 + const valueSize = rowHeight <= 72 ? 13 : rowHeight <= 84 ? 15 : 16 + + return { tilePadding, labelSize, valueSize } +} + +function getTreemapItemValueSize( + item: { colSpan: number; rowSpan: number }, + baseValueSize: number +) { + if (isCompactTreemapItem(item)) { + return Math.max(11, baseValueSize - 2) + } + return baseValueSize +} + function DataList() { const screens = useBreakpoint() const isCompact = !screens.lg @@ -579,57 +629,44 @@ function DataList() { })) }, [summary, treemapDimension]) + const treemapGap = isCompact ? 8 : 10 + const treemapMinCellSize = isCompact ? 72 : 52 const treemapColumns = useMemo(() => { - if (isCompact) return summaryBodyWidth >= 320 ? 2 : 1 - if (leftPanelWidth < 360) return 2 - if (leftPanelWidth < 520) return 3 - return 4 - }, [isCompact, leftPanelWidth, summaryBodyWidth]) + return getTreemapColumnCount(summaryBodyWidth, treemapMinCellSize, treemapGap, isCompact) + }, [isCompact, summaryBodyWidth, treemapGap, treemapMinCellSize]) const treemapItems = useMemo(() => { const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate'] - const limitedItems = distributionItems.slice(0, isCompact ? 6 : 10) + const maxItems = isCompact ? 6 : 10 + const limitedItems = distributionItems.slice(0, maxItems) const maxValue = Math.max(...limitedItems.map((item) => item.value), 1) - const allowFeaturedTile = !isCompact && treemapColumns > 1 && limitedItems.length > 2 return limitedItems.map((item, index) => { - const ratio = item.value / maxValue - let colSpan = 1 - let rowSpan = 1 - - if (allowFeaturedTile && index === 0 && ratio >= 0.35) { - colSpan = Math.min(2, treemapColumns) - rowSpan = colSpan - } + const span = Math.min(getTreemapSpan(item.value, maxValue, treemapColumns), treemapColumns) return { ...item, - colSpan, - rowSpan, + colSpan: span, + rowSpan: span, tone: palette[index % palette.length], } }) - }, [distributionItems, isCompact, leftPanelWidth, treemapColumns]) + }, [distributionItems, isCompact, treemapColumns]) const treemapRows = useMemo( () => estimateTreemapRows(treemapItems, treemapColumns), [treemapColumns, treemapItems] ) - const treemapGap = isCompact ? 8 : 10 const treemapBaseSize = Math.max( - isCompact ? 88 : 68, - Math.min( - isCompact ? 220 : 180, - Math.floor((Math.max(summaryBodyWidth, 0) - Math.max(0, treemapColumns - 1) * treemapGap) / treemapColumns) - ) || (isCompact ? 88 : 68) + treemapMinCellSize, + getTreemapBaseSize(summaryBodyWidth, treemapColumns, treemapGap, treemapMinCellSize) ) const treemapAvailableHeight = Math.max(summaryBodyHeight, 0) const treemapRowHeight = treemapBaseSize const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap - const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12 - const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12 - const treemapValueSize = treemapRowHeight <= 72 ? 13 : treemapRowHeight <= 84 ? 15 : 16 + const { tilePadding: treemapTilePadding, labelSize: treemapLabelSize, valueSize: treemapValueSize } = + getTreemapTypography(treemapRowHeight) const pageHeight = '100%' const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132 @@ -801,18 +838,28 @@ function DataList() { {treemapItems.length > 0 ? treemapItems.map((item) => (