"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 (
{t("loading")}
); } const Globe = dynamic(() => import("react-globe.gl"), { ssr: false, loading: () => , }); 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(undefined); const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [hoveredPoint, setHoveredPoint] = useState(null); const [selectedPoint, setSelectedPoint] = useState(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 }); } }, []); const handlePointClick = useCallback((point: object) => { setSelectedPoint(point as HeatmapPoint); }, []); return (
{dimensions.width > 0 && ( "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) htmlElementsData={labels} htmlLat={(d: object) => (d as LabelData).lat} htmlLng={(d: object) => (d as LabelData).lng} htmlAltitude={0.01} htmlElement={(d: object) => { const label = d as LabelData; const el = document.createElement("div"); el.textContent = label.name; el.style.color = "rgba(0, 240, 255, 0.5)"; el.style.fontSize = "10px"; el.style.fontFamily = "system-ui, -apple-system, sans-serif"; el.style.pointerEvents = "none"; el.style.userSelect = "none"; el.style.whiteSpace = "nowrap"; el.style.textShadow = "0 0 4px rgba(0, 240, 255, 0.3)"; return el; }} // Graticules showGraticules={true} // 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 && (
🦞

{hoveredPoint.city}

{hoveredPoint.country}

{t("totalClaws", { total: hoveredPoint.clawCount, online: hoveredPoint.onlineCount })}

)} {/* Click Popup */} {selectedPoint && (

{selectedPoint.city}

{selectedPoint.country}

{tPopup("total", { count: selectedPoint.clawCount })} {tPopup("online", { count: selectedPoint.onlineCount })}
{selectedPoint.claws.length > 0 && (
{selectedPoint.claws.map((claw) => (
{claw.name}
))}
)}
)}
); }