Polish DottedMap: glow effect, tooltips, better fill
- SVG filter for orange glow behind markers - Hover tooltips showing country name + pageview count - Reduced viewBox height (75→68) to fill card better - Bumped mapSamples to 8000 for crisper landmass - Centered map vertically with flexbox wrapper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { createMap } from 'svg-dotted-map'
|
import { createMap } from 'svg-dotted-map'
|
||||||
import { cn } from '@ciphera-net/ui'
|
import { cn, formatNumber } from '@ciphera-net/ui'
|
||||||
import { countryCentroids } from '@/lib/country-centroids'
|
import { countryCentroids } from '@/lib/country-centroids'
|
||||||
|
|
||||||
interface DottedMapProps {
|
interface DottedMapProps {
|
||||||
@@ -10,14 +10,24 @@ interface DottedMapProps {
|
|||||||
className?: string
|
className?: 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 }: DottedMapProps) {
|
export default function DottedMap({ data, className }: DottedMapProps) {
|
||||||
const width = 150
|
const width = 150
|
||||||
const height = 75
|
const height = 68
|
||||||
const dotRadius = 0.2
|
const dotRadius = 0.2
|
||||||
|
const [tooltip, setTooltip] = useState<{ x: number; y: number; country: string; pageviews: number } | null>(null)
|
||||||
|
|
||||||
const { points, addMarkers } = createMap({ width, height, mapSamples: 5000 })
|
const { points, addMarkers } = createMap({ width, height, mapSamples: 8000 })
|
||||||
|
|
||||||
const markers = useMemo(() => {
|
const markerData = useMemo(() => {
|
||||||
if (!data.length) return []
|
if (!data.length) return []
|
||||||
|
|
||||||
const max = Math.max(...data.map((d) => d.pageviews))
|
const max = Math.max(...data.map((d) => d.pageviews))
|
||||||
@@ -29,10 +39,16 @@ export default function DottedMap({ data, className }: DottedMapProps) {
|
|||||||
lat: countryCentroids[d.country].lat,
|
lat: countryCentroids[d.country].lat,
|
||||||
lng: countryCentroids[d.country].lng,
|
lng: countryCentroids[d.country].lng,
|
||||||
size: 0.4 + (d.pageviews / max) * 0.8,
|
size: 0.4 + (d.pageviews / max) * 0.8,
|
||||||
|
country: d.country,
|
||||||
|
pageviews: d.pageviews,
|
||||||
}))
|
}))
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const processedMarkers = addMarkers(markers)
|
const markerInputs = useMemo(
|
||||||
|
() => markerData.map((d) => ({ lat: d.lat, lng: d.lng, size: d.size })),
|
||||||
|
[markerData],
|
||||||
|
)
|
||||||
|
const processedMarkers = addMarkers(markerInputs)
|
||||||
|
|
||||||
// Compute stagger helpers
|
// Compute stagger helpers
|
||||||
const { xStep, yToRowIndex } = useMemo(() => {
|
const { xStep, yToRowIndex } = useMemo(() => {
|
||||||
@@ -59,37 +75,76 @@ export default function DottedMap({ data, className }: DottedMapProps) {
|
|||||||
}, [points])
|
}, [points])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
<svg
|
||||||
className={cn('text-neutral-300 dark:text-neutral-700', className)}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
style={{ width: '100%', height: '100%' }}
|
className={cn('text-neutral-300 dark:text-neutral-700', className)}
|
||||||
>
|
style={{ width: '100%', height: 'auto', maxHeight: '100%' }}
|
||||||
{points.map((point, index) => {
|
>
|
||||||
const rowIndex = yToRowIndex.get(point.y) ?? 0
|
<defs>
|
||||||
const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0
|
<filter id="marker-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
return (
|
<feGaussianBlur in="SourceGraphic" stdDeviation="0.8" result="blur" />
|
||||||
<circle
|
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0 0 0 0 0 0 0.6 0" />
|
||||||
cx={point.x + offsetX}
|
<feMerge>
|
||||||
cy={point.y}
|
<feMergeNode />
|
||||||
r={dotRadius}
|
<feMergeNode in="SourceGraphic" />
|
||||||
fill="currentColor"
|
</feMerge>
|
||||||
key={`${point.x}-${point.y}-${index}`}
|
</filter>
|
||||||
/>
|
</defs>
|
||||||
)
|
{points.map((point, index) => {
|
||||||
})}
|
const rowIndex = yToRowIndex.get(point.y) ?? 0
|
||||||
{processedMarkers.map((marker, index) => {
|
const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0
|
||||||
const rowIndex = yToRowIndex.get(marker.y) ?? 0
|
return (
|
||||||
const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0
|
<circle
|
||||||
return (
|
cx={point.x + offsetX}
|
||||||
<circle
|
cy={point.y}
|
||||||
cx={marker.x + offsetX}
|
r={dotRadius}
|
||||||
cy={marker.y}
|
fill="currentColor"
|
||||||
r={marker.size ?? dotRadius}
|
key={`${point.x}-${point.y}-${index}`}
|
||||||
fill="#FD5E0F"
|
/>
|
||||||
key={`marker-${marker.x}-${marker.y}-${index}`}
|
)
|
||||||
/>
|
})}
|
||||||
)
|
{processedMarkers.map((marker, index) => {
|
||||||
})}
|
const rowIndex = yToRowIndex.get(marker.y) ?? 0
|
||||||
</svg>
|
const offsetX = rowIndex % 2 === 1 ? xStep / 2 : 0
|
||||||
|
const info = markerData[index]
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={marker.x + offsetX}
|
||||||
|
cy={marker.y}
|
||||||
|
r={marker.size ?? dotRadius}
|
||||||
|
fill="#FD5E0F"
|
||||||
|
filter="url(#marker-glow)"
|
||||||
|
className="cursor-pointer"
|
||||||
|
key={`marker-${marker.x}-${marker.y}-${index}`}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (info) {
|
||||||
|
const rect = (e.target as SVGCircleElement).closest('svg')!.getBoundingClientRect()
|
||||||
|
const svgX = marker.x + offsetX
|
||||||
|
const svgY = marker.y
|
||||||
|
setTooltip({
|
||||||
|
x: rect.left + (svgX / width) * rect.width,
|
||||||
|
y: rect.top + (svgY / height) * rect.height,
|
||||||
|
country: info.country,
|
||||||
|
pageviews: info.pageviews,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-neutral-900 dark:bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full -mt-2"
|
||||||
|
style={{ left: tooltip.x, top: tooltip.y }}
|
||||||
|
>
|
||||||
|
<span>{getCountryName(tooltip.country)}</span>
|
||||||
|
<span className="ml-1.5 text-brand-orange font-bold">{formatNumber(tooltip.pageviews)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user