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%
13.1 KB · 242 lines tsx
Raw Blame History
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