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

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 { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json'; 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 { import {
getNodeStatusLabel, getNodeStatusLabel,
getOpenrestyStatusLabel, getOpenrestyStatusLabel,
@@ -76,6 +79,7 @@ type MapNodeDatum = {
type CountryRegionDatum = { type CountryRegionDatum = {
name: string; name: string;
value: number;
itemStyle: { itemStyle: {
areaColor: string; areaColor: string;
borderColor: string; borderColor: string;
@@ -133,10 +137,9 @@ function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
} }
return { return {
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [ coordinates: [
number, ...fallbackCoordinates[index % fallbackCoordinates.length],
number, ] as [number, number],
],
derivedFromGeo: false, derivedFromGeo: false,
}; };
} }
@@ -156,7 +159,10 @@ function ensureWorldMapRegistered() {
try { try {
const geoJson = worldGeoJson as WorldGeoJson; 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'); throw new Error('invalid world geojson payload');
} }
@@ -173,82 +179,22 @@ function ensureWorldMapRegistered() {
return true; return true;
} catch (error) { } catch (error) {
const registrationError = 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); console.error('Failed to register ECharts world map', registrationError);
return false; 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({ export function WorldStageMap({
isDark, isDark,
nodes, nodes,
sourceCountries,
}: { }: {
isDark: boolean; isDark: boolean;
nodes: DashboardNodeHealth[]; nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) { }) {
const router = useRouter(); const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null); const chartContainerRef = useRef<HTMLDivElement | null>(null);
@@ -304,10 +250,8 @@ export function WorldStageMap({
areaColor: '#13233b', areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.14)', borderColor: 'rgba(125,211,252,0.14)',
labelColor: '#e2e8f0', labelColor: '#e2e8f0',
healthyAreaColor: 'rgba(74, 222, 128, 0.18)', sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
dangerAreaColor: 'rgba(251, 113, 133, 0.18)', sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
healthyAreaBorder: 'rgba(134, 239, 172, 0.4)',
dangerAreaBorder: 'rgba(253, 164, 175, 0.42)',
healthyColor: '#34d399', healthyColor: '#34d399',
warningColor: '#fbbf24', warningColor: '#fbbf24',
dangerColor: '#fb7185', dangerColor: '#fb7185',
@@ -319,10 +263,8 @@ export function WorldStageMap({
areaColor: '#eaf2ff', areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.12)', borderColor: 'rgba(71,85,105,0.12)',
labelColor: '#0f172a', labelColor: '#0f172a',
healthyAreaColor: '#dcfce7', sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
dangerAreaColor: '#ffe4e6', sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
healthyAreaBorder: '#86efac',
dangerAreaBorder: '#fda4af',
healthyColor: '#10b981', healthyColor: '#10b981',
warningColor: '#f59e0b', warningColor: '#f59e0b',
dangerColor: '#f43f5e', dangerColor: '#f43f5e',
@@ -362,7 +304,11 @@ export function WorldStageMap({
activeEventCount: node.active_event_count, activeEventCount: node.active_event_count,
status: node.status, status: node.status,
openrestyStatus: node.openresty_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: { itemStyle: {
color: toneColor, color: toneColor,
borderColor: toneBorder, borderColor: toneBorder,
@@ -380,54 +326,40 @@ export function WorldStageMap({
); );
const countryRegions = useMemo<CountryRegionDatum[]>(() => { const countryRegions = useMemo<CountryRegionDatum[]>(() => {
const geoJson = worldGeoJson as WorldGeoJson; const maxRequests = Math.max(
const countryToneMap = new Map<string, Tone>(); 1,
...sourceCountries.map((item) => item.value || 0),
);
nodes.forEach((node, index) => { return sourceCountries
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index); .filter((item) => item.key && item.value > 0)
if (!derivedFromGeo) { .map((item) => {
return; 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); return {
if (!countryName) { name: item.key,
return; value: item.value,
}
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: { itemStyle: {
areaColor, areaColor,
borderColor, borderColor,
}, },
}, emphasis: {
}; itemStyle: {
}); areaColor,
}, [mapPalette, nodes]); borderColor,
},
},
};
});
}, [isDark, sourceCountries]);
const responsiveMapScale = useMemo(() => { const responsiveMapScale = useMemo(() => {
const { width, height } = containerSize; const { width, height } = containerSize;
@@ -472,8 +404,25 @@ export function WorldStageMap({
fontSize: 12, fontSize: 12,
}, },
formatter: (params: unknown) => { formatter: (params: unknown) => {
const data = (params as { data?: MapNodeDatum }).data; const payload = params as {
if (!data) { 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 ''; return '';
} }
@@ -512,17 +461,35 @@ export function WorldStageMap({
}, },
}, },
series: [ series: [
{
type: 'map',
map: 'world',
geoIndex: 0,
data: countryRegions,
silent: true,
z: 1,
emphasis: {
disabled: true,
},
},
{ {
type: 'scatter', type: 'scatter',
coordinateSystem: 'geo', coordinateSystem: 'geo',
data: mapNodes, data: mapNodes,
z: 3,
progressive: 64, progressive: 64,
large: true, large: true,
largeThreshold: 24, largeThreshold: 24,
symbolSize: (value: unknown) => { 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; 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: { label: {
show: false, 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) { if (!mapReady) {
@@ -14,7 +14,7 @@ import type {
} from '@/features/dashboard/types'; } from '@/features/dashboard/types';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
type Tone = 'healthy' | 'warning' | 'danger'; type Tone = 'healthy' | 'warning' | 'danger' | 'source';
const WorldStageMap = dynamic( const WorldStageMap = dynamic(
() => () =>
@@ -61,13 +61,18 @@ function HeroMetric({
</p> </p>
<p <p
className={cn( className={cn(
'mt-3 text-[30px] font-semibold leading-none', 'mt-3 text-[30px] leading-none font-semibold',
isDark ? 'text-white' : 'text-slate-950', isDark ? 'text-white' : 'text-slate-950',
)} )}
> >
{value} {value}
</p> </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} {hint}
</p> </p>
</div> </div>
@@ -92,9 +97,13 @@ function LegendPill({
? isDark ? isDark
? 'border-amber-300/20 bg-amber-400/10 text-amber-100' ? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
: 'border-amber-200 bg-amber-50 text-amber-700' : 'border-amber-200 bg-amber-50 text-amber-700'
: isDark : tone === 'source'
? 'border-rose-300/20 bg-rose-400/10 text-rose-100' ? isDark
: 'border-rose-200 bg-rose-50 text-rose-700'; ? '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 ( return (
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}> <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({ export function WorldStage({
summary, summary,
traffic, 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="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<p <p
className={cn( className={cn(
'text-[11px] tracking-[0.34em] uppercase', 'text-[11px] tracking-[0.34em] uppercase',
isDark ? 'text-sky-200/80' : 'text-sky-700/80', isDark ? 'text-sky-200/80' : 'text-sky-700/80',
)} )}
> >
Global Stage Global Stage
</p> </p>
<h2 <h2
className={cn( className={cn(
'text-2xl font-semibold', 'text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950', isDark ? 'text-white' : 'text-slate-950',
)} )}
> >
全球态势板 全球态势板
</h2> </h2>
@@ -266,18 +223,22 @@ export function WorldStage({
> >
<div <div
className={cn( 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 isDark
? 'bg-sky-400/20 text-sky-100' ? 'bg-sky-400/20 text-sky-100'
: 'bg-sky-100/90 text-sky-700', : 'bg-sky-100/90 text-sky-700',
)} )}
> >
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'} {sourceCountries.length > 0
? '访客来源热度'
: geoConfiguredNodes > 0
? '节点地理坐标'
: '节点覆盖信号'}
</div> </div>
<div <div
className={cn( 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 isDark
? 'bg-slate-900/45 text-slate-200' ? 'bg-slate-900/45 text-slate-200'
: 'bg-white/85 text-slate-600 shadow-[0_10px_24px_rgba(148,163,184,0.16)]', : '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>
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2"> <div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} /> <LegendPill
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} /> label="国家底色: 来源热度"
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} /> tone="source"
isDark={isDark}
/>
<LegendPill
label="绿色: 运行正常"
tone="healthy"
isDark={isDark}
/>
<LegendPill
label="黄色: 资源承压"
tone="warning"
isDark={isDark}
/>
<LegendPill
label="红色: 异常待处理"
tone="danger"
isDark={isDark}
/>
</div> </div>
<div <div
ref={mapViewportRef} 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"> <div className="h-full w-full min-w-0 overflow-hidden">
{shouldRenderMap ? ( {shouldRenderMap ? (
<WorldStageMap isDark={isDark} nodes={nodes} /> <WorldStageMap
isDark={isDark}
nodes={nodes}
sourceCountries={sourceCountries}
/>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<EmptyState <EmptyState
@@ -311,7 +293,7 @@ export function WorldStage({
</div> </div>
{shouldRenderMap && nodes.length === 0 ? ( {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 <div
className={cn( className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur', 'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
@@ -324,30 +306,6 @@ export function WorldStage({
</div> </div>
</div> </div>
) : null} ) : 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>
</div> </div>