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%
8.2 KB

# 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 <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).
  • 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.