Files
openclaw-market/components/globe/globe-view.tsx
richarjiang e79d721615 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 类型声明
2026-03-13 14:46:23 +08:00

232 lines
8.2 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
import { GlobeControls } from "./globe-controls";
function GlobeLoading() {
const t = useTranslations("globe");
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--accent-cyan)] border-t-transparent" />
<span className="font-mono text-xs text-[var(--text-muted)]">{t("loading")}</span>
</div>
</div>
);
}
const Globe = dynamic(() => import("react-globe.gl"), {
ssr: false,
loading: () => <GlobeLoading />,
});
interface ArcData {
startLat: number;
startLng: number;
endLat: number;
endLng: number;
color: string;
}
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 only between online points
const arcs = useMemo((): ArcData[] => {
const onlinePoints = points.filter((p) => p.onlineCount > 0);
if (onlinePoints.length < 2) return [];
const result: ArcData[] = [];
const maxArcs = Math.min(onlinePoints.length - 1, 8);
for (let i = 0; i < maxArcs; i++) {
const from = onlinePoints[i];
const to = onlinePoints[(i + 1) % onlinePoints.length];
result.push({
startLat: from.lat,
startLng: from.lng,
endLat: to.lat,
endLng: to.lng,
color: "rgba(0, 240, 255, 0.4)",
});
}
return result;
}, [points]);
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
});
}
};
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (globeRef.current) {
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 2.5 });
}
}, [dimensions]);
const handleResetView = useCallback(() => {
if (globeRef.current) {
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 2.5 });
}
}, []);
const handleZoomIn = useCallback(() => {
if (globeRef.current) {
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 1.5 });
}
}, []);
const handleZoomOut = useCallback(() => {
if (globeRef.current) {
globeRef.current.pointOfView({ lat: 20, lng: 100, altitude: 3.5 });
}
}, []);
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 && (
<Globe
ref={globeRef}
width={dimensions.width}
height={dimensions.height}
backgroundColor="rgba(0,0,0,0)"
globeImageUrl="//unpkg.com/three-globe/example/img/earth-dark.jpg"
atmosphereColor="#00f0ff"
atmosphereAltitude={0.15}
// Points
pointsData={points}
pointLat={(d: object) => (d as HeatmapPoint).lat}
pointLng={(d: object) => (d as HeatmapPoint).lng}
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}
arcStartLng={(d: object) => (d as ArcData).startLng}
arcEndLat={(d: object) => (d as ArcData).endLat}
arcEndLng={(d: object) => (d as ArcData).endLng}
arcColor={(d: object) => (d as ArcData).color}
arcDashLength={0.5}
arcDashGap={0.2}
arcDashAnimateTime={2000}
arcStroke={0.3}
// Auto-rotate
animateIn={true}
enablePointerInteraction={true}
/>
)}
{/* 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">
<span className="text-lg">🦞</span>
<div>
<p className="font-mono text-sm font-medium text-[var(--accent-cyan)]">
{hoveredPoint.city}
</p>
<p className="text-xs text-[var(--text-muted)]">{hoveredPoint.country}</p>
</div>
</div>
<p className="mt-1 text-xs text-[var(--text-secondary)]">
{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}
onZoomOut={handleZoomOut}
/>
</div>
);
}