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:
richarjiang
2026-03-13 14:46:23 +08:00
parent 9e30771180
commit e79d721615
23 changed files with 1215 additions and 494 deletions

View File

@@ -9,7 +9,7 @@ import { useSSE } from "@/hooks/use-sse";
interface FeedItem {
id: string;
type: "task" | "online" | "offline";
type: "task" | "online" | "offline" | "registered";
clawName: string;
city?: string;
country?: string;
@@ -24,7 +24,7 @@ export function ClawFeed() {
const [items, setItems] = useState<FeedItem[]>([]);
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
if (event.type === "task" || event.type === "online" || event.type === "offline") {
if (event.type === "task" || event.type === "online" || event.type === "offline" || event.type === "registered") {
const newItem: FeedItem = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: event.type as FeedItem["type"],
@@ -45,19 +45,25 @@ export function ClawFeed() {
onEvent: handleEvent,
});
// Load initial recent tasks
// Load initial recent tasks + newest registrations
useEffect(() => {
const fetchRecent = async () => {
try {
const res = await fetch("/api/v1/claws?limit=10");
if (res.ok) {
const data = await res.json();
const feedItems: FeedItem[] = (data.claws ?? [])
const [taskRes, newestRes] = await Promise.all([
fetch("/api/v1/claws?limit=10"),
fetch("/api/v1/claws?sort=newest&limit=5"),
]);
const feedItems: FeedItem[] = [];
if (taskRes.ok) {
const data = await taskRes.json();
const taskItems: FeedItem[] = (data.claws ?? [])
.filter((l: Record<string, unknown>) => l.lastTask)
.map((l: Record<string, unknown>) => {
const task = l.lastTask as Record<string, unknown>;
return {
id: `init-${l.id}`,
id: `init-task-${l.id}`,
type: "task" as const,
clawName: l.name as string,
city: l.city as string,
@@ -67,8 +73,35 @@ export function ClawFeed() {
timestamp: new Date(task.timestamp as string).getTime(),
};
});
setItems(feedItems);
feedItems.push(...taskItems);
}
if (newestRes.ok) {
const data = await newestRes.json();
const regItems: FeedItem[] = (data.claws ?? [])
.filter((l: Record<string, unknown>) => l.createdAt)
.map((l: Record<string, unknown>) => ({
id: `init-reg-${l.id}`,
type: "registered" as const,
clawName: l.name as string,
city: l.city as string,
country: l.country as string,
timestamp: new Date(l.createdAt as string).getTime(),
}));
feedItems.push(...regItems);
}
// Deduplicate by clawName+type, sort by timestamp desc
const seen = new Set<string>();
const unique = feedItems.filter((item) => {
const key = `${item.clawName}-${item.type}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
unique.sort((a, b) => b.timestamp - a.timestamp);
setItems(unique);
} catch {
// will populate via SSE
}
@@ -90,11 +123,20 @@ export function ClawFeed() {
return "🟢";
case "offline":
return "⭕";
case "registered":
return "🦞";
default:
return "🦞";
}
};
const getDescription = (item: FeedItem) => {
if (item.type === "registered" && item.city && item.country) {
return t("joinedFrom", { city: item.city, country: item.country });
}
return item.summary;
};
return (
<Card className="border-white/5">
<CardHeader className="pb-2">
@@ -123,15 +165,15 @@ export function ClawFeed() {
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
{item.clawName}
</span>
{item.city && (
{item.city && item.type !== "registered" && (
<span className="text-xs text-[var(--text-muted)]">
{item.city}, {item.country}
</span>
)}
</div>
{item.summary && (
{getDescription(item) && (
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
{item.summary}
{getDescription(item)}
</p>
)}
<div className="mt-1 flex items-center gap-2">