Files
pn-new-crm/src/components/website-analytics/visitor-world-map.tsx

203 lines
6.0 KiB
TypeScript
Raw Normal View History

'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<void> | null = null;
async function ensureWorldMapRegistered(): Promise<void> {
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}<br/><span style="color:#94a3b8">No visitors</span>`
: `${params.name}<br/><strong>${params.value.toLocaleString()}</strong> 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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Visitors by country</CardTitle>
</CardHeader>
<CardContent>
{loading || !mapReady ? (
<Skeleton className="h-[560px] w-full" />
) : (
<ReactEChartsCore
echarts={echarts}
option={option}
onEvents={onEvents}
style={{ height: 560, width: '100%' }}
notMerge
lazyUpdate
/>
)}
</CardContent>
</Card>
);
}
/**
* Bucket the visitor-count scale into 5 readable bins. Umami country data
* is heavily skewed (top country may have 900+, most have 05), 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()}+` },
];
}