'x'
This commit is contained in:
135
app/lib/charts/MembersMap.tsx
Normal file
135
app/lib/charts/MembersMap.tsx
Normal 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
66
app/lib/charts/README.md
Normal 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`: 饼图数据项
|
||||
156
app/lib/charts/ReportChart.tsx
Normal file
156
app/lib/charts/ReportChart.tsx
Normal 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
16
app/lib/charts/index.ts
Normal 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
33
app/lib/charts/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user