266 lines
9.8 KiB
TypeScript
266 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
|
import dynamic from "next/dynamic";
|
|
import { useTranslations, useLocale } from "next-intl";
|
|
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
|
import { useCountryData, type LabelData } from "./use-country-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");
|
|
const locale = useLocale();
|
|
// 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);
|
|
const { countries, labels } = useCountryData(locale);
|
|
|
|
// 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 });
|
|
}
|
|
}, []);
|
|
|
|
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}
|
|
// Country polygons
|
|
polygonsData={countries}
|
|
polygonCapColor={() => "rgba(26, 31, 46, 0.6)"}
|
|
polygonSideColor={() => "rgba(0, 240, 255, 0.05)"}
|
|
polygonStrokeColor={() => "rgba(0, 240, 255, 0.15)"}
|
|
polygonAltitude={0.005}
|
|
// Country name labels (use HTML elements for CJK support)
|
|
labelsData={labels}
|
|
labelLat={(d: object) => (d as LabelData).lat}
|
|
labelLng={(d: object) => (d as LabelData).lng}
|
|
labelAltitude={0.01}
|
|
labelSize={1.5}
|
|
labelDotRadius={0}
|
|
labelText={(d: object) => (d as LabelData).name}
|
|
labelColor={() => "rgba(0, 240, 255, 0.5)"}
|
|
labelResolution={2}
|
|
// Graticules
|
|
showGraticules={true}
|
|
// Points with claw icons (using htmlElements for custom icons)
|
|
htmlElementsData={points}
|
|
htmlLat={(d: object) => (d as HeatmapPoint).lat}
|
|
htmlLng={(d: object) => (d as HeatmapPoint).lng}
|
|
htmlAltitude={(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;
|
|
}}
|
|
htmlElement={(d: object) => {
|
|
const p = d as HeatmapPoint;
|
|
const el = document.createElement("div");
|
|
const size = p.onlineCount > 0 ? Math.max(p.weight * 0.8, 16) : Math.max(p.weight * 0.5, 12);
|
|
el.style.width = `${size}px`;
|
|
el.style.height = `${size}px`;
|
|
el.style.backgroundImage = "url(/claw-icon.svg)";
|
|
el.style.backgroundSize = "contain";
|
|
el.style.backgroundRepeat = "no-repeat";
|
|
el.style.backgroundPosition = "center";
|
|
el.style.opacity = p.onlineCount > 0 ? "1" : "0.4";
|
|
el.style.filter = p.onlineCount > 0 ? "drop-shadow(0 0 4px rgba(0, 240, 255, 0.6))" : "none";
|
|
el.style.cursor = "pointer";
|
|
el.style.transition = "transform 0.2s ease";
|
|
el.dataset.pointId = `${p.city}-${p.country}`;
|
|
|
|
// Add click handler directly to the element
|
|
el.addEventListener("click", () => {
|
|
setSelectedPoint(p);
|
|
});
|
|
el.addEventListener("mouseenter", () => {
|
|
setHoveredPoint(p);
|
|
});
|
|
el.addEventListener("mouseleave", () => {
|
|
setHoveredPoint(null);
|
|
});
|
|
|
|
return el;
|
|
}}
|
|
// 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>
|
|
);
|
|
}
|