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 (
+
+
+
+
+
+
+ {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]]]
+ }
+ }
+ ]
+}