diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index a8d8c334..f90895fc 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -84,49 +84,6 @@ function buildNodeRankItems( })); } -function OverviewMetric({ - label, - value, - hint, - accent = 'sky', -}: { - label: string; - value: string | number; - hint?: string; - accent?: 'sky' | 'amber' | 'emerald' | 'rose'; -}) { - const accentClass = - accent === 'amber' - ? 'from-amber-400/14 to-transparent' - : accent === 'emerald' - ? 'from-emerald-400/14 to-transparent' - : accent === 'rose' - ? 'from-rose-400/14 to-transparent' - : 'from-sky-400/14 to-transparent'; - - return ( -
-
-
-

- {label} -

-

- {value} -

- {hint ? ( -

- {hint} -

- ) : null} -
-
-
- ); -} - function NodeHealthRow({ node }: { node: DashboardNodeHealth }) { return ( - -
- - - - - - -
-
-
point[1] !== yj > point[1] && + point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi || Number.EPSILON) + xi + ) { + inside = !inside; + } + } + + return inside; +} + +function isPointInPolygon(point: [number, number], rings: number[][][]) { + if (rings.length === 0 || !isPointInRing(point, rings[0] ?? [])) { + return false; + } + + for (let index = 1; index < rings.length; index += 1) { + if (isPointInRing(point, rings[index] ?? [])) { + return false; + } + } + + return true; +} + +function getCountryNameByCoordinates( + coordinates: [number, number], + geoJson: WorldGeoJson, +) { + for (const feature of geoJson.features ?? []) { + const name = feature.properties?.name; + const geometryType = feature.geometry?.type; + const geometryCoordinates = feature.geometry?.coordinates; + + if (!name || !geometryType || !geometryCoordinates) { + continue; + } + + if ( + geometryType === 'Polygon' && + isPointInPolygon(coordinates, geometryCoordinates as number[][][]) + ) { + return name; + } + + if (geometryType === 'MultiPolygon') { + for (const polygon of geometryCoordinates as number[][][][]) { + if (isPointInPolygon(coordinates, polygon)) { + return name; + } + } + } + } + + return null; +} + export function WorldStageMap({ isDark, nodes, @@ -176,6 +264,10 @@ export function WorldStageMap({ areaColor: '#13233b', borderColor: 'rgba(125,211,252,0.14)', labelColor: '#e2e8f0', + healthyAreaColor: 'rgba(74, 222, 128, 0.18)', + dangerAreaColor: 'rgba(251, 113, 133, 0.18)', + healthyAreaBorder: 'rgba(134, 239, 172, 0.4)', + dangerAreaBorder: 'rgba(253, 164, 175, 0.42)', healthyColor: '#34d399', warningColor: '#fbbf24', dangerColor: '#fb7185', @@ -187,6 +279,10 @@ export function WorldStageMap({ areaColor: '#eaf2ff', borderColor: 'rgba(71,85,105,0.12)', labelColor: '#0f172a', + healthyAreaColor: '#dcfce7', + dangerAreaColor: '#ffe4e6', + healthyAreaBorder: '#86efac', + dangerAreaBorder: '#fda4af', healthyColor: '#10b981', warningColor: '#f59e0b', dangerColor: '#f43f5e', @@ -243,6 +339,56 @@ export function WorldStageMap({ [mapPalette, nodes], ); + const countryRegions = useMemo(() => { + const geoJson = worldGeoJson as WorldGeoJson; + const countryToneMap = new Map(); + + nodes.forEach((node, index) => { + const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index); + if (!derivedFromGeo) { + return; + } + + const countryName = getCountryNameByCoordinates(coordinates, geoJson); + if (!countryName) { + return; + } + + const nextTone = getNodeTone(node); + const normalizedTone = nextTone === 'healthy' ? 'healthy' : 'danger'; + const currentTone = countryToneMap.get(countryName); + + if (!currentTone || currentTone === 'healthy') { + countryToneMap.set(countryName, normalizedTone); + } + }); + + return Array.from(countryToneMap.entries()).map(([name, tone]) => { + const areaColor = + tone === 'healthy' + ? mapPalette.healthyAreaColor + : mapPalette.dangerAreaColor; + const borderColor = + tone === 'healthy' + ? mapPalette.healthyAreaBorder + : mapPalette.dangerAreaBorder; + + return { + name, + itemStyle: { + areaColor, + borderColor, + }, + emphasis: { + itemStyle: { + areaColor, + borderColor, + }, + }, + }; + }); + }, [mapPalette, nodes]); + const mapOption = useMemo( () => ({ animation: false, @@ -287,6 +433,7 @@ export function WorldStageMap({ layoutCenter: ['50%', '50%'], layoutSize: '180%', zoom: 1, + regions: countryRegions, itemStyle: { areaColor: mapPalette.areaColor, borderColor: mapPalette.borderColor, @@ -334,7 +481,7 @@ export function WorldStageMap({ }, ], }), - [isDark, mapNodes, mapPalette], + [countryRegions, isDark, mapNodes, mapPalette], ); if (!mapReady) { diff --git a/atsf_server/web/features/dashboard/components/world-stage.tsx b/atsf_server/web/features/dashboard/components/world-stage.tsx index 76428f3f..8d662ccc 100644 --- a/atsf_server/web/features/dashboard/components/world-stage.tsx +++ b/atsf_server/web/features/dashboard/components/world-stage.tsx @@ -233,18 +233,28 @@ export function WorldStage({ )} >
-

- Global Stage -

+
+

+ Global Stage +

+

+ 全球态势板 +

+
-
+
- -
- 当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了地理坐标。 - 未配置坐标的节点会映射到预设城市点位,用于维持全球覆盖视图的连续性。 -
@@ -368,6 +366,18 @@ export function WorldStage({ hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`} isDark={isDark} /> + +