spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Note } from '@/components/ui/section';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { ClaimBadge } from '@/components/ui/badge';7import { WorldMap, mapScaleFor, undrawnCountries, type MapCountryDatum } from '@/components/charts/world-map';8import { CITY_LIMIT, SITE_METRICS, SITE_PHASES, cityCounts, countryCounts, distinctTrialCount, listTopLevelCancers, siteCountsAvailable, type SiteMetric, type SitePhase } from '@/lib/queries/trial-sites';9import { getDescendantIds } from '@/lib/queries/cancers';10import { fmtInt, fmtPct, phaseLabel } from '@/lib/format';11import { classLabel } from '@/lib/map-scale';12import { oneOf, str, withParams, type SP } from '@/lib/search-params';1314export const metadata: Metadata = {15 title: 'Clinical trial map',16 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.',17};18export const revalidate = 600;1920const TRIALS_STATUS_FOR_RECRUITING = 'RECRUITING';2122export default async function TrialMapPage({ searchParams }: { searchParams: Promise<SP> }) {23 const sp = await searchParams;24 const cancerSlug = str(sp, 'cancer').slice(0, 120);25 const phase = (oneOf(sp, 'phase', [...SITE_PHASES, ''] as const, '') || null) as SitePhase | null;26 const recruiting = str(sp, 'recruiting') === '1';27 const metric: SiteMetric = oneOf(sp, 'metric', SITE_METRICS, 'sites');2829 const cancers = await listTopLevelCancers();30 const cancer = cancerSlug ? (cancers.find((c) => c.slug === cancerSlug) ?? null) : null;31 const scope = { cancerId: cancer?.id ?? null, phase, recruitingOnly: recruiting };32 const cancerIds = cancer ? await getDescendantIds(cancer.id) : null;33 const live = { cancerIds, phase, recruitingOnly: recruiting };34 const [rows, trialsDistinct, cities, available] = await Promise.all([countryCounts(scope), distinctTrialCount(live), cancer ? cityCounts(live) : Promise.resolve([]), siteCountsAvailable()]);3536 const current = { cancer: cancer?.slug ?? '', phase: phase ?? '', recruiting: recruiting ? '1' : '', metric: metric === 'sites' ? '' : metric };37 const href = (o: Record<string, string | number | null | undefined>) => `/trials/map${withParams(current, o)}`;38 const trialsHref = (country: string) => `/trials${withParams({ country, phase: phase ?? '', status: recruiting ? TRIALS_STATUS_FOR_RECRUITING : '', cancer: cancer?.slug ?? '' }, {})}`;3940 const totalSites = rows.reduce((s, r) => s + r.sites, 0);41 const data: MapCountryDatum[] = rows.map((r) => ({ country: r.country, iso3: r.iso3, sites: r.sites, trials: r.trials, href: trialsHref(r.country) }));42 const sorted = [...data].sort((a, b) => b[metric] - a[metric] || a.country.localeCompare(b.country));43 const scale = mapScaleFor(data, metric);44 const undrawn = undrawnCountries(data);45 const computedAt = rows[0]?.computed_at ?? null;46 const formula = rows[0]?.formula_version ?? null;47 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(' · ');48 const tableId = 'trial-map-table';4950 return (51 <div>52 <PageHeader kicker="Clinical trials" title="Clinical trial map" lede="Registered study sites per country, as entered by registrants on ClinicalTrials.gov. A study with 40 sites in one country weighs 40 there; the trials column counts each study once per country.">53 <p className="mt-2 text-[13px] text-ink-3">54 <Link href="/trials" className="ci-link">55 Trials list56 </Link>{' '}57 ·{' '}58 <Link href="/methodology/trial-map" className="ci-link">59 Method60 </Link>61 </p>62 </PageHeader>6364 <form method="get" action="/trials/map" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_auto]" role="search" aria-label="Filter the trial map">65 <label className="flex flex-col gap-1">66 <span className="ci-kicker">Cancer (top-level)</span>67 <select name="cancer" defaultValue={cancer?.slug ?? ''} className="border border-rule-strong bg-white px-2 py-1.5">68 <option value="">All oncology trials</option>69 {cancers.map((c) => (70 <option key={c.id} value={c.slug}>71 {c.canonical_name}72 </option>73 ))}74 </select>75 </label>76 <label className="flex flex-col gap-1">77 <span className="ci-kicker">Phase</span>78 <select name="phase" defaultValue={phase ?? ''} className="border border-rule-strong bg-white px-2 py-1.5">79 <option value="">Any</option>80 {SITE_PHASES.map((p) => (81 <option key={p} value={p}>82 {phaseLabel(p)}83 {p === 'PHASE1' ? ' (incl. early phase 1)' : ''}84 </option>85 ))}86 </select>87 </label>88 <label className="flex flex-col gap-1">89 <span className="ci-kicker">Sites</span>90 <select name="recruiting" defaultValue={recruiting ? '1' : ''} className="border border-rule-strong bg-white px-2 py-1.5">91 <option value="">All statuses</option>92 <option value="1">Recruiting only</option>93 </select>94 </label>95 <label className="flex flex-col gap-1">96 <span className="ci-kicker">Colour by</span>97 <select name="metric" defaultValue={metric} className="border border-rule-strong bg-white px-2 py-1.5">98 <option value="sites">Sites</option>99 <option value="trials">Trials</option>100 </select>101 </label>102 <div className="flex items-end">103 <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">104 Apply105 </button>106 </div>107 </form>108109 {cancerSlug && !cancer ? (110 <p className="mt-2 text-[13px] text-warn" role="status">111 Unknown or non-top-level cancer slug “{cancerSlug}” — the map is precomputed for top-level cancers only; showing all oncology trials.112 </p>113 ) : null}114115 {rows.length === 0 ? (116 <div className="mt-4">117 <EmptyState title="Data not yet available" knows={[{ label: 'Trials list', href: '/trials' }, { label: 'Cancers explorer', href: '/cancers' }]}>118 {available ? (119 <>No registered site matches this scope ({scopeText}). Relax a filter.</>120 ) : (121 <>122 Country aggregates of trial sites have not been computed on this environment. Run <code className="ci-mono">pnpm cix intel</code> after the ClinicalTrials.gov connector to populate <code className="ci-mono">trial_site_country_counts</code>.123 </>124 )}125 </EmptyState>126 </div>127 ) : (128 <>129 <dl className="mt-4 flex flex-wrap gap-x-8 gap-y-2 text-[13.5px]" aria-label="Scope totals">130 <div className="min-w-[8rem]">131 <dt className="ci-kicker">Countries with sites</dt>132 <dd className="ci-num text-left text-xl text-ink">{fmtInt(rows.length)}</dd>133 </div>134 <div className="min-w-[8rem]">135 <dt className="ci-kicker">Sites</dt>136 <dd className="ci-num text-left text-xl text-ink">{fmtInt(totalSites)}</dd>137 </div>138 <div className="min-w-[8rem]">139 <dt className="ci-kicker">Trials</dt>140 <dd className="ci-num text-left text-xl text-ink">{fmtInt(trialsDistinct)}</dd>141 </div>142 </dl>143 <p className="mt-1 text-[12.5px] text-ink-3" role="status">144 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.145 </p>146147 <div className="mt-4 max-w-[960px]">148 <WorldMap data={data} metric={metric} cities={cities.length ? cities : undefined} ariaLabel={`World map of clinical trial ${metric} per country — ${scopeText}. The table below lists the same values.`} describedBy={tableId} />149 </div>150 {cancer ? (151 <p className="mt-1 text-[12px] text-ink-3">152 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.' : ''}153 </p>154 ) : (155 <p className="mt-1 text-[12px] text-ink-3">City-level dots appear when a cancer is selected (the whole-registry city aggregate is too heavy to run per request).</p>156 )}157 {undrawn.length > 0 ? (158 <p className="mt-1 text-[12px] text-ink-3">159 Not drawn at this scale ({undrawn.length}): {undrawn.map((u) => `${u.country} ${fmtInt(u[metric])}`).join(', ')}. They are in the table.160 </p>161 ) : null}162163 <div className="mt-5 ci-table-wrap">164 <table className="ci-table" id={tableId}>165 <caption className="text-left">166 <span className="flex flex-wrap items-center gap-1.5 text-[12px] text-ink-3">167 <ClaimBadge kind="computed" />168 <span>169 Country aggregates · formula <span className="ci-mono">{formula}</span> · source clinicaltrials · class = quantile class on the map170 </span>171 </span>172 </caption>173 <thead>174 <tr>175 <th className="num">#</th>176 <th>Country</th>177 <th>ISO3</th>178 <th className="num">Sites</th>179 <th className="num">Trials</th>180 <th className="num">Share of sites</th>181 <th>Class</th>182 <th>Trials list</th>183 </tr>184 </thead>185 <tbody>186 {sorted.map((r, i) => {187 const cls = scale.classes.find((c) => r[metric] >= c.lo && r[metric] <= c.hi);188 return (189 <tr key={r.country}>190 <td className="num">{i + 1}</td>191 <td>{r.country}</td>192 <td className="ci-mono">{r.iso3 ?? <span className="text-ink-4">—</span>}</td>193 <td className="num">{fmtInt(r.sites)}</td>194 <td className="num">{fmtInt(r.trials)}</td>195 <td className="num">{fmtPct(totalSites ? r.sites / totalSites : null)}</td>196 <td>197 {cls ? (198 <span className="inline-flex items-center gap-1.5">199 <span className="inline-block h-3 w-3 border border-rule-strong" style={{ background: cls.fill }} aria-hidden />200 <span className="ci-num text-[12px]">{classLabel(cls, (n) => fmtInt(n))}</span>201 </span>202 ) : (203 '—'204 )}205 </td>206 <td>207 <Link href={r.href} className="ci-link">208 View trials →209 </Link>210 </td>211 </tr>212 );213 })}214 </tbody>215 </table>216 </div>217 <Freshness dataUpdatedAt={computedAt} extra={`${fmtInt(rows.length)} countries · formula ${formula ?? '—'} · source: clinicaltrials`} />218219 <div className="mt-4 space-y-2">220 <Note>221 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.222 </Note>223 <Note>224 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)' : ''}.{' '}225 <Link href="/methodology/trial-map" className="ci-link">226 Full method227 </Link>228 .229 </Note>230 </div>231 <p className="mt-3 text-[12px] text-ink-3">232 Permalink:{' '}233 <Link href={href({})} className="ci-link ci-mono">234 {href({}) || '/trials/map'}235 </Link>236 </p>237 </>238 )}239 </div>240 );241}242