[优化] 添加来源热度显示功能,更新相关数据结构和前端组件

This commit is contained in:
ryan
2026-03-15 16:00:44 +08:00
parent 6d5d47c216
commit d68773c554
2 changed files with 160 additions and 227 deletions
@@ -11,7 +11,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type { DashboardNodeHealth } from '@/features/dashboard/types';
import type {
DashboardNodeHealth,
DistributionItem,
} from '@/features/dashboard/types';
import {
getNodeStatusLabel,
getOpenrestyStatusLabel,
@@ -76,6 +79,7 @@ type MapNodeDatum = {
type CountryRegionDatum = {
name: string;
value: number;
itemStyle: {
areaColor: string;
borderColor: string;
@@ -133,10 +137,9 @@ function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
}
return {
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
number,
number,
],
coordinates: [
...fallbackCoordinates[index % fallbackCoordinates.length],
] as [number, number],
derivedFromGeo: false,
};
}
@@ -156,7 +159,10 @@ function ensureWorldMapRegistered() {
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
if (
geoJson.type !== 'FeatureCollection' ||
!Array.isArray(geoJson.features)
) {
throw new Error('invalid world geojson payload');
}
@@ -173,82 +179,22 @@ function ensureWorldMapRegistered() {
return true;
} catch (error) {
const registrationError =
error instanceof Error ? error : new Error('unknown world map registration error');
error instanceof Error
? error
: new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', registrationError);
return false;
}
}
function isPointInRing(point: [number, number], ring: number[][]) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i] ?? [];
const [xj, yj] = ring[j] ?? [];
if (
yi > 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,
sourceCountries,
}: {
isDark: boolean;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null);
@@ -304,10 +250,8 @@ 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)',
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
@@ -319,10 +263,8 @@ export function WorldStageMap({
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.12)',
labelColor: '#0f172a',
healthyAreaColor: '#dcfce7',
dangerAreaColor: '#ffe4e6',
healthyAreaBorder: '#86efac',
dangerAreaBorder: '#fda4af',
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#f43f5e',
@@ -362,7 +304,11 @@ export function WorldStageMap({
activeEventCount: node.active_event_count,
status: node.status,
openrestyStatus: node.openresty_status,
value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)],
value: [
coordinates[0],
coordinates[1],
Math.max(node.request_count, 1),
],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
@@ -380,54 +326,40 @@ export function WorldStageMap({
);
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
const geoJson = worldGeoJson as WorldGeoJson;
const countryToneMap = new Map<string, Tone>();
const maxRequests = Math.max(
1,
...sourceCountries.map((item) => item.value || 0),
);
nodes.forEach((node, index) => {
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
if (!derivedFromGeo) {
return;
}
return sourceCountries
.filter((item) => item.key && item.value > 0)
.map((item) => {
const intensity = Math.max(0.18, item.value / maxRequests);
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
const areaColor = isDark
? `rgba(56, 189, 248, ${areaOpacity})`
: `rgba(14, 165, 233, ${areaOpacity})`;
const borderColor = isDark
? `rgba(125, 211, 252, ${borderOpacity})`
: `rgba(14, 165, 233, ${borderOpacity})`;
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: {
return {
name: item.key,
value: item.value,
itemStyle: {
areaColor,
borderColor,
},
},
};
});
}, [mapPalette, nodes]);
emphasis: {
itemStyle: {
areaColor,
borderColor,
},
},
};
});
}, [isDark, sourceCountries]);
const responsiveMapScale = useMemo(() => {
const { width, height } = containerSize;
@@ -472,8 +404,25 @@ export function WorldStageMap({
fontSize: 12,
},
formatter: (params: unknown) => {
const data = (params as { data?: MapNodeDatum }).data;
if (!data) {
const payload = params as {
data?: MapNodeDatum | CountryRegionDatum;
seriesType?: string;
name?: string;
};
const data = payload.data;
if (
payload.seriesType !== 'scatter' &&
data &&
'value' in data &&
typeof data.value === 'number'
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
].join('');
}
if (!data || !('requestCount' in data)) {
return '';
}
@@ -512,17 +461,35 @@ export function WorldStageMap({
},
},
series: [
{
type: 'map',
map: 'world',
geoIndex: 0,
data: countryRegions,
silent: true,
z: 1,
emphasis: {
disabled: true,
},
},
{
type: 'scatter',
coordinateSystem: 'geo',
data: mapNodes,
z: 3,
progressive: 64,
large: true,
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
const size =
Array.isArray(value) && typeof value[2] === 'number'
? value[2]
: 1;
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
return Math.max(7, Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)));
return Math.max(
7,
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
);
},
label: {
show: false,
@@ -550,7 +517,15 @@ export function WorldStageMap({
},
],
}),
[computedLayoutSize, computedZoom, countryRegions, isDark, mapNodes, mapPalette, responsiveMapScale],
[
computedLayoutSize,
computedZoom,
countryRegions,
isDark,
mapNodes,
mapPalette,
responsiveMapScale,
],
);
if (!mapReady) {
@@ -14,7 +14,7 @@ import type {
} from '@/features/dashboard/types';
import { cn } from '@/lib/utils/cn';
type Tone = 'healthy' | 'warning' | 'danger';
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
const WorldStageMap = dynamic(
() =>
@@ -61,13 +61,18 @@ function HeroMetric({
</p>
<p
className={cn(
'mt-3 text-[30px] font-semibold leading-none',
'mt-3 text-[30px] leading-none font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{value}
</p>
<p className={cn('mt-2 text-sm', isDark ? 'text-slate-300' : 'text-slate-600')}>
<p
className={cn(
'mt-2 text-sm',
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
{hint}
</p>
</div>
@@ -92,9 +97,13 @@ function LegendPill({
? isDark
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
: 'border-amber-200 bg-amber-50 text-amber-700'
: isDark
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
: 'border-rose-200 bg-rose-50 text-rose-700';
: tone === 'source'
? isDark
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
: 'border-sky-200 bg-sky-50 text-sky-700'
: isDark
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
: 'border-rose-200 bg-rose-50 text-rose-700';
return (
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
@@ -103,58 +112,6 @@ function LegendPill({
);
}
function CountrySignal({
item,
index,
isDark,
}: {
item: DistributionItem;
index: number;
isDark: boolean;
}) {
const darkAccents = [
'from-sky-400/35 to-cyan-400/10',
'from-violet-400/35 to-fuchsia-400/10',
'from-emerald-400/35 to-teal-400/10',
];
const lightAccents = [
'from-sky-100 via-white to-cyan-50',
'from-indigo-100 via-white to-fuchsia-50',
'from-emerald-100 via-white to-teal-50',
];
return (
<div
className={cn(
'rounded-2xl border bg-gradient-to-br px-4 py-3 backdrop-blur',
isDark
? `border-white/10 ${darkAccents[index % darkAccents.length]}`
: `border-slate-200/80 ${lightAccents[index % lightAccents.length]} shadow-[0_14px_30px_rgba(148,163,184,0.12)]`,
)}
>
<p
className={cn(
'text-[11px] tracking-[0.24em] uppercase',
isDark ? 'text-slate-200' : 'text-slate-500',
)}
>
{item.key}
</p>
<p
className={cn(
'mt-2 text-lg font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{item.value.toLocaleString('zh-CN')}
</p>
<p className={cn('mt-1 text-xs', isDark ? 'text-slate-300' : 'text-slate-600')}>
最近 24 小时来源信号
</p>
</div>
);
}
export function WorldStage({
summary,
traffic,
@@ -235,18 +192,18 @@ export function WorldStage({
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-2">
<p
className={cn(
'text-[11px] tracking-[0.34em] uppercase',
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
)}
className={cn(
'text-[11px] tracking-[0.34em] uppercase',
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
)}
>
Global Stage
</p>
<h2
className={cn(
'text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
className={cn(
'text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
全球态势板
</h2>
@@ -266,18 +223,22 @@ export function WorldStage({
>
<div
className={cn(
'absolute left-6 top-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
isDark
? 'bg-sky-400/20 text-sky-100'
: 'bg-sky-100/90 text-sky-700',
)}
>
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
{sourceCountries.length > 0
? '访客来源热度'
: geoConfiguredNodes > 0
? '节点地理坐标'
: '节点覆盖信号'}
</div>
<div
className={cn(
'absolute left-6 top-16 z-10 rounded-full px-3 py-1 text-[11px] backdrop-blur',
'absolute top-16 left-6 z-10 rounded-full px-3 py-1 text-[11px] backdrop-blur',
isDark
? 'bg-slate-900/45 text-slate-200'
: 'bg-white/85 text-slate-600 shadow-[0_10px_24px_rgba(148,163,184,0.16)]',
@@ -286,19 +247,40 @@ export function WorldStage({
拖动平移,滚轮缩放
</div>
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} />
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} />
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} />
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
<LegendPill
label="国家底色: 来源热度"
tone="source"
isDark={isDark}
/>
<LegendPill
label="绿色: 运行正常"
tone="healthy"
isDark={isDark}
/>
<LegendPill
label="黄色: 资源承压"
tone="warning"
isDark={isDark}
/>
<LegendPill
label="红色: 异常待处理"
tone="danger"
isDark={isDark}
/>
</div>
<div
ref={mapViewportRef}
className="absolute inset-x-3 top-16 bottom-28 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-32"
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
>
<div className="h-full w-full min-w-0 overflow-hidden">
{shouldRenderMap ? (
<WorldStageMap isDark={isDark} nodes={nodes} />
<WorldStageMap
isDark={isDark}
nodes={nodes}
sourceCountries={sourceCountries}
/>
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
@@ -311,7 +293,7 @@ export function WorldStage({
</div>
{shouldRenderMap && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-6 bottom-28 z-10">
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
@@ -324,30 +306,6 @@ export function WorldStage({
</div>
</div>
) : null}
<div className="absolute bottom-6 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
{sourceCountries.length > 0 ? (
sourceCountries.slice(0, 3).map((item, index) => (
<CountrySignal
key={`${item.key}-${item.value}`}
item={item}
index={index}
isDark={isDark}
/>
))
) : (
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur md:col-span-3',
isDark
? 'border-white/15 bg-white/5 text-slate-300'
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
暂无可用于全球来源展示的国家分布数据。
</div>
)}
</div>
</div>
</div>