Files
pulse/components/dashboard/Globe.tsx
Usman Baig 5f797112ec Memoize expensive computations in Chart and Globe components
Chart: wrap chartData (Date formatting on every data point) and
metricsWithTrends in useMemo — these ran on every render.
Globe: memoize marker computation (Math.max + filter + map on every render).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:44:19 +01:00

114 lines
3.9 KiB
TypeScript

'use client'
import { useEffect, useMemo, useRef } from 'react'
import createGlobe from 'cobe'
import { useTheme } from '@ciphera-net/ui'
import { countryCentroids } from '@/lib/country-centroids'
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 dragRef = useRef(0)
const pointerRef = useRef<number | null>(null)
const { resolvedTheme } = useTheme()
const isDarkRef = useRef(resolvedTheme === 'dark')
const markersRef = useRef<Array<{ location: [number, number]; size: number }>>([])
// Update refs without causing effect re-runs
isDarkRef.current = resolvedTheme === 'dark'
// Compute markers into ref (memoized to avoid recalculating on every render)
const markers = useMemo(() => {
const max = data.length ? Math.max(...data.map((d) => d.pageviews)) : 0
return max > 0
? 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])
markersRef.current = markers
useEffect(() => {
if (!canvasRef.current) return
const size = canvasRef.current.offsetWidth
const pixelRatio = Math.min(window.devicePixelRatio, 2)
const isDark = isDarkRef.current
const globe = createGlobe(canvasRef.current, {
width: size * pixelRatio,
height: size * pixelRatio,
devicePixelRatio: pixelRatio,
phi: phiRef.current,
theta: 0.3,
dark: isDark ? 1 : 0,
diffuse: isDark ? 2 : 0.4,
mapSamples: 16000,
mapBrightness: isDark ? 2 : 1.2,
baseColor: isDark ? [0.5, 0.5, 0.5] : [1, 1, 1],
markerColor: [253 / 255, 94 / 255, 15 / 255],
glowColor: isDark ? [0.15, 0.15, 0.15] : [1, 1, 1],
markers: markersRef.current,
onRender: (state) => {
if (!pointerRef.current) phiRef.current += 0.002
state.phi = phiRef.current + dragRef.current
},
})
setTimeout(() => {
if (canvasRef.current) canvasRef.current.style.opacity = '1'
}, 0)
return () => { globe.destroy() }
// Only recreate on theme change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedTheme])
return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div className="absolute left-1/2 -translate-x-1/2 top-0 aspect-square w-[130%]">
<canvas
className="size-full opacity-0 transition-opacity duration-500"
style={{ contain: 'layout paint size' }}
ref={canvasRef}
onPointerDown={(e) => {
pointerRef.current = e.clientX
canvasRef.current!.style.cursor = 'grabbing'
}}
onPointerUp={() => {
pointerRef.current = null
canvasRef.current!.style.cursor = 'grab'
}}
onPointerOut={() => {
pointerRef.current = null
if (canvasRef.current) canvasRef.current.style.cursor = 'grab'
}}
onMouseMove={(e) => {
if (pointerRef.current !== null) {
const delta = e.clientX - pointerRef.current
dragRef.current += delta / 800
pointerRef.current = e.clientX
}
}}
onTouchMove={(e) => {
if (pointerRef.current !== null && e.touches[0]) {
const delta = e.touches[0].clientX - pointerRef.current
dragRef.current += delta / 800
pointerRef.current = e.touches[0].clientX
}
}}
/>
</div>
<div className="pointer-events-none absolute inset-0 h-full bg-[radial-gradient(circle_at_50%_200%,rgba(0,0,0,0.2),rgba(255,255,255,0))]" />
</div>
)
}