spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1'use client';2import { geoNaturalEarth1, geoPath } from 'd3-geo';3import { scaleSqrt } from 'd3-scale';4import { useRouter } from 'next/navigation';5import { useMemo, useState } from 'react';6import { feature } from 'topojson-client';7import type { GeometryCollection, Topology } from 'topojson-specification';8import world from 'world-atlas/countries-110m.json';9import { cn } from '@/lib/cn';10import { alpha2FromNumeric, countryName } from '@/lib/countries';11import { fmtInt } from '@/lib/format';12import type { MapBucket } from '@/lib/types';1314const W = 960;15const H = 470;1617type Metric = 'events_30d' | 'companies' | 'jobs_open';1819/**20 * Global Activity Map: Natural Earth projection over the offline `world-atlas` 110m TopoJSON, bubbles from `/map` buckets21 * sized by the chosen metric (sqrt scale). Hover shows a tooltip; clicking a bubble or a country opens `/country/<code>`.22 */23export function WorldMap({ buckets, metric = 'events_30d', className, height, interactive = true, highlight }: { buckets: MapBucket[]; metric?: Metric; className?: string; height?: number; interactive?: boolean; highlight?: string[] }) {24 const router = useRouter();25 const [tip, setTip] = useState<{ x: number; y: number; b: MapBucket } | null>(null);26 const [hoverCountry, setHoverCountry] = useState<string | null>(null);2728 const { land, path, projection } = useMemo(() => {29 const topo = world as unknown as Topology<{ countries: GeometryCollection<{ name: string }> }>;30 const fc = feature(topo, topo.objects.countries);31 const projection = geoNaturalEarth1().fitExtent(32 [33 [4, 4],34 [W - 4, H - 4],35 ],36 { type: 'Sphere' },37 );38 const path = geoPath(projection);39 return { land: fc.features, path, projection };40 }, []);4142 const r = useMemo(() => {43 const max = Math.max(1, ...buckets.map((b) => b[metric] ?? 0));44 return scaleSqrt().domain([0, max]).range([0, 26]);45 }, [buckets, metric]);4647 const placed = useMemo(48 () =>49 buckets50 .map((b) => {51 const p = projection([b.lon, b.lat]);52 return p ? { b, x: p[0], y: p[1], rr: r(b[metric] ?? 0) } : null;53 })54 .filter((x): x is { b: MapBucket; x: number; y: number; rr: number } => x !== null && x.rr > 0)55 .sort((a, b) => b.rr - a.rr),56 [buckets, projection, r, metric],57 );5859 const hl = new Set((highlight ?? []).map((c) => c.toUpperCase()));60 const go = (code: string | null) => {61 if (interactive && code) router.push(`/country/${code.toLowerCase()}`);62 };6364 return (65 <div className={cn('relative w-full', className)}>66 <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full" style={height ? { maxHeight: height } : undefined} role="img" aria-label="World map of monitored company activity">67 <path d={path({ type: 'Sphere' }) ?? ''} fill="var(--surface)" stroke="var(--rule)" />68 {land.map((f, li) => {69 const code = alpha2FromNumeric(f.id as string | number | undefined);70 const on = code !== null && (hoverCountry === code || hl.has(code));71 return (72 <path73 key={`${String(f.id)}-${li}`}74 d={path(f) ?? ''}75 fill={on ? 'var(--accent-soft)' : 'var(--map-land)'}76 stroke="var(--map-stroke)"77 strokeWidth={0.5}78 className={cn(interactive && code && 'cursor-pointer')}79 onMouseEnter={() => setHoverCountry(code)}80 onMouseLeave={() => setHoverCountry(null)}81 onClick={() => go(code)}82 >83 <title>{f.properties?.name ?? countryName(code)}</title>84 </path>85 );86 })}87 {placed.map(({ b, x, y, rr }, i) => (88 <g key={`${b.country}-${b.city ?? ''}-${i}`} transform={`translate(${x},${y})`} className={cn(interactive && 'cursor-pointer')} onMouseEnter={() => setTip({ x, y, b })} onMouseLeave={() => setTip(null)} onFocus={() => setTip({ x, y, b })} onBlur={() => setTip(null)} onClick={() => go(b.country)}>89 <title>{`${b.city ?? countryName(b.country)}: ${fmtInt(b[metric])} ${metricLabel(metric)}`}</title>90 <circle r={rr} fill="var(--map-bubble)" stroke="var(--map-bubble-stroke)" strokeWidth={0.8} />91 {rr > 8 && <circle r={1.6} fill="var(--map-bubble-stroke)" />}92 </g>93 ))}94 {tip && (95 <g transform={`translate(${Math.min(W - 190, Math.max(6, tip.x + 12))},${Math.max(6, Math.min(H - 70, tip.y - 20))})`} pointerEvents="none">96 <rect width={180} height={tip.b.top.length ? 62 : 44} rx={4} fill="var(--surface)" stroke="var(--rule-strong)" />97 <text x={10} y={17} fontSize={12} fontWeight={600} fill="var(--ink)">98 {tip.b.city ? `${tip.b.city}, ${tip.b.country}` : countryName(tip.b.country)}99 </text>100 <text x={10} y={33} fontSize={11} fill="var(--ink-2)" className="tnum">101 {fmtInt(tip.b.companies)} companies · {fmtInt(tip.b.events_30d)} events 30d · {fmtInt(tip.b.jobs_open)} jobs102 </text>103 {tip.b.top.length > 0 && (104 <text x={10} y={50} fontSize={11} fill="var(--ink-3)">105 {tip.b.top106 .slice(0, 3)107 .map((t) => t.display_name)108 .join(' · ')109 .slice(0, 34)}110 </text>111 )}112 </g>113 )}114 </svg>115 </div>116 );117}118119function metricLabel(m: Metric): string {120 return m === 'events_30d' ? 'events in 30 days' : m === 'companies' ? 'companies' : 'open jobs';121}122