Adds the read-side Umami integration queued in last week's website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`): - Realtime panel polls Umami at 5s intervals; world map renders visitor origins via echarts + `public/world-map/echarts-world.json` topo. - Sessions list + session-detail-sheet drill-down (per-session event timeline pulled from `/api/v1/website-analytics`). - Weekly heatmap (day-of-week × hour-of-day) for engagement timing. - Metric-detail pages under `/[portSlug]/website-analytics/[metric]` for pageviews / referrers / events deep-dives. - Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF beacon backed by `email_open_tracking` (migration 0076); resolves inline on render in inbox. - Tracked-link redirect: `/q/[slug]` routes through `tracked_links` (migration 0077) and forwards to the canonical destination after logging the click. - Dashboard `website-glance-tile` now reads from the live Umami service instead of placeholder data. Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`, `@types/topojson-client`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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()}+` },
|
||
];
|
||
}
|