import type { Metadata } from 'next'; import Link from 'next/link'; import { PageHeader, Note } from '@/components/ui/section'; import { EmptyState } from '@/components/ui/empty-state'; import { Freshness } from '@/components/ui/freshness'; import { ClaimBadge } from '@/components/ui/badge'; import { WorldMap, mapScaleFor, undrawnCountries, type MapCountryDatum } from '@/components/charts/world-map'; import { CITY_LIMIT, SITE_METRICS, SITE_PHASES, cityCounts, countryCounts, distinctTrialCount, listTopLevelCancers, siteCountsAvailable, type SiteMetric, type SitePhase } from '@/lib/queries/trial-sites'; import { getDescendantIds } from '@/lib/queries/cancers'; import { fmtInt, fmtPct, phaseLabel } from '@/lib/format'; import { classLabel } from '@/lib/map-scale'; import { oneOf, str, withParams, type SP } from '@/lib/search-params'; export const metadata: Metadata = { title: 'Clinical trial map', description: 'Where oncology trials recruit: registered ClinicalTrials.gov study sites per country, for all cancers or one top-level cancer, by phase and recruiting status.', }; export const revalidate = 600; const TRIALS_STATUS_FOR_RECRUITING = 'RECRUITING'; export default async function TrialMapPage({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; const cancerSlug = str(sp, 'cancer').slice(0, 120); const phase = (oneOf(sp, 'phase', [...SITE_PHASES, ''] as const, '') || null) as SitePhase | null; const recruiting = str(sp, 'recruiting') === '1'; const metric: SiteMetric = oneOf(sp, 'metric', SITE_METRICS, 'sites'); const cancers = await listTopLevelCancers(); const cancer = cancerSlug ? (cancers.find((c) => c.slug === cancerSlug) ?? null) : null; const scope = { cancerId: cancer?.id ?? null, phase, recruitingOnly: recruiting }; const cancerIds = cancer ? await getDescendantIds(cancer.id) : null; const live = { cancerIds, phase, recruitingOnly: recruiting }; const [rows, trialsDistinct, cities, available] = await Promise.all([countryCounts(scope), distinctTrialCount(live), cancer ? cityCounts(live) : Promise.resolve([]), siteCountsAvailable()]); const current = { cancer: cancer?.slug ?? '', phase: phase ?? '', recruiting: recruiting ? '1' : '', metric: metric === 'sites' ? '' : metric }; const href = (o: Record) => `/trials/map${withParams(current, o)}`; const trialsHref = (country: string) => `/trials${withParams({ country, phase: phase ?? '', status: recruiting ? TRIALS_STATUS_FOR_RECRUITING : '', cancer: cancer?.slug ?? '' }, {})}`; const totalSites = rows.reduce((s, r) => s + r.sites, 0); const data: MapCountryDatum[] = rows.map((r) => ({ country: r.country, iso3: r.iso3, sites: r.sites, trials: r.trials, href: trialsHref(r.country) })); const sorted = [...data].sort((a, b) => b[metric] - a[metric] || a.country.localeCompare(b.country)); const scale = mapScaleFor(data, metric); const undrawn = undrawnCountries(data); const computedAt = rows[0]?.computed_at ?? null; const formula = rows[0]?.formula_version ?? null; const scopeText = [cancer ? `${cancer.canonical_name} (and NCIt descendants)` : 'all oncology trials', phase ? phaseLabel(phase) : 'any phase', recruiting ? 'recruiting sites only' : 'all site statuses'].join(' · '); const tableId = 'trial-map-table'; return (

Trials list {' '} ·{' '} Method

{cancerSlug && !cancer ? (

Unknown or non-top-level cancer slug “{cancerSlug}” — the map is precomputed for top-level cancers only; showing all oncology trials.

) : null} {rows.length === 0 ? (
{available ? ( <>No registered site matches this scope ({scopeText}). Relax a filter. ) : ( <> Country aggregates of trial sites have not been computed on this environment. Run pnpm cix intel after the ClinicalTrials.gov connector to populate trial_site_country_counts. )}
) : ( <>
Countries with sites
{fmtInt(rows.length)}
Sites
{fmtInt(totalSites)}
Trials
{fmtInt(trialsDistinct)}

Scope: {scopeText}. Trials = distinct studies with ≥ 1 site in a named country; the per-country trial column sums to more because multinational studies count once per country.

{cancer ? (

City dots: top {fmtInt(Math.min(CITY_LIMIT, cities.length))} cities by sites for this cancer (registrant-entered city, mean geocoded position), {recruiting ? 'recruiting sites only' : 'all statuses'}. {cities.length === 0 ? 'No geocoded site in scope.' : ''}

) : (

City-level dots appear when a cancer is selected (the whole-registry city aggregate is too heavy to run per request).

)} {undrawn.length > 0 ? (

Not drawn at this scale ({undrawn.length}): {undrawn.map((u) => `${u.country} ${fmtInt(u[metric])}`).join(', ')}. They are in the table.

) : null}
{sorted.map((r, i) => { const cls = scale.classes.find((c) => r[metric] >= c.lo && r[metric] <= c.hi); return ( ); })}
Country aggregates · formula {formula} · source clinicaltrials · class = quantile class on the map
# Country ISO3 Sites Trials Share of sites Class Trials list
{i + 1} {r.country} {r.iso3 ?? —} {fmtInt(r.sites)} {fmtInt(r.trials)} {fmtPct(totalSites ? r.sites / totalSites : null)} {cls ? ( {classLabel(cls, (n) => fmtInt(n))} ) : ( '—' )} View trials →
Site counts come from the locations entered by registrants (a study with 40 US sites weighs 40 in the United States). “Recruiting” is the location status when the registrant provided one; otherwise the study’s overall status is used. Interventional and observational studies are both included; the country name is the registrant’s. Historical names without an ISO 3166-1 code (Serbia and Montenegro, Federal Republic of Yugoslavia, Netherlands Antilles) are listed without ISO3 and not painted. Classes are quantiles of the displayed metric over countries with at least one site, recomputed for every filter, so colours are comparable within one view, not across views. Numbers, not colours, carry the meaning: hover or focus a country, or read the table. The “View trials” link filters the trials list by country{recruiting ? ' and by study status RECRUITING (an approximation of site status)' : ''}.{' '} Full method .

Permalink:{' '} {href({}) || '/trials/map'}

)}
); }