# Clinical trial map — method Formula version: `ci-trial-sites-v1` · Layer: DERIVED (`trial_site_country_counts`) · Claim category: computed metric · Source: ClinicalTrials.gov (connector `clinicaltrials`). 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. ## Definitions | Term | Definition | |---|---| | **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. | | **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. | | **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). | | **Study type** | Interventional **and** observational studies are both included; the map does not filter on `study_type`. | | **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. | ## Scopes (rows of `trial_site_country_counts`) One row per `(cancer_id, phase, recruiting_only, country)` over the cartesian product: - **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). - **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". - **recruiting_only**: `false`, `true` as defined above. 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. ## ISO 3166-1 mapping and unmapped names `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. 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. ## Drawing - **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. - **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`). - **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`). - **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 `` "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). - **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"). - **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. ## Limitations - 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. - 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. - A study mapped to several top-level cancers (e.g. a basket trial) counts in each; the "all oncology trials" scope counts it once. - Country names are registrant-entered; the mapping table handles known variants, and unknown variants fail a test rather than being guessed. - Geocoding of city dots comes from the upstream registry; cities with no coordinates are excluded from the dot layer (not from the country counts). - 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.