136 lines
3.5 KiB
TypeScript
136 lines
3.5 KiB
TypeScript
"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%" }}
|
|
/>
|
|
);
|
|
}
|