'use client' import { useMemo, useState } from 'react' import { createMap } from 'svg-dotted-map' import { cn, formatNumber } from '@ciphera-net/ui' import { countryCentroids } from '@/lib/country-centroids' // ─── Module-level constants ──────────────────────────────────────── // Computed once when the module loads, survives component unmount/remount. const MAP_WIDTH = 150 const MAP_HEIGHT = 68 const DOT_RADIUS = 0.25 const { points: MAP_POINTS, addMarkers } = createMap({ width: MAP_WIDTH, height: MAP_HEIGHT, mapSamples: 8000 }) // Pre-compute stagger helpers (row offsets for hex-grid pattern) const _stagger = (() => { const sorted = [...MAP_POINTS].sort((a, b) => a.y - b.y || a.x - b.x) const rowMap = new Map() 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 } })() // Pre-compute the base map dots as a single SVG path string (~8000 circles → 1 path) const BASE_DOTS_PATH = (() => { const r = DOT_RADIUS const d = r * 2 const parts: string[] = [] for (const point of MAP_POINTS) { const rowIndex = _stagger.yToRowIndex.get(point.y) ?? 0 const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0 const cx = point.x + offsetX const cy = point.y parts.push(`M${cx - r},${cy}a${r},${r} 0 1,0 ${d},0a${r},${r} 0 1,0 ${-d},0`) } return parts.join('') })() // ─── Component ───────────────────────────────────────────────────── interface DottedMapProps { data: Array<{ country: string; pageviews: number }> className?: string /** Custom formatter for tooltip values. Defaults to formatNumber. */ formatValue?: (value: number) => string } function getCountryName(code: string): string { try { const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }) return regionNames.of(code) || code } catch { return code } } export default function DottedMap({ data, className, formatValue = formatNumber }: DottedMapProps) { const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null) const markerData = 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, country: d.country, pageviews: d.pageviews, })) }, [data]) const processedMarkers = useMemo( () => addMarkers(markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size }))), [markerData], ) return (
{processedMarkers.map((marker, index) => { const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0 const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0 const info = markerData[index] const cx = marker.x + offsetX const cy = marker.y return ( { if (info) { const rect = (e.target as SVGElement).closest('svg')!.getBoundingClientRect() setTooltip({ x: rect.left + (cx / MAP_WIDTH) * rect.width, y: rect.top + (cy / MAP_HEIGHT) * rect.height, country: info.country, pageviews: info.pageviews, }) } }} onMouseLeave={() => setTooltip(null)} > {/* Invisible larger hitbox */} {/* Visible dot */} ) })} {tooltip && (
{getCountryName(tooltip.country)} {formatValue(tooltip.pageviews)}
)}
) }