This commit is contained in:
eric
2026-03-09 05:07:59 -05:00
parent 302a072036
commit eb852fb11d
27 changed files with 1523 additions and 169 deletions

View File

@@ -0,0 +1,135 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as echarts from "echarts";
import type { MapCity } from "./types";
const CHINA_MAP_URL = "/api/geo/china";
export interface MembersMapProps {
data: MapCity[];
locale?: "zh" | "en";
dark?: boolean;
className?: string;
onCityHover?: (city: MapCity | null) => void;
}
export function MembersMap({
data,
locale = "zh",
dark = false,
className = "",
onCityHover,
}: MembersMapProps) {
const chartRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<echarts.ECharts | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
if (!chartRef.current || data.length === 0) return;
const init = async () => {
try {
const res = await fetch(CHINA_MAP_URL);
const geoJson = await res.json();
echarts.registerMap("china", geoJson);
} catch {
// 若加载失败,使用 scatter 在 geo 坐标系(无底图)
echarts.registerMap("china", {
type: "FeatureCollection",
features: [],
});
}
const chart = echarts.init(chartRef.current!);
instanceRef.current = chart;
const scatterData = data.map((c) => ({
name: locale === "zh" ? c.name : c.nameEn,
value: [c.lng, c.lat, c.nomadsNow],
id: c.id,
}));
const maxVal = Math.max(...data.map((c) => c.nomadsNow), 1);
const option: echarts.EChartsOption = {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
formatter: (params: unknown) => {
const p = params as { data: { value: [number, number, number]; name: string } };
return `${p.data.name}<br/>${p.data.value[2]} 人在此`;
},
},
geo: {
map: "china",
roam: true,
zoom: 1.2,
itemStyle: {
areaColor: dark ? "#1e293b" : "#f1f5f9",
borderColor: dark ? "#334155" : "#e2e8f0",
},
emphasis: {
itemStyle: {
areaColor: dark ? "#334155" : "#e2e8f0",
},
},
},
series: [
{
type: "scatter",
coordinateSystem: "geo",
data: scatterData,
symbolSize: (val: number[]) => 8 + (val[2] / maxVal) * 24,
itemStyle: {
color: "#ff4d4f",
borderColor: "#fff",
borderWidth: 1,
},
emphasis: {
scale: 1.3,
itemStyle: {
borderWidth: 2,
},
},
},
],
};
chart.setOption(option);
setReady(true);
chart.on("mouseover", (params: { data?: { id?: number } }) => {
const id = params.data?.id;
const city = id ? (data.find((c) => c.id === id) ?? null) : null;
onCityHover?.(city);
});
chart.on("mouseout", () => onCityHover?.(null));
};
init();
return () => {
instanceRef.current?.dispose();
instanceRef.current = null;
};
}, [data, locale, onCityHover]);
useEffect(() => {
if (!instanceRef.current || !ready) return;
instanceRef.current.resize();
}, [ready]);
useEffect(() => {
const handleResize = () => instanceRef.current?.resize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div
ref={chartRef}
className={className}
style={{ minHeight: 360, width: "100%" }}
/>
);
}

66
app/lib/charts/README.md Normal file
View File

@@ -0,0 +1,66 @@
# 图表 SDK
基于 ECharts 封装的图表组件库,供项目统一调用。
## 组件
### MembersMap 成员地图
中国地图 + 城市散点,展示成员分布。
```tsx
import { MembersMap } from "@/app/lib/charts";
import { MAP_CITIES } from "@/app/data/map-cities";
<MembersMap
data={MAP_CITIES}
locale="zh"
dark={false}
onCityHover={(city) => setHoveredCity(city)}
/>
```
### BarChart 柱状图
```tsx
import { BarChart } from "@/app/lib/charts";
<BarChart
data={[{ name: "上海", value: 1200 }, { name: "深圳", value: 850 }]}
title="城市排名"
dark={false}
/>
```
### PieChart 饼图
```tsx
import { PieChart } from "@/app/lib/charts";
<PieChart
data={[{ name: "华东", value: 3200 }, { name: "华南", value: 1800 }]}
title="区域分布"
dark={false}
/>
```
### LineChart 折线图
```tsx
import { LineChart } from "@/app/lib/charts";
<LineChart
data={{
xAxis: ["1月", "2月", "3月"],
series: [{ name: "新增成员", data: [320, 480, 520] }],
}}
title="增长趋势"
dark={false}
/>
```
## 类型
- `MapCity`: 地图城市数据
- `BarChartDataItem`: 柱状图数据项
- `PieChartDataItem`: 饼图数据项

View File

@@ -0,0 +1,156 @@
"use client";
import { useEffect, useRef } from "react";
import * as echarts from "echarts";
import type { BarChartDataItem, PieChartDataItem } from "./types";
export interface BarChartProps {
data: BarChartDataItem[];
title?: string;
dark?: boolean;
color?: string;
className?: string;
}
export function BarChart({
data,
title,
dark = false,
color = "#ff4d4f",
className = "",
}: BarChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const textColor = dark ? "#e2e8f0" : "#334155";
chart.setOption({
title: title ? { text: title, left: "center", textStyle: { color: textColor } } : undefined,
tooltip: { trigger: "axis" },
grid: { left: "3%", right: "4%", bottom: "3%", top: title ? 40 : 20, containLabel: true },
xAxis: {
type: "category",
data: data.map((d) => d.name),
axisLabel: { color: textColor },
},
yAxis: {
type: "value",
axisLabel: { color: textColor },
splitLine: { lineStyle: { color: dark ? "#334155" : "#e2e8f0" } },
},
series: [
{
type: "bar",
data: data.map((d) => d.value),
itemStyle: { color },
barMaxWidth: 36,
},
],
});
return () => chart.dispose();
}, [data, title, dark, color]);
return <div ref={ref} className={className} style={{ height: 280, width: "100%" }} />;
}
export interface PieChartProps {
data: PieChartDataItem[];
title?: string;
dark?: boolean;
className?: string;
}
const PIE_COLORS = ["#ff4d4f", "#f97316", "#eab308", "#22c55e", "#0ea5e9", "#8b5cf6"];
export function PieChart({
data,
title,
dark = false,
className = "",
}: PieChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const textColor = dark ? "#e2e8f0" : "#334155";
chart.setOption({
title: title ? { text: title, left: "center", textStyle: { color: textColor } } : undefined,
tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" },
legend: { bottom: 0, textStyle: { color: textColor } },
series: [
{
type: "pie",
radius: ["40%", "70%"],
center: ["50%", title ? "45%" : "50%"],
data: data.map((d, i) => ({
name: d.name,
value: d.value,
itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] },
})),
},
],
});
return () => chart.dispose();
}, [data, title, dark]);
return <div ref={ref} className={className} style={{ height: 280, width: "100%" }} />;
}
export interface LineChartProps {
data: { xAxis: string[]; series: { name: string; data: number[] }[] };
title?: string;
dark?: boolean;
className?: string;
}
export function LineChart({
data,
title,
dark = false,
className = "",
}: LineChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || !data.xAxis?.length) return;
const chart = echarts.init(ref.current);
const textColor = dark ? "#e2e8f0" : "#334155";
chart.setOption({
title: title ? { text: title, left: "center", textStyle: { color: textColor } } : undefined,
tooltip: { trigger: "axis" },
legend: { bottom: 0, textStyle: { color: textColor } },
grid: { left: "3%", right: "4%", bottom: 60, top: title ? 40 : 20, containLabel: true },
xAxis: {
type: "category",
data: data.xAxis,
axisLabel: { color: textColor },
},
yAxis: {
type: "value",
axisLabel: { color: textColor },
splitLine: { lineStyle: { color: dark ? "#334155" : "#e2e8f0" } },
},
series: data.series.map((s, i) => ({
type: "line",
name: s.name,
data: s.data,
smooth: true,
itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] },
})),
});
return () => chart.dispose();
}, [data, title, dark]);
return <div ref={ref} className={className} style={{ height: 280, width: "100%" }} />;
}

16
app/lib/charts/index.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* 图表 SDK - 基于 ECharts 封装
* 提供成员地图、柱状图、饼图、折线图等组件
*/
export { MembersMap } from "./MembersMap";
export { BarChart, PieChart, LineChart } from "./ReportChart";
export type {
MapCity,
ChartOption,
BarChartDataItem,
PieChartDataItem,
LineChartDataItem,
} from "./types";
export type { MembersMapProps } from "./MembersMap";
export type { BarChartProps, PieChartProps, LineChartProps } from "./ReportChart";

33
app/lib/charts/types.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 图表 SDK 类型定义
*/
export interface MapCity {
id: number;
name: string;
nameEn: string;
emoji: string;
lat: number;
lng: number;
nomadsNow: number;
}
export interface ChartOption {
title?: string;
dark?: boolean;
}
export interface BarChartDataItem {
name: string;
value: number;
}
export interface PieChartDataItem {
name: string;
value: number;
}
export interface LineChartDataItem {
name: string;
data: number[];
}