Changelog 37f1291
Fix cannabis users in Northern Cyprus and Transnistria (110M and 120M, now 16,000 and 2,100)
extract-unodc.py took each place's population aged 15 to 64 from the World Bank by two-letter code, and the World Bank's income aggregates share codes with the site's disputed territories: XN "Lower middle income" became Northern Cyprus, XT "Upper middle income" Transnistria, about two billion people each. - The script now drops World Bank aggregates, and a disputed territory with no survey takes the figure of the country it is part of (new column estimate_from), applied to its own total population times that country's share aged 15 to 64, instead of the median of its region. Moldova surveyed 0.7%, where Europe's median, 5.75%, had stood in for Transnistria. - The four territories' rows are recomputed that way (the World Bank API cannot be reached from here to rerun the script): Transnistria 0.7% / 2,100, Northern Cyprus 6.0% / 16,000, Abkhazia 4.6% / 7,200, Somaliland 2.5% / 72,000. No other row changed. - The profile, the rankings tooltip and the map say whose survey a territory's figure comes from. - The build fails if a place has more users, or more people of an age band, than people. Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd
1 dataset updated5 files of code, design or docs +612 −568 lines
Datasets 1
- datasets/unodc-cannabis-use.csv 543 → 543 rows
Code, design and documents 5 files
datasets/datasets.yaml +9 −3
@@ -58,5 +58,10 @@ instead: "earlier" keeps the figure this site had before, taken from Wikipedia's list of older UNODC figures; "estimate" is the median of the measured countries in the same region, - computed here, not a regional total published by UNODC. `users` applies the share to a + computed here, not a regional total published by UNODC. A disputed territory with no survey + takes instead the figure of the country it is internationally regarded as part of + (`estimate_from`), whose survey covered nearly the same people, applied to its own population + aged 15 to 64: its total from datasets/population.csv times that country's share of those + ages. Corrected on 25 October 2026: the World Bank's regional and income aggregates had been + taken for Northern Cyprus (XN) and Transnistria (XT), which gave them some two billion people. `users` applies the share to a population of the survey's own age band, from the World Bank's five-year age groups (male and female summed, latest year). A band that cuts through a group is split evenly across @@ -75,6 +80,7 @@ period: { column: period, type: text, about: "Survey year or period as UNODC gives it" } ages: { column: ages, type: text, about: "The survey's age band, shown with the figure" } - source: { column: source, type: text, about: "wdr2026, earlier or estimate", values: { wdr2026: "UNODC 2026", earlier: "earlier figure, not in UNODC 2026", estimate: "median of surveyed countries in the region, computed here" } } - estimate: { column: estimate, type: boolean, about: "Marks a median computed here for a country with no survey (shown with ~)" } + source: { column: source, type: text, about: "wdr2026, earlier or estimate", values: { wdr2026: "UNODC 2026", earlier: "earlier figure, not in UNODC 2026", estimate: "computed here: the median of surveyed countries in the region, or the figure of the country a territory is part of" } } + estimate: { column: estimate, type: boolean, about: "Marks a figure computed here for a place with no survey (shown with ~)" } + estimate_from: { column: estimate_from, type: text, about: "For a disputed territory with no survey: the country whose figure it takes" } population_15_64: { column: population_15_64, about: "Population aged 15 to 64, kept for reference" } population_band: { column: population_band, about: "Population the user count is applied to" }
scripts/extract-unodc.py +39 −14
@@ -18,5 +18,8 @@ use table 1.2, sheet "Cannabis": past-year cannabis use, every survey for source "wdr2026" from the table; "earlier" a figure from before this table was used (Wikipedia's list of older UNODC figures), kept where the table has - none; "estimate" the median of the measured countries in the same region + none; "estimate" the median of the measured countries in the same region, + or, for a disputed territory, the figure of the country it is + internationally regarded as part of (`estimate_from`), whose survey covers + nearly the same people users the share applied to a population of the same age band, from the World Bank's five-year age groups (fetched when the script runs), to two @@ -25,5 +28,7 @@ use table 1.2, sheet "Cannabis": past-year cannabis use, every survey for regional median, use ages 15 to 64. Where that population is missing, the row has no user count. - Places the World Bank does not list keep the 15-to-64 population the file already had. + Places the World Bank does not list keep the 15-to-64 population the file already had; + a disputed territory takes its own total (datasets/population.csv) times the share + aged 15 to 64 in the country it is part of. youth table 1.4, sheet "Cannabis": cannabis use among 15- and 16-year-olds, every survey @@ -102,5 +107,5 @@ def norm(s): def country_files(): - """id → (name, region) for every country file""" + """id → (name, region, part_of) for every country file; part_of for a disputed territory""" out = {} for path in glob.glob("countries/*.yaml") + glob.glob("countries/*/index.yaml"): @@ -109,10 +114,11 @@ def country_files(): name = re.search(r"^name: (.+)$", text, re.M).group(1).strip().strip("\"'") region = re.search(r"^region: (.+)$", text, re.M).group(1).strip() - out[cid] = (name, region) + part_of = re.search(r"^ part_of: ([a-z]{2})$", text, re.M) + out[cid] = (name, region, part_of and part_of.group(1)) return out def matcher(files, missed): - by_name = {norm(name): cid for cid, (name, _) in files.items()} + by_name = {norm(name): cid for cid, (name, *_) in files.items()} def match(name): name = name.strip() @@ -175,7 +181,12 @@ def world_bank_latest(code): with urllib.request.urlopen(url, timeout=120) as res: rows = json.load(res)[1] or [] + # Regional and income aggregates come back too, with two-letter codes of their own, some of + # them the site's codes for disputed territories: XN "Lower middle income" is not Northern + # Cyprus, XT "Upper middle income" is not Transnistria. Keep countries only. + with urllib.request.urlopen("https://api.worldbank.org/v2/country?format=json&per_page=400", timeout=120) as res: + aggregates = {c["iso2Code"].lower() for c in json.load(res)[1] if c["region"]["id"] == "NA"} latest = {} for r in rows: - if r["value"] is None: + if r["value"] is None or r["country"]["id"].lower() in aggregates: continue latest[r["country"]["id"].lower()] = (int(r["date"]), int(round(r["value"]))) @@ -319,8 +330,21 @@ def use(): row["population_year"], row["population_15_64"] = "", 0 out[cid] = row - # no survey at all: the median of the measured countries in the same region. - # That median is computed here. It is not a regional total published by UNODC. + # no survey at all: a disputed territory takes the figure of the country it is part of, which + # surveyed nearly the same people; anywhere else, the median of the measured countries in the + # same region. That median is computed here. It is not a regional total published by UNODC. + totals = {} + for r in csv.DictReader(open("datasets/population.csv", encoding="utf-8")): + if int(r["year"]) >= totals.get(r["country"], (0, 0))[0]: + totals[r["country"]] = (int(r["year"]), int(r["population"])) for cid, row in out.items(): - if row["source"] == "estimate": + if row["source"] != "estimate": + continue + parent = files[cid][2] + if parent and out.get(parent, {}).get("source") in ("wdr2026", "earlier"): + row["prevalence"], row["estimate_from"] = out[parent]["prevalence"], parent + if cid not in wb and cid in totals and out[parent].get("population_15_64") and parent in totals: + share = out[parent]["population_15_64"] / totals[parent][1] + row["population_year"], row["population_15_64"] = totals[cid][0], int(round(totals[cid][1] * share)) + else: region = files[cid][1] row["prevalence"] = round(statistics.median(r["prevalence"] for k, r in out.items() if r["source"] != "estimate" and files[k][1] == region), 2) @@ -332,5 +356,5 @@ def use(): e = history[cid][year] if r["source"] == "wdr2026" and year < r["year"]: - lines.append([cid, files[cid][0], e["unodc_name"], e["prevalence"], year, e["period"], e["ages"], "", "wdr2026", "", "", "", "", "", ""]) + lines.append([cid, files[cid][0], e["unodc_name"], e["prevalence"], year, e["period"], e["ages"], "", "", "wdr2026", "", "", "", "", "", ""]) if not r["population_year"] and cid in groups: r["population_year"] = groups[cid][0] @@ -340,8 +364,8 @@ def use(): omitted.append(f"{cid} ({r.get('ages') or r['source']})") lines.append([cid, files[cid][0], r.get("unodc_name", ""), r["prevalence"], r.get("year", ""), r.get("period", ""), r.get("ages", ""), - "true" if r["source"] == "estimate" else "", r["source"], r["population_15_64"] or "", r["population_year"], + "true" if r["source"] == "estimate" else "", r.get("estimate_from", ""), r["source"], r["population_15_64"] or "", r["population_year"], band or "", band_ages, "true" if approximate else "", users]) write("datasets/unodc-cannabis-use.csv", - ["country", "name", "unodc_name", "prevalence", "year", "period", "ages", "estimate", "source", + ["country", "name", "unodc_name", "prevalence", "year", "period", "ages", "estimate", "estimate_from", "source", "population_15_64", "population_year", "population_band", "population_ages", "users_approximate", "users"], lines) counts = {s: sum(1 for r in out.values() if r["source"] == s) for s in ("wdr2026", "earlier", "estimate")} @@ -365,6 +389,7 @@ def headcount(cid, row, groups): if not pop and cid in groups: pop, _ = band_population(groups[cid][1], 15, 64) - # the median has no survey age; an earlier figure with no age is only assumed to be 15–64 - return (pop, "15-64", row["source"] != "estimate") if pop else (0, "", False) + # the median has no survey age; an earlier figure with no age is only assumed to be 15–64; + # a territory's 15–64 population is borrowed from the share in the country it is part of + return (pop, "15-64", row["source"] != "estimate" or bool(row.get("estimate_from"))) if pop else (0, "", False) lo, hi = ages if (lo, hi) == (15, 64) and pop:
src/components/CountryArticle.astro +7 −1
@@ -61,4 +61,6 @@ const people = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)} million` : n.toL const use = sub ? null : c.use; const unitUse = sub?.use ?? null; // a state's or province's own survey figure +// a disputed territory with no survey takes the figure of the country it is part of +const useFrom = use?.estimate_from ? countries.find((x) => x.id === use.estimate_from.toUpperCase()) : null; // Crossing a border with cannabis. This is general guidance, the same rule almost everywhere, // worded for the country's status; it is not country-specific research. @@ -186,5 +188,7 @@ const culture = cultureOf(c); <dd>{use.estimate ? "about " : ""}{use.prevalence}%</dd> </div> - <p>{use.estimate + <p>{useFrom + ? `This site's figure: there is no survey of ${c.name} alone, so ${useFrom.name}'s rate from its ${useFrom.use.period ?? useFrom.use.year} survey is applied to an estimate of the people aged 15–64 here.` + : use.estimate ? `This site's figure: there is no national survey, so the median rate of surveyed countries in ${c.region} is applied to people aged 15–64.` : `People who used cannabis in the past year, from a ${use.period ?? use.year} survey${use.users != null ? `, applied to today's population aged ${useAges({ ages: use.population_ages || use.ages })}` : ""}${use.users_approximate ? ". The population is approximate: the survey ages cut through an age group, or were not recorded and ages 15–64 are used" : ""}${use.source === "earlier" ? ". An earlier figure: UNODC's 2026 table has none for this country" : ""}.`}</p> @@ -193,4 +197,6 @@ const culture = cultureOf(c); : use.source === "earlier" ? <>An earlier UNODC figure, as collected in <a href="https://en.wikipedia.org/wiki/List_of_countries_by_annual_cannabis_use" target="_blank" rel="noopener noreferrer">Wikipedia's list of annual cannabis use by country</a></> + : useFrom + ? <>{useFrom.name}'s figure from the <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a>; the population aged 15–64 is this place's total times {useFrom.name}'s share of those ages</> : <>A median computed on this site from surveyed countries in {c.region}, using <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a> figures</>}{use.users != null && <>. Population from the <a href="https://data.worldbank.org/" target="_blank" rel="noopener noreferrer">World Bank</a>, aged {useAges({ ages: use.population_ages || use.ages })}</>}. <a href={`${base}sources/#dataset-cannabis_use`}>The whole dataset →</a></SourceTip> </dl>
src/components/HomePage.astro +5 −3
@@ -63,4 +63,6 @@ const count = (fn) => countries.filter(fn).length; const compact = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)}M` : n >= 1e3 ? `${+(n / 1e3).toPrecision(2)}k` : String(n)); const useNote = (c) => { + const from = c.use.estimate_from && places.find((x) => x.id === c.use.estimate_from.toUpperCase()); + if (from) return `This site's figure: no survey of ${c.name} alone, so ${from.name}'s rate, ${c.use.prevalence}% of people aged 15–64, is applied to an estimate of the people here of those ages.`; if (c.use.estimate) return `This site's figure: no national survey, so the median rate in ${c.region}, ${c.use.prevalence}% of people aged 15–64, is used.`; const ages = useAges(c.use), when = c.use.period ?? c.use.year; @@ -102,5 +104,5 @@ const unitData = Object.fromEntries(countries.flatMap((c) => { const USE_SCALES = { users: { label: "Cannabis users", steps: [[0, "Under 10k"], [1e4, "10k–100k"], [1e5, "100k–1M"], [1e6, "1M–10M"], [1e7, "10M or more"]], - note: "Each survey rate applied to a population of the same age band. A ~ is this site's median for a country with no survey, or a count whose ages were estimated." }, + note: "Each survey rate applied to a population of the same age band. A ~ is this site's estimate for a place with no survey (the regional median, or for a disputed territory the rate of the country it is part of), or a count whose ages were estimated." }, prevalence: { label: "Share who use cannabis", steps: [[0, "Under 1%"], [1, "1–3%"], [3, "3–7%"], [7, "7–15%"], [15, "15% or more"]] }, ratio: { @@ -301,5 +303,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", {scale.steps.map(([, text], i) => <span><i class="sw" style={`background:var(--u${i})`}></i>{text}</span>)} <span><i class="sw sw-nodata"></i>No data</span> - <span class="legend-note">{scale.note ?? "Adults who used cannabis in the past year, from each country's latest survey (most ask ages 15–64). A ~ is this site's median of surveyed countries in the region, used where that country has no survey."}</span> + <span class="legend-note">{scale.note ?? "Adults who used cannabis in the past year, from each country's latest survey (most ask ages 15–64). A ~ is this site's estimate where a place has no survey of its own: the median of surveyed countries in the region, or for a disputed territory the rate of the country it is part of."}</span> </div> ))} @@ -940,5 +942,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", return head(c.flag, c.name, metric === "users" ? usersText : `${rateMark}${c.use.prevalence}%`, useColor(c)) + row("Users", usersText) + row(`Share aged ${c.use.ages}`, `${rateMark}${c.use.prevalence}%`) - + row(c.use.estimate ? "Source" : "Survey year", c.use.estimate ? "median computed here" : c.use.period ?? c.use.year) + + row(c.use.estimate ? "Source" : "Survey year", c.use.estimate_from ? `${byId.get(c.use.estimate_from.toUpperCase())?.name ?? c.use.estimate_from.toUpperCase()}'s rate` : c.use.estimate ? "median computed here" : c.use.period ?? c.use.year) + (c.use.source === "earlier" ? row("Source", "earlier figure, not in UNODC's 2026 table") : "") + foot(c); }
src/lib/countries.js +8 −3
@@ -55,8 +55,13 @@ const scoreList = (scores) => SEGMENTS.map((seg) => scores[seg.id]); // the age band a cannabis-use figure is for: the survey's own, or 15–64 where none is given export const useAges = (use) => (use.ages ? use.ages.replace("-", "–") : "15–64"); -// cannabis use comes from its dataset, which must have a row for every place -const useOf = (slug) => { +// cannabis use comes from its dataset, which must have a row for every place. A place cannot have +// more people of an age band, or more users, than people: Northern Cyprus and Transnistria once +// took the World Bank's figures for two income groups, some two billion people each. +const useOf = (slug, population) => { const use = latestOf(slug).cannabis_use; if (!use) throw new Error(`datasets/unodc-cannabis-use.csv: no row for "${slug}"`); + for (const key of ["users", "population_band", "population_15_64"]) { + if (use[key] > population) throw new Error(`datasets/unodc-cannabis-use.csv: "${slug}" has ${key} ${use[key]}, more than its population of ${population}`); + } return use; }; @@ -91,5 +96,5 @@ const everyPlace = (await getCollection("countries")) populationByYear: populationOf(slug).map((r) => [r.year, r.population]), population: populationOf(slug).at(-1).population, // the latest figure - use: useOf(slug), // { prevalence, year?, estimate?, users }, from datasets/unodc-cannabis-use.csv + use: useOf(slug, populationOf(slug).at(-1).population), // { prevalence, year?, estimate?, users }, from datasets/unodc-cannabis-use.csv datasets: latestOf(slug), // the latest figures from the outside datasets in /datasets, see datasets.js series: datasetsOf(slug), // and every year of them