Add interactive 3D Globe tab to Locations using cobe WebGL
- Magic UI Globe component with auto-rotation and drag interaction - Dark/light mode reactive (base color, glow, brightness) - Country markers from visitor data using existing centroids - Brand orange (#FD5E0F) marker color matching DottedMap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
133
components/dashboard/Globe.tsx
Normal file
133
components/dashboard/Globe.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useMemo } from 'react'
|
||||||
|
import createGlobe, { type COBEOptions } from 'cobe'
|
||||||
|
import { useMotionValue, useSpring } from 'framer-motion'
|
||||||
|
import { useTheme } from '@ciphera-net/ui'
|
||||||
|
import { countryCentroids } from '@/lib/country-centroids'
|
||||||
|
|
||||||
|
const MOVEMENT_DAMPING = 1400
|
||||||
|
|
||||||
|
interface GlobeProps {
|
||||||
|
data: Array<{ country: string; pageviews: number }>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Globe({ data, className }: GlobeProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const phiRef = useRef(0)
|
||||||
|
const widthRef = useRef(0)
|
||||||
|
const pointerInteracting = useRef<number | null>(null)
|
||||||
|
const pointerInteractionMovement = useRef(0)
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
|
||||||
|
const markers = useMemo(() => {
|
||||||
|
if (!data.length) return []
|
||||||
|
const max = Math.max(...data.map((d) => d.pageviews))
|
||||||
|
if (max === 0) return []
|
||||||
|
|
||||||
|
return data
|
||||||
|
.filter((d) => d.country && d.country !== 'Unknown' && countryCentroids[d.country])
|
||||||
|
.map((d) => ({
|
||||||
|
location: [countryCentroids[d.country].lat, countryCentroids[d.country].lng] as [number, number],
|
||||||
|
size: 0.03 + (d.pageviews / max) * 0.12,
|
||||||
|
}))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const r = useMotionValue(0)
|
||||||
|
const rs = useSpring(r, {
|
||||||
|
mass: 1,
|
||||||
|
damping: 30,
|
||||||
|
stiffness: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatePointerInteraction = (value: number | null) => {
|
||||||
|
pointerInteracting.current = value
|
||||||
|
if (canvasRef.current) {
|
||||||
|
canvasRef.current.style.cursor = value !== null ? 'grabbing' : 'grab'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMovement = (clientX: number) => {
|
||||||
|
if (pointerInteracting.current !== null) {
|
||||||
|
const delta = clientX - pointerInteracting.current
|
||||||
|
pointerInteractionMovement.current = delta
|
||||||
|
r.set(r.get() + delta / MOVEMENT_DAMPING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
widthRef.current = canvasRef.current.offsetWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
onResize()
|
||||||
|
|
||||||
|
const config: COBEOptions = {
|
||||||
|
width: widthRef.current * 2,
|
||||||
|
height: widthRef.current * 2,
|
||||||
|
onRender: () => {},
|
||||||
|
devicePixelRatio: 2,
|
||||||
|
phi: 0,
|
||||||
|
theta: 0.3,
|
||||||
|
dark: isDark ? 1 : 0,
|
||||||
|
diffuse: 0.4,
|
||||||
|
mapSamples: 16000,
|
||||||
|
mapBrightness: isDark ? 1.8 : 1.2,
|
||||||
|
baseColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1],
|
||||||
|
markerColor: [253 / 255, 94 / 255, 15 / 255],
|
||||||
|
glowColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1],
|
||||||
|
markers,
|
||||||
|
}
|
||||||
|
|
||||||
|
const globe = createGlobe(canvasRef.current, {
|
||||||
|
...config,
|
||||||
|
width: widthRef.current * 2,
|
||||||
|
height: widthRef.current * 2,
|
||||||
|
onRender: (state) => {
|
||||||
|
if (!pointerInteracting.current) phiRef.current += 0.005
|
||||||
|
state.phi = phiRef.current + rs.get()
|
||||||
|
state.width = widthRef.current * 2
|
||||||
|
state.height = widthRef.current * 2
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (canvasRef.current) canvasRef.current.style.opacity = '1'
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globe.destroy()
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
}
|
||||||
|
}, [rs, markers, isDark])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative w-full h-full flex items-center justify-center ${className ?? ''}`}>
|
||||||
|
<div className="relative aspect-square w-full max-w-[320px]">
|
||||||
|
<canvas
|
||||||
|
className="size-full opacity-0 transition-opacity duration-500"
|
||||||
|
style={{ contain: 'layout paint size' }}
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
pointerInteracting.current = e.clientX
|
||||||
|
updatePointerInteraction(e.clientX)
|
||||||
|
}}
|
||||||
|
onPointerUp={() => updatePointerInteraction(null)}
|
||||||
|
onPointerOut={() => updatePointerInteraction(null)}
|
||||||
|
onMouseMove={(e) => updateMovement(e.clientX)}
|
||||||
|
onTouchMove={(e) =>
|
||||||
|
e.touches[0] && updateMovement(e.touches[0].clientX)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
|||||||
import * as Flags from 'country-flag-icons/react/3x2'
|
import * as Flags from 'country-flag-icons/react/3x2'
|
||||||
import iso3166 from 'iso-3166-2'
|
import iso3166 from 'iso-3166-2'
|
||||||
import DottedMap from './DottedMap'
|
import DottedMap from './DottedMap'
|
||||||
|
import Globe from './Globe'
|
||||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||||
import { ListSkeleton } from '@/components/skeletons'
|
import { ListSkeleton } from '@/components/skeletons'
|
||||||
import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react'
|
import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react'
|
||||||
@@ -23,7 +24,7 @@ interface LocationProps {
|
|||||||
onFilter?: (filter: DimensionFilter) => void
|
onFilter?: (filter: DimensionFilter) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'map' | 'countries' | 'regions' | 'cities'
|
type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities'
|
||||||
|
|
||||||
const LIMIT = 7
|
const LIMIT = 7
|
||||||
|
|
||||||
@@ -173,15 +174,16 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawData = activeTab === 'map' ? [] : getData()
|
const isVisualTab = activeTab === 'map' || activeTab === 'globe'
|
||||||
|
const rawData = isVisualTab ? [] : getData()
|
||||||
const data = filterUnknown(rawData)
|
const data = filterUnknown(rawData)
|
||||||
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
||||||
const hasData = activeTab === 'map'
|
const hasData = isVisualTab
|
||||||
? (countries && filterUnknown(countries).length > 0)
|
? (countries && filterUnknown(countries).length > 0)
|
||||||
: (data && data.length > 0)
|
: (data && data.length > 0)
|
||||||
const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
|
const displayedData = (!isVisualTab && hasData) ? data.slice(0, LIMIT) : []
|
||||||
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
||||||
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
|
const showViewAll = !isVisualTab && hasData && data.length > LIMIT
|
||||||
|
|
||||||
const getDisabledMessage = () => {
|
const getDisabledMessage = () => {
|
||||||
if (geoDataLevel === 'none') {
|
if (geoDataLevel === 'none') {
|
||||||
@@ -201,7 +203,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
Locations
|
Locations
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
||||||
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
{(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
@@ -224,8 +226,12 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
||||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'map' ? (
|
) : isVisualTab ? (
|
||||||
hasData ? <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
|
hasData ? (
|
||||||
|
activeTab === 'globe'
|
||||||
|
? <Globe data={filterUnknown(countries) as { country: string; pageviews: number }[]} />
|
||||||
|
: <DottedMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} />
|
||||||
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@stripe/react-stripe-js": "^5.6.0",
|
"@stripe/react-stripe-js": "^5.6.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
@@ -6014,6 +6015,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cobe": {
|
||||||
|
"version": "0.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cobe/-/cobe-0.6.5.tgz",
|
||||||
|
"integrity": "sha512-MA8bu81EFY6JjQpj+FovEuhyJ25khx2Q7Lh+ot/UkCJe5yKyDgzdc6u2lGZIOmsZTXK6Itg1i4lQZIJZbPWnAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"phenomenon": "^1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/codepage": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
@@ -10520,6 +10530,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/phenomenon": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/phenomenon/-/phenomenon-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@stripe/react-stripe-js": "^5.6.0",
|
"@stripe/react-stripe-js": "^5.6.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
|
|||||||
Reference in New Issue
Block a user