SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
6.6 KB · 153 lines tsx
Raw Blame History
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