import type { ReactNode } from 'react'; import world from 'world-atlas/countries-110m.json'; import { MAP_HEIGHT, MAP_WIDTH, buildWorldGeometry } from '@/lib/map-geo'; import { MAP_NO_DATA_FILL, classLabel, fillFor, quantileScale, sqrtRadius, type MapScale } from '@/lib/map-scale'; import { fmtInt } from '@/lib/format'; // Projected once per server process: 177 paths, ≈ 90 KB of path data, reused by every request. const GEO = buildWorldGeometry(world as unknown as Parameters[0]); export interface MapCountryDatum { /** Country name as ClinicalTrials.gov writes it (used for the filtered trials link). */ country: string; iso3: string | null; sites: number; trials: number; /** Link target for the country polygon (filtered trials list). */ href: string; } export interface MapCity { country: string; city: string; state: string | null; lat: number; lng: number; sites: number; trials: number; } export type MapMetric = 'sites' | 'trials'; /** Countries with data but no polygon at 1:110m (small states, territories) — surfaced under the map instead of vanishing. */ export function undrawnCountries(data: MapCountryDatum[]): MapCountryDatum[] { const drawn = new Set(GEO.countries.map((c) => c.iso3).filter((x): x is string => !!x)); return data.filter((d) => !d.iso3 || !drawn.has(d.iso3)); } /** Class breaks for the metric — exported so the page can describe the classes in text. */ export function mapScaleFor(data: MapCountryDatum[], metric: MapMetric): MapScale { return quantileScale(data.map((d) => d[metric])); } /** * Server-rendered choropleth (Equal Earth, 960×480 viewBox) of trial sites per country, quantized * in ≤ 5 quantile classes with a text legend; optional proportional-symbol city layer. Every * country path is a link to the filtered trials list and carries a with the numbers, so * colour is never the only carrier. The caller MUST render an equivalent table. */ export function WorldMap({ data, metric = 'sites', cities, ariaLabel, describedBy, compact = false, legend = true, children, }: { data: MapCountryDatum[]; metric?: MapMetric; cities?: MapCity[]; ariaLabel: string; /** id of the data table equivalent to the map. */ describedBy?: string; compact?: boolean; legend?: boolean; children?: ReactNode; }) { const scale = mapScaleFor(data, metric); const byIso = new Map<string, MapCountryDatum>(); for (const d of data) { if (!d.iso3) continue; const prev = byIso.get(d.iso3); // Two registrant spellings mapped to one code (rare): sum sites; keep the larger name for the link. byIso.set(d.iso3, prev ? { ...prev, sites: prev.sites + d.sites, trials: prev.trials + d.trials } : d); } const cityMax = cities && cities.length ? Math.max(...cities.map((c) => c.sites)) : 0; const metricLabel = metric === 'sites' ? 'sites' : 'trials'; const extra = describedBy ? { 'aria-describedby': describedBy } : {}; return ( <figure className="w-full"> <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} width="100%" role="img" aria-label={ariaLabel} className="ci-worldmap block" style={{ maxHeight: compact ? 260 : undefined }} {...extra}> <title>{ariaLabel} {/* One style block instead of a class string on each of ~180 paths (every attribute is shipped twice: HTML + RSC payload). */} {GEO.countries.map((c) => { const d = c.iso3 ? byIso.get(c.iso3) : undefined; const fill = d ? fillFor(d[metric], scale) : MAP_NO_DATA_FILL; const title = d ? `${d.country} — ${fmtInt(d.sites)} sites · ${fmtInt(d.trials)} trials` : `${c.name} — no registered trial site`; const path = ( {title} ); return d ? ( {path} ) : ( {path} ); })} {cities && cities.length > 0 ? ( {cities.map((c) => { const p = GEO.project(c.lng, c.lat); if (!p) return null; const r = sqrtRadius(c.sites, cityMax, compact ? 9 : 14); const label = `${c.city}${c.state ? `, ${c.state}` : ''}, ${c.country} — ${fmtInt(c.sites)} sites · ${fmtInt(c.trials)} trials`; return ( {label} ); })} ) : null} {legend ? (
{metricLabel} per country {scale.classes.length === 0 ? ( no data ) : ( scale.classes.map((c) => ( {classLabel(c, (n) => fmtInt(n))} ({c.n}) )) )} no site {cities && cities.length > 0 ? ( city dots: area ∝ sites (top {fmtInt(cities.length)}) ) : null} quantile classes (equal count of countries per class) {children}
) : null} ); }