init
This commit is contained in:
37
components/globe/globe-controls.tsx
Normal file
37
components/globe/globe-controls.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { RotateCw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
interface GlobeControlsProps {
|
||||
onResetView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
}
|
||||
|
||||
export function GlobeControls({ onResetView, onZoomIn, onZoomOut }: GlobeControlsProps) {
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={onZoomIn}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onZoomOut}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onResetView}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-[var(--bg-card)]/80 text-[var(--text-secondary)] backdrop-blur-sm transition-all hover:border-[var(--accent-cyan)]/30 hover:text-[var(--accent-cyan)]"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
components/globe/globe-view.tsx
Normal file
159
components/globe/globe-view.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useHeatmapData, type HeatmapPoint } from "@/hooks/use-heatmap-data";
|
||||
import { GlobeControls } from "./globe-controls";
|
||||
|
||||
const Globe = dynamic(() => import("react-globe.gl"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<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)]">Loading globe...</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface ArcData {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function GlobeView() {
|
||||
// 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 { points } = useHeatmapData(30000);
|
||||
|
||||
// Generate arcs from recent activity (connecting pairs of active points)
|
||||
const arcs = useMemo((): ArcData[] => {
|
||||
if (points.length < 2) return [];
|
||||
const result: ArcData[] = [];
|
||||
const maxArcs = Math.min(points.length - 1, 8);
|
||||
for (let i = 0; i < maxArcs; i++) {
|
||||
const from = points[i];
|
||||
const to = points[(i + 1) % points.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}
|
||||
// Points
|
||||
pointsData={points}
|
||||
pointLat={(d: object) => (d as HeatmapPoint).lat}
|
||||
pointLng={(d: object) => (d as HeatmapPoint).lng}
|
||||
pointAltitude={(d: object) => Math.min((d as HeatmapPoint).weight * 0.02, 0.15)}
|
||||
pointRadius={(d: object) => Math.max((d as HeatmapPoint).weight * 0.3, 0.4)}
|
||||
pointColor={() => "#00f0ff"}
|
||||
pointsMerge={false}
|
||||
onPointHover={(point: object | null) => setHoveredPoint(point as HeatmapPoint | null)}
|
||||
// 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredPoint && (
|
||||
<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)]">
|
||||
{hoveredPoint.lobsterCount} active lobster{hoveredPoint.lobsterCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GlobeControls
|
||||
onResetView={handleResetView}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/globe/lobster-tooltip.tsx
Normal file
28
components/globe/lobster-tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface LobsterTooltipProps {
|
||||
city: string;
|
||||
country: string;
|
||||
lobsterCount: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export function LobsterTooltip({ city, country, lobsterCount, weight }: LobsterTooltipProps) {
|
||||
return (
|
||||
<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)]">{city}</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">{country}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="online">{lobsterCount} active</Badge>
|
||||
<Badge variant="secondary">weight: {weight.toFixed(1)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user