mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
[优化] 添加来源热度显示功能,更新相关数据结构和前端组件
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user