Cannabis Freedom Index

Changelog 2a2827e

Apply each cannabis-use survey rate to a population of the same age band.

A band that cuts through a five-year group is marked approximate, and a country with no matching population is left blank. Fixes #4

1 dataset updated6 files of code, design or docs +742 −598 lines

Datasets 1

Code, design and documents 6 files

datasets/datasets.yaml +18 −10
@@ -48,16 +48,21 @@
     The share of adults who used cannabis in the past year, from each country's most recent
     national survey as reported to UNODC, and the number of people that share stands for today.
-    Most surveys ask people aged 15 to 64; where a survey used another age band, the band is
-    given with the figure. Every country and territory on the site has a row.
+    The count uses a population of the same age band as the survey. Most surveys ask people aged
+    15 to 64; where a survey used another age band, the band is given with the figure. Every
+    country and territory on the site has a row.
   origin: >-
-    Rebuilt on 21 October 2026 by scripts/extract-unodc.py from UNODC's spreadsheet, which is
+    Rebuilt on 23 October 2026 by scripts/extract-unodc.py from UNODC's spreadsheet, which is
     published here unchanged: sheet "Cannabis", the best estimate of the latest survey for each
     country (a survey period such as 2022/23 counts as its last year). The United Kingdom is
     represented by England and Wales. Where the table has no figure, `source` says what was used
     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.
-    `users` is the share applied to the population aged 15 to 64 from the World Bank
-    (SP.POP.1564.TO, fetched the same day), to two significant figures; places the World Bank
-    does not list keep the population used before.
+    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
+    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
+    those ages and marked `users_approximate`. A survey with no recorded age, and a regional
+    median, use the population aged 15 to 64 (SP.POP.1564.TO). Where that population is missing,
+    the row has no user count. The sum of these country counts is not UNODC's separate estimate
+    of 256 million cannabis users worldwide in 2024.
   profile:
     headline: "{prevalence} of adults"
@@ -70,7 +75,10 @@
     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: "regional estimate, no survey" } }
-    estimate: { column: estimate, type: boolean, about: "Marks a regional estimate (shown with ~)" }
-    population_15_64: { column: population_15_64, about: "What users are worked out from" }
+    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 ~)" }
+    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" }
+    population_ages: { column: population_ages, type: text, about: "Age band of population_band" }
+    users_approximate: { column: users_approximate, type: boolean, about: "The user count splits an age group, or assumes ages 15 to 64 because the survey age was not recorded" }
   pages:
     - { label: the map of users, path: map/users/ }

docs/DATA-MAINTENANCE.md +6 −4
@@ -357,8 +357,10 @@ group) and are never part of the score.
   period, age band, number of users). Rebuilt by `python3 scripts/extract-unodc.py use` from
   the UNODC spreadsheet kept beside it (`unodc-wdr2026-1.2-prevalence-national.xlsx`, World
-  Drug Report 2026 annex, table 1.2), with the World Bank's population aged 15–64 fetched when
-  it runs. `source` says where each row comes from: `wdr2026`, `earlier` (a figure the table
-  lacks, kept from Wikipedia's list of older UNODC figures) or `estimate` (regional median). It
-  feeds the Use views of the map, the rankings and the profiles, so every country file needs a
+  Drug Report 2026 annex, table 1.2). Each survey rate is applied to a World Bank population of
+  the same age band: five-year age groups, split evenly where the band cuts through a group.
+  A survey with no recorded age, and a regional median, use ages 15–64. `estimate` is that
+  median computed here, not a regional total from UNODC. `source` says where each row comes
+  from: `wdr2026`, `earlier` (a figure the table lacks, kept from Wikipedia's list of older
+  UNODC figures) or `estimate`. It feeds the Use views of the map, the rankings and the profiles, so every country file needs a
   row; the build stops if one is missing. A new country gets one when the script runs again.
 - `unodc-cannabis-use-youth.csv`: past-year cannabis use among 15- and 16-year-olds, from

scripts/extract-unodc.py +144 −19
@@ -19,7 +19,11 @@ use       table 1.2, sheet "Cannabis": past-year cannabis use, every survey for
                     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
-            users   the share applied to the population aged 15 to 64 from the World Bank
-                    (fetched when the script runs), to two significant figures
-          Places the World Bank does not list keep the population the file already had.
+            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
+                    significant figures. A band that cuts through a group is split evenly
+                    and marked users_approximate. A survey with no recorded age, and a
+                    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.
 
 youth     table 1.4, sheet "Cannabis": cannabis use among 15- and 16-year-olds, every survey
@@ -41,4 +45,5 @@ an alias can be added below. Run from the project root.
 import csv, glob, json, re, statistics, sys, unicodedata, urllib.request, zipfile
 import xml.etree.ElementTree as ET
+from concurrent.futures import ThreadPoolExecutor
 
 NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
@@ -165,16 +170,99 @@ def two_figures(n):
 
 
-def world_bank_15_64():
-    url = "https://api.worldbank.org/v2/country/all/indicator/SP.POP.1564.TO?format=json&per_page=20000&date=2015:2030"
+def world_bank_latest(code):
+    """iso2 → (year, people), the indicator's most recent value for each country"""
+    url = f"https://api.worldbank.org/v2/country/all/indicator/{code}?format=json&mrnev=1&per_page=20000"
     with urllib.request.urlopen(url, timeout=120) as res:
-        data = json.load(res)[1]
+        rows = json.load(res)[1] or []
     latest = {}
-    for r in data:
-        cid = r["country"]["id"].lower()
-        if r["value"] is not None and (cid not in latest or int(r["date"]) > latest[cid][0]):
-            latest[cid] = (int(r["date"]), int(r["value"]))
+    for r in rows:
+        if r["value"] is None:
+            continue
+        latest[r["country"]["id"].lower()] = (int(r["date"]), int(round(r["value"])))
     return latest
 
 
+def world_bank_15_64():
+    return world_bank_latest("SP.POP.1564.TO")
+
+
+# Five-year groups, then 80+. The last group is open-ended; a survey that stops inside it
+# is split across ages 80–99.
+AGE_GROUPS = [
+    (0, 4, "SP.POP.0004"), (5, 9, "SP.POP.0509"), (10, 14, "SP.POP.1014"),
+    (15, 19, "SP.POP.1519"), (20, 24, "SP.POP.2024"), (25, 29, "SP.POP.2529"),
+    (30, 34, "SP.POP.3034"), (35, 39, "SP.POP.3539"), (40, 44, "SP.POP.4044"),
+    (45, 49, "SP.POP.4549"), (50, 54, "SP.POP.5054"), (55, 59, "SP.POP.5559"),
+    (60, 64, "SP.POP.6064"), (65, 69, "SP.POP.6569"), (70, 74, "SP.POP.7074"),
+    (75, 79, "SP.POP.7579"), (80, None, "SP.POP.80UP"),
+]
+OPEN_SPAN = 20
+
+
+def world_bank_age_groups():
+    """iso2 → (year, [people in each AGE_GROUPS band]). A country missing any group is left out."""
+    codes = [stem + sex for _, _, stem in AGE_GROUPS for sex in (".MA", ".FE")]
+    with ThreadPoolExecutor(max_workers=8) as pool:
+        fetched = dict(zip(codes, pool.map(world_bank_latest, codes)))
+    out = {}
+    ids = set()
+    for series in fetched.values():
+        ids.update(series)
+    for cid in ids:
+        counts, years, missing = [], [], False
+        for _, _, stem in AGE_GROUPS:
+            male, female = fetched[stem + ".MA"].get(cid), fetched[stem + ".FE"].get(cid)
+            if not male or not female:
+                missing = True
+                break
+            counts.append(male[1] + female[1])
+            years.extend((male[0], female[0]))
+        if not missing:
+            out[cid] = (min(years), counts)
+    return out
+
+
+def parse_ages(text):
+    """'15-64' → (15, 64); '15+' → (15, None). None when the text is not an age band."""
+    text = (text or "").strip()
+    plus = re.fullmatch(r"(\d+)\s*\+", text)
+    if plus:
+        return int(plus.group(1)), None
+    span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", text)
+    if span:
+        lo, hi = int(span.group(1)), int(span.group(2))
+        if lo <= hi:
+            return lo, hi
+    return None
+
+
+def band_population(counts, lo, hi):
+    """People from age lo through hi (hi None means no upper limit). The second value is true
+    when a five-year group, or the open 80+ group, had to be split."""
+    total, approximate = 0.0, False
+    for (g0, g1, _), n in zip(AGE_GROUPS, counts):
+        if hi is not None and g0 > hi:
+            break
+        if g1 is not None and g1 < lo:
+            continue
+        if g1 is None:
+            if hi is None or hi >= 80 + OPEN_SPAN - 1:
+                total += n
+            elif hi >= 80:
+                total += n * (hi - 80 + 1) / OPEN_SPAN
+                approximate = True
+            continue
+        start, end = max(lo, g0), g1 if hi is None else min(hi, g1)
+        if start > end:
+            continue
+        span, cover = g1 - g0 + 1, end - start + 1
+        if cover == span:
+            total += n
+        else:
+            total += n * cover / span
+            approximate = True
+    return int(round(total)), approximate
+
+
 def use():
     rows = read_sheet("datasets/unodc-wdr2026-1.2-prevalence-national.xlsx", "Cannabis")
@@ -208,5 +296,7 @@ def use():
         if r["users"] or r["country"] not in before:
             before[r["country"]] = r
+    print("fetching World Bank populations…", file=sys.stderr)
     wb = world_bank_15_64()
+    groups = world_bank_age_groups()
     out = {}
     for cid in files:
@@ -218,21 +308,22 @@ def use():
                    "ages": old.get("ages", ""), "unodc_name": "", "source": "earlier"}
         else:
-            row = {"source": "estimate"}
+            row = {"source": "estimate", "ages": ""}
         if cid in wb:
             row["population_year"], row["population_15_64"] = wb[cid]
         elif old.get("population_15_64"):
             row["population_year"], row["population_15_64"] = old.get("population_year", ""), int(old["population_15_64"])
-        elif old and float(old["prevalence"]) > 0:
-            # the population the old users figure was worked out from
+        elif old.get("users") and old.get("prevalence") and float(old["prevalence"]) > 0:
+            # only a fallback for the 15–64 reference: the previous file applied every rate to that band
             row["population_year"], row["population_15_64"] = "", int(round(int(old["users"]) / (float(old["prevalence"]) / 100)))
         else:
             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
+    # 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.
     for cid, row in out.items():
         if row["source"] == "estimate":
             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)
… 59 more lines
src/components/CountryArticle.astro +9 −7
@@ -165,8 +165,10 @@ const culture = cultureOf(c);
     {use && (
       <dl class="c-use">
-        <div>
-          <dt>Cannabis users</dt>
-          <dd>{use.estimate ? "about " : ""}{people(use.users)}</dd>
-        </div>
+        {use.users != null && (
+          <div>
+            <dt>Cannabis users</dt>
+            <dd>{use.estimate || use.users_approximate ? "about " : ""}{people(use.users)}</dd>
+          </div>
+        )}
         <div>
           <dt>Share aged {useAges(use)}</dt>
@@ -174,11 +176,11 @@ const culture = cultureOf(c);
         </div>
         <p>{use.estimate
-          ? `An estimate: there is no national survey, so the median rate of surveyed countries in ${c.region} is assumed.`
-          : `People who used cannabis in the past year, from a ${use.period ?? use.year} survey applied to today's population${use.source === "earlier" ? ". An earlier figure: UNODC's 2026 table has none for this country" : ""}.`}</p>
+          ? `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>
         <SourceTip>{use.source === "wdr2026"
           ? <><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>, annex table 1.2</>
           : 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></>
-            : <>The median of surveyed countries in {c.region}, from <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</>}; users from the population aged 15–64 (<a href="https://data.worldbank.org/indicator/SP.POP.1564.TO" target="_blank" rel="noopener noreferrer">World Bank</a>). <a href={`${base}sources/#dataset-cannabis_use`}>The whole dataset →</a></SourceTip>
+            : <>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 +20 −13
@@ -62,7 +62,11 @@ const count = (fn) => countries.filter(fn).length;
 // 48,000,000 → "48M", 770,000 → "770k"
 const compact = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)}M` : n >= 1e3 ? `${+(n / 1e3).toPrecision(2)}k` : String(n));
-const useNote = (c) => c.use.estimate
-  ? `Estimate: no national survey, so the regional median of ${c.use.prevalence}% of people aged 15–64 is assumed`
-  : `${c.use.prevalence}% of people aged ${useAges(c.use)} used cannabis in the past year (${c.use.period ?? c.use.year} survey)`;
+const useNote = (c) => {
+  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;
+  if (c.use.users == null) return `${c.use.prevalence}% of people aged ${ages} (${when}). No user count: the population for that age band is missing.`;
+  if (c.use.users_approximate) return `About ${compact(c.use.users)}: ${c.use.prevalence}% aged ${ages} (${when}), on an approximate population of those ages.`;
+  return `${c.use.prevalence}% of people aged ${ages} used cannabis in the past year (${when} survey)`;
+};
 const stats = [
   [places.length, "territories scored"],
@@ -97,5 +101,6 @@ const unitData = Object.fromEntries(countries.flatMap((c) => {
 // Cannabis use on the map: one hue from light to dark, in five steps. Each step starts at `min`.
 const USE_SCALES = {
-  users: { label: "Cannabis users", steps: [[0, "Under 10k"], [1e4, "10k–100k"], [1e5, "100k–1M"], [1e6, "1M–10M"], [1e7, "10M or more"]] },
+  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." },
   prevalence: { label: "Share who use cannabis", steps: [[0, "Under 1%"], [1, "1–3%"], [3, "3–7%"], [7, "7–15%"], [15, "15% or more"]] },
   ratio: {
@@ -295,5 +300,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). Figures marked ~ are regional estimates."}</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>
           </div>
         ))}
@@ -385,10 +390,10 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
             {places.map((c) => (
               <tr data-id={c.id} data-rank={c.rank ?? c.worldRank - 0.5} data-territory={c.rank ? undefined : ""} data-name={c.name} data-region={c.region}
-                  data-rec={REC_ORDER.indexOf(c.rec)} data-med={MED_ORDER.indexOf(c.med)} data-reckey={c.rec} data-users={c.use.users} data-share={c.use.prevalence} data-price={SHOW_PRICES ? streetFlower(c)?.usd ?? -1 : undefined}
+                  data-rec={REC_ORDER.indexOf(c.rec)} data-med={MED_ORDER.indexOf(c.med)} data-reckey={c.rec} data-users={c.use.users ?? -1} data-share={c.use.prevalence} data-price={SHOW_PRICES ? streetFlower(c)?.usd ?? -1 : undefined}
                   {...Object.fromEntries(EXTRA_COLUMNS.map((col) => [`data-${col.key}`, col.sort(c)]))}>
                 <td class="num">{c.rank ?? "–"}</td>
                 <td><a class="cname" href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span>{c.name}{trendOf(c) && ((t) => <span class={`trend ${t.delta > 0 ? "up" : "down"}`} title={`${t.delta > 0 ? "+" : "−"}${Math.abs(t.delta)} ${Math.abs(t.delta) === 1 ? "point" : "points"} since ${t.since}. Latest change: ${t.last.title} (${changeDate(t.last)})`}>{t.delta > 0 ? "▲" : "▼"}{Math.abs(t.delta)}</span>)(trendOf(c))}</a>{c.territory && <span class="tag-disputed" title={`${c.territory.kind}. ${c.territory.note}`}>{c.territory.tag}</span>}{estimateNote(c) && <span class="tag-disputed" title={estimateNote(c)}>national estimate</span>}</td>
                 <td class="hide-sm" data-col="region">{c.region}</td>
-                <td class="num-col hide-md" data-col="users" title={useNote(c)}>{c.use.estimate && <span class="approx">~</span>}{compact(c.use.users)}</td>
+                <td class="num-col hide-md" data-col="users" title={useNote(c)}>{(c.use.estimate || c.use.users_approximate) && c.use.users != null && <span class="approx">~</span>}{c.use.users != null ? compact(c.use.users) : "—"}</td>
                 <td class="num-col hide-md" data-col="share" title={useNote(c)}>{c.use.estimate && <span class="approx">~</span>}{c.use.prevalence.toFixed(1)}%</td>
                 {SHOW_PRICES && <td class="num-col hide-md" data-col="price">{streetFlower(c) ? `$${streetFlower(c).usd.toFixed(2)}` : "—"}</td>}
@@ -606,4 +611,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
       const steps = USE_SCALES[metric].steps;
       const v = c.use[metric];
+      if (v == null || Number.isNaN(v)) return "url(#hatch)";
       let i = 0;
       while (i < steps.length - 1 && v >= steps[i + 1][0]) i++;
@@ -922,14 +928,15 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
             + (c.atlas.length ? `<ul class="tip-list">${c.atlas.map((t) => `<li>${esc(t)}</li>`).join("")}${k > c.atlas.length ? `<li class="more">and ${k - c.atlas.length} more</li>` : ""}</ul>` : "") + foot(c);
         }
-        const est = c.use.estimate ? "~" : "";
+        const rateMark = c.use.estimate ? "~" : "";
+        const usersText = c.use.users == null ? "—" : `${c.use.estimate || c.use.users_approximate ? "~" : ""}${compact(c.use.users)}`;
         if (metric === "ratio") {
-          return head(c.flag, c.name, `${est}${c.use.ratio}`, useColor(c))
-            + row(`Share aged ${c.use.ages}`, `${est}${c.use.prevalence}%`) + row("Index", `${c.score} / 100`)
+          return head(c.flag, c.name, `${rateMark}${c.use.ratio}`, useColor(c))
+            + row(`Share aged ${c.use.ages}`, `${rateMark}${c.use.prevalence}%`) + row("Index", `${c.score} / 100`)
             + para(USE_SCALES.ratio.note) + foot(c);
         }
         if (isUse()) {
-          return head(c.flag, c.name, metric === "users" ? `${est}${compact(c.use.users)}` : `${est}${c.use.prevalence}%`, useColor(c))
-            + row("Users", `${est}${compact(c.use.users)}`) + row(`Share aged ${c.use.ages}`, `${est}${c.use.prevalence}%`)
-            + row(c.use.estimate ? "Source" : "Survey year", c.use.estimate ? "regional estimate" : c.use.period ?? c.use.year)
+          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)
             + (c.use.source === "earlier" ? row("Source", "earlier figure, not in UNODC's 2026 table") : "") + foot(c);
         }

src/lib/map-views.js +1 −1
@@ -19,5 +19,5 @@ export const MAP_VIEWS = [
   })),
   { slug: "users", metric: "users", title: "Cannabis users by country",
-    lede: "How many people used cannabis in the past year, country by country. The stronger the blue, the more users." },
+    lede: "How many people used cannabis in the past year. Each survey rate is applied to a population of the same age band. The stronger the blue, the more users." },
   { slug: "share-of-adults", metric: "prevalence", title: "Share of adults who use cannabis, by country",
     lede: "The share of adults who used cannabis in the past year, from each country's latest national survey (most ask people aged 15 to 64). The stronger the blue, the higher the share." },

    ↑↓ to move, ↵ to open.