SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.4 KB · 122 lines tsx
Raw Blame History
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