fix: refine treemap sizing and add earth bgp collectors
This commit is contained in:
@@ -328,6 +328,29 @@ def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any
|
|||||||
return {"type": "FeatureCollection", "features": features}
|
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 ==============
|
# ============== API Endpoints ==============
|
||||||
|
|
||||||
|
|
||||||
@@ -553,6 +576,12 @@ async def get_bgp_anomalies_geojson(
|
|||||||
return {**geojson, "count": len(geojson.get("features", []))}
|
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")
|
@router.get("/all")
|
||||||
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
|
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
|
||||||
"""获取所有可视化数据的统一端点
|
"""获取所有可视化数据的统一端点
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ This project follows the repository versioning rule:
|
|||||||
- `feature` -> `+0.1.0`
|
- `feature` -> `+0.1.0`
|
||||||
- `bugfix` -> `+0.0.1`
|
- `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
|
## 0.21.6
|
||||||
|
|
||||||
Released: 2026-03-27
|
Released: 2026-03-27
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.5-dev",
|
"version": "0.21.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.5-dev",
|
"version": "0.21.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"antd": "^5.12.5",
|
"antd": "^5.12.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.5-dev",
|
"version": "0.21.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
|||||||
496
frontend/public/earth/js/bgp.js
Normal file
496
frontend/public/earth/js/bgp.js
Normal 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 异常" },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export const PATHS = {
|
|||||||
cablesApi: '/api/v1/visualization/geo/cables',
|
cablesApi: '/api/v1/visualization/geo/cables',
|
||||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||||
|
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
|
||||||
geoJSON: './geo.json',
|
geoJSON: './geo.json',
|
||||||
landingPointsStatic: './landing-point-geo.geojson',
|
landingPointsStatic: './landing-point-geo.geojson',
|
||||||
};
|
};
|
||||||
@@ -73,17 +74,22 @@ export const SATELLITE_CONFIG = {
|
|||||||
export const BGP_CONFIG = {
|
export const BGP_CONFIG = {
|
||||||
defaultFetchLimit: 200,
|
defaultFetchLimit: 200,
|
||||||
maxRenderedMarkers: 200,
|
maxRenderedMarkers: 200,
|
||||||
altitudeOffset: 1.2,
|
altitudeOffset: 2.1,
|
||||||
|
collectorAltitudeOffset: 1.6,
|
||||||
baseScale: 6.2,
|
baseScale: 6.2,
|
||||||
|
collectorScale: 7.4,
|
||||||
hoverScale: 1.16,
|
hoverScale: 1.16,
|
||||||
dimmedScale: 0.92,
|
dimmedScale: 0.92,
|
||||||
pulseSpeed: 0.0045,
|
pulseSpeed: 0.0045,
|
||||||
|
collectorPulseSpeed: 0.0024,
|
||||||
normalPulseAmplitude: 0.08,
|
normalPulseAmplitude: 0.08,
|
||||||
lockedPulseAmplitude: 0.28,
|
lockedPulseAmplitude: 0.28,
|
||||||
opacity: {
|
opacity: {
|
||||||
normal: 0.78,
|
normal: 0.78,
|
||||||
hover: 1.0,
|
hover: 1.0,
|
||||||
dimmed: 0.24,
|
dimmed: 0.24,
|
||||||
|
collector: 0.82,
|
||||||
|
collectorHover: 1.0,
|
||||||
lockedMin: 0.65,
|
lockedMin: 0.65,
|
||||||
lockedMax: 1.0
|
lockedMax: 1.0
|
||||||
},
|
},
|
||||||
@@ -98,7 +104,8 @@ export const BGP_CONFIG = {
|
|||||||
high: 1.08,
|
high: 1.08,
|
||||||
medium: 1.0,
|
medium: 1.0,
|
||||||
low: 0.94
|
low: 0.94
|
||||||
}
|
},
|
||||||
|
collectorColor: 0x6db7ff
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PREDICTED_ORBIT_CONFIG = {
|
export const PREDICTED_ORBIT_CONFIG = {
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ const CARD_CONFIG = {
|
|||||||
{ key: 'summary', label: '摘要' }
|
{ 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: {
|
supercomputer: {
|
||||||
icon: '🖥️',
|
icon: '🖥️',
|
||||||
title: '超算详情',
|
title: '超算详情',
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ import {
|
|||||||
} from "./satellites.js";
|
} from "./satellites.js";
|
||||||
import {
|
import {
|
||||||
loadBGPAnomalies,
|
loadBGPAnomalies,
|
||||||
getBGPMarkers,
|
getBGPAnomalyMarkers,
|
||||||
|
getBGPCollectorMarkers,
|
||||||
getBGPLegendItems,
|
getBGPLegendItems,
|
||||||
getBGPCount,
|
getBGPCount,
|
||||||
getShowBGP,
|
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() {
|
function applyCableVisualState() {
|
||||||
const allCables = getCableLines();
|
const allCables = getCableLines();
|
||||||
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
||||||
@@ -638,9 +650,17 @@ function onMouseMove(event) {
|
|||||||
|
|
||||||
const frontCables = getFrontFacingCables(getCableLines());
|
const frontCables = getFrontFacingCables(getCableLines());
|
||||||
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
|
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
|
||||||
const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
|
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
|
||||||
const bgpIntersects = getShowBGP()
|
getBGPAnomalyMarkers(),
|
||||||
? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
|
);
|
||||||
|
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
|
||||||
|
getBGPCollectorMarkers(),
|
||||||
|
);
|
||||||
|
const bgpAnomalyIntersects = getShowBGP()
|
||||||
|
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
|
||||||
|
: [];
|
||||||
|
const bgpCollectorIntersects = getShowBGP()
|
||||||
|
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
let hoveredSat = null;
|
let hoveredSat = null;
|
||||||
@@ -661,7 +681,10 @@ function onMouseMove(event) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
hoveredBGP &&
|
hoveredBGP &&
|
||||||
(!bgpIntersects.length || bgpIntersects[0]?.object !== hoveredBGP)
|
(!bgpAnomalyIntersects.length ||
|
||||||
|
bgpAnomalyIntersects[0]?.object !== hoveredBGP) &&
|
||||||
|
(!bgpCollectorIntersects.length ||
|
||||||
|
bgpCollectorIntersects[0]?.object !== hoveredBGP)
|
||||||
) {
|
) {
|
||||||
if (hoveredBGP !== lockedObject) {
|
if (hoveredBGP !== lockedObject) {
|
||||||
setBGPMarkerState(hoveredBGP, "normal");
|
setBGPMarkerState(hoveredBGP, "normal");
|
||||||
@@ -690,8 +713,8 @@ function onMouseMove(event) {
|
|||||||
hoveredSatelliteIndex = null;
|
hoveredSatelliteIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bgpIntersects.length > 0 && getShowBGP()) {
|
if (bgpAnomalyIntersects.length > 0 && getShowBGP()) {
|
||||||
const marker = bgpIntersects[0].object;
|
const marker = bgpAnomalyIntersects[0].object;
|
||||||
hoveredBGP = marker;
|
hoveredBGP = marker;
|
||||||
if (marker !== lockedObject) {
|
if (marker !== lockedObject) {
|
||||||
setBGPMarkerState(marker, "hover");
|
setBGPMarkerState(marker, "hover");
|
||||||
@@ -699,6 +722,15 @@ function onMouseMove(event) {
|
|||||||
showBGPInfo(marker);
|
showBGPInfo(marker);
|
||||||
setInfoCardNoBorder(true);
|
setInfoCardNoBorder(true);
|
||||||
hideTooltip();
|
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()) {
|
} else if (cableIntersects.length > 0 && getShowCables()) {
|
||||||
const cable = cableIntersects[0].object;
|
const cable = cableIntersects[0].object;
|
||||||
hoveredCable = cable;
|
hoveredCable = cable;
|
||||||
@@ -725,6 +757,8 @@ function onMouseMove(event) {
|
|||||||
setInfoCardNoBorder(true);
|
setInfoCardNoBorder(true);
|
||||||
} else if (lockedObjectType === "bgp" && lockedObject) {
|
} else if (lockedObjectType === "bgp" && lockedObject) {
|
||||||
showBGPInfo(lockedObject);
|
showBGPInfo(lockedObject);
|
||||||
|
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||||
|
showBGPCollectorInfo(lockedObject);
|
||||||
} else if (lockedObjectType === "cable" && lockedObject) {
|
} else if (lockedObjectType === "cable" && lockedObject) {
|
||||||
showCableInfo(lockedObject);
|
showCableInfo(lockedObject);
|
||||||
} else if (lockedObjectType === "satellite" && lockedSatellite) {
|
} else if (lockedObjectType === "satellite" && lockedSatellite) {
|
||||||
@@ -798,18 +832,26 @@ function onClick(event) {
|
|||||||
const cableIntersects = interactionRaycaster.intersectObjects(
|
const cableIntersects = interactionRaycaster.intersectObjects(
|
||||||
getFrontFacingCables(getCableLines()),
|
getFrontFacingCables(getCableLines()),
|
||||||
);
|
);
|
||||||
const frontFacingBGPMarkers = getFrontFacingBGPMarkers(getBGPMarkers());
|
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
|
||||||
const bgpIntersects = getShowBGP()
|
getBGPAnomalyMarkers(),
|
||||||
? interactionRaycaster.intersectObjects(frontFacingBGPMarkers)
|
);
|
||||||
|
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
|
||||||
|
getBGPCollectorMarkers(),
|
||||||
|
);
|
||||||
|
const bgpAnomalyIntersects = getShowBGP()
|
||||||
|
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
|
||||||
|
: [];
|
||||||
|
const bgpCollectorIntersects = getShowBGP()
|
||||||
|
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
|
||||||
: [];
|
: [];
|
||||||
const satIntersects = getShowSatellites()
|
const satIntersects = getShowSatellites()
|
||||||
? interactionRaycaster.intersectObject(getSatellitePoints())
|
? interactionRaycaster.intersectObject(getSatellitePoints())
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (bgpIntersects.length > 0 && getShowBGP()) {
|
if (bgpAnomalyIntersects.length > 0 && getShowBGP()) {
|
||||||
clearLockedObject();
|
clearLockedObject();
|
||||||
|
|
||||||
const clickedMarker = bgpIntersects[0].object;
|
const clickedMarker = bgpAnomalyIntersects[0].object;
|
||||||
setBGPMarkerState(clickedMarker, "locked");
|
setBGPMarkerState(clickedMarker, "locked");
|
||||||
|
|
||||||
lockedObject = clickedMarker;
|
lockedObject = clickedMarker;
|
||||||
@@ -823,6 +865,23 @@ function onClick(event) {
|
|||||||
return;
|
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()) {
|
if (cableIntersects.length > 0 && getShowCables()) {
|
||||||
clearLockedObject();
|
clearLockedObject();
|
||||||
|
|
||||||
|
|||||||
@@ -703,6 +703,22 @@ body {
|
|||||||
overflow: hidden;
|
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 {
|
.data-list-treemap-tile--ocean {
|
||||||
background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%);
|
background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,56 @@ function estimateTreemapRows(
|
|||||||
return Math.max(occupancy.length, 1)
|
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() {
|
function DataList() {
|
||||||
const screens = useBreakpoint()
|
const screens = useBreakpoint()
|
||||||
const isCompact = !screens.lg
|
const isCompact = !screens.lg
|
||||||
@@ -579,57 +629,44 @@ function DataList() {
|
|||||||
}))
|
}))
|
||||||
}, [summary, treemapDimension])
|
}, [summary, treemapDimension])
|
||||||
|
|
||||||
|
const treemapGap = isCompact ? 8 : 10
|
||||||
|
const treemapMinCellSize = isCompact ? 72 : 52
|
||||||
const treemapColumns = useMemo(() => {
|
const treemapColumns = useMemo(() => {
|
||||||
if (isCompact) return summaryBodyWidth >= 320 ? 2 : 1
|
return getTreemapColumnCount(summaryBodyWidth, treemapMinCellSize, treemapGap, isCompact)
|
||||||
if (leftPanelWidth < 360) return 2
|
}, [isCompact, summaryBodyWidth, treemapGap, treemapMinCellSize])
|
||||||
if (leftPanelWidth < 520) return 3
|
|
||||||
return 4
|
|
||||||
}, [isCompact, leftPanelWidth, summaryBodyWidth])
|
|
||||||
|
|
||||||
const treemapItems = useMemo(() => {
|
const treemapItems = useMemo(() => {
|
||||||
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
|
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 maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
|
||||||
const allowFeaturedTile = !isCompact && treemapColumns > 1 && limitedItems.length > 2
|
|
||||||
|
|
||||||
return limitedItems.map((item, index) => {
|
return limitedItems.map((item, index) => {
|
||||||
const ratio = item.value / maxValue
|
const span = Math.min(getTreemapSpan(item.value, maxValue, treemapColumns), treemapColumns)
|
||||||
let colSpan = 1
|
|
||||||
let rowSpan = 1
|
|
||||||
|
|
||||||
if (allowFeaturedTile && index === 0 && ratio >= 0.35) {
|
|
||||||
colSpan = Math.min(2, treemapColumns)
|
|
||||||
rowSpan = colSpan
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
colSpan,
|
colSpan: span,
|
||||||
rowSpan,
|
rowSpan: span,
|
||||||
tone: palette[index % palette.length],
|
tone: palette[index % palette.length],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [distributionItems, isCompact, leftPanelWidth, treemapColumns])
|
}, [distributionItems, isCompact, treemapColumns])
|
||||||
|
|
||||||
const treemapRows = useMemo(
|
const treemapRows = useMemo(
|
||||||
() => estimateTreemapRows(treemapItems, treemapColumns),
|
() => estimateTreemapRows(treemapItems, treemapColumns),
|
||||||
[treemapColumns, treemapItems]
|
[treemapColumns, treemapItems]
|
||||||
)
|
)
|
||||||
|
|
||||||
const treemapGap = isCompact ? 8 : 10
|
|
||||||
const treemapBaseSize = Math.max(
|
const treemapBaseSize = Math.max(
|
||||||
isCompact ? 88 : 68,
|
treemapMinCellSize,
|
||||||
Math.min(
|
getTreemapBaseSize(summaryBodyWidth, treemapColumns, treemapGap, treemapMinCellSize)
|
||||||
isCompact ? 220 : 180,
|
|
||||||
Math.floor((Math.max(summaryBodyWidth, 0) - Math.max(0, treemapColumns - 1) * treemapGap) / treemapColumns)
|
|
||||||
) || (isCompact ? 88 : 68)
|
|
||||||
)
|
)
|
||||||
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
|
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
|
||||||
const treemapRowHeight = treemapBaseSize
|
const treemapRowHeight = treemapBaseSize
|
||||||
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
|
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
|
||||||
const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12
|
const { tilePadding: treemapTilePadding, labelSize: treemapLabelSize, valueSize: treemapValueSize } =
|
||||||
const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12
|
getTreemapTypography(treemapRowHeight)
|
||||||
const treemapValueSize = treemapRowHeight <= 72 ? 13 : treemapRowHeight <= 84 ? 15 : 16
|
|
||||||
|
|
||||||
const pageHeight = '100%'
|
const pageHeight = '100%'
|
||||||
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
|
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
|
||||||
@@ -801,18 +838,28 @@ function DataList() {
|
|||||||
{treemapItems.length > 0 ? treemapItems.map((item) => (
|
{treemapItems.length > 0 ? treemapItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
|
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}${isCompactTreemapItem(item) ? ' data-list-treemap-tile--compact' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
gridColumn: `span ${item.colSpan}`,
|
gridColumn: `span ${item.colSpan}`,
|
||||||
gridRow: `span ${item.rowSpan}`,
|
gridRow: `span ${item.rowSpan}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="data-list-treemap-head">
|
<div className="data-list-treemap-head">
|
||||||
<span className="data-list-summary-tile-icon">{item.icon}</span>
|
<Tooltip title={item.label}>
|
||||||
<Text className="data-list-treemap-label">{item.label}</Text>
|
<span className="data-list-summary-tile-icon">{item.icon}</span>
|
||||||
|
</Tooltip>
|
||||||
|
{!isCompactTreemapItem(item) ? (
|
||||||
|
<Tooltip title={item.label}>
|
||||||
|
<Text className="data-list-treemap-label">{item.label}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="data-list-treemap-body">
|
<div className="data-list-treemap-body">
|
||||||
<Text strong className="data-list-summary-tile-value">
|
<Text
|
||||||
|
strong
|
||||||
|
className="data-list-summary-tile-value"
|
||||||
|
style={{ fontSize: getTreemapItemValueSize(item, treemapValueSize) }}
|
||||||
|
>
|
||||||
{item.value.toLocaleString()}
|
{item.value.toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user