Replace WorldMap with Magic UI DottedMap for visitor locations
- New DottedMap component using svg-dotted-map with country centroid markers - Marker size scales by pageview proportion (brand orange) - Static country-centroids.ts lookup (~200 ISO codes) - Remove react-simple-maps, i18n-iso-countries, world-atlas CDN dependency
This commit is contained in:
95
components/dashboard/DottedMap.tsx
Normal file
95
components/dashboard/DottedMap.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { createMap } from 'svg-dotted-map'
|
||||
import { cn } from '@ciphera-net/ui'
|
||||
import { countryCentroids } from '@/lib/country-centroids'
|
||||
|
||||
interface DottedMapProps {
|
||||
data: Array<{ country: string; pageviews: number }>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DottedMap({ data, className }: DottedMapProps) {
|
||||
const width = 150
|
||||
const height = 75
|
||||
const dotRadius = 0.2
|
||||
|
||||
const { points, addMarkers } = createMap({ width, height, mapSamples: 5000 })
|
||||
|
||||
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) => ({
|
||||
lat: countryCentroids[d.country].lat,
|
||||
lng: countryCentroids[d.country].lng,
|
||||
size: 0.4 + (d.pageviews / max) * 0.8,
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
const processedMarkers = addMarkers(markers)
|
||||
|
||||
// Compute stagger helpers
|
||||
const { xStep, yToRowIndex } = useMemo(() => {
|
||||
const sorted = [...points].sort((a, b) => a.y - b.y || a.x - b.x)
|
||||
const rowMap = new Map<number, number>()
|
||||
let step = 0
|
||||
let prevY = Number.NaN
|
||||
let prevXInRow = Number.NaN
|
||||
|
||||
for (const p of sorted) {
|
||||
if (p.y !== prevY) {
|
||||
prevY = p.y
|
||||
prevXInRow = Number.NaN
|
||||
if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size)
|
||||
}
|
||||
if (!Number.isNaN(prevXInRow)) {
|
||||
const delta = p.x - prevXInRow
|
||||
if (delta > 0) step = step === 0 ? delta : Math.min(step, delta)
|
||||
}
|
||||
prevXInRow = p.x
|
||||
}
|
||||
|
||||
return { xStep: step || 1, yToRowIndex: rowMap }
|
||||
}, [points])
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={cn('text-neutral-300 dark:text-neutral-700', className)}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{points.map((point, index) => {
|
||||
const rowIndex = yToRowIndex.get(point.y) ?? 0
|
||||
const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0
|
||||
return (
|
||||
<circle
|
||||
cx={point.x + offsetX}
|
||||
cy={point.y}
|
||||
r={dotRadius}
|
||||
fill="currentColor"
|
||||
key={`${point.x}-${point.y}-${index}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{processedMarkers.map((marker, index) => {
|
||||
const rowIndex = yToRowIndex.get(marker.y) ?? 0
|
||||
const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0
|
||||
return (
|
||||
<circle
|
||||
cx={marker.x + offsetX}
|
||||
cy={marker.y}
|
||||
r={marker.size ?? dotRadius}
|
||||
fill="#FD5E0F"
|
||||
key={`marker-${marker.x}-${marker.y}-${index}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { formatNumber } from '@ciphera-net/ui'
|
||||
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
||||
import * as Flags from 'country-flag-icons/react/3x2'
|
||||
import iso3166 from 'iso-3166-2'
|
||||
import WorldMap from './WorldMap'
|
||||
import DottedMap from './DottedMap'
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { ListSkeleton } from '@/components/skeletons'
|
||||
import { ShieldCheck, Detective, Broadcast } from '@phosphor-icons/react'
|
||||
@@ -225,7 +225,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
|
||||
</div>
|
||||
) : activeTab === 'map' ? (
|
||||
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
|
||||
hasData ? <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="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { memo, useMemo, useState } from 'react'
|
||||
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
|
||||
import countries from 'i18n-iso-countries'
|
||||
import enLocale from 'i18n-iso-countries/langs/en.json'
|
||||
import { useTheme } from '@ciphera-net/ui'
|
||||
|
||||
countries.registerLocale(enLocale)
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"
|
||||
|
||||
interface WorldMapProps {
|
||||
data: Array<{ country: string; pageviews: number }>
|
||||
}
|
||||
|
||||
const WorldMap = ({ data }: WorldMapProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [tooltipContent, setTooltipContent] = useState<{ content: string; x: number; y: number } | null>(null)
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
let max = 0
|
||||
data.forEach(item => {
|
||||
if (item.country === 'Unknown') return
|
||||
// API returns 2 letter code. Convert to numeric (3 digits string)
|
||||
const numericCode = countries.alpha2ToNumeric(item.country)
|
||||
if (numericCode) {
|
||||
map.set(numericCode, item.pageviews)
|
||||
if (item.pageviews > max) max = item.pageviews
|
||||
}
|
||||
})
|
||||
return { map, max }
|
||||
}, [data])
|
||||
|
||||
// Plausible-like colors based on provided SVG snippet
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const defaultFill = isDark ? "var(--color-neutral-800)" : "var(--color-neutral-100)"
|
||||
const defaultStroke = isDark ? "var(--color-neutral-900)" : "#ffffff"
|
||||
const brandOrange = "var(--color-brand-orange)"
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<ComposableMap
|
||||
width={800}
|
||||
height={400}
|
||||
projectionConfig={{ rotate: [-10, 0, 0], scale: 170, center: [0, 10] }}
|
||||
className="w-full h-auto"
|
||||
>
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies
|
||||
.filter(geo => geo.id !== "010") // Remove Antarctica
|
||||
.map((geo) => {
|
||||
const id = String(geo.id).padStart(3, '0')
|
||||
const count = processedData.map.get(id) || 0
|
||||
const fillColor = count > 0 ? brandOrange : defaultFill
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={fillColor}
|
||||
stroke={defaultStroke}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none", transition: "all 250ms" },
|
||||
hover: {
|
||||
fill: fillColor,
|
||||
stroke: brandOrange,
|
||||
strokeWidth: 2,
|
||||
outline: "none",
|
||||
cursor: 'pointer',
|
||||
zIndex: 100 // Bring border to front
|
||||
},
|
||||
pressed: { outline: "none" },
|
||||
}}
|
||||
onMouseEnter={(evt) => {
|
||||
const { name } = geo.properties
|
||||
setTooltipContent({
|
||||
content: `${name}: ${count} visitors`,
|
||||
x: evt.clientX,
|
||||
y: evt.clientY
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltipContent(null)
|
||||
}}
|
||||
onMouseMove={(evt) => {
|
||||
setTooltipContent(prev => prev ? { ...prev, x: evt.clientX, y: evt.clientY } : null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ComposableMap>
|
||||
{tooltipContent && (
|
||||
<div
|
||||
className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full -mt-2.5"
|
||||
style={{ left: tooltipContent.x, top: tooltipContent.y }}
|
||||
>
|
||||
{tooltipContent.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorldMap)
|
||||
Reference in New Issue
Block a user