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%

Clinical trial map: country site aggregates (trial_site_country_counts), ISO mapping, Equal Earth choropleth, /trials/map, /v1/trials/sites, methodology

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent fef9977

21 changed files +2,018 −6

modified apps/api/src/app.ts +2 −0
@@ -31,6 +31,7 @@ import { epidemiologyRoutes } from './routes/epidemiology.js';
31 31 import { approvalRoutes } from './routes/approvals.js';
32 32 import { intelligenceRoutes } from './routes/intelligence.js';
33 33 import { researchGapRoutes } from './routes/research-gap.js';
34 +import { trialSiteRoutes } from './routes/trial-sites.js';
34 35
35 36 export interface BuildOptions {
36 37 db?: Database;
@@ -166,6 +167,7 @@ export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance
166 167 await v1.register(graphRoutes);
167 168 await v1.register(intelligenceRoutes);
168 169 await v1.register(researchGapRoutes);
170 + await v1.register(trialSiteRoutes);
169 171 await v1.register(adminRoutes, { prefix: '/admin' });
170 172 },
171 173 { prefix: '/v1' },
added apps/api/src/routes/trial-sites.ts +108 −0
@@ -0,0 +1,108 @@
1 +import { sql } from 'drizzle-orm';
2 +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
3 +import { z } from 'zod';
4 +import { paginate } from '../lib/envelope.js';
5 +import { descendantIds } from '../lib/descendants.js';
6 +import { BadRequest } from '../lib/errors.js';
7 +import { boolQuery, pageQuery } from '../lib/pagination.js';
8 +import { resolveCancer } from '../lib/resolve.js';
9 +import { AnyList, num, ok, respond } from '../lib/respond.js';
10 +
11 +const PHASES = ['PHASE1', 'PHASE2', 'PHASE3', 'PHASE4'] as const;
12 +const FORMULA = 'ci-trial-sites-v1';
13 +
14 +/**
15 + * GET /trials/sites — trial site aggregates for the map (SPEC §11).
16 + * level=country (default): from the derived table trial_site_country_counts (recomputed by `cix intel`),
17 + * cancer = top-level cancer id/slug (descendants included) or omitted for every oncology trial.
18 + * level=city: live aggregate of trial_locations (registrant city/state, mean lat/lng); requires
19 + * `cancer` or `recruiting=true` (the unfiltered registry-wide aggregate is too slow per request).
20 + * Every row is a computed_metric with its formula version; sites are registrant-entered locations.
21 + * NOT registered in app.ts by this module — the integrator adds `await app.register(trialSiteRoutes)`.
22 + */
23 +export const trialSiteRoutes: FastifyPluginAsyncZod = async (app) => {
24 + app.get(
25 + '/trials/sites',
26 + {
27 + schema: {
28 + tags: ['trials'],
29 + summary: 'Trial sites per country (precomputed) or per city (live) — the trial map data',
30 + querystring: z.object({
31 + cancer: z.string().optional().describe('Top-level cancer id/slug (country level) or any cancer id/slug (city level); descendants included'),
32 + phase: z.enum(PHASES).optional().describe('PHASE1 includes EARLY_PHASE1; omitted = any phase'),
33 + recruiting: boolQuery.describe('true = location status RECRUITING (or study RECRUITING when the location has no status)'),
34 + level: z.enum(['country', 'city']).default('country'),
35 + ...pageQuery,
36 + }),
37 + response: ok(AnyList, true),
38 + },
39 + },
40 + async (req) => {
41 + const q = req.query;
42 + const recruiting = q.recruiting ?? false;
43 + const phase = q.phase ?? null;
44 + const cancer = q.cancer ? await resolveCancer(app.db, q.cancer) : null;
45 +
46 + if (q.level === 'country') {
47 + const rows = await app.db.execute<{ country: string; iso3: string | null; sites: number; trials: number; formula_version: string; updated_at: string; total: string }>(sql`
48 + SELECT s.country, s.iso3, s.sites, s.trials, s.formula_version, s.updated_at, count(*) OVER() AS total
49 + FROM trial_site_country_counts s
50 + WHERE s.cancer_id IS NOT DISTINCT FROM ${cancer?.id ?? null} AND s.phase IS NOT DISTINCT FROM ${phase} AND s.recruiting_only = ${recruiting}
51 + ORDER BY s.sites DESC, s.country LIMIT ${q.limit} OFFSET ${q.offset}`);
52 + if (cancer && rows.length === 0 && q.offset === 0) {
53 + const top = await app.db.execute<{ top_level: boolean }>(sql`SELECT top_level FROM cancers WHERE id = ${cancer.id}`);
54 + if (top[0] && !top[0].top_level) throw new BadRequest(`Country aggregates are precomputed for top-level cancers only; ${cancer.slug} is not top-level. Use level=city or a top-level ancestor.`);
55 + }
56 + const total = rows.length ? num(rows[0]!.total) : 0;
57 + const data = rows.map((r) => ({
58 + level: 'country' as const,
59 + country: r.country,
60 + iso3: r.iso3,
61 + sites: num(r.sites),
62 + trials: num(r.trials),
63 + cancer: cancer ? { id: cancer.id, slug: cancer.slug } : null,
64 + phase,
65 + recruitingOnly: recruiting,
66 + claim: 'computed_metric',
67 + formulaVersion: r.formula_version,
68 + computedAt: r.updated_at,
69 + }));
70 + return respond(app, data, data.length ? ['clinicaltrials'] : [], paginate(total, q.limit, q.offset));
71 + }
72 +
73 + // level=city — live aggregate.
74 + if (!cancer && !recruiting) throw new BadRequest('level=city requires a cancer filter or recruiting=true (the registry-wide city aggregate is not served per request).');
75 + const conds = [sql`l.country IS NOT NULL AND l.country <> '' AND l.lat IS NOT NULL AND l.lng IS NOT NULL AND l.city IS NOT NULL`];
76 + if (phase) conds.push(phase === 'PHASE1' ? sql`(t.phases && ARRAY['PHASE1','EARLY_PHASE1']::text[])` : sql`${phase} = ANY(t.phases)`);
77 + if (recruiting) conds.push(sql`coalesce(l.status, t.overall_status) = 'RECRUITING'`);
78 + if (cancer) {
79 + const ids = await descendantIds(app.db, cancer.id);
80 + conds.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = l.trial_id AND tc.cancer_id = ANY(${sql.param(ids)}::text[]))`);
81 + }
82 + const rows = await app.db.execute<{ country: string; city: string; state: string | null; lat: number; lng: number; sites: string; trials: string; total: string }>(sql`
83 + SELECT l.country, l.city, l.state, avg(l.lat)::float8 AS lat, avg(l.lng)::float8 AS lng, count(*) AS sites, count(DISTINCT l.trial_id) AS trials, count(*) OVER() AS total
84 + FROM trial_locations l JOIN clinical_trials t ON t.id = l.trial_id
85 + WHERE ${sql.join(conds, sql` AND `)}
86 + GROUP BY l.country, l.city, l.state
87 + ORDER BY sites DESC, trials DESC, l.country, l.city LIMIT ${q.limit} OFFSET ${q.offset}`);
88 + const total = rows.length ? num(rows[0]!.total) : 0;
89 + const data = rows.map((r) => ({
90 + level: 'city' as const,
91 + country: r.country,
92 + city: r.city,
93 + state: r.state,
94 + lat: Number(r.lat),
95 + lng: Number(r.lng),
96 + sites: num(r.sites),
97 + trials: num(r.trials),
98 + cancer: cancer ? { id: cancer.id, slug: cancer.slug } : null,
99 + phase,
100 + recruitingOnly: recruiting,
101 + claim: 'computed_metric',
102 + formulaVersion: FORMULA,
103 + computedAt: new Date().toISOString(),
104 + }));
105 + return respond(app, data, data.length ? ['clinicaltrials'] : [], paginate(total, q.limit, q.offset));
106 + },
107 + );
108 +};
modified apps/web/package.json +8 −1
@@ -16,19 +16,26 @@
16 16 "@cancerindex/ontology": "workspace:*",
17 17 "@cancerindex/ranking": "workspace:*",
18 18 "@cancerindex/shared": "workspace:*",
19 + "d3-geo": "^3.1.1",
19 20 "drizzle-orm": "^0.45.0",
20 21 "lucide-react": "^1.0.0",
21 22 "next": "16.3.4",
22 23 "postgres": "^3.4.7",
23 24 "react": "19.2.8",
24 25 "react-dom": "19.2.8",
25 − "server-only": "^0.0.1"
26 + "server-only": "^0.0.1",
27 + "topojson-client": "^3.1.0",
28 + "world-atlas": "^2.0.2"
26 29 },
27 30 "devDependencies": {
28 31 "@tailwindcss/postcss": "^4",
32 + "@types/d3-geo": "^3.1.1",
33 + "@types/geojson": "^7946.0.16",
29 34 "@types/node": "^24.0.0",
30 35 "@types/react": "^19",
31 36 "@types/react-dom": "^19",
37 + "@types/topojson-client": "^3.1.5",
38 + "@types/topojson-specification": "^1.0.5",
32 39 "tailwindcss": "^4",
33 40 "typescript": "^5.9.3",
34 41 "vitest": "^3.2.0"
added apps/web/src/app/methodology/trial-map/page.tsx +102 −0
@@ -0,0 +1,102 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader, Section, KV } from '@/components/ui/section';
4 +import { ClaimBadge } from '@/components/ui/badge';
5 +import { MAP_RAMP } from '@/lib/map-scale';
6 +import { UNMAPPED_COUNTRY_NAMES } from '@cancerindex/ranking';
7 +
8 +export const metadata: Metadata = { title: 'Methodology — clinical trial map', description: 'How trial sites per country are counted, aggregated over the cancer hierarchy, mapped to ISO 3166-1 and drawn (Equal Earth, quantile classes).' };
9 +export const revalidate = 3600;
10 +
11 +/** Web rendering of docs/methodology/trial-map.md (kept in sync by hand; the markdown is the reference). */
12 +export default function TrialMapMethodPage() {
13 + return (
14 + <div className="ci-prose max-w-4xl">
15 + <PageHeader kicker="Methodology" title="Clinical trial map" lede="Registered study sites per country, for all oncology trials or one top-level cancer, by phase and recruiting status. Every number is recomputed deterministically from canonical tables; nothing is estimated.">
16 + <p className="mt-2 flex flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
17 + <ClaimBadge kind="computed" /> formula <code className="ci-mono">ci-trial-sites-v1</code> · table <code className="ci-mono">trial_site_country_counts</code> · source clinicaltrials ·{' '}
18 + <Link href="/trials/map" className="ci-link">
19 + Open the map
20 + </Link>
21 + </p>
22 + </PageHeader>
23 +
24 + <Section id="definitions" kicker="§11" title="Definitions">
25 + <KV
26 + items={[
27 + { k: 'Site', v: 'One trial_locations row: a facility entered by the registrant for a study. A study listing 40 US facilities contributes 40 sites to the United States. Locations with an empty country are excluded.' },
28 + { k: 'Trial', v: 'A distinct study with at least one site in the country. A multinational study counts once per country, so the trials column summed over countries exceeds the number of distinct studies; the page headline "Trials" is the distinct count over the whole scope, computed live.' },
29 + { k: 'Recruiting', v: "The location's own status is RECRUITING; when the registrant gave no location status (about 82 % of rows) the study's overall status RECRUITING is used instead. \"All statuses\" includes completed and withdrawn studies." },
30 + { k: 'Study type', v: 'Interventional and observational studies are both included; there is no study_type filter.' },
31 + { k: 'Country name', v: 'The registrant\'s spelling as exported by ClinicalTrials.gov, kept verbatim so the "View trials" link filters the list exactly; ISO 3166-1 alpha-3 is added for drawing.' },
32 + ]}
33 + />
34 + </Section>
35 +
36 + <Section id="scopes" kicker="§11" title="Scopes and aggregation">
37 + <p>
38 + One row per <code className="ci-mono">(cancer, phase, recruiting_only, country)</code>. <strong>Cancer</strong>: all oncology trials, or one of the active top-level cancers; a trial belongs to a top-level cancer when any of its mapped conditions is the cancer <em>or one of its descendants</em> in the NCIt hierarchy (recursive traversal, depth ≤ 12 — the same traversal as the entity counters). A study mapped to several top-level cancers counts in each; the all-trials scope counts it once. <strong>Phase</strong>: any, or Phase 1–4; a PHASE2|PHASE3 study counts under both, EARLY_PHASE1 counts under Phase 1, studies with phase N/A count only under "any". <strong>Recruiting</strong>: as defined above.
39 + </p>
40 + <p className="mt-2">
41 + Counts: <code className="ci-mono">sites = count(*)</code>, <code className="ci-mono">trials = count(DISTINCT trial_id)</code>. Rebuilt in one transaction by <code className="ci-mono">pnpm cix intel</code> (≈ 11 800 rows, 178 country names, about 30 s). The freshness line shows the rebuild time.
42 + </p>
43 + </Section>
44 +
45 + <Section id="iso" kicker="ISO 3166-1" title="Country mapping and unmapped names">
46 + <p>
47 + Current short names and legacy long forms ("Korea, Republic of", "Russian Federation", "Viet Nam", "Réunion", "Palestinian Territory, occupied") are mapped to alpha-3, case-, whitespace- and apostrophe-insensitively. Territories keep their own code (Puerto Rico PRI, Hong Kong HKG, Réunion REU, Guam GUM…) because that is how the registrant counted the site; Kosovo uses the user-assigned XKX; "Virgin Islands" is read as the U.S. Virgin Islands.
48 + </p>
49 + <p className="mt-2">
50 + Names without a current ISO code stay unmapped, appear in the table by name and are never painted:{' '}
51 + {UNMAPPED_COUNTRY_NAMES.filter((n) => n)
52 + .map((n) => `“${n}”`)
53 + .join(', ')}
54 + . A unit test checks that every distinct country name in the database either resolves or is on this explicit list, so a new spelling cannot silently vanish from the map.
55 + </p>
56 + </Section>
57 +
58 + <Section id="drawing" kicker="Cartography" title="Projection, classes and colour">
59 + <ul>
60 + <li>
61 + <strong>Geometry</strong>: Natural Earth 1:110m (world-atlas, public domain); numeric ISO ids converted to alpha-3; Antarctica dropped; Northern Cyprus and Somaliland have no code and render as "no site".
62 + </li>
63 + <li>
64 + <strong>Projection</strong>: Equal Earth (d3-geo), fitted to the sphere in a 960×480 viewBox — equal-area, so high-latitude countries are not visually inflated.
65 + </li>
66 + <li>
67 + <strong>Class breaks</strong>: quantiles (equal number of countries per class), at most 5 classes, computed on the displayed metric over countries with ≥ 1 site for the <em>current filter</em>. Site counts are extremely skewed (the United States hosts about half of all sites; the median country has a few dozen): equal intervals would put every country but one in the first class and a logarithmic scale would hide the difference between 1 and 30 sites. Thresholds are the observed class maxima, so the legend shows the exact range and country count of each class. Colours are comparable within one view only.
68 + </li>
69 + <li className="flex flex-wrap items-center gap-2">
70 + <strong>Colour</strong>: sequential teal ramp
71 + {MAP_RAMP.map((c) => (
72 + <span key={c} className="inline-flex items-center gap-1 text-[12px]">
73 + <span className="inline-block h-3 w-3 border border-rule-strong" style={{ background: c }} aria-hidden /> <code className="ci-mono">{c}</code>
74 + </span>
75 + ))}
76 + ; "no site" is the paper-3 tone. Colour is never the only carrier: each country is a link with a text title, the legend is textual and the full table is always rendered.
77 + </li>
78 + <li>
79 + <strong>City layer</strong>: only when a cancer is selected — live aggregate of locations by (country, city, state) with the mean geocoded position, top 300 by sites, dot area ∝ sites. The registry-wide, all-status city aggregate (≈ 3 s) is not served per request.
80 + </li>
81 + </ul>
82 + </Section>
83 +
84 + <Section id="limitations" kicker="Caveats" title="Limitations">
85 + <ul>
86 + <li>Sites reflect registration practice, not research capacity: US sponsors list every facility, other sponsors often list one coordinating site per country; only ClinicalTrials.gov is ingested.</li>
87 + <li>Location status is missing for ~82 % of rows; falling back to the study status over-counts recruiting sites at facilities that have closed.</li>
88 + <li>Basket trials mapped to several top-level cancers count in each cancer scope.</li>
89 + <li>Coordinates come from the upstream registry; cities without coordinates are absent from the dot layer only.</li>
90 + <li>Counts are not normalised by population or burden; such views belong to the rankings layer with their own formula version.</li>
91 + </ul>
92 + <p className="mt-2 text-[13px] text-ink-3">
93 + Reference text: <code className="ci-mono">docs/methodology/trial-map.md</code>. General methodology:{' '}
94 + <Link href="/methodology" className="ci-link">
95 + How the index is built
96 + </Link>
97 + .
98 + </p>
99 + </Section>
100 + </div>
101 + );
102 +}
added apps/web/src/app/trials/map/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { PageSkeleton } from '@/components/ui/skeleton';
2 +
3 +export default function Loading() {
4 + return <PageSkeleton title="Loading clinical trial map" />;
5 +}
added apps/web/src/app/trials/map/page.tsx +241 −0
@@ -0,0 +1,241 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { PageHeader, Note } from '@/components/ui/section';
4 +import { EmptyState } from '@/components/ui/empty-state';
5 +import { Freshness } from '@/components/ui/freshness';
6 +import { ClaimBadge } from '@/components/ui/badge';
7 +import { WorldMap, mapScaleFor, undrawnCountries, type MapCountryDatum } from '@/components/charts/world-map';
8 +import { CITY_LIMIT, SITE_METRICS, SITE_PHASES, cityCounts, countryCounts, distinctTrialCount, listTopLevelCancers, siteCountsAvailable, type SiteMetric, type SitePhase } from '@/lib/queries/trial-sites';
9 +import { getDescendantIds } from '@/lib/queries/cancers';
10 +import { fmtInt, fmtPct, phaseLabel } from '@/lib/format';
11 +import { classLabel } from '@/lib/map-scale';
12 +import { oneOf, str, withParams, type SP } from '@/lib/search-params';
13 +
14 +export 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 +};
18 +export const revalidate = 600;
19 +
20 +const TRIALS_STATUS_FOR_RECRUITING = 'RECRUITING';
21 +
22 +export 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');
28 +
29 + 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()]);
35 +
36 + 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 ?? '' }, {})}`;
39 +
40 + 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';
49 +
50 + 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 list
56 + </Link>{' '}
57 + ·{' '}
58 + <Link href="/methodology/trial-map" className="ci-link">
59 + Method
60 + </Link>
61 + </p>
62 + </PageHeader>
63 +
64 + <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 + Apply
105 + </button>
106 + </div>
107 + </form>
108 +
109 + {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}
114 +
115 + {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>
146 +
147 + <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}
162 +
163 + <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 map
170 + </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`} />
218 +
219 + <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 method
227 + </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 +}
added apps/web/src/components/charts/world-map.tsx +152 −0
@@ -0,0 +1,152 @@
1 +import type { ReactNode } from 'react';
2 +import world from 'world-atlas/countries-110m.json';
3 +import { MAP_HEIGHT, MAP_WIDTH, buildWorldGeometry } from '@/lib/map-geo';
4 +import { MAP_NO_DATA_FILL, classLabel, fillFor, quantileScale, sqrtRadius, type MapScale } from '@/lib/map-scale';
5 +import { fmtInt } from '@/lib/format';
6 +
7 +// Projected once per server process: 177 paths, ≈ 90 KB of path data, reused by every request.
8 +const GEO = buildWorldGeometry(world as unknown as Parameters<typeof buildWorldGeometry>[0]);
9 +
10 +export 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 +}
19 +
20 +export 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 +}
29 +
30 +export type MapMetric = 'sites' | 'trials';
31 +
32 +/** Countries with data but no polygon at 1:110m (small states, territories) — surfaced under the map instead of vanishing. */
33 +export 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 +}
37 +
38 +/** Class breaks for the metric — exported so the page can describe the classes in text. */
39 +export function mapScaleFor(data: MapCountryDatum[], metric: MapMetric): MapScale {
40 + return quantileScale(data.map((d) => d[metric]));
41 +}
42 +
43 +/**
44 + * Server-rendered choropleth (Equal Earth, 960×480 viewBox) of trial sites per country, quantized
45 + * in ≤ 5 quantile classes with a text legend; optional proportional-symbol city layer. Every
46 + * country path is a link to the filtered trials list and carries a <title> with the numbers, so
47 + * colour is never the only carrier. The caller MUST render an equivalent table.
48 + */
49 +export 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 site
139 + </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 +}
added apps/web/src/components/home/trial-map-module.tsx +79 −0
@@ -0,0 +1,79 @@
1 +import Link from 'next/link';
2 +import { Section } from '@/components/ui/section';
3 +import { EmptyState } from '@/components/ui/empty-state';
4 +import { Freshness } from '@/components/ui/freshness';
5 +import { ClaimBadge } from '@/components/ui/badge';
6 +import { WorldMap, type MapCountryDatum } from '@/components/charts/world-map';
7 +import { countryCounts } from '@/lib/queries/trial-sites';
8 +import { fmtInt } from '@/lib/format';
9 +
10 +/**
11 + * Home module: compact choropleth of RECRUITING trial sites per country, all cancers, any phase
12 + * (precomputed by `pnpm cix intel`). Links to the full map with filters and its data table. The
13 + * country list under the map keeps the module readable without colour or hover.
14 + */
15 +export async function TrialMapModule({ topN = 8 }: { topN?: number }) {
16 + const rows = await countryCounts({ cancerId: null, phase: null, recruitingOnly: true });
17 + const totalSites = rows.reduce((s, r) => s + r.sites, 0);
18 + const data: MapCountryDatum[] = rows.map((r) => ({ country: r.country, iso3: r.iso3, sites: r.sites, trials: r.trials, href: `/trials?country=${encodeURIComponent(r.country)}&status=RECRUITING` }));
19 + return (
20 + <Section
21 + id="trial-map"
22 + kicker="Where trials recruit"
23 + title="Recruiting trial sites by country"
24 + description="Study locations with status RECRUITING (or in a recruiting study when the site has no status), all cancers, any phase. A site is one registrant-entered location; multi-site studies weigh by their number of sites."
25 + actions={
26 + <Link href="/trials/map?recruiting=1" className="ci-link">
27 + Full map and filters →
28 + </Link>
29 + }
30 + >
31 + {rows.length === 0 ? (
32 + <EmptyState title="Trial map not yet computed" compact>
33 + Country aggregates appear after <code className="ci-mono">pnpm cix intel</code> has run on ingested ClinicalTrials.gov locations.
34 + </EmptyState>
35 + ) : (
36 + <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
37 + <div className="min-w-0">
38 + <WorldMap data={data} metric="sites" compact ariaLabel={`World map of recruiting clinical trial sites per country, all cancers: ${fmtInt(totalSites)} sites in ${fmtInt(rows.length)} countries. The list beside the map gives the leading countries; the full table is on the trial map page.`} describedBy="trial-map-top" />
39 + </div>
40 + <div className="min-w-0">
41 + <div className="ci-table-wrap">
42 + <table className="ci-table" id="trial-map-top">
43 + <thead>
44 + <tr>
45 + <th className="num">#</th>
46 + <th>Country</th>
47 + <th className="num">Recruiting sites</th>
48 + <th className="num">Trials</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {rows.slice(0, topN).map((r, i) => (
53 + <tr key={r.country}>
54 + <td className="num">{i + 1}</td>
55 + <td>
56 + <Link href={data[i]!.href} className="ci-link">
57 + {r.country}
58 + </Link>
59 + </td>
60 + <td className="num">{fmtInt(r.sites)}</td>
61 + <td className="num">{fmtInt(r.trials)}</td>
62 + </tr>
63 + ))}
64 + </tbody>
65 + </table>
66 + </div>
67 + <p className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">
68 + <ClaimBadge kind="computed" />
69 + <span>
70 + {fmtInt(totalSites)} recruiting sites · {fmtInt(rows.length)} countries · <span className="ci-mono">{rows[0]?.formula_version}</span> · source: clinicaltrials
71 + </span>
72 + </p>
73 + <Freshness dataUpdatedAt={rows[0]?.computed_at ?? null} extra="registrant-entered locations" />
74 + </div>
75 + </div>
76 + )}
77 + </Section>
78 + );
79 +}
added apps/web/src/lib/iso-numeric.ts +48 −0
@@ -0,0 +1,48 @@
1 +/**
2 + * ISO 3166-1 numeric → alpha-3, for the `world-atlas` TopoJSON (Natural Earth 1:110m), whose
3 + * geometries carry only the numeric code as `id` and an English short `name` in properties.
4 + * Pure data; covers every id present in `countries-110m.json` plus the territories that appear in
5 + * ClinicalTrials.gov exports (they have no polygon at 1:110m but keep a stable code).
6 + */
7 +export const ISO_NUMERIC_TO_ALPHA3: Readonly<Record<string, string>> = {
8 + '004': 'AFG', '008': 'ALB', '010': 'ATA', '012': 'DZA', '016': 'ASM', '020': 'AND', '024': 'AGO', '028': 'ATG', '031': 'AZE', '032': 'ARG',
9 + '036': 'AUS', '040': 'AUT', '044': 'BHS', '048': 'BHR', '050': 'BGD', '051': 'ARM', '052': 'BRB', '056': 'BEL', '060': 'BMU', '064': 'BTN',
10 + '068': 'BOL', '070': 'BIH', '072': 'BWA', '076': 'BRA', '084': 'BLZ', '090': 'SLB', '092': 'VGB', '096': 'BRN', '100': 'BGR', '104': 'MMR',
11 + '108': 'BDI', '112': 'BLR', '116': 'KHM', '120': 'CMR', '124': 'CAN', '132': 'CPV', '136': 'CYM', '140': 'CAF', '144': 'LKA', '148': 'TCD',
12 + '152': 'CHL', '156': 'CHN', '158': 'TWN', '170': 'COL', '174': 'COM', '175': 'MYT', '178': 'COG', '180': 'COD', '188': 'CRI', '191': 'HRV',
13 + '192': 'CUB', '196': 'CYP', '203': 'CZE', '204': 'BEN', '208': 'DNK', '212': 'DMA', '214': 'DOM', '218': 'ECU', '222': 'SLV', '226': 'GNQ',
14 + '231': 'ETH', '232': 'ERI', '233': 'EST', '234': 'FRO', '238': 'FLK', '242': 'FJI', '246': 'FIN', '250': 'FRA', '254': 'GUF', '258': 'PYF',
15 + '260': 'ATF', '262': 'DJI', '266': 'GAB', '268': 'GEO', '270': 'GMB', '275': 'PSE', '276': 'DEU', '288': 'GHA', '292': 'GIB', '300': 'GRC',
16 + '304': 'GRL', '308': 'GRD', '312': 'GLP', '316': 'GUM', '320': 'GTM', '324': 'GIN', '328': 'GUY', '332': 'HTI', '340': 'HND', '344': 'HKG',
17 + '348': 'HUN', '352': 'ISL', '356': 'IND', '360': 'IDN', '364': 'IRN', '368': 'IRQ', '372': 'IRL', '376': 'ISR', '380': 'ITA', '384': 'CIV',
18 + '388': 'JAM', '392': 'JPN', '398': 'KAZ', '400': 'JOR', '404': 'KEN', '408': 'PRK', '410': 'KOR', '414': 'KWT', '417': 'KGZ', '418': 'LAO',
19 + '422': 'LBN', '426': 'LSO', '428': 'LVA', '430': 'LBR', '434': 'LBY', '438': 'LIE', '440': 'LTU', '442': 'LUX', '446': 'MAC', '450': 'MDG',
20 + '454': 'MWI', '458': 'MYS', '462': 'MDV', '466': 'MLI', '470': 'MLT', '474': 'MTQ', '478': 'MRT', '480': 'MUS', '484': 'MEX', '492': 'MCO',
21 + '496': 'MNG', '498': 'MDA', '499': 'MNE', '504': 'MAR', '508': 'MOZ', '512': 'OMN', '516': 'NAM', '524': 'NPL', '528': 'NLD', '531': 'CUW',
22 + '533': 'ABW', '540': 'NCL', '548': 'VUT', '554': 'NZL', '558': 'NIC', '562': 'NER', '566': 'NGA', '578': 'NOR', '580': 'MNP', '586': 'PAK',
23 + '591': 'PAN', '598': 'PNG', '600': 'PRY', '604': 'PER', '608': 'PHL', '616': 'POL', '620': 'PRT', '624': 'GNB', '626': 'TLS', '630': 'PRI',
24 + '634': 'QAT', '638': 'REU', '642': 'ROU', '643': 'RUS', '646': 'RWA', '659': 'KNA', '662': 'LCA', '670': 'VCT', '674': 'SMR', '678': 'STP',
25 + '682': 'SAU', '686': 'SEN', '688': 'SRB', '690': 'SYC', '694': 'SLE', '702': 'SGP', '703': 'SVK', '704': 'VNM', '705': 'SVN', '706': 'SOM',
26 + '710': 'ZAF', '716': 'ZWE', '724': 'ESP', '728': 'SSD', '729': 'SDN', '732': 'ESH', '740': 'SUR', '748': 'SWZ', '752': 'SWE', '756': 'CHE',
27 + '760': 'SYR', '762': 'TJK', '764': 'THA', '768': 'TGO', '776': 'TON', '780': 'TTO', '784': 'ARE', '788': 'TUN', '792': 'TUR', '795': 'TKM',
28 + '800': 'UGA', '804': 'UKR', '807': 'MKD', '818': 'EGY', '826': 'GBR', '834': 'TZA', '840': 'USA', '850': 'VIR', '854': 'BFA', '858': 'URY',
29 + '860': 'UZB', '862': 'VEN', '882': 'WSM', '887': 'YEM', '894': 'ZMB',
30 +};
31 +
32 +/**
33 + * Natural Earth draws three polygons with no ISO numeric id (`id` undefined in the TopoJSON):
34 + * Kosovo (user-assigned XKX, used by the World Bank), Northern Cyprus and Somaliland (no code —
35 + * they render as "no data"). Keyed by the Natural Earth `name` property.
36 + */
37 +export const ATLAS_NAME_TO_ALPHA3: Readonly<Record<string, string | null>> = { Kosovo: 'XKX', 'N. Cyprus': null, Somaliland: null };
38 +
39 +/** Alpha-3 for a world-atlas geometry (`id` numeric string, `name` from properties); null when it has no code. */
40 +export function atlasGeometryIso3(id: string | number | null | undefined, name?: string | null): string | null {
41 + if (id != null && id !== '') {
42 + const key = String(id).padStart(3, '0');
43 + const iso = ISO_NUMERIC_TO_ALPHA3[key];
44 + if (iso) return iso;
45 + }
46 + if (name && name in ATLAS_NAME_TO_ALPHA3) return ATLAS_NAME_TO_ALPHA3[name] ?? null;
47 + return null;
48 +}
added apps/web/src/lib/map-geo.ts +71 −0
@@ -0,0 +1,71 @@
1 +import { geoEqualEarth, geoPath, type GeoPermissibleObjects, type GeoProjection } from 'd3-geo';
2 +import { feature } from 'topojson-client';
3 +import type { Topology, GeometryCollection } from 'topojson-specification';
4 +import type { Feature, FeatureCollection, Geometry } from 'geojson';
5 +import { atlasGeometryIso3 } from '@/lib/iso-numeric';
6 +
7 +/**
8 + * Geometry side of the trial map — pure helpers (no React, no I/O) so the projection can be unit-
9 + * tested: TopoJSON → GeoJSON features keyed by ISO3, an Equal Earth projection fitted to the SVG
10 + * viewport, and a `project()` for the city layer. Equal Earth (Šavrič, Patterson & Jenny 2018) is
11 + * equal-area, so a country's visual weight is not inflated at high latitudes, and its outline is
12 + * familiar; it is fitted to a 960×480 viewBox and scaled by the browser.
13 + */
14 +
15 +export const MAP_WIDTH = 960;
16 +export const MAP_HEIGHT = 480;
17 +
18 +export interface CountryFeature {
19 + /** ISO 3166-1 alpha-3, or null when Natural Earth has no code for the polygon (N. Cyprus, Somaliland). */
20 + iso3: string | null;
21 + /** Natural Earth short name (display fallback only). */
22 + name: string;
23 + /** SVG path data in viewBox coordinates. */
24 + d: string;
25 +}
26 +
27 +export interface WorldGeometry {
28 + countries: CountryFeature[];
29 + /** Outline of the globe (the projection's sphere). */
30 + sphere: string;
31 + project: (lng: number, lat: number) => [number, number] | null;
32 +}
33 +
34 +type CountriesTopology = Topology<{ countries: GeometryCollection<{ name: string }> }>;
35 +
36 +/** Fitted Equal Earth projection for the given viewport (default 960×480, 8 px padding). */
37 +export function fitProjection(collection: FeatureCollection | GeoPermissibleObjects, width = MAP_WIDTH, height = MAP_HEIGHT, pad = 8): GeoProjection {
38 + return geoEqualEarth().fitExtent(
39 + [
40 + [pad, pad],
41 + [width - pad, height - pad],
42 + ],
43 + collection as GeoPermissibleObjects,
44 + );
45 +}
46 +
47 +/** TopoJSON (world-atlas countries-110m) → projected SVG paths keyed by ISO3. */
48 +export function buildWorldGeometry(topology: CountriesTopology, width = MAP_WIDTH, height = MAP_HEIGHT): WorldGeometry {
49 + const fc = feature(topology, topology.objects.countries) as FeatureCollection<Geometry, { name: string }>;
50 + // Fit on the sphere (not on the land bbox) so Antarctica's absence/presence never shifts the frame.
51 + const projection = fitProjection({ type: 'Sphere' }, width, height);
52 + // One decimal in a 960-unit viewBox is sub-pixel at any realistic display width and shrinks the
53 + // path data (shipped twice: HTML + RSC payload) by about 40 % versus the default 3 digits.
54 + const path = geoPath(projection).digits(1);
55 + const countries: CountryFeature[] = [];
56 + for (const f of fc.features as Array<Feature<Geometry, { name: string }>>) {
57 + const name = f.properties?.name ?? '';
58 + // Antarctica carries no trial sites and would dominate the lower band of the map.
59 + if (f.id === '010') continue;
60 + const d = path(f);
61 + if (!d) continue;
62 + countries.push({ iso3: atlasGeometryIso3(f.id as string | number | undefined, name), name, d });
63 + }
64 + const sphere = path({ type: 'Sphere' }) ?? '';
65 + const project = (lng: number, lat: number): [number, number] | null => {
66 + if (!Number.isFinite(lng) || !Number.isFinite(lat) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
67 + const p = projection([lng, lat]);
68 + return p ? [Math.round(p[0] * 10) / 10, Math.round(p[1] * 10) / 10] : null;
69 + };
70 + return { countries, sphere, project };
71 +}
added apps/web/src/lib/map-scale.ts +104 −0
@@ -0,0 +1,104 @@
1 +/**
2 + * Choropleth scale for the trial map — pure functions, unit-tested.
3 + *
4 + * Class breaks use QUANTILES (equal-count classes) rather than equal intervals or a log scale:
5 + * trial-site counts are extremely skewed (the United States hosts about half of all registered
6 + * sites; the median country has a few dozen), so equal intervals would put every country but one
7 + * in the first class, and a log scale hides the difference between 1 and 30 sites, which matters
8 + * for low- and middle-income countries. Quantiles guarantee each of the 5 classes has roughly
9 + * the same number of countries; the legend shows the actual value range of every class so the
10 + * reader is never asked to infer values from colour alone.
11 + */
12 +
13 +export const MAP_CLASS_COUNT = 5;
14 +
15 +/** Sequential teal ramp (light → dark), legible on the off-white paper and on dark surfaces; class 0 is the lightest. */
16 +export const MAP_RAMP: readonly string[] = ['#e2eeee', '#b5d3d4', '#7fb1b3', '#3f8286', '#0b4a4d'];
17 +
18 +/** Ink colour that stays legible on each ramp step (used for optional in-map labels). */
19 +export const MAP_RAMP_INK: readonly string[] = ['#1c1c1a', '#1c1c1a', '#1c1c1a', '#fafaf7', '#fafaf7'];
20 +
21 +/** Fill for polygons with no data at all (never confused with class 0, which always has ≥ 1). */
22 +export const MAP_NO_DATA_FILL = 'var(--color-paper-3)';
23 +
24 +export interface MapClass {
25 + /** 0-based class index (0 = lightest). */
26 + index: number;
27 + /** Inclusive lower bound of the class (actual minimum value present in the class). */
28 + lo: number;
29 + /** Inclusive upper bound of the class (actual maximum value present in the class). */
30 + hi: number;
31 + /** Number of items in the class. */
32 + n: number;
33 + fill: string;
34 +}
35 +
36 +export interface MapScale {
37 + method: 'quantile';
38 + /** Upper thresholds of classes 0..k-2 (a value v belongs to the first class i with v <= breaks[i]; otherwise the last class). */
39 + breaks: number[];
40 + classes: MapClass[];
41 +}
42 +
43 +/** Quantile of a SORTED ascending array (linear interpolation, R-7 like d3.quantile). */
44 +export function quantileSorted(sorted: readonly number[], p: number): number {
45 + const n = sorted.length;
46 + if (n === 0) return NaN;
47 + if (p <= 0) return sorted[0]!;
48 + if (p >= 1) return sorted[n - 1]!;
49 + const i = (n - 1) * p;
50 + const i0 = Math.floor(i);
51 + const v0 = sorted[i0]!;
52 + const v1 = sorted[Math.min(n - 1, i0 + 1)]!;
53 + return v0 + (v1 - v0) * (i - i0);
54 +}
55 +
56 +/**
57 + * Build a quantile scale over positive values (zeros/negatives/non-finite are ignored: countries
58 + * with no sites are "no data", not class 0). Degenerate inputs (few distinct values) collapse
59 + * duplicate thresholds so classes never overlap; empty input yields no classes.
60 + */
61 +export function quantileScale(values: readonly number[], k = MAP_CLASS_COUNT): MapScale {
62 + const sorted = values.filter((v) => Number.isFinite(v) && v > 0).sort((a, b) => a - b);
63 + if (sorted.length === 0) return { method: 'quantile', breaks: [], classes: [] };
64 + const raw: number[] = [];
65 + for (let i = 1; i < k; i++) raw.push(Math.ceil(quantileSorted(sorted, i / k)));
66 + // Distinct, strictly increasing thresholds (integer counts → ceil keeps "v <= break" meaningful).
67 + const breaks = raw.filter((b, i) => i === 0 || b > raw[i - 1]!).filter((b) => b < sorted[sorted.length - 1]!);
68 + const buckets: number[][] = Array.from({ length: breaks.length + 1 }, () => []);
69 + for (const v of sorted) buckets[classIndex(v, breaks)]!.push(v);
70 + const filled = buckets.filter((b) => b.length > 0);
71 + const classes: MapClass[] = filled.map((b, i) => ({ index: i, lo: b[0]!, hi: b[b.length - 1]!, n: b.length, fill: rampColor(i, filled.length) }));
72 + // Final thresholds are the observed class maxima, so `classIndex(v, breaks)` and `classes[i]` agree exactly.
73 + return { method: 'quantile', breaks: classes.slice(0, -1).map((c) => c.hi), classes };
74 +}
75 +
76 +/** Class index for a value: first i with value <= breaks[i], else breaks.length. */
77 +export function classIndex(value: number, breaks: readonly number[]): number {
78 + for (let i = 0; i < breaks.length; i++) if (value <= breaks[i]!) return i;
79 + return breaks.length;
80 +}
81 +
82 +/** Ramp colour for class i of n (n ≤ 5 spreads across the ramp so the darkest step is always used). */
83 +export function rampColor(i: number, n: number): string {
84 + if (n <= 1) return MAP_RAMP[MAP_RAMP.length - 1]!;
85 + const pos = Math.round((i / (n - 1)) * (MAP_RAMP.length - 1));
86 + return MAP_RAMP[Math.max(0, Math.min(MAP_RAMP.length - 1, pos))]!;
87 +}
88 +
89 +/** Fill for a value under a scale; `MAP_NO_DATA_FILL` when the value is absent or ≤ 0. */
90 +export function fillFor(value: number | null | undefined, scale: MapScale): string {
91 + if (value == null || !Number.isFinite(value) || value <= 0 || scale.classes.length === 0) return MAP_NO_DATA_FILL;
92 + return scale.classes[classIndex(value, scale.breaks)]?.fill ?? MAP_NO_DATA_FILL;
93 +}
94 +
95 +/** "1–12", "13–80", "608,226" — legend label for a class. */
96 +export function classLabel(c: MapClass, fmt: (n: number) => string = String): string {
97 + return c.lo === c.hi ? fmt(c.lo) : `${fmt(c.lo)}–${fmt(c.hi)}`;
98 +}
99 +
100 +/** Radius (px) for a proportional-symbol dot: area ∝ value, clamped to [min, max]. */
101 +export function sqrtRadius(value: number, maxValue: number, maxRadius = 14, minRadius = 1.5): number {
102 + if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(maxValue) || maxValue <= 0) return 0;
103 + return Math.max(minRadius, Math.min(maxRadius, Math.sqrt(value / maxValue) * maxRadius));
104 +}
added apps/web/src/lib/queries/trial-sites.ts +129 −0
@@ -0,0 +1,129 @@
1 +import 'server-only';
2 +import { run, sql, safe } from '@/lib/db';
3 +
4 +/**
5 + * Trial map queries. Country aggregates come from the DERIVED table `trial_site_country_counts`
6 + * (rebuilt by `pnpm cix intel`, formula ci-trial-sites-v1); the city layer and the distinct-trial
7 + * headline are read live from `trial_locations` because they are not precomputed.
8 + */
9 +
10 +export const SITE_PHASES = ['PHASE1', 'PHASE2', 'PHASE3', 'PHASE4'] as const;
11 +export type SitePhase = (typeof SITE_PHASES)[number];
12 +export const SITE_METRICS = ['sites', 'trials'] as const;
13 +export type SiteMetric = (typeof SITE_METRICS)[number];
14 +
15 +export interface SiteScope {
16 + /** Top-level cancer id, or null for every oncology trial. */
17 + cancerId: string | null;
18 + phase: SitePhase | null;
19 + recruitingOnly: boolean;
20 +}
21 +
22 +export interface SiteCountryRow {
23 + country: string;
24 + iso3: string | null;
25 + sites: number;
26 + trials: number;
27 + formula_version: string;
28 + computed_at: Date | string;
29 +}
30 +
31 +/** Country aggregates for one scope, sorted by sites desc (≈ 180 rows at most). */
32 +export async function countryCounts(s: SiteScope): Promise<SiteCountryRow[]> {
33 + const rows = await safe(
34 + () =>
35 + run<SiteCountryRow & { sites: string | number; trials: string | number }>(sql`
36 + SELECT country, iso3, sites, trials, formula_version, updated_at AS computed_at
37 + FROM trial_site_country_counts
38 + WHERE cancer_id IS NOT DISTINCT FROM ${s.cancerId} AND phase IS NOT DISTINCT FROM ${s.phase} AND recruiting_only = ${s.recruitingOnly}
39 + ORDER BY sites DESC, country`),
40 + [],
41 + );
42 + return rows.map((r) => ({ ...r, sites: Number(r.sites), trials: Number(r.trials) }));
43 +}
44 +
45 +/** True when the derived table has been populated at all (distinguishes "not computed" from "no match"). */
46 +export async function siteCountsAvailable(): Promise<boolean> {
47 + const r = await safe(() => run<{ n: string }>(sql`SELECT count(*) AS n FROM trial_site_country_counts`), [{ n: '0' }]);
48 + return Number(r[0]?.n ?? 0) > 0;
49 +}
50 +
51 +export interface TopLevelCancerOption {
52 + id: string;
53 + slug: string;
54 + canonical_name: string;
55 +}
56 +
57 +/** Active top-level cancers (the only cancer scopes precomputed for the map). */
58 +export async function listTopLevelCancers(): Promise<TopLevelCancerOption[]> {
59 + return safe(() => run<TopLevelCancerOption>(sql`SELECT id, slug, canonical_name FROM cancers WHERE status = 'active' AND top_level ORDER BY canonical_name`), []);
60 +}
61 +
62 +export interface LiveScope {
63 + /** Cancer + descendants (semi-join on trial_conditions), or null for every trial. */
64 + cancerIds: string[] | null;
65 + phase: SitePhase | null;
66 + recruitingOnly: boolean;
67 +}
68 +
69 +/** WHERE fragment shared by the live queries; alias `l` = trial_locations, `t` = clinical_trials (joined only when needed). */
70 +function liveWhere(s: LiveScope): { where: ReturnType<typeof sql>; needsTrial: boolean } {
71 + const parts = [sql`l.country IS NOT NULL AND l.country <> ''`];
72 + let needsTrial = false;
73 + if (s.phase) {
74 + needsTrial = true;
75 + parts.push(s.phase === 'PHASE1' ? sql`(t.phases && ARRAY['PHASE1','EARLY_PHASE1']::text[])` : sql`${s.phase} = ANY(t.phases)`);
76 + }
77 + if (s.recruitingOnly) {
78 + needsTrial = true;
79 + parts.push(sql`coalesce(l.status, t.overall_status) = 'RECRUITING'`);
80 + }
81 + if (s.cancerIds) parts.push(sql`EXISTS (SELECT 1 FROM trial_conditions tc WHERE tc.trial_id = l.trial_id AND tc.cancer_id IN (${sql.join(s.cancerIds.map((i) => sql`${i}`), sql`, `)}))`);
82 + return { where: sql.join(parts, sql` AND `), needsTrial };
83 +}
84 +
85 +/** Distinct studies with ≥ 1 site in a named country for the scope (live; ≈ 100–150 ms on 1.2 M rows). */
86 +export async function distinctTrialCount(s: LiveScope): Promise<number> {
87 + if (s.cancerIds && s.cancerIds.length === 0) return 0;
88 + const { where, needsTrial } = liveWhere(s);
89 + const join = needsTrial ? sql`JOIN clinical_trials t ON t.id = l.trial_id` : sql``;
90 + const r = await safe(() => run<{ n: string }>(sql`SELECT count(DISTINCT l.trial_id) AS n FROM trial_locations l ${join} WHERE ${where}`), [{ n: '0' }]);
91 + return Number(r[0]?.n ?? 0);
92 +}
93 +
94 +export interface SiteCityRow {
95 + country: string;
96 + city: string;
97 + state: string | null;
98 + lat: number;
99 + lng: number;
100 + sites: number;
101 + trials: number;
102 +}
103 +
104 +export const CITY_LIMIT = 300;
105 +
106 +/**
107 + * City aggregates (registrant-entered city/state, mean of geocoded lat/lng, sites, distinct trials),
108 + * top `limit` by sites. Live on trial_locations: ≈ 0.3–0.5 s with a cancer or recruiting filter,
109 + * but ≈ 3 s for the whole registry without any filter — that case returns [] and the caller omits
110 + * the layer (documented in docs/methodology/trial-map.md).
111 + */
112 +export async function cityCounts(s: LiveScope, limit = CITY_LIMIT): Promise<SiteCityRow[]> {
113 + if (s.cancerIds && s.cancerIds.length === 0) return [];
114 + if (!s.cancerIds && !s.recruitingOnly) return [];
115 + const { where, needsTrial } = liveWhere(s);
116 + const join = needsTrial ? sql`JOIN clinical_trials t ON t.id = l.trial_id` : sql``;
117 + const rows = await safe(
118 + () =>
119 + run<{ country: string; city: string; state: string | null; lat: number; lng: number; sites: string; trials: string }>(sql`
120 + SELECT l.country, l.city, l.state, avg(l.lat)::float8 AS lat, avg(l.lng)::float8 AS lng, count(*) AS sites, count(DISTINCT l.trial_id) AS trials
121 + FROM trial_locations l ${join}
122 + WHERE l.lat IS NOT NULL AND l.lng IS NOT NULL AND l.city IS NOT NULL AND ${where}
123 + GROUP BY l.country, l.city, l.state
124 + ORDER BY sites DESC, trials DESC, l.country, l.city
125 + LIMIT ${limit}`),
126 + [],
127 + );
128 + return rows.map((r) => ({ ...r, lat: Number(r.lat), lng: Number(r.lng), sites: Number(r.sites), trials: Number(r.trials) }));
129 +}
added apps/web/test/iso-numeric.test.ts +39 −0
@@ -0,0 +1,39 @@
1 +import { createRequire } from 'node:module';
2 +import { describe, expect, it } from 'vitest';
3 +import { ATLAS_NAME_TO_ALPHA3, ISO_NUMERIC_TO_ALPHA3, atlasGeometryIso3 } from '@/lib/iso-numeric';
4 +
5 +const require = createRequire(import.meta.url);
6 +const atlas = require('world-atlas/countries-110m.json') as { objects: { countries: { geometries: Array<{ id?: string; properties: { name: string } }> } } };
7 +
8 +describe('ISO numeric → alpha-3', () => {
9 + it('has well-formed keys and values', () => {
10 + for (const [k, v] of Object.entries(ISO_NUMERIC_TO_ALPHA3)) {
11 + expect(k).toMatch(/^\d{3}$/);
12 + expect(v).toMatch(/^[A-Z]{3}$/);
13 + }
14 + const values = Object.values(ISO_NUMERIC_TO_ALPHA3);
15 + expect(new Set(values).size).toBe(values.length);
16 + });
17 + it('resolves a few well-known codes', () => {
18 + expect(atlasGeometryIso3('840')).toBe('USA');
19 + expect(atlasGeometryIso3(840)).toBe('USA');
20 + expect(atlasGeometryIso3('004')).toBe('AFG');
21 + expect(atlasGeometryIso3(4)).toBe('AFG');
22 + expect(atlasGeometryIso3('410')).toBe('KOR');
23 + expect(atlasGeometryIso3('158')).toBe('TWN');
24 + expect(atlasGeometryIso3('275')).toBe('PSE');
25 + expect(atlasGeometryIso3(undefined, 'Kosovo')).toBe('XKX');
26 + expect(atlasGeometryIso3(undefined, 'N. Cyprus')).toBeNull();
27 + expect(atlasGeometryIso3(undefined, 'Somaliland')).toBeNull();
28 + expect(atlasGeometryIso3(undefined, 'Nowhere')).toBeNull();
29 + expect(atlasGeometryIso3('999')).toBeNull();
30 + });
31 + it('covers every geometry in world-atlas countries-110m (id or name fallback)', () => {
32 + const geoms = atlas.objects.countries.geometries;
33 + expect(geoms.length).toBeGreaterThan(170);
34 + const unresolved = geoms.filter((g) => g.id != null && atlasGeometryIso3(g.id, g.properties.name) === null).map((g) => `${g.id} ${g.properties.name}`);
35 + expect(unresolved).toEqual([]);
36 + const idless = geoms.filter((g) => g.id == null).map((g) => g.properties.name);
37 + for (const n of idless) expect(n in ATLAS_NAME_TO_ALPHA3).toBe(true);
38 + });
39 +});
added apps/web/test/map-geo.test.ts +60 −0
@@ -0,0 +1,60 @@
1 +import { createRequire } from 'node:module';
2 +import { describe, expect, it } from 'vitest';
3 +import { MAP_HEIGHT, MAP_WIDTH, buildWorldGeometry, fitProjection } from '@/lib/map-geo';
4 +
5 +const require = createRequire(import.meta.url);
6 +// eslint-disable-next-line @typescript-eslint/no-explicit-any
7 +const atlas = require('world-atlas/countries-110m.json') as any;
8 +
9 +describe('fitProjection (Equal Earth)', () => {
10 + it('maps the origin to the centre of the viewport and keeps the sphere inside it', () => {
11 + const p = fitProjection({ type: 'Sphere' });
12 + const c = p([0, 0])!;
13 + expect(c[0]).toBeCloseTo(MAP_WIDTH / 2, 0);
14 + expect(c[1]).toBeCloseTo(MAP_HEIGHT / 2, 0);
15 + for (const [lng, lat] of [
16 + [-180, 0],
17 + [180, 0],
18 + [0, 90],
19 + [0, -90],
20 + [-73.6, 45.5],
21 + ] as Array<[number, number]>) {
22 + const q = p([lng, lat])!;
23 + expect(q[0]).toBeGreaterThanOrEqual(0);
24 + expect(q[0]).toBeLessThanOrEqual(MAP_WIDTH);
25 + expect(q[1]).toBeGreaterThanOrEqual(0);
26 + expect(q[1]).toBeLessThanOrEqual(MAP_HEIGHT);
27 + }
28 + // west is left, north is up
29 + const montreal = p([-73.6, 45.5])!;
30 + const tokyo = p([139.7, 35.7])!;
31 + const capeTown = p([18.4, -33.9])!;
32 + expect(montreal[0]).toBeLessThan(tokyo[0]);
33 + expect(montreal[1]).toBeLessThan(capeTown[1]);
34 + });
35 +});
36 +
37 +describe('buildWorldGeometry', () => {
38 + const geo = buildWorldGeometry(atlas);
39 + it('produces one path per country with ISO3 keys, without Antarctica', () => {
40 + expect(geo.countries.length).toBeGreaterThan(170);
41 + const iso = geo.countries.map((c) => c.iso3);
42 + expect(iso).toContain('USA');
43 + expect(iso).toContain('FRA');
44 + expect(iso).toContain('KOR');
45 + expect(iso).toContain('XKX');
46 + expect(iso).not.toContain('ATA');
47 + expect(geo.countries.filter((c) => c.iso3 === null).map((c) => c.name).sort()).toEqual(['N. Cyprus', 'Somaliland']);
48 + for (const c of geo.countries) expect(c.d.length).toBeGreaterThan(5);
49 + expect(geo.sphere.startsWith('M')).toBe(true);
50 + });
51 + it('projects lng/lat to viewBox pixels and rejects invalid coordinates', () => {
52 + const p = geo.project(-73.6, 45.5)!;
53 + expect(p[0]).toBeGreaterThan(0);
54 + expect(p[0]).toBeLessThan(MAP_WIDTH / 2);
55 + expect(p[1]).toBeLessThan(MAP_HEIGHT / 2);
56 + expect(geo.project(NaN, 10)).toBeNull();
57 + expect(geo.project(10, 91)).toBeNull();
58 + expect(geo.project(181, 0)).toBeNull();
59 + });
60 +});
added apps/web/test/map-scale.test.ts +86 −0
@@ -0,0 +1,86 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { MAP_NO_DATA_FILL, MAP_RAMP, classIndex, classLabel, fillFor, quantileScale, quantileSorted, rampColor, sqrtRadius } from '@/lib/map-scale';
3 +
4 +describe('quantileSorted', () => {
5 + it('interpolates like R-7 / d3.quantile', () => {
6 + const s = [1, 2, 3, 4, 5];
7 + expect(quantileSorted(s, 0)).toBe(1);
8 + expect(quantileSorted(s, 0.5)).toBe(3);
9 + expect(quantileSorted(s, 0.25)).toBe(2);
10 + expect(quantileSorted(s, 1)).toBe(5);
11 + expect(quantileSorted([10, 20], 0.5)).toBe(15);
12 + expect(Number.isNaN(quantileSorted([], 0.5))).toBe(true);
13 + });
14 +});
15 +
16 +describe('quantileScale', () => {
17 + it('splits skewed counts into 5 equal-count classes with non-overlapping integer bounds', () => {
18 + const values = [608226, 68847, 68832, 49415, 47021, 44579, 32882, 30740, 28329, 19933, 500, 300, 120, 80, 40, 12, 9, 5, 2, 1];
19 + const s = quantileScale(values);
20 + expect(s.method).toBe('quantile');
21 + expect(s.classes).toHaveLength(5);
22 + expect(s.breaks).toHaveLength(4);
23 + // every value lands in exactly one class whose [lo, hi] contains it
24 + for (const v of values) {
25 + const c = s.classes[classIndex(v, s.breaks)]!;
26 + expect(v).toBeGreaterThanOrEqual(c.lo);
27 + expect(v).toBeLessThanOrEqual(c.hi);
28 + }
29 + // classes are ordered and disjoint
30 + for (let i = 1; i < s.classes.length; i++) expect(s.classes[i]!.lo).toBeGreaterThan(s.classes[i - 1]!.hi);
31 + // roughly equal counts (20 values → about 4 per class; integer thresholds shift one or two items)
32 + expect(s.classes.reduce((n, c) => n + c.n, 0)).toBe(20);
33 + for (const c of s.classes) expect(c.n).toBeGreaterThanOrEqual(3);
34 + for (const c of s.classes) expect(c.n).toBeLessThanOrEqual(5);
35 + expect(s.classes[4]!.hi).toBe(608226);
36 + expect(s.classes[0]!.fill).toBe(MAP_RAMP[0]);
37 + expect(s.classes[4]!.fill).toBe(MAP_RAMP[4]);
38 + });
39 + it('ignores zeros, negatives and non-finite values', () => {
40 + const s = quantileScale([0, -3, NaN, Infinity, 5, 10]);
41 + expect(s.classes.reduce((n, c) => n + c.n, 0)).toBe(2);
42 + expect(fillFor(0, s)).toBe(MAP_NO_DATA_FILL);
43 + expect(fillFor(null, s)).toBe(MAP_NO_DATA_FILL);
44 + });
45 + it('collapses to fewer classes when there are few distinct values', () => {
46 + expect(quantileScale([]).classes).toEqual([]);
47 + const one = quantileScale([7, 7, 7]);
48 + expect(one.classes).toHaveLength(1);
49 + expect(one.breaks).toEqual([]);
50 + expect(one.classes[0]).toMatchObject({ lo: 7, hi: 7, n: 3, fill: MAP_RAMP[MAP_RAMP.length - 1] });
51 + const two = quantileScale([1, 1, 1, 1, 100]);
52 + expect(two.classes.length).toBeGreaterThanOrEqual(2);
53 + expect(two.classes.length).toBeLessThanOrEqual(5);
54 + expect(fillFor(100, two)).toBe(MAP_RAMP[MAP_RAMP.length - 1]);
55 + expect(fillFor(1, two)).toBe(MAP_RAMP[0]);
56 + });
57 +});
58 +
59 +describe('helpers', () => {
60 + it('classIndex uses inclusive upper thresholds', () => {
61 + expect(classIndex(1, [1, 10, 100])).toBe(0);
62 + expect(classIndex(2, [1, 10, 100])).toBe(1);
63 + expect(classIndex(100, [1, 10, 100])).toBe(2);
64 + expect(classIndex(101, [1, 10, 100])).toBe(3);
65 + expect(classIndex(5, [])).toBe(0);
66 + });
67 + it('rampColor spreads n classes across the ramp', () => {
68 + expect(rampColor(0, 5)).toBe(MAP_RAMP[0]);
69 + expect(rampColor(4, 5)).toBe(MAP_RAMP[4]);
70 + expect(rampColor(0, 2)).toBe(MAP_RAMP[0]);
71 + expect(rampColor(1, 2)).toBe(MAP_RAMP[4]);
72 + expect(rampColor(0, 1)).toBe(MAP_RAMP[4]);
73 + });
74 + it('classLabel formats ranges and singletons', () => {
75 + const fmt = (n: number) => n.toLocaleString('en-US');
76 + expect(classLabel({ index: 0, lo: 1, hi: 12, n: 3, fill: '#000' }, fmt)).toBe('1–12');
77 + expect(classLabel({ index: 4, lo: 608226, hi: 608226, n: 1, fill: '#000' }, fmt)).toBe('608,226');
78 + });
79 + it('sqrtRadius scales area with value and clamps', () => {
80 + expect(sqrtRadius(100, 100)).toBe(14);
81 + expect(sqrtRadius(25, 100)).toBe(7);
82 + expect(sqrtRadius(1, 1_000_000)).toBe(1.5);
83 + expect(sqrtRadius(0, 100)).toBe(0);
84 + expect(sqrtRadius(5, 0)).toBe(0);
85 + });
86 +});
added docs/methodology/trial-map.md +49 −0
@@ -0,0 +1,49 @@
1 +# Clinical trial map — method
2 +
3 +Formula version: `ci-trial-sites-v1` · Layer: DERIVED (`trial_site_country_counts`) · Claim category: computed metric · Source: ClinicalTrials.gov (connector `clinicaltrials`).
4 +
5 +The trial map (`/trials/map`, API `GET /v1/trials/sites`, home module "Recruiting trial sites by country") shows where oncology studies have registered sites. Every number is recomputed deterministically from canonical tables by `pnpm cix intel` (`computeTrialSiteCounts` in `packages/ranking/src/trial-sites.ts`); nothing is estimated.
6 +
7 +## Definitions
8 +
9 +| Term | Definition |
10 +|---|---|
11 +| **Site** | One row of `trial_locations`: a facility entered by the registrant for a study, with its city, state, country and (when geocoded upstream) latitude/longitude. A study listing 40 US facilities contributes **40 sites** to the United States. Locations whose country is empty (13 rows) are excluded. |
12 +| **Trial** | A distinct study (`clinical_trials.id`) with at least one site in the country. A multinational study counts once **per country**, so the sum of the trials column across countries exceeds the number of distinct studies. The page headline "Trials" is the distinct count of studies over the whole scope, computed live. |
13 +| **Recruiting** (`recruiting_only = true`) | The location's own status is `RECRUITING`; when the registrant gave no location status (about 82 % of rows), the study's `overall_status = RECRUITING` is used instead (`coalesce(location.status, trial.overall_status) = 'RECRUITING'`). `recruiting_only = false` = every site regardless of status (including completed and withdrawn studies). |
14 +| **Study type** | Interventional **and** observational studies are both included; the map does not filter on `study_type`. |
15 +| **Country name** | The registrant's spelling as exported by ClinicalTrials.gov (API v2 short names: "United States", "South Korea", "Turkey (Türkiye)"). It is kept verbatim in `country` so the "View trials" link filters the trials list exactly; ISO 3166-1 alpha-3 is added in `iso3` for drawing. |
16 +
17 +## Scopes (rows of `trial_site_country_counts`)
18 +
19 +One row per `(cancer_id, phase, recruiting_only, country)` over the cartesian product:
20 +
21 +- **cancer_id**: `NULL` = all oncology trials in the index; otherwise one of the active `top_level` cancers (36 at the time of writing). A trial belongs to a top-level cancer when any of its `trial_conditions` is mapped to the cancer **or to one of its descendants** in `cancer_hierarchy` (recursive traversal, depth ≤ 12 — the same traversal as `entity_counters`, `packages/ranking/src/counters.ts`). A study mapped to "Lung Adenocarcinoma" therefore counts for "Malignant Lung Neoplasm". A study mapped to several top-level cancers counts in each. Non-top-level cancers are **not** precomputed (the API returns 400 with a hint; the city level remains available for any cancer).
22 +- **phase**: `NULL` = any phase; `PHASE1`, `PHASE2`, `PHASE3`, `PHASE4`. A study registered as `PHASE2|PHASE3` counts under both. `EARLY_PHASE1` counts under `PHASE1`. Studies with phase `NA` or no phase (most observational studies) count only under "any phase".
23 +- **recruiting_only**: `false`, `true` as defined above.
24 +
25 +Counts: `sites = count(*)`, `trials = count(DISTINCT trial_id)`. Rebuilt in one transaction (delete + set-based insert) from temporary tables; ≈ 11 800 rows, 178 country names, ≈ 30 s on the production copy. `computed_at` (column `updated_at`) is the rebuild time shown by the Freshness line.
26 +
27 +## ISO 3166-1 mapping and unmapped names
28 +
29 +`packages/ranking/src/country-codes.ts` maps ClinicalTrials.gov spellings (current short names and legacy long forms such as "Korea, Republic of", "Russian Federation", "Viet Nam", "Réunion", "Palestinian Territory, occupied") to alpha-3. Matching is case-, whitespace- and apostrophe-insensitive. Territories keep their own code (Puerto Rico PRI, Hong Kong HKG, Réunion REU, Guam GUM, Martinique MTQ…) because that is how registrants counted the site; Kosovo uses the user-assigned `XKX`; "Virgin Islands" is read as the U.S. Virgin Islands (`VIR`), the British Virgin Islands being spelled out by the registry.
30 +
31 +Names with **no current ISO code** stay `iso3 = NULL`, appear in the table by name and are never painted: `Serbia and Montenegro` (10 sites), `Federal Republic of Yugoslavia` (2), `Netherlands Antilles` (1), plus the empty country (excluded). A unit test (`packages/ranking/test/country-codes.test.ts`) checks every distinct name present in the database (fixture `packages/ranking/src/fixtures/trial-countries.json`, refreshed from `SELECT DISTINCT country FROM trial_locations`) either resolves or is on the explicit `UNMAPPED_COUNTRY_NAMES` list, so a new spelling cannot silently disappear from the map.
32 +
33 +## Drawing
34 +
35 +- **Geometry**: Natural Earth 1:110m via `world-atlas` (public domain), TopoJSON → GeoJSON with `topojson-client`. Polygons carry ISO numeric ids; `apps/web/src/lib/iso-numeric.ts` converts them to alpha-3 (Kosovo, Northern Cyprus and Somaliland have no id and are matched by name; the latter two have no code and render as "no data"). Antarctica is dropped.
36 +- **Projection**: Equal Earth (`d3-geo`, `geoEqualEarth`), fitted to the sphere in a 960×480 viewBox and scaled by the browser. Equal-area, so a country's visual weight is not inflated at high latitudes. Paths are projected once per server process and reused (`apps/web/src/lib/map-geo.ts`).
37 +- **Class breaks**: **quantiles** (equal number of countries per class), at most 5 classes, computed on the displayed metric (sites or trials) over countries with ≥ 1 site, **for the current filter**. Site counts are extremely skewed (the United States hosts about half of all sites; the median country has a few dozen): equal intervals would put every country but one in the first class, and a logarithmic scale would hide the difference between 1 and 30 sites. Thresholds are the observed class maxima (integers), so the legend shows the exact value range and country count of each class; duplicate thresholds collapse when few distinct values exist. Colours are therefore comparable **within one view only** (`apps/web/src/lib/map-scale.ts`).
38 +- **Colour**: sequential teal ramp `#e2eeee → #b5d3d4 → #7fb1b3 → #3f8286 → #0b4a4d`, country borders in the paper colour; "no site" is `--color-paper-3`. Colour is never the only carrier: each country path is a link to the filtered trials list with a `<title>` "Country — N sites · M trials", the legend is textual, and the page always renders the full table (rank, country, ISO3, sites, trials, share of sites, class, link).
39 +- **Countries without a polygon at 1:110m** (Hong Kong, Singapore, Malta, Monaco, Martinique, Guadeloupe, Réunion, Guam, American Samoa, the small Caribbean states…; Puerto Rico, Taiwan, Greenland and New Caledonia are drawn) are listed under the map with their values ("Not drawn at this scale").
40 +- **City layer**: shown only when a cancer is selected. Live aggregate of `trial_locations` by (country, city, state) with the mean geocoded position, top 300 by sites, dot area ∝ sites (`sqrt` radius, clamped), each with a `<title>`. The registry-wide, all-status city aggregate takes ≈ 3 s on 1.2 M rows and is not served per request; with a cancer or recruiting filter it takes 0.3–0.5 s.
41 +
42 +## Limitations
43 +
44 +- Sites reflect registration practice, not research capacity: US-based sponsors list every participating facility, while some registries and sponsors list one coordinating site per country. Countries with national registries (e.g. EU CTR, CTRI, ChiCTR) are under-represented because only ClinicalTrials.gov is ingested.
45 +- Location status is missing for ~82 % of rows; the fallback to study status over-counts recruiting sites in studies that are recruiting overall but closed at some facilities.
46 +- A study mapped to several top-level cancers (e.g. a basket trial) counts in each; the "all oncology trials" scope counts it once.
47 +- Country names are registrant-entered; the mapping table handles known variants, and unknown variants fail a test rather than being guessed.
48 +- Geocoding of city dots comes from the upstream registry; cities with no coordinates are excluded from the dot layer (not from the country counts).
49 +- No burden normalisation: the map shows counts, not sites per capita or per case. Population-normalised views belong to the rankings layer with their own formula version.
added packages/ranking/src/country-codes.ts +290 −0
@@ -0,0 +1,290 @@
1 +/**
2 + * ClinicalTrials.gov country names → ISO 3166-1 alpha-3 (pure, no I/O).
3 + *
4 + * ClinicalTrials.gov stores the country of a location as free text chosen by the registrant from
5 + * its own list; the API v2 returns mostly short English names ("United States", "South Korea",
6 + * "Turkey (Türkiye)", "Reunion") while older records and the legacy API used ISO-style long names
7 + * ("Korea, Republic of", "Russian Federation", "Réunion", "Viet Nam"). Both families are accepted
8 + * here. Territories keep their own ISO code (Puerto Rico → PRI, Hong Kong → HKG, Réunion → REU…)
9 + * because that is how the registrant counted the site; the UI may roll them up if it wishes.
10 + *
11 + * Dissolved states that have no ISO 3166-1 code today are deliberately left unmapped and listed in
12 + * `UNMAPPED_COUNTRY_NAMES` so a test fails when a new name appears in the database without a decision.
13 + */
14 +
15 +export interface CountryCode {
16 + /** ISO 3166-1 alpha-3 (Kosovo uses the user-assigned XKX, as the World Bank and EU do). */
17 + iso3: string;
18 + /** Display name (short English form). */
19 + name: string;
20 + /** Spellings seen in ClinicalTrials.gov exports, in addition to `name`. */
21 + aliases?: string[];
22 +}
23 +
24 +export const COUNTRY_CODES: readonly CountryCode[] = [
25 + { iso3: 'AFG', name: 'Afghanistan' },
26 + { iso3: 'ALB', name: 'Albania' },
27 + { iso3: 'DZA', name: 'Algeria' },
28 + { iso3: 'ASM', name: 'American Samoa' },
29 + { iso3: 'AND', name: 'Andorra' },
30 + { iso3: 'AGO', name: 'Angola' },
31 + { iso3: 'ATG', name: 'Antigua and Barbuda' },
32 + { iso3: 'ARG', name: 'Argentina' },
33 + { iso3: 'ARM', name: 'Armenia' },
34 + { iso3: 'ABW', name: 'Aruba' },
35 + { iso3: 'AUS', name: 'Australia' },
36 + { iso3: 'AUT', name: 'Austria' },
37 + { iso3: 'AZE', name: 'Azerbaijan' },
38 + { iso3: 'BHS', name: 'Bahamas', aliases: ['The Bahamas', 'Bahamas, The'] },
39 + { iso3: 'BHR', name: 'Bahrain' },
40 + { iso3: 'BGD', name: 'Bangladesh' },
41 + { iso3: 'BRB', name: 'Barbados' },
42 + { iso3: 'BLR', name: 'Belarus' },
43 + { iso3: 'BEL', name: 'Belgium' },
44 + { iso3: 'BLZ', name: 'Belize' },
45 + { iso3: 'BEN', name: 'Benin' },
46 + { iso3: 'BMU', name: 'Bermuda' },
47 + { iso3: 'BTN', name: 'Bhutan' },
48 + { iso3: 'BOL', name: 'Bolivia', aliases: ['Bolivia, Plurinational State of', 'Bolivia (Plurinational State of)'] },
49 + { iso3: 'BIH', name: 'Bosnia and Herzegovina' },
50 + { iso3: 'BWA', name: 'Botswana' },
51 + { iso3: 'BRA', name: 'Brazil' },
52 + { iso3: 'BRN', name: 'Brunei', aliases: ['Brunei Darussalam'] },
53 + { iso3: 'BGR', name: 'Bulgaria' },
54 + { iso3: 'BFA', name: 'Burkina Faso' },
55 + { iso3: 'BDI', name: 'Burundi' },
56 + { iso3: 'KHM', name: 'Cambodia' },
57 + { iso3: 'CMR', name: 'Cameroon' },
58 + { iso3: 'CAN', name: 'Canada' },
59 + { iso3: 'CPV', name: 'Cape Verde', aliases: ['Cabo Verde'] },
60 + { iso3: 'CYM', name: 'Cayman Islands' },
61 + { iso3: 'CAF', name: 'Central African Republic' },
62 + { iso3: 'TCD', name: 'Chad' },
63 + { iso3: 'CHL', name: 'Chile' },
64 + { iso3: 'CHN', name: 'China' },
65 + { iso3: 'COL', name: 'Colombia' },
66 + { iso3: 'COM', name: 'Comoros' },
67 + { iso3: 'COG', name: 'Congo', aliases: ['Republic of the Congo', 'Congo, Republic of the'] },
68 + { iso3: 'COD', name: 'Democratic Republic of the Congo', aliases: ['Congo, The Democratic Republic of the', 'Congo, Democratic Republic of the', 'Congo, The Democratic Republic'] },
69 + { iso3: 'CRI', name: 'Costa Rica' },
70 + { iso3: 'CIV', name: "Côte d'Ivoire", aliases: ["Cote d'Ivoire", 'Ivory Coast'] },
71 + { iso3: 'HRV', name: 'Croatia' },
72 + { iso3: 'CUB', name: 'Cuba' },
73 + { iso3: 'CUW', name: 'Curaçao', aliases: ['Curacao'] },
74 + { iso3: 'CYP', name: 'Cyprus' },
75 + { iso3: 'CZE', name: 'Czechia', aliases: ['Czech Republic'] },
76 + { iso3: 'DNK', name: 'Denmark' },
77 + { iso3: 'DJI', name: 'Djibouti' },
78 + { iso3: 'DMA', name: 'Dominica' },
79 + { iso3: 'DOM', name: 'Dominican Republic' },
80 + { iso3: 'ECU', name: 'Ecuador' },
81 + { iso3: 'EGY', name: 'Egypt' },
82 + { iso3: 'SLV', name: 'El Salvador' },
83 + { iso3: 'GNQ', name: 'Equatorial Guinea' },
84 + { iso3: 'ERI', name: 'Eritrea' },
85 + { iso3: 'EST', name: 'Estonia' },
86 + { iso3: 'SWZ', name: 'Eswatini', aliases: ['Swaziland'] },
87 + { iso3: 'ETH', name: 'Ethiopia' },
88 + { iso3: 'FRO', name: 'Faroe Islands', aliases: ['Faeroe Islands'] },
89 + { iso3: 'FJI', name: 'Fiji' },
90 + { iso3: 'FIN', name: 'Finland' },
91 + { iso3: 'FRA', name: 'France' },
92 + { iso3: 'GUF', name: 'French Guiana' },
93 + { iso3: 'PYF', name: 'French Polynesia' },
94 + { iso3: 'GAB', name: 'Gabon' },
95 + { iso3: 'GMB', name: 'Gambia', aliases: ['The Gambia', 'Gambia, The'] },
96 + { iso3: 'GEO', name: 'Georgia' },
97 + { iso3: 'DEU', name: 'Germany' },
98 + { iso3: 'GHA', name: 'Ghana' },
99 + { iso3: 'GIB', name: 'Gibraltar' },
100 + { iso3: 'GRC', name: 'Greece' },
101 + { iso3: 'GRL', name: 'Greenland' },
102 + { iso3: 'GRD', name: 'Grenada' },
103 + { iso3: 'GLP', name: 'Guadeloupe' },
104 + { iso3: 'GUM', name: 'Guam' },
105 + { iso3: 'GTM', name: 'Guatemala' },
106 + { iso3: 'GIN', name: 'Guinea' },
107 + { iso3: 'GNB', name: 'Guinea-Bissau' },
108 + { iso3: 'GUY', name: 'Guyana' },
109 + { iso3: 'HTI', name: 'Haiti' },
110 + { iso3: 'HND', name: 'Honduras' },
111 + { iso3: 'HKG', name: 'Hong Kong', aliases: ['Hong Kong SAR', 'Hong Kong, China'] },
112 + { iso3: 'HUN', name: 'Hungary' },
113 + { iso3: 'ISL', name: 'Iceland' },
114 + { iso3: 'IND', name: 'India' },
115 + { iso3: 'IDN', name: 'Indonesia' },
116 + { iso3: 'IRN', name: 'Iran', aliases: ['Iran, Islamic Republic of', 'Iran (Islamic Republic of)'] },
117 + { iso3: 'IRQ', name: 'Iraq' },
118 + { iso3: 'IRL', name: 'Ireland' },
119 + { iso3: 'ISR', name: 'Israel' },
120 + { iso3: 'ITA', name: 'Italy' },
121 + { iso3: 'JAM', name: 'Jamaica' },
122 + { iso3: 'JPN', name: 'Japan' },
123 + { iso3: 'JOR', name: 'Jordan' },
124 + { iso3: 'KAZ', name: 'Kazakhstan' },
125 + { iso3: 'KEN', name: 'Kenya' },
126 + { iso3: 'PRK', name: 'North Korea', aliases: ["Korea, Democratic People's Republic of", "Democratic People's Republic of Korea"] },
127 + { iso3: 'KOR', name: 'South Korea', aliases: ['Korea, Republic of', 'Republic of Korea', 'Korea'] },
128 + { iso3: 'XKX', name: 'Kosovo' },
129 + { iso3: 'KWT', name: 'Kuwait' },
130 + { iso3: 'KGZ', name: 'Kyrgyzstan' },
131 + { iso3: 'LAO', name: 'Laos', aliases: ["Lao People's Democratic Republic"] },
132 + { iso3: 'LVA', name: 'Latvia' },
133 + { iso3: 'LBN', name: 'Lebanon' },
134 + { iso3: 'LSO', name: 'Lesotho' },
135 + { iso3: 'LBR', name: 'Liberia' },
136 + { iso3: 'LBY', name: 'Libya', aliases: ['Libyan Arab Jamahiriya'] },
137 + { iso3: 'LIE', name: 'Liechtenstein' },
138 + { iso3: 'LTU', name: 'Lithuania' },
139 + { iso3: 'LUX', name: 'Luxembourg' },
140 + { iso3: 'MAC', name: 'Macao', aliases: ['Macau', 'Macao SAR'] },
141 + { iso3: 'MDG', name: 'Madagascar' },
142 + { iso3: 'MWI', name: 'Malawi' },
143 + { iso3: 'MYS', name: 'Malaysia' },
144 + { iso3: 'MDV', name: 'Maldives' },
145 + { iso3: 'MLI', name: 'Mali' },
146 + { iso3: 'MLT', name: 'Malta' },
147 + { iso3: 'MTQ', name: 'Martinique' },
148 + { iso3: 'MRT', name: 'Mauritania' },
149 + { iso3: 'MUS', name: 'Mauritius' },
150 + { iso3: 'MYT', name: 'Mayotte' },
151 + { iso3: 'MEX', name: 'Mexico' },
152 + { iso3: 'MDA', name: 'Moldova', aliases: ['Moldova, Republic of', 'Republic of Moldova'] },
153 + { iso3: 'MCO', name: 'Monaco' },
154 + { iso3: 'MNG', name: 'Mongolia' },
155 + { iso3: 'MNE', name: 'Montenegro' },
156 + { iso3: 'MAR', name: 'Morocco' },
157 + { iso3: 'MOZ', name: 'Mozambique' },
158 + { iso3: 'MMR', name: 'Myanmar', aliases: ['Burma'] },
159 + { iso3: 'NAM', name: 'Namibia' },
160 + { iso3: 'NPL', name: 'Nepal' },
161 + { iso3: 'NLD', name: 'Netherlands', aliases: ['The Netherlands', 'Netherlands, The'] },
162 + { iso3: 'NCL', name: 'New Caledonia' },
163 + { iso3: 'NZL', name: 'New Zealand' },
164 + { iso3: 'NIC', name: 'Nicaragua' },
165 + { iso3: 'NER', name: 'Niger' },
166 + { iso3: 'NGA', name: 'Nigeria' },
167 + { iso3: 'MKD', name: 'North Macedonia', aliases: ['Macedonia, The Former Yugoslav Republic of', 'Macedonia', 'North Macedonia, Republic of'] },
168 + { iso3: 'MNP', name: 'Northern Mariana Islands' },
169 + { iso3: 'NOR', name: 'Norway' },
170 + { iso3: 'OMN', name: 'Oman' },
171 + { iso3: 'PAK', name: 'Pakistan' },
172 + { iso3: 'PSE', name: 'Palestinian Territories', aliases: ['Palestinian Territory, occupied', 'Palestinian Territory', 'Palestine, State of', 'State of Palestine', 'Palestine'] },
173 + { iso3: 'PAN', name: 'Panama' },
174 + { iso3: 'PNG', name: 'Papua New Guinea' },
175 + { iso3: 'PRY', name: 'Paraguay' },
176 + { iso3: 'PER', name: 'Peru' },
177 + { iso3: 'PHL', name: 'Philippines' },
178 + { iso3: 'POL', name: 'Poland' },
179 + { iso3: 'PRT', name: 'Portugal' },
180 + { iso3: 'PRI', name: 'Puerto Rico' },
181 + { iso3: 'QAT', name: 'Qatar' },
182 + { iso3: 'REU', name: 'Réunion', aliases: ['Reunion'] },
183 + { iso3: 'ROU', name: 'Romania' },
184 + { iso3: 'RUS', name: 'Russia', aliases: ['Russian Federation'] },
185 + { iso3: 'RWA', name: 'Rwanda' },
186 + { iso3: 'KNA', name: 'Saint Kitts and Nevis' },
187 + { iso3: 'LCA', name: 'Saint Lucia' },
188 + { iso3: 'VCT', name: 'Saint Vincent and the Grenadines' },
189 + { iso3: 'WSM', name: 'Samoa' },
190 + { iso3: 'SMR', name: 'San Marino' },
191 + { iso3: 'STP', name: 'São Tomé and Príncipe', aliases: ['Sao Tome and Principe'] },
192 + { iso3: 'SAU', name: 'Saudi Arabia' },
193 + { iso3: 'SEN', name: 'Senegal' },
194 + { iso3: 'SRB', name: 'Serbia' },
195 + { iso3: 'SYC', name: 'Seychelles' },
196 + { iso3: 'SLE', name: 'Sierra Leone' },
197 + { iso3: 'SGP', name: 'Singapore' },
198 + { iso3: 'SVK', name: 'Slovakia' },
199 + { iso3: 'SVN', name: 'Slovenia' },
200 + { iso3: 'SLB', name: 'Solomon Islands' },
201 + { iso3: 'SOM', name: 'Somalia' },
202 + { iso3: 'ZAF', name: 'South Africa' },
203 + { iso3: 'SSD', name: 'South Sudan' },
204 + { iso3: 'ESP', name: 'Spain' },
205 + { iso3: 'LKA', name: 'Sri Lanka' },
206 + { iso3: 'SDN', name: 'Sudan' },
207 + { iso3: 'SUR', name: 'Suriname' },
208 + { iso3: 'SWE', name: 'Sweden' },
209 + { iso3: 'CHE', name: 'Switzerland' },
210 + { iso3: 'SYR', name: 'Syria', aliases: ['Syrian Arab Republic'] },
211 + { iso3: 'TWN', name: 'Taiwan', aliases: ['Taiwan, Province of China'] },
212 + { iso3: 'TJK', name: 'Tajikistan' },
213 + { iso3: 'TZA', name: 'Tanzania', aliases: ['Tanzania, United Republic of', 'United Republic of Tanzania'] },
214 + { iso3: 'THA', name: 'Thailand' },
215 + { iso3: 'TLS', name: 'Timor-Leste', aliases: ['East Timor'] },
216 + { iso3: 'TGO', name: 'Togo' },
217 + { iso3: 'TON', name: 'Tonga' },
218 + { iso3: 'TTO', name: 'Trinidad and Tobago' },
219 + { iso3: 'TUN', name: 'Tunisia' },
220 + { iso3: 'TUR', name: 'Türkiye', aliases: ['Turkey (Türkiye)', 'Turkey', 'Turkiye'] },
221 + { iso3: 'TKM', name: 'Turkmenistan' },
222 + { iso3: 'UGA', name: 'Uganda' },
223 + { iso3: 'UKR', name: 'Ukraine' },
224 + { iso3: 'ARE', name: 'United Arab Emirates' },
225 + { iso3: 'GBR', name: 'United Kingdom', aliases: ['United Kingdom of Great Britain and Northern Ireland', 'UK', 'Great Britain'] },
226 + { iso3: 'USA', name: 'United States', aliases: ['United States of America', 'USA'] },
227 + { iso3: 'URY', name: 'Uruguay' },
228 + { iso3: 'UZB', name: 'Uzbekistan' },
229 + { iso3: 'VUT', name: 'Vanuatu' },
230 + { iso3: 'VEN', name: 'Venezuela', aliases: ['Venezuela, Bolivarian Republic of', 'Venezuela (Bolivarian Republic of)'] },
231 + { iso3: 'VNM', name: 'Vietnam', aliases: ['Viet Nam'] },
232 + // ClinicalTrials.gov writes "Virgin Islands" for sites in the U.S. Virgin Islands (facilities in St. Thomas / St. Croix); the British Virgin Islands are spelled out.
233 + { iso3: 'VIR', name: 'U.S. Virgin Islands', aliases: ['Virgin Islands', 'Virgin Islands (U.S.)', 'Virgin Islands, U.S.', 'United States Virgin Islands'] },
234 + { iso3: 'VGB', name: 'British Virgin Islands', aliases: ['Virgin Islands, British'] },
235 + { iso3: 'YEM', name: 'Yemen' },
236 + { iso3: 'ZMB', name: 'Zambia' },
237 + { iso3: 'ZWE', name: 'Zimbabwe' },
238 +];
239 +
240 +/**
241 + * Names present in ClinicalTrials.gov exports that have NO current ISO 3166-1 code (dissolved
242 + * states or an empty field). They keep `iso3 = null` in `trial_site_country_counts`, appear in the
243 + * table by name, and are never painted on the map.
244 + */
245 +export const UNMAPPED_COUNTRY_NAMES: readonly string[] = ['', 'Serbia and Montenegro', 'Former Serbia and Montenegro', 'Federal Republic of Yugoslavia', 'Former Yugoslavia', 'Yugoslavia', 'Netherlands Antilles', 'Czechoslovakia', 'Union of Soviet Socialist Republics', 'USSR'];
246 +
247 +/** Lower-case, trimmed, straight apostrophes, collapsed whitespace. */
248 +export function normalizeCountryName(name: string): string {
249 + return name
250 + .normalize('NFC')
251 + .replace(/[‘’ʼ`´]/g, "'")
252 + .replace(/\s+/g, ' ')
253 + .trim()
254 + .toLowerCase();
255 +}
256 +
257 +const NAME_INDEX: ReadonlyMap<string, CountryCode> = (() => {
258 + const m = new Map<string, CountryCode>();
259 + for (const c of COUNTRY_CODES) {
260 + m.set(normalizeCountryName(c.name), c);
261 + for (const a of c.aliases ?? []) m.set(normalizeCountryName(a), c);
262 + m.set(c.iso3.toLowerCase(), c);
263 + }
264 + return m;
265 +})();
266 +
267 +const ISO_INDEX: ReadonlyMap<string, CountryCode> = new Map(COUNTRY_CODES.map((c) => [c.iso3, c]));
268 +
269 +/** ISO 3166-1 alpha-3 for a ClinicalTrials.gov country name, or null when unknown/unmappable. */
270 +export function countryToIso3(name: string | null | undefined): string | null {
271 + if (name == null) return null;
272 + const c = NAME_INDEX.get(normalizeCountryName(name));
273 + return c ? c.iso3 : null;
274 +}
275 +
276 +/** Full entry (iso3 + display name) for a ClinicalTrials.gov country name or an ISO3 code. */
277 +export function lookupCountry(nameOrIso3: string | null | undefined): CountryCode | null {
278 + if (nameOrIso3 == null) return null;
279 + return NAME_INDEX.get(normalizeCountryName(nameOrIso3)) ?? null;
280 +}
281 +
282 +/** Display name for an ISO3 code (falls back to the code). */
283 +export function iso3ToName(iso3: string): string {
284 + return ISO_INDEX.get(iso3.toUpperCase())?.name ?? iso3;
285 +}
286 +
287 +export function isKnownUnmapped(name: string): boolean {
288 + const n = normalizeCountryName(name);
289 + return UNMAPPED_COUNTRY_NAMES.some((u) => normalizeCountryName(u) === n);
290 +}
added packages/ranking/src/fixtures/trial-countries.json +180 −0
@@ -0,0 +1,180 @@
1 +[
2 + "Albania",
3 + "Algeria",
4 + "American Samoa",
5 + "Andorra",
6 + "Antigua and Barbuda",
7 + "Argentina",
8 + "Armenia",
9 + "Australia",
10 + "Austria",
11 + "Azerbaijan",
12 + "Bahrain",
13 + "Bangladesh",
14 + "Barbados",
15 + "Belarus",
16 + "Belgium",
17 + "Belize",
18 + "Benin",
19 + "Bhutan",
20 + "Bolivia",
21 + "Bosnia and Herzegovina",
22 + "Botswana",
23 + "Brazil",
24 + "Brunei",
25 + "Bulgaria",
26 + "Burkina Faso",
27 + "Burma",
28 + "Burundi",
29 + "Cambodia",
30 + "Cameroon",
31 + "Canada",
32 + "Chile",
33 + "China",
34 + "Colombia",
35 + "Costa Rica",
36 + "Côte d’Ivoire",
37 + "Croatia",
38 + "Cuba",
39 + "Cyprus",
40 + "Czechia",
41 + "Democratic Republic of the Congo",
42 + "Denmark",
43 + "Dominica",
44 + "Dominican Republic",
45 + "Ecuador",
46 + "Egypt",
47 + "El Salvador",
48 + "Estonia",
49 + "Eswatini",
50 + "Ethiopia",
51 + "Faroe Islands",
52 + "Federal Republic of Yugoslavia",
53 + "Fiji",
54 + "Finland",
55 + "France",
56 + "French Guiana",
57 + "French Polynesia",
58 + "Gabon",
59 + "Georgia",
60 + "Germany",
61 + "Ghana",
62 + "Greece",
63 + "Greenland",
64 + "Grenada",
65 + "Guadeloupe",
66 + "Guam",
67 + "Guatemala",
68 + "Guinea",
69 + "Guinea-Bissau",
70 + "Haiti",
71 + "Honduras",
72 + "Hong Kong",
73 + "Hungary",
74 + "Iceland",
75 + "India",
76 + "Indonesia",
77 + "Iran",
78 + "Iraq",
79 + "Ireland",
80 + "Israel",
81 + "Italy",
82 + "Jamaica",
83 + "Japan",
84 + "Jordan",
85 + "Kazakhstan",
86 + "Kenya",
87 + "Kosovo",
88 + "Kuwait",
89 + "Kyrgyzstan",
90 + "Laos",
91 + "Latvia",
92 + "Lebanon",
93 + "Lesotho",
94 + "Libya",
95 + "Lithuania",
96 + "Luxembourg",
97 + "Madagascar",
98 + "Malawi",
99 + "Malaysia",
100 + "Mali",
101 + "Malta",
102 + "Martinique",
103 + "Mauritania",
104 + "Mexico",
105 + "Moldova",
106 + "Monaco",
107 + "Mongolia",
108 + "Montenegro",
109 + "Morocco",
110 + "Mozambique",
111 + "Nepal",
112 + "Netherlands",
113 + "Netherlands Antilles",
114 + "New Caledonia",
115 + "New Zealand",
116 + "Nicaragua",
117 + "Niger",
118 + "Nigeria",
119 + "North Korea",
120 + "North Macedonia",
121 + "Northern Mariana Islands",
122 + "Norway",
123 + "Oman",
124 + "Pakistan",
125 + "Palestinian Territories",
126 + "Panama",
127 + "Paraguay",
128 + "Peru",
129 + "Philippines",
130 + "Poland",
131 + "Portugal",
132 + "Puerto Rico",
133 + "Qatar",
134 + "Reunion",
135 + "Romania",
136 + "Russia",
137 + "Rwanda",
138 + "Saint Kitts and Nevis",
139 + "Saint Lucia",
140 + "Saint Vincent and the Grenadines",
141 + "Saudi Arabia",
142 + "Senegal",
143 + "Serbia",
144 + "Serbia and Montenegro",
145 + "Sierra Leone",
146 + "Singapore",
147 + "Slovakia",
148 + "Slovenia",
149 + "South Africa",
150 + "South Korea",
151 + "Spain",
152 + "Sri Lanka",
153 + "Sudan",
154 + "Sweden",
155 + "Switzerland",
156 + "Syria",
157 + "Taiwan",
158 + "Tajikistan",
159 + "Tanzania",
160 + "Thailand",
161 + "The Bahamas",
162 + "The Gambia",
163 + "Togo",
164 + "Trinidad and Tobago",
165 + "Tunisia",
166 + "Turkey (Türkiye)",
167 + "Uganda",
168 + "Ukraine",
169 + "United Arab Emirates",
170 + "United Kingdom",
171 + "United States",
172 + "Uruguay",
173 + "Uzbekistan",
174 + "Venezuela",
175 + "Vietnam",
176 + "Virgin Islands",
177 + "Yemen",
178 + "Zambia",
179 + "Zimbabwe"
180 +]
\ No newline at end of file
modified packages/ranking/src/trial-sites.ts +106 −5
@@ -1,16 +1,117 @@
1 +import { sql } from 'drizzle-orm';
1 2 import type { Database } from '@cancerindex/database';
3 +import { countryToIso3 } from './country-codes.js';
4 +
5 +// The package index (owned by the integrator) already re-exports this module; surface the pure
6 +// country mapping through it so apps import `countryToIso3` / `iso3ToName` from '@cancerindex/ranking'.
7 +export * from './country-codes.js';
2 8
3 9 export const TRIAL_SITE_COUNTRY_FORMULA_VERSION = 'ci-trial-sites-v1';
4 10
11 +/** Phase buckets exposed by the map; EARLY_PHASE1 is folded into PHASE1, NA and empty phases only count under "any phase". */
12 +export const TRIAL_SITE_PHASES = ['PHASE1', 'PHASE2', 'PHASE3', 'PHASE4'] as const;
13 +export type TrialSitePhase = (typeof TRIAL_SITE_PHASES)[number];
14 +
5 15 export interface TrialSiteCountsResult {
16 + /** Rows written to trial_site_country_counts. */
6 17 rows: number;
18 + /** Distinct non-empty country names seen in trial_locations. */
19 + countries: number;
20 + /** Country names that could not be mapped to ISO 3166-1 alpha-3 (kept with iso3 = null). */
21 + unmapped: string[];
22 + /** Top-level cancers aggregated (in addition to the all-oncology scope). */
23 + cancers: number;
24 + ms: number;
7 25 }
8 26
9 27 /**
10 − * STUB — implemented by the Trial Map work package.
11 − * Recomputes `trial_site_country_counts` (country aggregates of trial sites for all oncology trials
12 − * and for each top-level cancer, any phase / per phase, all statuses / recruiting only).
28 + * Recompute `trial_site_country_counts` (SPEC §11 — trial map), formula `ci-trial-sites-v1`.
29 + *
30 + * One row per (cancer_id, phase, recruiting_only, country) for the cartesian product
31 + * cancer_id ∈ {NULL = every oncology trial} ∪ {each active `top_level` cancer, trials attributed
32 + * through `trial_conditions` to the cancer or any NCIt descendant (depth ≤ 12,
33 + * same traversal as counters.ts)}
34 + * phase ∈ {NULL = any phase, PHASE1, PHASE2, PHASE3, PHASE4} — a PHASE2|PHASE3 study is
35 + * counted under both; EARLY_PHASE1 counts under PHASE1; NA / no phase → any only
36 + * recruiting_only∈ {false = every location, true = location status RECRUITING, or, when the
37 + * location has no status, study overall_status RECRUITING}
38 + *
39 + * sites = number of `trial_locations` rows (a study with 40 US sites weighs 40)
40 + * trials = distinct studies with ≥ 1 site in the country
41 + *
42 + * Interventional and observational studies are both included (no study_type filter). Locations
43 + * with an empty country are excluded. Deterministic, set-based, one transaction (delete + insert).
13 44 */
14 −export async function computeTrialSiteCounts(_db: Database): Promise<TrialSiteCountsResult> {
15 − return { rows: 0 };
45 +export async function computeTrialSiteCounts(db: Database): Promise<TrialSiteCountsResult> {
46 + const t0 = Date.now();
47 + return db.transaction(async (tx) => {
48 + // 1. Locations joined to their study, with the effective "recruiting" flag and phase array.
49 + await tx.execute(sql`
50 + CREATE TEMP TABLE _tl ON COMMIT DROP AS
51 + SELECT l.trial_id, l.country, (coalesce(l.status, t.overall_status) = 'RECRUITING') AS recruiting, t.phases
52 + FROM trial_locations l JOIN clinical_trials t ON t.id = l.trial_id
53 + WHERE l.country IS NOT NULL AND l.country <> ''`);
54 +
55 + // 2. Country → ISO3 through the pure mapping (JS side; ~200 names).
56 + const names = await tx.execute<{ country: string }>(sql`SELECT DISTINCT country FROM _tl ORDER BY country`);
57 + const mapping = Array.from(names).map((r) => ({ country: r.country, iso3: countryToIso3(r.country) }));
58 + const unmapped = mapping.filter((m) => m.iso3 === null).map((m) => m.country);
59 + await tx.execute(sql`CREATE TEMP TABLE _iso (country text PRIMARY KEY, iso3 text) ON COMMIT DROP`);
60 + const mapped = mapping.filter((m) => m.iso3 !== null);
61 + for (let i = 0; i < mapped.length; i += 100) {
62 + const chunk = mapped.slice(i, i + 100);
63 + await tx.execute(sql`INSERT INTO _iso (country, iso3) VALUES ${sql.join(chunk.map((m) => sql`(${m.country}, ${m.iso3})`), sql`, `)}`);
64 + }
65 +
66 + // 3. Location × phase bucket (NULL = any phase, plus each normalized phase present on the study).
67 + const phaseList = sql.raw(`(${['EARLY_PHASE1', ...TRIAL_SITE_PHASES].map((p) => `'${p}'`).join(',')})`);
68 + await tx.execute(sql`
69 + CREATE TEMP TABLE _tlp ON COMMIT DROP AS
70 + SELECT trial_id, country, recruiting, NULL::text AS phase FROM _tl
71 + UNION ALL
72 + SELECT l.trial_id, l.country, l.recruiting, p.phase
73 + FROM _tl l CROSS JOIN LATERAL (
74 + SELECT DISTINCT CASE WHEN x = 'EARLY_PHASE1' THEN 'PHASE1' ELSE x END AS phase
75 + FROM unnest(l.phases) x WHERE x IN ${phaseList}
76 + ) p`);
77 + await tx.execute(sql`CREATE INDEX ON _tlp (trial_id)`);
78 +
79 + // 4. Trial × cancer scope: NULL (all oncology trials) + every active top-level cancer whose
80 + // descendant set (NCIt hierarchy, depth ≤ 12) contains a mapped condition of the trial.
81 + await tx.execute(sql`
82 + CREATE TEMP TABLE _desc ON COMMIT DROP AS
83 + WITH RECURSIVE d AS (
84 + SELECT id AS ancestor, id AS descendant, 0 AS depth FROM cancers WHERE status = 'active' AND top_level
85 + UNION
86 + SELECT d.ancestor, h.child_id, d.depth + 1 FROM d JOIN cancer_hierarchy h ON h.parent_id = d.descendant
87 + WHERE d.depth < 12
88 + )
89 + SELECT DISTINCT ancestor, descendant FROM d`);
90 + await tx.execute(sql`CREATE INDEX ON _desc (descendant)`);
91 + await tx.execute(sql`
92 + CREATE TEMP TABLE _tc ON COMMIT DROP AS
93 + SELECT NULL::varchar(32) AS cancer_id, t.id AS trial_id FROM clinical_trials t
94 + UNION ALL
95 + SELECT DISTINCT d.ancestor, tc.trial_id FROM trial_conditions tc JOIN _desc d ON d.descendant = tc.cancer_id WHERE tc.cancer_id IS NOT NULL`);
96 + await tx.execute(sql`CREATE INDEX ON _tc (trial_id)`);
97 +
98 + // 5. Rebuild.
99 + await tx.execute(sql`DELETE FROM trial_site_country_counts`);
100 + const inserted = await tx.execute<{ n: string }>(sql`
101 + WITH ins AS (
102 + -- Schema field computedAt is declared with the shared updatedAt() helper, hence column updated_at.
103 + INSERT INTO trial_site_country_counts (cancer_id, phase, recruiting_only, country, iso3, sites, trials, formula_version, updated_at)
104 + SELECT c.cancer_id, p.phase, r.recruiting_only, p.country, i.iso3, count(*)::int, count(DISTINCT p.trial_id)::int, ${TRIAL_SITE_COUNTRY_FORMULA_VERSION}, now()
105 + FROM _tlp p
106 + JOIN _tc c ON c.trial_id = p.trial_id
107 + CROSS JOIN (VALUES (false), (true)) AS r(recruiting_only)
108 + LEFT JOIN _iso i ON i.country = p.country
109 + WHERE NOT r.recruiting_only OR p.recruiting
110 + GROUP BY c.cancer_id, p.phase, r.recruiting_only, p.country, i.iso3
111 + RETURNING 1
112 + )
113 + SELECT count(*)::text AS n FROM ins`);
114 + const cancers = await tx.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM cancers WHERE status = 'active' AND top_level`);
115 + return { rows: Number(inserted[0]?.n ?? 0), countries: mapping.length, unmapped, cancers: Number(cancers[0]?.n ?? 0), ms: Date.now() - t0 };
116 + });
16 117 }
added packages/ranking/test/country-codes.test.ts +71 −0
@@ -0,0 +1,71 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { COUNTRY_CODES, UNMAPPED_COUNTRY_NAMES, countryToIso3, iso3ToName, isKnownUnmapped, lookupCountry, normalizeCountryName } from '../src/country-codes.js';
3 +import dbCountries from '../src/fixtures/trial-countries.json' with { type: 'json' };
4 +
5 +describe('countryToIso3', () => {
6 + it('maps the ClinicalTrials.gov v2 short names', () => {
7 + expect(countryToIso3('United States')).toBe('USA');
8 + expect(countryToIso3('South Korea')).toBe('KOR');
9 + expect(countryToIso3('Turkey (Türkiye)')).toBe('TUR');
10 + expect(countryToIso3('Russia')).toBe('RUS');
11 + expect(countryToIso3('Vietnam')).toBe('VNM');
12 + expect(countryToIso3('Reunion')).toBe('REU');
13 + expect(countryToIso3('Palestinian Territories')).toBe('PSE');
14 + expect(countryToIso3('The Bahamas')).toBe('BHS');
15 + expect(countryToIso3('Burma')).toBe('MMR');
16 + expect(countryToIso3('Côte d’Ivoire')).toBe('CIV'); // curly apostrophe as stored in the DB
17 + });
18 + it('maps the legacy ISO-style long names', () => {
19 + expect(countryToIso3('Korea, Republic of')).toBe('KOR');
20 + expect(countryToIso3('Russian Federation')).toBe('RUS');
21 + expect(countryToIso3('Iran, Islamic Republic of')).toBe('IRN');
22 + expect(countryToIso3('Viet Nam')).toBe('VNM');
23 + expect(countryToIso3('Réunion')).toBe('REU');
24 + expect(countryToIso3('Palestinian Territory, occupied')).toBe('PSE');
25 + expect(countryToIso3('Türkiye')).toBe('TUR');
26 + expect(countryToIso3('Czechia')).toBe('CZE');
27 + expect(countryToIso3('Hong Kong')).toBe('HKG');
28 + expect(countryToIso3('Macao')).toBe('MAC');
29 + expect(countryToIso3('Taiwan')).toBe('TWN');
30 + });
31 + it('is case/whitespace insensitive and accepts ISO3 itself', () => {
32 + expect(countryToIso3(' united states ')).toBe('USA');
33 + expect(countryToIso3('usa')).toBe('USA');
34 + expect(countryToIso3('FRA')).toBe('FRA');
35 + });
36 + it('returns null for dissolved states, empty and unknown strings', () => {
37 + expect(countryToIso3('Former Serbia and Montenegro')).toBeNull();
38 + expect(countryToIso3('Former Yugoslavia')).toBeNull();
39 + expect(countryToIso3('Netherlands Antilles')).toBeNull();
40 + expect(countryToIso3('')).toBeNull();
41 + expect(countryToIso3(null)).toBeNull();
42 + expect(countryToIso3('Atlantis')).toBeNull();
43 + expect(isKnownUnmapped('Serbia and Montenegro')).toBe(true);
44 + expect(isKnownUnmapped('Atlantis')).toBe(false);
45 + });
46 + it('exposes display names', () => {
47 + expect(iso3ToName('KOR')).toBe('South Korea');
48 + expect(iso3ToName('ZZZ')).toBe('ZZZ');
49 + expect(lookupCountry('Viet Nam')).toEqual({ iso3: 'VNM', name: 'Vietnam', aliases: ['Viet Nam'] });
50 + expect(normalizeCountryName(" Côte d’Ivoire ")).toBe("côte d'ivoire");
51 + });
52 +});
53 +
54 +describe('COUNTRY_CODES table', () => {
55 + it('has unique ISO3 codes and unique normalized names/aliases', () => {
56 + const codes = COUNTRY_CODES.map((c) => c.iso3);
57 + expect(new Set(codes).size).toBe(codes.length);
58 + for (const c of codes) expect(c).toMatch(/^[A-Z]{3}$/);
59 + const names = COUNTRY_CODES.flatMap((c) => [c.name, ...(c.aliases ?? [])]).map(normalizeCountryName);
60 + const dupes = names.filter((n, i) => names.indexOf(n) !== i);
61 + expect(dupes).toEqual([]);
62 + });
63 + it('resolves every distinct country currently in trial_locations, or lists it as UNMAPPED', () => {
64 + const missing = (dbCountries as string[]).filter((n) => countryToIso3(n) === null && !isKnownUnmapped(n));
65 + expect(missing).toEqual([]);
66 + // Sanity: the fixture is the real export (≈ 180 names), not an empty file.
67 + expect((dbCountries as string[]).length).toBeGreaterThan(150);
68 + // Explicitly unmapped names never resolve (a mapping added later must remove them from UNMAPPED).
69 + for (const u of UNMAPPED_COUNTRY_NAMES) expect(countryToIso3(u)).toBeNull();
70 + });
71 +});
modified pnpm-lock.yaml +88 −0
@@ -121,6 +121,9 @@ importers:
121 121 '@cancerindex/shared':
122 122 specifier: workspace:*
123 123 version: link:../../packages/shared
124 + d3-geo:
125 + specifier: ^3.1.1
126 + version: 3.1.1
124 127 drizzle-orm:
125 128 specifier: ^0.45.0
126 129 version: 0.45.2(pg@8.23.0)(postgres@3.4.9)
@@ -142,10 +145,22 @@ importers:
142 145 server-only:
143 146 specifier: ^0.0.1
144 147 version: 0.0.1
148 + topojson-client:
149 + specifier: ^3.1.0
150 + version: 3.1.0
151 + world-atlas:
152 + specifier: ^2.0.2
153 + version: 2.0.2
145 154 devDependencies:
146 155 '@tailwindcss/postcss':
147 156 specifier: ^4
148 157 version: 4.3.3
158 + '@types/d3-geo':
159 + specifier: ^3.1.1
160 + version: 3.1.1
161 + '@types/geojson':
162 + specifier: ^7946.0.16
163 + version: 7946.0.16
149 164 '@types/node':
150 165 specifier: ^24.0.0
151 166 version: 24.13.3
@@ -155,6 +170,12 @@ importers:
155 170 '@types/react-dom':
156 171 specifier: ^19
157 172 version: 19.2.7(@types/react@19.2.18)
173 + '@types/topojson-client':
174 + specifier: ^3.1.5
175 + version: 3.1.5
176 + '@types/topojson-specification':
177 + specifier: ^1.0.5
178 + version: 1.0.5
158 179 tailwindcss:
159 180 specifier: ^4
160 181 version: 4.3.3
@@ -1328,12 +1349,18 @@ packages:
1328 1349 '@types/chai@5.2.3':
1329 1350 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
1330 1351
1352 + '@types/d3-geo@3.1.1':
1353 + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
1354 +
1331 1355 '@types/deep-eql@4.0.2':
1332 1356 resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
1333 1357
1334 1358 '@types/estree@1.0.9':
1335 1359 resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
1336 1360
1361 + '@types/geojson@7946.0.16':
1362 + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
1363 +
1337 1364 '@types/node@24.13.3':
1338 1365 resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
1339 1366
@@ -1345,6 +1372,12 @@ packages:
1345 1372 '@types/react@19.2.18':
1346 1373 resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
1347 1374
1375 + '@types/topojson-client@3.1.5':
1376 + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
1377 +
1378 + '@types/topojson-specification@1.0.5':
1379 + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
1380 +
1348 1381 '@vitest/expect@3.2.7':
1349 1382 resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
1350 1383
@@ -1446,6 +1479,9 @@ packages:
1446 1479 client-only@0.0.1:
1447 1480 resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1448 1481
1482 + commander@2.20.3:
1483 + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
1484 +
1449 1485 content-disposition@1.1.0:
1450 1486 resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
1451 1487 engines: {node: '>=18'}
@@ -1464,6 +1500,14 @@ packages:
1464 1500 csstype@3.2.3:
1465 1501 resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1466 1502
1503 + d3-array@3.2.4:
1504 + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
1505 + engines: {node: '>=12'}
1506 +
1507 + d3-geo@3.1.1:
1508 + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
1509 + engines: {node: '>=12'}
1510 +
1467 1511 debug@4.4.3:
1468 1512 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1469 1513 engines: {node: '>=6.0'}
@@ -1717,6 +1761,10 @@ packages:
1717 1761 inherits@2.0.4:
1718 1762 resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
1719 1763
1764 + internmap@2.0.3:
1765 + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
1766 + engines: {node: '>=12'}
1767 +
1720 1768 ipaddr.js@2.5.0:
1721 1769 resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
1722 1770 engines: {node: '>= 10'}
@@ -2224,6 +2272,10 @@ packages:
2224 2272 resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
2225 2273 engines: {node: '>=0.6'}
2226 2274
2275 + topojson-client@3.1.0:
2276 + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
2277 + hasBin: true
2278 +
2227 2279 tslib@2.8.1:
2228 2280 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
2229 2281
@@ -2325,6 +2377,9 @@ packages:
2325 2377 engines: {node: '>=8'}
2326 2378 hasBin: true
2327 2379
2380 + world-atlas@2.0.2:
2381 + resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
2382 +
2328 2383 wrappy@1.0.2:
2329 2384 resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
2330 2385
@@ -2987,10 +3042,16 @@ snapshots:
2987 3042 '@types/deep-eql': 4.0.2
2988 3043 assertion-error: 2.0.1
2989 3044
3045 + '@types/d3-geo@3.1.1':
3046 + dependencies:
3047 + '@types/geojson': 7946.0.16
3048 +
2990 3049 '@types/deep-eql@4.0.2': {}
2991 3050
2992 3051 '@types/estree@1.0.9': {}
2993 3052
3053 + '@types/geojson@7946.0.16': {}
3054 +
2994 3055 '@types/node@24.13.3':
2995 3056 dependencies:
2996 3057 undici-types: 7.18.2
@@ -3003,6 +3064,15 @@ snapshots:
3003 3064 dependencies:
3004 3065 csstype: 3.2.3
3005 3066
3067 + '@types/topojson-client@3.1.5':
3068 + dependencies:
3069 + '@types/geojson': 7946.0.16
3070 + '@types/topojson-specification': 1.0.5
3071 +
3072 + '@types/topojson-specification@1.0.5':
3073 + dependencies:
3074 + '@types/geojson': 7946.0.16
3075 +
3006 3076 '@vitest/expect@3.2.7':
3007 3077 dependencies:
3008 3078 '@types/chai': 5.2.3
@@ -3106,6 +3176,8 @@ snapshots:
3106 3176
3107 3177 client-only@0.0.1: {}
3108 3178
3179 + commander@2.20.3: {}
3180 +
3109 3181 content-disposition@1.1.0: {}
3110 3182
3111 3183 cookie@1.1.1: {}
@@ -3118,6 +3190,14 @@ snapshots:
3118 3190
3119 3191 csstype@3.2.3: {}
3120 3192
3193 + d3-array@3.2.4:
3194 + dependencies:
3195 + internmap: 2.0.3
3196 +
3197 + d3-geo@3.1.1:
3198 + dependencies:
3199 + d3-array: 3.2.4
3200 +
3121 3201 debug@4.4.3:
3122 3202 dependencies:
3123 3203 ms: 2.1.3
@@ -3368,6 +3448,8 @@ snapshots:
3368 3448
3369 3449 inherits@2.0.4: {}
3370 3450
3451 + internmap@2.0.3: {}
3452 +
3371 3453 ipaddr.js@2.5.0: {}
3372 3454
3373 3455 is-unsafe@2.0.2: {}
@@ -3855,6 +3937,10 @@ snapshots:
3855 3937
3856 3938 toidentifier@1.0.1: {}
3857 3939
3940 + topojson-client@3.1.0:
3941 + dependencies:
3942 + commander: 2.20.3
3943 +
3858 3944 tslib@2.8.1: {}
3859 3945
3860 3946 tsx@4.23.13:
@@ -3956,6 +4042,8 @@ snapshots:
3956 4042 siginfo: 2.0.0
3957 4043 stackback: 0.0.2
3958 4044
4045 + world-atlas@2.0.2: {}
4046 +
3959 4047 wrappy@1.0.2: {}
3960 4048
3961 4049 xml-naming@0.3.0: {}
3962 4050