spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { ReactNode } from 'react';2import world from 'world-atlas/countries-110m.json';3import { MAP_HEIGHT, MAP_WIDTH, buildWorldGeometry } from '@/lib/map-geo';4import { MAP_NO_DATA_FILL, classLabel, fillFor, quantileScale, sqrtRadius, type MapScale } from '@/lib/map-scale';5import { fmtInt } from '@/lib/format';67// Projected once per server process: 177 paths, ≈ 90 KB of path data, reused by every request.8const GEO = buildWorldGeometry(world as unknown as Parameters<typeof buildWorldGeometry>[0]);910export interface MapCountryDatum {11 /** Country name as ClinicalTrials.gov writes it (used for the filtered trials link). */12 country: string;13 iso3: string | null;14 sites: number;15 trials: number;16 /** Link target for the country polygon (filtered trials list). */17 href: string;18}1920export interface MapCity {21 country: string;22 city: string;23 state: string | null;24 lat: number;25 lng: number;26 sites: number;27 trials: number;28}2930export type MapMetric = 'sites' | 'trials';3132/** Countries with data but no polygon at 1:110m (small states, territories) — surfaced under the map instead of vanishing. */33export function undrawnCountries(data: MapCountryDatum[]): MapCountryDatum[] {34 const drawn = new Set(GEO.countries.map((c) => c.iso3).filter((x): x is string => !!x));35 return data.filter((d) => !d.iso3 || !drawn.has(d.iso3));36}3738/** Class breaks for the metric — exported so the page can describe the classes in text. */39export function mapScaleFor(data: MapCountryDatum[], metric: MapMetric): MapScale {40 return quantileScale(data.map((d) => d[metric]));41}4243/**44 * Server-rendered choropleth (Equal Earth, 960×480 viewBox) of trial sites per country, quantized45 * in ≤ 5 quantile classes with a text legend; optional proportional-symbol city layer. Every46 * country path is a link to the filtered trials list and carries a <title> with the numbers, so47 * colour is never the only carrier. The caller MUST render an equivalent table.48 */49export function WorldMap({50 data,51 metric = 'sites',52 cities,53 ariaLabel,54 describedBy,55 compact = false,56 legend = true,57 children,58}: {59 data: MapCountryDatum[];60 metric?: MapMetric;61 cities?: MapCity[];62 ariaLabel: string;63 /** id of the data table equivalent to the map. */64 describedBy?: string;65 compact?: boolean;66 legend?: boolean;67 children?: ReactNode;68}) {69 const scale = mapScaleFor(data, metric);70 const byIso = new Map<string, MapCountryDatum>();71 for (const d of data) {72 if (!d.iso3) continue;73 const prev = byIso.get(d.iso3);74 // Two registrant spellings mapped to one code (rare): sum sites; keep the larger name for the link.75 byIso.set(d.iso3, prev ? { ...prev, sites: prev.sites + d.sites, trials: prev.trials + d.trials } : d);76 }77 const cityMax = cities && cities.length ? Math.max(...cities.map((c) => c.sites)) : 0;78 const metricLabel = metric === 'sites' ? 'sites' : 'trials';79 const extra = describedBy ? { 'aria-describedby': describedBy } : {};80 return (81 <figure className="w-full">82 <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}>83 <title>{ariaLabel}</title>84 {/* One style block instead of a class string on each of ~180 paths (every attribute is shipped twice: HTML + RSC payload). */}85 <style>{`.ci-worldmap a:hover path{opacity:.8}.ci-worldmap a:focus-visible path{stroke:var(--color-ink);stroke-width:1.5}.ci-worldmap a:focus{outline:none}`}</style>86 <path d={GEO.sphere} fill="var(--color-paper-2)" stroke="var(--color-rule)" strokeWidth="1" />87 <g stroke="var(--color-paper)" strokeWidth="0.6" strokeLinejoin="round">88 {GEO.countries.map((c) => {89 const d = c.iso3 ? byIso.get(c.iso3) : undefined;90 const fill = d ? fillFor(d[metric], scale) : MAP_NO_DATA_FILL;91 const title = d ? `${d.country} — ${fmtInt(d.sites)} sites · ${fmtInt(d.trials)} trials` : `${c.name} — no registered trial site`;92 const path = (93 <path d={c.d} fill={fill}>94 <title>{title}</title>95 </path>96 );97 return d ? (98 <a key={c.iso3 ?? c.name} href={d.href} aria-label={title}>99 {path}100 </a>101 ) : (102 <g key={c.iso3 ?? c.name}>{path}</g>103 );104 })}105 </g>106 {cities && cities.length > 0 ? (107 <g fill="var(--color-warn)" fillOpacity="0.55" stroke="var(--color-paper)" strokeWidth="0.5">108 {cities.map((c) => {109 const p = GEO.project(c.lng, c.lat);110 if (!p) return null;111 const r = sqrtRadius(c.sites, cityMax, compact ? 9 : 14);112 const label = `${c.city}${c.state ? `, ${c.state}` : ''}, ${c.country} — ${fmtInt(c.sites)} sites · ${fmtInt(c.trials)} trials`;113 return (114 <circle key={`${c.country}|${c.state ?? ''}|${c.city}`} cx={p[0]} cy={p[1]} r={r}>115 <title>{label}</title>116 </circle>117 );118 })}119 </g>120 ) : null}121 </svg>122 {legend ? (123 <figcaption className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-ink-2">124 <span className="ci-kicker">{metricLabel} per country</span>125 {scale.classes.length === 0 ? (126 <span>no data</span>127 ) : (128 scale.classes.map((c) => (129 <span key={c.index} className="inline-flex items-center gap-1.5">130 <span className="inline-block h-3 w-3 border border-rule-strong" style={{ background: c.fill }} aria-hidden />131 <span className="ci-num">{classLabel(c, (n) => fmtInt(n))}</span>132 <span className="text-ink-3">({c.n})</span>133 </span>134 ))135 )}136 <span className="inline-flex items-center gap-1.5">137 <span className="inline-block h-3 w-3 border border-rule-strong" style={{ background: MAP_NO_DATA_FILL }} aria-hidden />138 no site139 </span>140 {cities && cities.length > 0 ? (141 <span className="inline-flex items-center gap-1.5">142 <span className="inline-block h-3 w-3 rounded-full border border-paper" style={{ background: 'var(--color-warn)', opacity: 0.6 }} aria-hidden />143 city dots: area ∝ sites (top {fmtInt(cities.length)})144 </span>145 ) : null}146 <span className="text-ink-3">quantile classes (equal count of countries per class)</span>147 {children}148 </figcaption>149 ) : null}150 </figure>151 );152}153