Add globe drag interaction and visitor country flag banner

Globe can now be dragged left/right to rotate (mouse + touch), with
auto-rotation resuming from the new position on release. Visitor API
aggregates hits by country/region/city, displayed as a scrolling
flag emoji banner at the bottom of the globe card with expandable
region breakdowns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shivam Patel
2026-02-09 02:39:30 -05:00
parent 8e18bf64b2
commit 9d8d34abb3
2 changed files with 150 additions and 8 deletions

View File

@@ -1,17 +1,77 @@
'use client';
import createGlobe from 'cobe';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
interface CountryData {
code: string;
count: number;
regions: {
region: string;
count: number;
cities: { city: string; count: number }[];
}[];
}
function countryCodeToFlag(code: string): string {
return [...code.toUpperCase()].map(c => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)).join('');
}
function CountryChip({ country }: { country: CountryData }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="relative flex-shrink-0">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-neutral-800/80 border border-neutral-700/50 hover:border-neutral-600 transition-colors text-sm cursor-pointer"
>
<span className="text-base leading-none">{countryCodeToFlag(country.code)}</span>
<span className="text-neutral-400 font-mono text-xs">{country.code}</span>
<span className="text-neutral-200 font-semibold text-xs">{country.count}</span>
</button>
{expanded && (
<div className="absolute bottom-full left-0 mb-1 w-56 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-30 p-2 max-h-48 overflow-y-auto">
{country.regions.map((r) => (
<div key={r.region} className="mb-1.5 last:mb-0">
<div className="flex justify-between text-xs px-1">
<span className="text-neutral-300 font-medium">{r.region}</span>
<span className="text-neutral-500">{r.count}</span>
</div>
{r.cities.map((c) => (
<div key={c.city} className="flex justify-between text-xs px-1 pl-3">
<span className="text-neutral-500">{c.city}</span>
<span className="text-neutral-600">{c.count}</span>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
export function GlobeCard() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [visitorStats, setVisitorStats] = useState({
const pointerInteracting = useRef<number | null>(null);
const pointerInteractionMovement = useRef(0);
const phiRef = useRef(0);
const [visitorStats, setVisitorStats] = useState<{
total: number;
active24h: number;
active7d: number;
locations: any[];
countries: CountryData[];
}>({
total: 0,
active24h: 0,
active7d: 0,
locations: [],
countries: [],
});
const [grabbing, setGrabbing] = useState(false);
useEffect(() => {
async function fetchVisitors() {
@@ -23,6 +83,7 @@ export function GlobeCard() {
active24h: data.active24h || 0,
active7d: data.active7d || 0,
locations: data.locations || [],
countries: data.countries || [],
});
} catch (e) {
console.error('Failed to load visitors', e);
@@ -34,8 +95,6 @@ export function GlobeCard() {
}, []);
useEffect(() => {
let phi = 0;
if (!canvasRef.current) return;
const globe = createGlobe(canvasRef.current, {
@@ -53,8 +112,10 @@ export function GlobeCard() {
glowColor: [0.1, 0.1, 0.2],
markers: visitorStats.locations,
onRender: (state) => {
state.phi = phi;
phi += 0.01;
if (pointerInteracting.current === null) {
phiRef.current += 0.005;
}
state.phi = phiRef.current + pointerInteractionMovement.current;
},
});
@@ -63,6 +124,27 @@ export function GlobeCard() {
};
}, [visitorStats.locations]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
pointerInteracting.current = e.clientX;
setGrabbing(true);
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (pointerInteracting.current !== null) {
const delta = e.clientX - pointerInteracting.current;
pointerInteractionMovement.current = delta / 200;
}
}, []);
const handlePointerUp = useCallback(() => {
if (pointerInteracting.current !== null) {
phiRef.current += pointerInteractionMovement.current;
pointerInteractionMovement.current = 0;
pointerInteracting.current = null;
}
setGrabbing(false);
}, []);
return (
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
<div className="absolute top-6 left-6 z-10 pointer-events-none">
@@ -92,9 +174,29 @@ export function GlobeCard() {
<div className="absolute inset-0 flex items-center justify-center opacity-80 mt-10">
<canvas
ref={canvasRef}
style={{ width: 600, height: 600, maxWidth: '100%', aspectRatio: 1 }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerOut={handlePointerUp}
style={{
width: 600,
height: 600,
maxWidth: '100%',
aspectRatio: 1,
cursor: grabbing ? 'grabbing' : 'grab',
}}
/>
</div>
{visitorStats.countries.length > 0 && (
<div className="absolute bottom-0 left-0 right-0 z-10 px-4 pb-3 pt-6 bg-gradient-to-t from-neutral-900 via-neutral-900/90 to-transparent">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
{visitorStats.countries.map((country) => (
<CountryChip key={country.code} country={country} />
))}
</div>
</div>
)}
</div>
);
}