Files
openclaw-market/components/map/continent-map.tsx
richarjiang fa4c458eda init
2026-03-13 11:00:01 +08:00

137 lines
4.2 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
import { HeatmapLayer } from "./heatmap-layer";
import { geoMercator } from "d3-geo";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
interface ContinentConfig {
center: [number, number];
zoom: number;
label: string;
}
const continentConfigs: Record<string, ContinentConfig> = {
asia: { center: [100, 35], zoom: 2.5, label: "Asia" },
europe: { center: [15, 52], zoom: 4, label: "Europe" },
americas: { center: [-80, 15], zoom: 1.8, label: "Americas" },
africa: { center: [20, 5], zoom: 2.2, label: "Africa" },
oceania: { center: [145, -25], zoom: 3, label: "Oceania" },
};
const continentRegionMap: Record<string, string> = {
asia: "Asia",
europe: "Europe",
americas: "Americas",
africa: "Africa",
oceania: "Oceania",
};
interface ContinentMapProps {
slug: string;
}
export function ContinentMap({ slug }: ContinentMapProps) {
const config = continentConfigs[slug] ?? continentConfigs.asia;
const regionFilter = continentRegionMap[slug];
const { points } = useHeatmapData(30000);
const [selectedPoint, setSelectedPoint] = useState<HeatmapPoint | null>(null);
const filteredPoints = useMemo(
() => (regionFilter ? points.filter(() => true) : points),
[points, regionFilter]
);
const projection = useMemo(
() =>
geoMercator()
.center(config.center)
.scale(150 * config.zoom)
.translate([400, 300]),
[config]
);
const projectionFn = (coords: [number, number]): [number, number] | null => {
const result = projection(coords);
return result ?? null;
};
return (
<div className="relative">
<div className="overflow-hidden rounded-xl border border-white/5 bg-[var(--bg-secondary)]">
<ComposableMap
projection="geoMercator"
projectionConfig={{
center: config.center,
scale: 150 * config.zoom,
}}
width={800}
height={600}
style={{ width: "100%", height: "auto" }}
>
<ZoomableGroup center={config.center} zoom={1}>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill="#1a1f2e"
stroke="#2a3040"
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { fill: "#242a3d", outline: "none" },
pressed: { outline: "none" },
}}
/>
))
}
</Geographies>
<HeatmapLayer
points={filteredPoints}
projection={projectionFn}
onPointClick={setSelectedPoint}
/>
</ZoomableGroup>
</ComposableMap>
</div>
{selectedPoint && (
<Card className="absolute bottom-4 left-4 z-10 w-64 border-[var(--accent-cyan)]/20">
<CardContent className="p-4">
<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">
<Badge variant="online">{selectedPoint.lobsterCount} lobsters</Badge>
<Badge variant="secondary">weight: {selectedPoint.weight.toFixed(1)}</Badge>
</div>
</CardContent>
</Card>
)}
</div>
);
}