From 5858e30af60fed1ba246f916dfb02f007c277204 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 15 Mar 2026 17:07:00 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BF=AE=E5=A4=8D]=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=81=97=E6=BC=8F=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/components/data/echarts-core.tsx | 38 ++++ .../web/components/data/rank-chart.tsx | 77 +++++++ .../web/components/data/trend-chart.tsx | 214 ++++++++++++++++++ .../features/dashboard/data/world-geo.json | 125 ++++++++++ 4 files changed, 454 insertions(+) create mode 100644 openflare_server/web/components/data/echarts-core.tsx create mode 100644 openflare_server/web/components/data/rank-chart.tsx create mode 100644 openflare_server/web/components/data/trend-chart.tsx create mode 100644 openflare_server/web/features/dashboard/data/world-geo.json diff --git a/openflare_server/web/components/data/echarts-core.tsx b/openflare_server/web/components/data/echarts-core.tsx new file mode 100644 index 00000000..605dd87f --- /dev/null +++ b/openflare_server/web/components/data/echarts-core.tsx @@ -0,0 +1,38 @@ +'use client'; + +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import { + BarChart, + LineChart, + type BarSeriesOption, + type LineSeriesOption, +} from 'echarts/charts'; +import { + GridComponent, + LegendComponent, + TooltipComponent, + type GridComponentOption, + type LegendComponentOption, + type TooltipComponentOption, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +echarts.use([ + GridComponent, + LegendComponent, + TooltipComponent, + LineChart, + BarChart, + CanvasRenderer, +]); + +export type AppChartOption = echarts.ComposeOption< + | GridComponentOption + | LegendComponentOption + | TooltipComponentOption + | LineSeriesOption + | BarSeriesOption +>; + +export { ReactEChartsCore, echarts }; diff --git a/openflare_server/web/components/data/rank-chart.tsx b/openflare_server/web/components/data/rank-chart.tsx new file mode 100644 index 00000000..038bc25d --- /dev/null +++ b/openflare_server/web/components/data/rank-chart.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { formatCompactNumber } from '@/lib/utils/metrics'; + +type RankChartItem = { + label: string; + value: number; +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function RankChart({ + items, + color, + valueFormatter = formatCompactNumber, + emptyMessage = '暂无数据', +}: { + items: RankChartItem[]; + color: string; + valueFormatter?: (value: number) => string; + emptyMessage?: string; +}) { + const validItems = items.filter( + (item) => item.label.trim() !== '' && Number.isFinite(item.value), + ); + const maxValue = Math.max(1, ...validItems.map((item) => item.value)); + + if (validItems.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {validItems.map((item, index) => { + const width = clamp((item.value / maxValue) * 100, 6, 100); + + return ( +
+
+
+

+ #{index + 1} +

+

+ {item.label} +

+
+

+ {valueFormatter(item.value)} +

+
+ +
+
+
+
+ ); + })} +
+ ); +} diff --git a/openflare_server/web/components/data/trend-chart.tsx b/openflare_server/web/components/data/trend-chart.tsx new file mode 100644 index 00000000..aaf26504 --- /dev/null +++ b/openflare_server/web/components/data/trend-chart.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useId } from 'react'; + +import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics'; + +type TrendSeries = { + label: string; + color: string; + values: number[]; + fillColor?: string; + variant?: 'line' | 'area'; + valueFormatter?: (value: number) => string; +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function buildPath(points: Array<[number, number]>) { + if (points.length === 0) { + return ''; + } + + return points + .map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`) + .join(' '); +} + +export function TrendChart({ + labels, + series, + height = 240, + yAxisValueFormatter = formatCompactNumber, +}: { + labels: string[]; + series: TrendSeries[]; + height?: number; + yAxisValueFormatter?: (value: number) => string; +}) { + const chartId = useId(); + const width = 720; + const paddingTop = 18; + const paddingRight = 18; + const paddingBottom = 42; + const paddingLeft = 54; + const innerWidth = width - paddingLeft - paddingRight; + const innerHeight = height - paddingTop - paddingBottom; + + const normalizedSeries = series.filter((entry) => + entry.values.some((value) => Number.isFinite(value)), + ); + const pointCount = Math.max( + labels.length, + ...normalizedSeries.map((entry) => entry.values.length), + 0, + ); + const allValues = normalizedSeries.flatMap((entry) => + entry.values.filter((value) => Number.isFinite(value)), + ); + const axisMax = calculateNiceAxisMax(allValues); + const yTicks = 4; + + if (normalizedSeries.length === 0 || pointCount === 0) { + return ( +
+ 暂无趋势数据 +
+ ); + } + + const xStep = pointCount > 1 ? innerWidth / (pointCount - 1) : innerWidth / 2; + + return ( +
+
+ + {Array.from({ length: yTicks + 1 }, (_, index) => { + const value = axisMax - (axisMax / yTicks) * index; + const y = paddingTop + (innerHeight / yTicks) * index; + + return ( + + + + {yAxisValueFormatter(value)} + + + ); + })} + + {labels.map((label, index) => { + const x = + pointCount > 1 + ? paddingLeft + xStep * index + : paddingLeft + innerWidth / 2; + + return ( + + {label} + + ); + })} + + {normalizedSeries.map((entry, seriesIndex) => { + const points = entry.values.map((value, index) => { + const x = + pointCount > 1 + ? paddingLeft + xStep * index + : paddingLeft + innerWidth / 2; + const safeValue = Number.isFinite(value) ? value : 0; + const y = + paddingTop + + innerHeight - + (clamp(safeValue, 0, axisMax) / axisMax) * innerHeight; + return [x, y] as [number, number]; + }); + + const linePath = buildPath(points); + const areaPath = + points.length > 0 + ? `${linePath} L ${points[points.length - 1][0]} ${paddingTop + innerHeight} L ${points[0][0]} ${paddingTop + innerHeight} Z` + : ''; + const gradientId = `${chartId}-gradient-${seriesIndex}`; + + return ( + + {entry.variant === 'area' && entry.fillColor ? ( + <> + + + + + + + + + ) : null} + + + + {points.map(([x, y], pointIndex) => { + const value = entry.values[pointIndex] ?? 0; + const label = labels[pointIndex] ?? `点 ${pointIndex + 1}`; + const formatter = entry.valueFormatter ?? yAxisValueFormatter; + + return ( + + {`${entry.label} · ${label} · ${formatter(value)}`} + + ); + })} + + ); + })} + +
+ +
+ {normalizedSeries.map((entry) => ( +
+ + {entry.label} +
+ ))} +
+
+ ); +} diff --git a/openflare_server/web/features/dashboard/data/world-geo.json b/openflare_server/web/features/dashboard/data/world-geo.json new file mode 100644 index 00000000..cf34403b --- /dev/null +++ b/openflare_server/web/features/dashboard/data/world-geo.json @@ -0,0 +1,125 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "name": "Canada" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-140, 55], [-52, 55], [-52, 70], [-140, 70], [-140, 55]]] + } + }, + { + "type": "Feature", + "properties": { "name": "United States" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-125, 25], [-66, 25], [-66, 49], [-125, 49], [-125, 25]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Mexico" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-117, 14], [-86, 14], [-86, 33], [-117, 33], [-117, 14]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Brazil" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-74, -34], [-34, -34], [-34, 5], [-74, 5], [-74, -34]]] + } + }, + { + "type": "Feature", + "properties": { "name": "United Kingdom" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-8, 50], [2, 50], [2, 59], [-8, 59], [-8, 50]]] + } + }, + { + "type": "Feature", + "properties": { "name": "France" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-5, 42], [8, 42], [8, 51], [-5, 51], [-5, 42]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Germany" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[5, 47], [16, 47], [16, 55], [5, 55], [5, 47]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Spain" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-9, 36], [3, 36], [3, 44], [-9, 44], [-9, 36]]] + } + }, + { + "type": "Feature", + "properties": { "name": "South Africa" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[16, -35], [33, -35], [33, -22], [16, -22], [16, -35]]] + } + }, + { + "type": "Feature", + "properties": { "name": "India" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[68, 8], [89, 8], [89, 35], [68, 35], [68, 8]]] + } + }, + { + "type": "Feature", + "properties": { "name": "China" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[73, 18], [135, 18], [135, 53], [73, 53], [73, 18]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Japan" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[129, 31], [146, 31], [146, 46], [129, 46], [129, 31]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Singapore" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[103.5, 1.1], [104.1, 1.1], [104.1, 1.6], [103.5, 1.6], [103.5, 1.1]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Indonesia" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[95, -11], [141, -11], [141, 6], [95, 6], [95, -11]]] + } + }, + { + "type": "Feature", + "properties": { "name": "Australia" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[113, -44], [154, -44], [154, -10], [113, -10], [113, -44]]] + } + } + ] +}