'use client'; import { geoNaturalEarth1, geoPath } from 'd3-geo'; import { scaleSqrt } from 'd3-scale'; import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; import { feature } from 'topojson-client'; import type { GeometryCollection, Topology } from 'topojson-specification'; import world from 'world-atlas/countries-110m.json'; import { cn } from '@/lib/cn'; import { alpha2FromNumeric, countryName } from '@/lib/countries'; import { fmtInt } from '@/lib/format'; import type { MapBucket } from '@/lib/types'; const W = 960; const H = 470; type Metric = 'events_30d' | 'companies' | 'jobs_open'; /** * Global Activity Map: Natural Earth projection over the offline `world-atlas` 110m TopoJSON, bubbles from `/map` buckets * sized by the chosen metric (sqrt scale). Hover shows a tooltip; clicking a bubble or a country opens `/country/`. */ export function WorldMap({ buckets, metric = 'events_30d', className, height, interactive = true, highlight }: { buckets: MapBucket[]; metric?: Metric; className?: string; height?: number; interactive?: boolean; highlight?: string[] }) { const router = useRouter(); const [tip, setTip] = useState<{ x: number; y: number; b: MapBucket } | null>(null); const [hoverCountry, setHoverCountry] = useState(null); const { land, path, projection } = useMemo(() => { const topo = world as unknown as Topology<{ countries: GeometryCollection<{ name: string }> }>; const fc = feature(topo, topo.objects.countries); const projection = geoNaturalEarth1().fitExtent( [ [4, 4], [W - 4, H - 4], ], { type: 'Sphere' }, ); const path = geoPath(projection); return { land: fc.features, path, projection }; }, []); const r = useMemo(() => { const max = Math.max(1, ...buckets.map((b) => b[metric] ?? 0)); return scaleSqrt().domain([0, max]).range([0, 26]); }, [buckets, metric]); const placed = useMemo( () => buckets .map((b) => { const p = projection([b.lon, b.lat]); return p ? { b, x: p[0], y: p[1], rr: r(b[metric] ?? 0) } : null; }) .filter((x): x is { b: MapBucket; x: number; y: number; rr: number } => x !== null && x.rr > 0) .sort((a, b) => b.rr - a.rr), [buckets, projection, r, metric], ); const hl = new Set((highlight ?? []).map((c) => c.toUpperCase())); const go = (code: string | null) => { if (interactive && code) router.push(`/country/${code.toLowerCase()}`); }; return (
{land.map((f, li) => { const code = alpha2FromNumeric(f.id as string | number | undefined); const on = code !== null && (hoverCountry === code || hl.has(code)); return ( setHoverCountry(code)} onMouseLeave={() => setHoverCountry(null)} onClick={() => go(code)} > {f.properties?.name ?? countryName(code)} ); })} {placed.map(({ b, x, y, rr }, i) => ( setTip({ x, y, b })} onMouseLeave={() => setTip(null)} onFocus={() => setTip({ x, y, b })} onBlur={() => setTip(null)} onClick={() => go(b.country)}> {`${b.city ?? countryName(b.country)}: ${fmtInt(b[metric])} ${metricLabel(metric)}`} {rr > 8 && } ))} {tip && ( {tip.b.city ? `${tip.b.city}, ${tip.b.country}` : countryName(tip.b.country)} {fmtInt(tip.b.companies)} companies · {fmtInt(tip.b.events_30d)} events 30d · {fmtInt(tip.b.jobs_open)} jobs {tip.b.top.length > 0 && ( {tip.b.top .slice(0, 3) .map((t) => t.display_name) .join(' · ') .slice(0, 34)} )} )}
); } function metricLabel(m: Metric): string { return m === 'events_30d' ? 'events in 30 days' : m === 'companies' ? 'companies' : 'open jobs'; }