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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user