'use client'; /** * Choropleth world map of visitor counts per country. Powers a single card * on the website-analytics page; hover any country for the visitor count, * click to filter the rest of the page to that country. * * Uses ECharts' own world.json (the GeoJSON shipped with their public * examples) — pre-cleaned, no antimeridian artifacts. Country features * are matched on `properties.name` (English country name from the source). */ import { useEffect, useMemo, useState } from 'react'; import dynamic from 'next/dynamic'; import * as echarts from 'echarts/core'; import { MapChart } from 'echarts/charts'; import { GeoComponent, TooltipComponent, VisualMapComponent, TitleComponent, } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; import type { FeatureCollection } from 'geojson'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { getCountryName } from '@/lib/i18n/countries'; import type { UmamiMetricRow } from '@/lib/services/umami.service'; echarts.use([ MapChart, GeoComponent, TooltipComponent, VisualMapComponent, TitleComponent, CanvasRenderer, ]); const ReactEChartsCore = dynamic(() => import('echarts-for-react/lib/core'), { ssr: false }); let registrationPromise: Promise | null = null; async function ensureWorldMapRegistered(): Promise { if (registrationPromise) return registrationPromise; registrationPromise = (async () => { const res = await fetch('/world-map/echarts-world.json'); const geo = (await res.json()) as FeatureCollection; echarts.registerMap('world', { geoJSON: geo as unknown as object } as never); })(); return registrationPromise; } interface Props { rows: UmamiMetricRow[] | null; loading: boolean; onCountryClick?: (iso2: string) => void; } export function VisitorWorldMap({ rows, loading, onCountryClick }: Props) { const [mapReady, setMapReady] = useState(false); useEffect(() => { let cancelled = false; ensureWorldMapRegistered().then(() => { if (!cancelled) setMapReady(true); }); return () => { cancelled = true; }; }, []); const data = useMemo(() => { if (!rows) return []; return rows.map((r) => ({ name: getCountryName(r.x, 'en'), value: r.y, iso2: r.x, })); }, [rows]); const maxValue = useMemo( () => (data.length > 0 ? Math.max(...data.map((d) => d.value)) : 0), [data], ); const option = useMemo( () => ({ tooltip: { trigger: 'item', formatter: (params: { name: string; value?: number }) => params.value === undefined || isNaN(params.value) ? `${params.name}
No visitors` : `${params.name}
${params.value.toLocaleString()} visitor${params.value === 1 ? '' : 's'}`, backgroundColor: 'rgba(15, 23, 42, 0.95)', borderColor: 'rgba(255, 255, 255, 0.1)', textStyle: { color: '#f1f5f9', fontSize: 12 }, }, visualMap: { type: 'piecewise', min: 0, max: maxValue, left: 16, bottom: 12, orient: 'horizontal', textStyle: { color: '#64748b', fontSize: 10 }, inRange: { color: ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1d4ed8', '#1e3a8a'], }, itemWidth: 18, itemHeight: 10, itemGap: 2, showLabel: true, // Bucket counts into 5 piecewise segments so the legend reads // like a discrete heat-scale rather than a hard-to-eyeball // gradient bar. pieces: bucketizeMax(maxValue), }, series: [ { type: 'map', map: 'world', roam: true, scaleLimit: { min: 0.8, max: 8 }, aspectScale: 0.85, itemStyle: { areaColor: '#f1f5f9', borderColor: '#cbd5e1', borderWidth: 0.4, }, emphasis: { itemStyle: { areaColor: '#fbbf24', borderColor: '#92400e' }, label: { show: false }, }, select: { itemStyle: { areaColor: '#f97316' }, label: { show: false }, }, data, }, ], }), [data, maxValue], ); const onEvents = useMemo( () => ({ click: (params: { data?: { iso2?: string } }) => { const iso2 = params?.data?.iso2; if (iso2 && onCountryClick) onCountryClick(iso2); }, }), [onCountryClick], ); return ( Visitors by country {loading || !mapReady ? ( ) : ( )} ); } /** * Bucket the visitor-count scale into 5 readable bins. Umami country data * is heavily skewed (top country may have 900+, most have 0–5), so a * linear gradient looks visually flat. The buckets are derived from the * observed max so the highest bin is always saturated. */ function bucketizeMax(max: number): Array<{ min?: number; max?: number; label: string }> { if (max <= 0) return [{ min: 0, max: 0, label: '0' }]; const step = Math.max(1, Math.ceil(max / 5)); return [ { min: 0, max: 0, label: '0' }, { min: 1, max: step, label: `1–${step.toLocaleString()}` }, { min: step + 1, max: step * 2, label: `${(step + 1).toLocaleString()}–${(step * 2).toLocaleString()}`, }, { min: step * 2 + 1, max: step * 3, label: `${(step * 2 + 1).toLocaleString()}–${(step * 3).toLocaleString()}`, }, { min: step * 3 + 1, label: `${(step * 3 + 1).toLocaleString()}+` }, ]; }