203 lines
6.0 KiB
TypeScript
203 lines
6.0 KiB
TypeScript
|
|
'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 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()}+` },
|
|||
|
|
];
|
|||
|
|
}
|