diff --git a/components/dashboard/Globe.tsx b/components/dashboard/Globe.tsx new file mode 100644 index 0000000..5f27e22 --- /dev/null +++ b/components/dashboard/Globe.tsx @@ -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(null) + const phiRef = useRef(0) + const widthRef = useRef(0) + const pointerInteracting = useRef(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 ( +
+
+ { + 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) + } + /> +
+
+ ) +} diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index faef66b..b1d3cfe 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import * as Flags from 'country-flag-icons/react/3x2' import iso3166 from 'iso-3166-2' import DottedMap from './DottedMap' +import Globe from './Globe' import { Modal, GlobeIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react' @@ -23,7 +24,7 @@ interface LocationProps { onFilter?: (filter: DimensionFilter) => void } -type Tab = 'map' | 'countries' | 'regions' | 'cities' +type Tab = 'map' | 'globe' | 'countries' | 'regions' | 'cities' 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 totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0) - const hasData = activeTab === 'map' + const hasData = isVisualTab ? (countries && filterUnknown(countries).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 showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT + const showViewAll = !isVisualTab && hasData && data.length > LIMIT const getDisabledMessage = () => { if (geoDataLevel === 'none') { @@ -201,7 +203,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' Locations
- {(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => ( + {(['map', 'globe', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (