feat: 用 react-map-gl + MapLibre 替换 react-simple-maps 实现 2D 地图
- 替换 react-simple-maps/d3-geo/topojson-client 为 react-map-gl + maplibre-gl - 使用 CARTO dark-matter 免费暗色瓦片,自带国家/城市名标注 - 基于 locale 动态切换地图标注语言(name:zh / name_en) - MapLibre 原生 heatmap + circle 双层渲染替代 SVG 热力图 - 提取 MapPopup 组件,配合 react-map-gl Popup 实现点击弹窗 - continent page 改为 dynamic import (ssr: false) - dev 模式去掉 Turbopack 以兼容 maplibre-gl - 删除 heatmap-layer.tsx 和 react-simple-maps 类型声明
This commit is contained in:
@@ -33,21 +33,24 @@ interface ArcData {
|
||||
|
||||
export function GlobeView() {
|
||||
const t = useTranslations("globe");
|
||||
const tPopup = useTranslations("clawPopup");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globeRef = useRef<any>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [hoveredPoint, setHoveredPoint] = useState<HeatmapPoint | null>(null);
|
||||
const [selectedPoint, setSelectedPoint] = useState<HeatmapPoint | null>(null);
|
||||
const { points } = useHeatmapData(30000);
|
||||
|
||||
// Generate arcs from recent activity (connecting pairs of active points)
|
||||
// Generate arcs only between online points
|
||||
const arcs = useMemo((): ArcData[] => {
|
||||
if (points.length < 2) return [];
|
||||
const onlinePoints = points.filter((p) => p.onlineCount > 0);
|
||||
if (onlinePoints.length < 2) return [];
|
||||
const result: ArcData[] = [];
|
||||
const maxArcs = Math.min(points.length - 1, 8);
|
||||
const maxArcs = Math.min(onlinePoints.length - 1, 8);
|
||||
for (let i = 0; i < maxArcs; i++) {
|
||||
const from = points[i];
|
||||
const to = points[(i + 1) % points.length];
|
||||
const from = onlinePoints[i];
|
||||
const to = onlinePoints[(i + 1) % onlinePoints.length];
|
||||
result.push({
|
||||
startLat: from.lat,
|
||||
startLng: from.lng,
|
||||
@@ -99,6 +102,10 @@ export function GlobeView() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointClick = useCallback((point: object) => {
|
||||
setSelectedPoint(point as HeatmapPoint);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-white/5">
|
||||
{dimensions.width > 0 && (
|
||||
@@ -114,11 +121,23 @@ export function GlobeView() {
|
||||
pointsData={points}
|
||||
pointLat={(d: object) => (d as HeatmapPoint).lat}
|
||||
pointLng={(d: object) => (d as HeatmapPoint).lng}
|
||||
pointAltitude={(d: object) => Math.min((d as HeatmapPoint).weight * 0.02, 0.15)}
|
||||
pointRadius={(d: object) => Math.max((d as HeatmapPoint).weight * 0.3, 0.4)}
|
||||
pointColor={() => "#00f0ff"}
|
||||
pointAltitude={(d: object) => {
|
||||
const p = d as HeatmapPoint;
|
||||
const base = Math.min(p.weight * 0.02, 0.15);
|
||||
return p.onlineCount > 0 ? base : base * 0.5;
|
||||
}}
|
||||
pointRadius={(d: object) => {
|
||||
const p = d as HeatmapPoint;
|
||||
const base = Math.max(p.weight * 0.3, 0.4);
|
||||
return p.onlineCount > 0 ? base : base * 0.6;
|
||||
}}
|
||||
pointColor={(d: object) => {
|
||||
const p = d as HeatmapPoint;
|
||||
return p.onlineCount > 0 ? "#00f0ff" : "#1a5c63";
|
||||
}}
|
||||
pointsMerge={false}
|
||||
onPointHover={(point: object | null) => setHoveredPoint(point as HeatmapPoint | null)}
|
||||
onPointClick={handlePointClick}
|
||||
// Arcs
|
||||
arcsData={arcs}
|
||||
arcStartLat={(d: object) => (d as ArcData).startLat}
|
||||
@@ -136,8 +155,8 @@ export function GlobeView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredPoint && (
|
||||
{/* Hover Tooltip */}
|
||||
{hoveredPoint && !selectedPoint && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-4 z-20 -translate-x-1/2">
|
||||
<div className="rounded-xl border border-white/10 bg-[var(--bg-card)]/95 p-3 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -150,12 +169,58 @@ export function GlobeView() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
{t("activeClaws", { count: hoveredPoint.clawCount })}
|
||||
{t("totalClaws", { total: hoveredPoint.clawCount, online: hoveredPoint.onlineCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Click Popup */}
|
||||
{selectedPoint && (
|
||||
<div className="absolute bottom-4 left-4 z-20 w-72">
|
||||
<div className="rounded-xl border border-[var(--accent-cyan)]/20 bg-[var(--bg-card)]/95 p-4 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">
|
||||
{selectedPoint.city}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">{selectedPoint.country}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<span className="rounded-full bg-[var(--accent-cyan)]/10 px-2 py-0.5 text-xs text-[var(--accent-cyan)]">
|
||||
{tPopup("total", { count: selectedPoint.clawCount })}
|
||||
</span>
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-xs text-green-400">
|
||||
{tPopup("online", { count: selectedPoint.onlineCount })}
|
||||
</span>
|
||||
</div>
|
||||
{selectedPoint.claws.length > 0 && (
|
||||
<div className="mt-3 max-h-40 space-y-1.5 overflow-y-auto">
|
||||
{selectedPoint.claws.map((claw) => (
|
||||
<div key={claw.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
claw.isOnline ? "bg-green-400" : "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate font-mono text-xs text-[var(--text-secondary)]">
|
||||
{claw.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GlobeControls
|
||||
onResetView={handleResetView}
|
||||
onZoomIn={handleZoomIn}
|
||||
|
||||
Reference in New Issue
Block a user