Cannabis Freedom Index

Changelog 9484b6e

more charts

14 datasets updated14 files of code, design or docs +39,744 −373 lines

Datasets 14

Code, design and documents 14 files

datasets/datasets.yaml +630 −0
@@ -1024,2 +1024,632 @@
   pages:
     - { label: the Charts page, path: charts/#state-merchant }
+- id: espad_lifetime
+  file: espad-cannabis-lifetime-trend.csv
+  name: Students who have tried cannabis (Europe)
+  publisher: ESPAD Group and the European Union Drugs Agency (EUDA)
+  credit: ESPAD
+  title: ESPAD Report 2024 — source tables, figure 32, lifetime use of cannabis by country, 1995–2024
+  url: https://www.espad.org/espad-report-2024
+  data_url: https://www.euda.europa.eu/system/files/documents/2025-10/espad-2024-report-source-tables.xlsx.zip
+  data_label: source tables, spreadsheet (figure 32)
+  when: 1995 to 2024, every four or five years
+  about: >-
+    The per cent of students aged 15 to 16 who have ever used cannabis, in each wave of the
+    European School Survey Project on Alcohol and Other Drugs from 1995 to 2024, from its trend
+    database. Germany's figures are for Bavaria only. Moldova, Montenegro and North Macedonia held
+    their 2007 wave in 2008.
+  origin: >-
+    The EUDA's site refuses scripted downloads, so the spreadsheet of source tables was opened in
+    a browser on 22 October 2026 and figure 32 was read from it and written here, one row per
+    country and wave, rounded to one decimal.
+  notes:
+    de: Bavaria only, the one German state in the ESPAD trend database.
+  profile:
+    headline: "{lifetime} of students have tried it"
+    details: "aged 15–16 · {year}"
+    each: "{lifetime} in {year}"
+    years: [1999, 2011]
+  country: { column: country, by: id }
+  year: { column: year }
+  fields:
+    lifetime: { column: lifetime, format: percent, about: "Students aged 15–16 who have ever used cannabis: map, Charts page and profile" }
+  map:
+    field: lifetime
+    format: percent
+    group: Use
+    since: 2024
+    slug: students-tried-cannabis
+    label: Students who have tried cannabis
+    missing: Not in the 2024 ESPAD school survey
+    steps: [[0, "Under 6%"], [6, "6–10%"], [10, "10–14%"], [14, "14–18%"], [18, "18% or more"]]
+    title: Students who have tried cannabis, Europe
+    lede: >-
+      The share of students aged 15 to 16 who have ever used cannabis, from the 2024 ESPAD school
+      survey. The stronger the blue, the more of them have tried it. The time control goes back
+      to 1995.
+    sub: >-
+      The stronger the blue, the more students aged 15 to 16 have tried cannabis. Countries
+      outside the ESPAD survey are hatched.
+    note: >-
+      ESPAD trend database 1995–2024, figure 32 of the 2024 report's source tables. Germany is
+      Bavaria only. Not part of the score.
+  pages:
+    - { label: its map, path: map/students-tried-cannabis/ }
+    - { label: the Charts page, path: charts/#teen-trends }
+- id: gallup_us
+  file: gallup-us-legalization.csv
+  name: Should marijuana be legal? (United States)
+  publisher: Gallup
+  title: Gallup — "Grassroots Support for Legalizing Marijuana Hits Record 70%" (8 November 2023), trend 1969–2023
+  url: https://news.gallup.com/poll/514007/grassroots-support-legalizing-marijuana-hits-record.aspx
+  data_label: the chart's data table
+  when: 1969 to 2023
+  about: >-
+    The per cent of American adults who answer yes to "Do you think the use of marijuana should be
+    made legal, or not?", in every Gallup poll that asked it from 1969 to 2023. Two polls were
+    taken in 2019.
+  origin: >-
+    Read on 22 October 2026 from the data table behind the trend chart of Gallup's article and
+    written here unchanged, with the date of each poll.
+  profile:
+    headline: "{legal} say it should be legal"
+    details: "Gallup poll of {date}"
+    each: "{legal} in {year}"
+    years: [1969, 2000]
+  country: { column: country, by: id }
+  year: { column: year }
+  fields:
+    legal: { column: legal, format: percent, about: "Adults who say marijuana should be legal: Charts page and profile" }
+    date: { column: date, type: text, format: text }
+  pages:
+    - { label: the Charts page, path: charts/#opinion-gap }
+- id: alcohol
+  file: owid-alcohol-consumption.csv
+  name: Alcohol drunk per person
+  publisher: World Health Organization, via Our World in Data
+  credit: WHO, Our World in Data
+  title: Our World in Data — Total alcohol consumption per capita (litres of pure alcohol), from the WHO Global Health Observatory
+  url: https://ourworldindata.org/grapher/total-alcohol-consumption-per-capita-litres-of-pure-alcohol
+  data_url: https://ourworldindata.org/grapher/total-alcohol-consumption-per-capita-litres-of-pure-alcohol.csv?v=1&csvType=full&useColumnShortNames=true
+  data_label: full data (CSV)
+  when: 2000 to 2022
+  about: >-
+    The WHO's estimate of the litres of pure alcohol drunk in a year per person aged 15 or over,
+    recorded sales and unrecorded alcohol together. A legal drug, for comparison with an illegal
+    one.
+  origin: >-
+    Downloaded on 22 October 2026 by scripts/fetch-open-data.py (alcohol), which keeps the
+    countries with a country file.
+  profile:
+    headline: "{litres} litres of pure alcohol a year"
+    details: "per person aged 15 or over · {year}"
+    each: "{litres} in {year}"
+    years: [2000, 2010]
+  country: { column: country, by: id }
+  year: { column: year }
+  fields:
+    litres: { column: litres, format: decimal, about: "Litres of pure alcohol per person aged 15+: map, Charts page and profile" }
+  map:
+    field: litres
+    format: decimal
+    group: Use
+    since: 2015
+    slug: alcohol
+    label: Alcohol drunk per person
+    missing: No WHO estimate since 2015
+    steps: [[0, "Under 2 litres"], [2, "2–5"], [5, "5–8"], [8, "8–11"], [11, "11 litres or more"]]
+    title: How much alcohol people drink
+    lede: >-
+      Litres of pure alcohol drunk in a year per person aged 15 or over, the WHO's estimate. The
+      stronger the blue, the more people drink: a legal drug, beside an illegal one.
+    sub: >-
+      The stronger the blue, the more litres of pure alcohol per person aged 15 or over.
+    note: >-
+      WHO Global Health Observatory, via Our World in Data; recorded and unrecorded alcohol. Not
+      part of the score.
+  pages:
+    - { label: its map, path: map/alcohol/ }
+    - { label: the Charts page, path: charts/#alcohol }
+- id: government_effectiveness
+  file: wb-government-effectiveness.csv
+  name: Government effectiveness
+  publisher: World Bank
+  title: World Bank — Worldwide Governance Indicators, government effectiveness (GOV_WGI_GE.EST and GOV_WGI_GE.SC)
+  url: https://www.worldbank.org/en/publication/worldwide-governance-indicators
+  data_url: https://api.worldbank.org/v2/country/all/indicator/GOV_WGI_GE.EST?format=json&per_page=20000&source=3
+  data_label: World Bank API
+  when: 1996 to 2025
+  about: >-
+    How well a state works, by the World Bank's estimate: the quality of its public services and
+    civil service, and how far it can carry out the policies it makes. An estimate from about
+    −2.5 to 2.5, and the same on a scale of 0 to 100. A measure of capacity, not of freedom.
+  origin: >-
+    Downloaded on 22 October 2026 by scripts/fetch-open-data.py (governance) from the World Bank's
+    API, which keeps the countries with a country file.
+  profile:
+    headline: "{score} of 100"
+    details: "estimate {effectiveness} on a scale of −2.5 to 2.5 · {year}"
+    each: "{score} in {year}"
+    years: [2000, 2015]
+  country: { column: country, by: id }
+  year: { column: year }
+  fields:
+    score: { column: score, decimals: 0, about: "Government effectiveness on a scale of 0 to 100: map, Charts page and profile" }
+    effectiveness: { column: effectiveness, format: decimal }
+  map:
+    field: score
+    format: number
+    group: Society
… 474 more lines
scripts/fetch-open-data.py +316 −4
@@ -1,6 +1,18 @@
-"""Fetches open, non-governmental datasets from their public APIs into CSVs the site can read.
+"""Fetches open datasets from their public APIs into CSVs the site can read.
 
-  python3 scripts/fetch-open-data.py gbif      # records of the cannabis plant, by country and year
-  python3 scripts/fetch-open-data.py openalex  # research papers about cannabis, by country and year
+  python3 scripts/fetch-open-data.py gbif        # records of the cannabis plant, by country and year
+  python3 scripts/fetch-open-data.py openalex    # research papers about cannabis, by country and year
+  python3 scripts/fetch-open-data.py alcohol     # alcohol drunk per person (WHO, via Our World in Data)
+  python3 scripts/fetch-open-data.py governance  # government effectiveness (World Bank WGI)
+  python3 scripts/fetch-open-data.py minwage     # monthly minimum wages in euros (Eurostat)
+  python3 scripts/fetch-open-data.py corruption  # Corruption Perceptions Index (Transparency International, via OWID)
+  python3 scripts/fetch-open-data.py religion    # the most common religion (Pew, via OWID)
+  python3 scripts/fetch-open-data.py gdp         # GDP per person at purchasing power parity (World Bank)
+  python3 scripts/fetch-open-data.py wikipedia   # monthly readers of the cannabis article, by language
+  python3 scripts/fetch-open-data.py development # internet, towns, schooling, age and health (World Bank)
+  python3 scripts/fetch-open-data.py books       # how often books name cannabis, by language (Google Books Ngram)
+  python3 scripts/fetch-open-data.py trials      # clinical trials of cannabis medicines, by country and year
+  python3 scripts/fetch-open-data.py parties     # political parties founded to legalise cannabis (Wikidata)
+  python3 scripts/fetch-open-data.py news        # the share of each country's news that mentions cannabis (GDELT)
 
 gbif      GBIF occurrence search, genus Cannabis (all its species and synonyms), faceted by the
@@ -13,4 +25,50 @@ openalex  OpenAlex works whose title or abstract mention cannabis, marijuana, ma
           1950 to last year. A paper counts once for each country among its authors. Writes
           datasets/openalex-cannabis-papers.csv.
+alcohol   Our World in Data's chart "Total alcohol consumption per capita", the WHO's estimate of
+          litres of pure alcohol drunk per person aged 15 or over in a year, recorded and
+          unrecorded. Writes datasets/owid-alcohol-consumption.csv, every year of every country.
+governance  The World Bank's Worldwide Governance Indicators, government effectiveness: an
+          estimate from about -2.5 to 2.5 of the quality of public services and of the state's
+          ability to carry out its policies, and the same on a scale of 0 to 100, every year from 1996.
+          Writes datasets/wb-government-effectiveness.csv.
+minwage   Eurostat table earn_mw_cur, the national monthly minimum wage in euros, twice a year
+          (January and July); countries without a national minimum wage have no figure. Writes
+          datasets/eurostat-minimum-wages.csv with the January figure of each year.
+corruption  Transparency International's Corruption Perceptions Index, 0 (highly corrupt) to 100
+          (very clean), as Our World in Data republishes it, every year from 2012. Writes
+          datasets/owid-corruption-perceptions.csv.
+religion  Pew Research Center's estimate of the religious group with the largest share of each
+          country's people in 2010 and 2020, as Our World in Data republishes it. Writes
+          datasets/owid-most-common-religion.csv.
+gdp       The World Bank's GDP per person at purchasing power parity, in constant 2021
+          international dollars (NY.GDP.PCAP.PP.KD), every year from 1990. Writes
+          datasets/wb-gdp-per-person.csv.
+wikipedia Monthly page views by people (not bots) of the Wikipedia article on cannabis as a drug,
+          in the languages spoken mainly in one country, from July 2015, from the Wikimedia
+          pageviews API; the article in each language is the one the English "Cannabis (drug)"
+          links to. Writes datasets/wikipedia-cannabis-views.csv with the country, the month (also as
+          a fraction of a year, `time`), the language, the article and each month's views.
+development  Five World Bank indicators, every year from 1990: the share of people using the
+          internet (IT.NET.USER.ZS), living in towns and cities (SP.URB.TOTL.IN.ZS) and aged 65 or
+          over (SP.POP.65UP.TO.ZS), life expectancy at birth (SP.DYN.LE00.IN) and the gross
+          enrolment ratio in higher education (SE.TER.ENRR). Writes
+          datasets/wb-development.csv, one row per country and year with any of them.
+books     Google Books Ngram Viewer, the share of all words in each year's printed books taken by
+          each name of the drug, in six languages, 1800 to 2022, unsmoothed, per billion words.
+          Worldwide: books are counted by language, not by country. Writes
+          datasets/ngram-cannabis-words.csv.
+trials    ClinicalTrials.gov (API v2): every registered study whose intervention mentions cannabis,
+          cannabidiol, THC, dronabinol, nabilone, nabiximols or a cannabinoid, counted once for
+          each country where it has a site, by the year it started, with the running total; studies that have not started yet
+          are left out. Writes
+          datasets/clinicaltrials-cannabis.csv.
+parties   Wikidata: political parties whose name or ideology mentions cannabis, with their country
+          and the year they were founded (and dissolved, if they were). Writes
+          datasets/wikidata-cannabis-parties.csv, one row per party.
+news      The GDELT Project's DOC 2.0 API: for the online news published in each country, the
+          share of articles that mention cannabis or marijuana (GDELT matches the words in 65
+          languages through its own translation), day by day from 2017, averaged here by year (`days` is how many days a year has).
+          Countries are asked for by name, one every ten seconds and after a minute's wait when
+          GDELT refuses, so a run takes about forty minutes. Writes datasets/gdelt-cannabis-news.csv.
 The OpenStreetMap count of cannabis shops is made by scripts/osm-shops.mjs, which needs the
 map's country shapes.
@@ -90,5 +148,259 @@ def openalex():
 
 
-TASKS = {"gbif": gbif, "openalex": openalex}
+def iso3_ids():
+    """three-letter code → country file name, from the World Bank's list of economies"""
+    ids, alpha2 = our_ids(), {}
+    for c in get("https://api.worldbank.org/v2/country?format=json&per_page=400")[1]:
+        if c["iso2Code"] in ids:
+            alpha2[c["id"]] = ids[c["iso2Code"]]
+    return alpha2
+
+
+def alcohol():
+    ids = iso3_ids()
+    url = "https://ourworldindata.org/grapher/total-alcohol-consumption-per-capita-litres-of-pure-alcohol.csv?v=1&csvType=full&useColumnShortNames=true"
+    with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": AGENT}), timeout=180) as res:
+        lines = res.read().decode("utf-8").splitlines()
+    rows = [[ids[r["code"]], int(r["year"]), r["sh_alc_pcap_li"]] for r in csv.DictReader(lines) if r["code"] in ids and r["sh_alc_pcap_li"]]
+    rows.sort()
+    write("datasets/owid-alcohol-consumption.csv", ["country", "year", "litres"], rows)
+
+
+def governance():
+    ids = iso3_ids()
+    rows = {}
+    for indicator, col in [("GOV_WGI_GE.EST", 0), ("GOV_WGI_GE.SC", 1)]:
+        url = f"https://api.worldbank.org/v2/country/all/indicator/{indicator}?format=json&per_page=20000&source=3"
+        for r in get(url)[1]:
+            cid = ids.get(r["countryiso3code"])
+            if cid and r["value"] is not None:
+                rows.setdefault((cid, int(r["date"])), [None, None])[col] = round(r["value"], 3)
+    write("datasets/wb-government-effectiveness.csv", ["country", "year", "effectiveness", "score"],
+          sorted([cid, year, *v] for (cid, year), v in rows.items()))
+
+
+def minwage():
+    ids = our_ids() | {"EL": "gr", "UK": "gb"}
+    d = get("https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/earn_mw_cur?format=JSON&lang=EN&currency=EUR")
+    geo, time_ = d["dimension"]["geo"]["category"]["index"], d["dimension"]["time"]["category"]["index"]
+    rows = []
+    for g, gi in geo.items():
+        for t, ti in time_.items():
+            v = d["value"].get(str(gi * len(time_) + ti))
+            if g in ids and t.endswith("S1") and v is not None:
+                rows.append([ids[g], int(t[:4]), v])
+    rows.sort()
+    write("datasets/eurostat-minimum-wages.csv", ["country", "year", "monthly_eur"], rows)
+
+
+def owid_csv(slug):
+    url = f"https://ourworldindata.org/grapher/{slug}.csv?v=1&csvType=full&useColumnShortNames=true"
+    with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": AGENT}), timeout=180) as res:
+        return list(csv.DictReader(res.read().decode("utf-8").splitlines()))
+
+
+def corruption():
+    ids = iso3_ids()
+    rows = sorted([ids[r["code"]], int(r["year"]), r["cpi_score"]] for r in owid_csv("ti-corruption-perception-index") if r["code"] in ids and r["cpi_score"])
+    write("datasets/owid-corruption-perceptions.csv", ["country", "year", "cpi"], rows)
+
+
+def religion():
+    ids = iso3_ids()
+    rows = sorted([ids[r["code"]], int(r["year"]), r["most_popular_religion"]] for r in owid_csv("most-common-religion") if r["code"] in ids and r["most_popular_religion"])
+    write("datasets/owid-most-common-religion.csv", ["country", "year", "religion"], rows)
+
+
+def gdp():
+    ids = iso3_ids()
+    rows = []
+    for r in get("https://api.worldbank.org/v2/country/all/indicator/NY.GDP.PCAP.PP.KD?format=json&per_page=20000&date=1990:2030")[1]:
+        cid = ids.get(r["countryiso3code"])
+        if cid and r["value"] is not None:
+            rows.append([cid, int(r["date"]), round(r["value"])])
+    write("datasets/wb-gdp-per-person.csv", ["country", "year", "gdp_ppp"], sorted(rows))
+
+
+DEVELOPMENT = {"internet": "IT.NET.USER.ZS", "urban": "SP.URB.TOTL.IN.ZS", "aged_65": "SP.POP.65UP.TO.ZS",
+               "life_expectancy": "SP.DYN.LE00.IN", "higher_education": "SE.TER.ENRR"}
+
+
+def development():
+    ids = iso3_ids()
+    cells = {}
+    for k, (name, code) in enumerate(DEVELOPMENT.items()):
+        for r in get(f"https://api.worldbank.org/v2/country/all/indicator/{code}?format=json&per_page=20000&date=1990:2030")[1]:
… 175 more lines
source-archive.json +12 −12

Not shown: a picture, a generated file, or past the length shown per change.

src/components/CountryDatasetTab.astro +8 −76
@@ -6,56 +6,14 @@
 // dataset covers; and links to its map and its entry on the sources page.
 import { DATASETS, datasetsOf } from "../lib/datasets.js";
-import { places } from "../lib/countries.js";
 import { viewByMetric } from "../lib/map-views.js";
+import { figure as figureOf, fill as fillOf, headline as headlineOf, headField, TONES, POLARITY, toneAt, CHART_OF, GROUPS, groupOf, standing as standingOf } from "../lib/dataset-text.js";
 
 const { c } = Astro.props;
 const base = import.meta.env.BASE_URL;
 const own = datasetsOf(c.slug);
-const num = (n) => n.toLocaleString("en-GB");
-const FORMAT = {
-  number: (v) => num(v),
-  percent: (v) => `${v}%`,
-  decimal: (v) => v.toFixed(2),
-  rank: (v, d) => `#${v} of ${d.total}`,
-  text: (v, d, f) => f.values?.[v] ?? String(v).replace(/(\d)-(\d)/g, "$1–$2"),
-};
-// one figure of a row, written out; undefined when the row lacks it
-const figure = (d, row, name) => {
-  if (name === "per") return d.map?.per ? d.map.format(d.map.value(row, c)) : undefined;
-  const f = d.fields[name];
-  if (!f) return row[name] != null ? String(row[name]) : undefined; // a series' year
-  return row[name] == null ? undefined : FORMAT[f.format ?? (f.type === "text" ? "text" : "number")](row[name], d, f);
-};
-// a template such as "{users} people · survey {period}": each part whose figures are all there
-const fill = (d, row, template = "") => template.split(" · ").map((part) => {
-  let missing = false;
-  const text = part.replace(/\{(\w+)\}/g, (_, name) => figure(d, row, name) ?? ((missing = true), ""));
-  return missing ? null : text.trim();
-}).filter(Boolean).join(" · ");
-// the headline's first figure, with any sign before it ("€"), is set large: [figure, the words after it]
-const headline = (d, row) => {
-  const m = d.profile.headline.match(/^([^{]*)\{(\w+)\}(.*)$/);
-  return m ? [m[1] + (figure(d, row, m[2]) ?? ""), fill(d, row, m[3])] : ["", fill(d, row, d.profile.headline)];
-};
-// What a higher figure means for people who use cannabis, for the colours: 1 freer (green, as the
-// index's free end), -1 harsher (red, as its prohibition end), with the words for the two ends of
-// the scale; a dataset not listed (use, research, hemp …) is neither, and takes the map's blue scale.
-const TONES = {
-  hfi: [1, "Less free", "Freer"], civil_liberties: [1, "Fewer liberties", "More liberties"],
-  osm_shops: [1, "Fewer shops", "More shops"], canada_sourcing: [1, "Less legal", "More legal"],
-  pew_marijuana: [-1, "Fewer disapprove", "More disapprove"], police_possession: [-1, "Fewer", "More"],
-  cannabis_offences: [-1, "Fewer", "More"], seizures: [-1, "Less seized", "More seized"],
-};
-const POLARITY = Object.fromEntries(Object.entries(TONES).map(([id, [sign]]) => [id, sign]));
-// a position from 0 (lowest) to 1 (highest) as one of the index's colours, --t1 (harsh) to --t5 (free)
-const toneAt = (at, polarity) => `var(--t${1 + Math.round((polarity > 0 ? at : 1 - at) * 4)})`;
-// the chart on the Charts page that uses the dataset
-const CHART_OF = {
-  cannabis_use: "use", pew_marijuana: "opinion", cannabis_offences: "punished", prices_europe: "price", potency_europe: "thc-per-euro",
-  hemp: "hemp", research_papers: "research", hfi: "freedom", espad_availability: "easy-to-get", wastewater: "wastewater",
-  colonial_links: "empire", canada_sourcing: "sourcing", canada_revenue: "state-merchant", population: "people",
-};
-// the field a headline is about
-const headField = (d) => (d.profile.headline.match(/\{(\w+)\}/) ?? [])[1];
+// the shared wording, bound to this country
+const figure = (d, row, name) => figureOf(c, d, row, name);
+const fill = (d, row, template) => fillOf(c, d, row, template);
+const headline = (d, row) => headlineOf(c, d, row);
 const mapHref = (d) => (d.map ? `${base}map/${viewByMetric[`data:${d.id}`].slug}/` : (d.pages ?? []).filter((p) => p.path.startsWith("map/")).map((p) => `${base}${p.path}`)[0]);
 
@@ -79,31 +37,5 @@ const trend = (d, series) => {
 };
 
-// Where the country stands among every place the dataset covers, by its headline figure (the
-// latest year of a series): the share of them with a lower figure. Null for text and ranks.
-const standing = (d) => {
-  const name = headField(d), f = d.fields[name];
-  if (!name || (f && (f.type === "text" || f.format === "rank"))) return null;
-  const valueOf = (place) => {
-    const data = d.values[place.slug];
-    if (!data) return null;
-    const row = Array.isArray(data) ? data.findLast((r) => r[name] != null || name === "per") : data;
-    if (!row) return null;
-    return name === "per" ? d.map.value(row, place) : row[name];
-  };
-  const all = places.filter((x) => !x.territory).map(valueOf).filter((v) => Number.isFinite(v));
-  const mine = valueOf(c);
-  const mapValue = (() => { if (!d.map) return null; const data = d.values[c.slug]; const row = Array.isArray(data) ? data.findLast((r) => r[d.map.field] != null) : data; return row ? d.map.value(row, c) : null; })();
-  if (!Number.isFinite(mine) || all.length < 5) return null;
-  const below = all.filter((v) => v < mine).length, same = all.filter((v) => v === mine).length;
-  const at = (below + same / 2) / all.length;
-  // its colour step, 0 to 4, as on the dataset's map view (--u0…--u4); without a map, its fifth of the countries
-  let step = Math.min(4, Math.floor(at * 5));
-  if (Number.isFinite(mapValue)) { step = 0; while (step < 4 && mapValue >= d.map.steps[step + 1][0]) step++; }
-  return { at, n: all.length, below: Math.round((below / all.length) * 100), step, label: d.map ? d.map.steps[step][1] : null };
-};
-const GROUPS = ["Use", "Law", "Society", "Context"];
-// datasets without a map view of their own sit with the views they belong to
-const GROUP_OF = { cannabis_use: "Use", youth_use: "Use", canada_sourcing: "Use", canada_revenue: "Society", colonial_links: "Society" };
-const groupOf = (d) => GROUP_OF[d.id] ?? d.map?.group ?? "Context";
+const standing = (d) => standingOf(c, d);
 // a long series is drawn as a line, its years shown only under the pointer
 const MANY = 12;
@@ -147,8 +79,8 @@ const groups = GROUPS.map((name) => ({ name, rows: rows.filter((r) => groupOf(r.
                   </svg>
                   {chart.points.map((p, i) => (
-                    <span class:list={["ds-spark-dot", { last: i === chart.points.length - 1 }]} style={`left:${(p.x / W) * 100}%;top:${(p.y / H) * 100}%`} tabindex="0" aria-label={`${p.t} in ${p.year}`}><em>{p.t} · {p.year}</em></span>
+                    <span class:list={["ds-spark-dot", { last: i === chart.points.length - 1 }]} style={`left:${(p.x / W) * 100}%;top:${(p.y / H) * 100}%`} tabindex="0" aria-label={`${p.t} in ${p.year}`}><em>{p.t} · {Number.isInteger(p.year) ? p.year : Math.floor(p.year)}</em></span>
                   ))}
                   </div>
-                  <figcaption><span>{chart.y0}</span><span>{chart.y1}</span></figcaption>
+                  <figcaption><span>{Math.floor(chart.y0)}</span><span>{Math.floor(chart.y1)}</span></figcaption>
                 </figure>
               )}

src/components/HomePage.astro +17 −2
@@ -874,6 +874,21 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
             : key === "police_possession"
             ? row("Per 100,000 people", d.t, dataColor(c)) + row("People", d.persons.toLocaleString("en-GB")) + row("Year", d.year) + `<div class="tip-meta">All drugs together; countries count differently</div>`
-            : row("Human freedom", `${d.human.toFixed(2)} / 10`, dataColor(c)) + (d.human_rank ? row("Rank", `#${d.human_rank} of ${scale.total}`) : "")
-              + (d.personal != null ? row("Personal freedom", `${d.personal.toFixed(2)} / 10`) : "") + (d.economic != null ? row("Economic freedom", `${d.economic.toFixed(2)} / 10`) : "");
+            : key === "hfi"
+            ? row("Human freedom", `${d.human.toFixed(2)} / 10`, dataColor(c)) + (d.human_rank ? row("Rank", `#${d.human_rank} of ${scale.total}`) : "")
+              + (d.personal != null ? row("Personal freedom", `${d.personal.toFixed(2)} / 10`) : "") + (d.economic != null ? row("Economic freedom", `${d.economic.toFixed(2)} / 10`) : "")
+            : key === "wastewater"
+            ? row("THC-COOH a day per 1,000 people", `${d.t} mg`, dataColor(c)) + (d.cities ? row("Cities measured", d.cities) : "") + row("Year", d.year)
+            : key === "minimum_wage"
+            ? row("Minimum wage", `€${d.t} a month`, dataColor(c)) + row("January", d.year)
+            : key === "alcohol"
+            ? row("Pure alcohol a year", `${d.t} litres`, dataColor(c)) + `<div class="tip-meta">Per person aged 15 or over</div>` + row("Year", d.year)
+            : key === "government_effectiveness"
+            ? row("Government effectiveness", `${d.t} of 100`, dataColor(c)) + (d.effectiveness != null ? row("Estimate, −2.5 to 2.5", d.effectiveness.toFixed(2)) : "") + row("Year", d.year)
+            : key === "espad_availability"
+            ? row("Say it is easy to get", d.t, dataColor(c)) + (d.easy_boys != null ? row("Boys", `${d.easy_boys}%`) : "") + (d.easy_girls != null ? row("Girls", `${d.easy_girls}%`) : "") + `<div class="tip-meta">Students aged 15–16, 2024</div>`
+            : key === "espad_lifetime"
+            ? row("Have tried cannabis", d.t, dataColor(c)) + `<div class="tip-meta">Students aged 15–16</div>` + row("Survey", d.year)
+            // any other dataset: its map figure, and its year when it has one
+            : row(esc(scale.label), d.t, dataColor(c)) + (d.year != null ? row("Year", d.year) : "");
           return head(c.flag, c.name, d.t, dataColor(c)) + rows + row("Cannabis index", `${c.score} / 100 · ${rankText(c)}`) + foot(c);
         }

src/content.config.ts +4 −2
@@ -280,5 +280,6 @@ const datasets = defineCollection({
       rail: z.boolean().default(false), // also shown in the profile's side column
       // how it reads in the profile's Datasets tab: {field} templates, see datasets.yaml
-      profile: z.object({ headline: z.string().min(1), details: z.string().optional(), years: z.array(z.number().int()).optional(), each: z.string().optional() }).strict(),
+      // optional for a worldwide dataset (no `country`), which no profile shows
+      profile: z.object({ headline: z.string().min(1), details: z.string().optional(), years: z.array(z.number().int()).optional(), each: z.string().optional() }).strict().optional(),
       origin: z.string().min(1), // how the file was made: downloaded as published or transcribed, and when
       // the publisher's file (or files) an extract was made from
@@ -294,5 +295,6 @@ const datasets = defineCollection({
       about: z.string().min(1),
       notes: z.record(z.string(), z.string()).default({}), // by country file name
-      country: z.object({ column: z.string().min(1), by: z.enum(["id", "name"]), aliases: z.record(z.string(), z.string()).default({}) }).strict(),
+      // which column names the country; left out for a worldwide dataset, whose rows are kept as they are
+      country: z.object({ column: z.string().min(1), by: z.enum(["id", "name"]), aliases: z.record(z.string(), z.string()).default({}) }).strict().optional(),
       year: z.object({ column: z.string().min(1) }).strict().optional(), // a column with the year of each row
       fields: z.record(z.string(), column),

src/lib/borders.js +20 −0
@@ -0,0 +1,20 @@
+// Land neighbours: two countries are neighbours when their shapes on the site's world map
+// (world-atlas, Natural Earth 1:50m) share a border line. Sea borders do not count.
+import { neighbors } from "topojson-client";
+import worldText from "world-atlas/countries-50m.json?raw";
+import { countries } from "./countries.js";
+
+const atlas = JSON.parse(worldText);
+const geoms = atlas.objects.countries.geometries;
+const byNum = new Map(countries.filter((c) => c.num != null).map((c) => [String(+c.num), c]));
+
+// country id → the ids of its land neighbours
+export const neighboursOf = new Map(countries.map((c) => [c.id, []]));
+neighbors(geoms).forEach((list, i) => {
+  const a = byNum.get(String(+geoms[i].id));
+  if (!a) return;
+  for (const j of list) {
+    const b = byNum.get(String(+geoms[j].id));
+    if (b && b !== a && !neighboursOf.get(a.id).includes(b.id)) neighboursOf.get(a.id).push(b.id);
+  }
+});

src/lib/dataset-text.js +86 −0
@@ -0,0 +1,86 @@
+// How the outside datasets read for one country: the figures written out as datasets.yaml sets
+// under `profile`, the headline figure over the years, where the country stands among all the
+// countries a dataset covers, and which group and colours it takes. Shared by the profile's
+// Datasets tab (CountryDatasetTab.astro) and the compare page's data (src/pages/api/[id].json.js).
+import { places } from "./countries.js";
+
+const num = (n) => n.toLocaleString("en-GB");
+const FORMAT = {
+  number: (v) => num(v),
+  percent: (v) => `${v}%`,
+  decimal: (v) => v.toFixed(2),
+  rank: (v, d) => `#${v} of ${d.total}`,
+  text: (v, d, f) => f.values?.[v] ?? String(v).replace(/(\d)-(\d)/g, "$1–$2"),
+};
+// one figure of a row, written out; undefined when the row lacks it
+export const figure = (c, d, row, name) => {
+  if (name === "per") return d.map?.per ? d.map.format(d.map.value(row, c)) : undefined;
+  const f = d.fields[name];
+  if (!f) return row[name] != null ? String(row[name]) : undefined; // a series' year
+  return row[name] == null ? undefined : FORMAT[f.format ?? (f.type === "text" ? "text" : "number")](row[name], d, f);
+};
+// a template such as "{users} people · survey {period}": each part whose figures are all there
+export const fill = (c, d, row, template = "") => template.split(" · ").map((part) => {
+  let missing = false;
+  const text = part.replace(/\{(\w+)\}/g, (_, name) => figure(c, d, row, name) ?? ((missing = true), ""));
+  return missing ? null : text.trim();
+}).filter(Boolean).join(" · ");
+// the headline's first figure, with any sign before it ("€"), is set large: [figure, the words after it]
+export const headline = (c, d, row) => {
+  const m = d.profile.headline.match(/^([^{]*)\{(\w+)\}(.*)$/);
+  return m ? [m[1] + (figure(c, d, row, m[2]) ?? ""), fill(c, d, row, m[3])] : ["", fill(c, d, row, d.profile.headline)];
+};
+// the field a headline is about
+export const headField = (d) => (d.profile.headline.match(/\{(\w+)\}/) ?? [])[1];
+// the number a headline field stands for, for charts and comparisons
+export const valueOf = (c, d, row, name = headField(d)) => (name === "per" ? d.map.value(row, c) : row?.[name]);
+
+// What a higher figure means for people who use cannabis, for the colours: 1 freer (green, as the
+// index's free end), -1 harsher (red, as its prohibition end), with the words for the two ends of
+// the scale; a dataset not listed (use, research, hemp …) is neither, and takes the map's blue scale.
+export const TONES = {
+  hfi: [1, "Less free", "Freer"], civil_liberties: [1, "Fewer liberties", "More liberties"],
+  osm_shops: [1, "Fewer shops", "More shops"], canada_sourcing: [1, "Less legal", "More legal"],
+  pew_marijuana: [-1, "Fewer disapprove", "More disapprove"], police_possession: [-1, "Fewer", "More"],
+  cannabis_offences: [-1, "Fewer", "More"], seizures: [-1, "Less seized", "More seized"],
+};
+export const POLARITY = Object.fromEntries(Object.entries(TONES).map(([id, [sign]]) => [id, sign]));
+// a position from 0 (lowest) to 1 (highest) as one of the index's colours, --t1 (harsh) to --t5 (free)
+export const toneAt = (at, polarity) => `var(--t${1 + Math.round((polarity > 0 ? at : 1 - at) * 4)})`;
+// the chart on the Charts page that uses the dataset
+export const CHART_OF = {
+  cannabis_use: "use", pew_marijuana: "opinion", cannabis_offences: "punished", prices_europe: "price", potency_europe: "thc-per-euro",
+  hemp: "hemp", research_papers: "research", hfi: "freedom", espad_availability: "easy-to-get", wastewater: "wastewater",
+  colonial_links: "empire", canada_sourcing: "sourcing", canada_revenue: "state-merchant", population: "users-treemap",
+  espad_lifetime: "teen-trends", alcohol: "alcohol", government_effectiveness: "state-capacity", minimum_wage: "work-for-a-gram",
+  corruption: "corruption", religion: "religion", gdp: "what-goes-with-it", development: "what-goes-with-it",
+  wikipedia_views: "looked-it-up", clinical_trials: "trials", cannabis_parties: "parties", treatment: "punish-or-treat",
+  gallup_us: "opinion-gap", news: "in-the-news",
+};
+export const GROUPS = ["Use", "Law", "Society", "Context"];
+// datasets without a map view of their own sit with the views they belong to
+const GROUP_OF = { cannabis_use: "Use", youth_use: "Use", canada_sourcing: "Use", canada_revenue: "Society", colonial_links: "Society", religion: "Society", gallup_us: "Society", wikipedia_views: "Society", cannabis_parties: "Society" };
+export const groupOf = (d) => GROUP_OF[d.id] ?? d.map?.group ?? "Context";
+
+// Where the country stands among every place the dataset covers, by its headline figure (the
+// latest year of a series): the share of them with a lower figure. Null for text and ranks.
+export const standing = (c, d) => {
+  const name = headField(d), f = d.fields[name];
+  if (!name || (f && (f.type === "text" || f.format === "rank"))) return null;
+  const latest = (place) => {
+    const data = d.values[place.slug];
+    if (!data) return null;
+    const row = Array.isArray(data) ? data.findLast((r) => r[name] != null || name === "per") : data;
+    return row ? valueOf(place, d, row, name) : null;
+  };
+  const all = places.filter((x) => !x.territory).map(latest).filter((v) => Number.isFinite(v));
+  const mine = latest(c);
+  const mapValue = (() => { if (!d.map) return null; const data = d.values[c.slug]; const row = Array.isArray(data) ? data.findLast((r) => r[d.map.field] != null) : data; return row ? d.map.value(row, c) : null; })();
+  if (!Number.isFinite(mine) || all.length < 5) return null;
+  const below = all.filter((v) => v < mine).length, same = all.filter((v) => v === mine).length;
+  const at = (below + same / 2) / all.length;
+  // its colour step, 0 to 4, as on the dataset's map view (--u0…--u4); without a map, its fifth of the countries
+  let step = Math.min(4, Math.floor(at * 5));
+  if (Number.isFinite(mapValue)) { step = 0; while (step < 4 && mapValue >= d.map.steps[step + 1][0]) step++; }
+  return { at, n: all.length, below: Math.round((below / all.length) * 100), step, label: d.map ? d.map.steps[step][1] : null };
+};

src/lib/datasets.js +7 −2
@@ -59,5 +59,5 @@ const loaded = (await getCollection("datasets")).flatMap(({ data: def }) => {
     return [];
   }
-  const values = {}, missed = [], places = new Set();
+  const values = {}, missed = [], places = new Set(), worldRows = [];
   const fileInfo = { files: [], originals: originalsOf(def).map((name) => ({ name, ...fileStats(fs.readFileSync(`datasets/${name}`)) })) };
   for (const { file, edition } of files) {
@@ -70,4 +70,9 @@ const loaded = (await getCollection("datasets")).flatMap(({ data: def }) => {
     fileInfo.files.push({ name: file, edition, ...fileStats(raw), rows: rows.length, columns: Object.keys(rows[0] ?? {}).length,
       years: years.length ? [Math.min(...years), Math.max(...years)] : null });
+    // a worldwide dataset: every row as it is, with its fields read and its year
+    if (!def.country) {
+      for (const r of rows) worldRows.push({ ...Object.fromEntries(Object.entries(def.fields).map(([name, { column, type }]) => [name, type === "text" ? r[column] : r[column] === "" ? undefined : +r[column]])), ...(yearOf(r) != null && { year: yearOf(r) }) });
+      continue;
+    }
     for (const r of rows) {
       const key = r[def.country.column];
@@ -93,5 +98,5 @@ const loaded = (await getCollection("datasets")).flatMap(({ data: def }) => {
   const allYears = fileInfo.files.flatMap((f) => f.years ?? []);
   fileInfo.years = allYears.length ? [Math.min(...allYears), Math.max(...allYears)] : null;
-  return [{ ...def, files: fileInfo.files.map((f) => f.name), values, fileInfo, count: Object.keys(values).length, total: places.size,
… 4 more lines
src/pages/api/[id].json.js +21 −2

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/charts.astro +1316 −239

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/compare.astro +149 −15

Not shown: a picture, a generated file, or past the length shown per change.

src/pages/sources/index.astro +3 −3

Not shown: a picture, a generated file, or past the length shown per change.

src/styles/global.css +247 −16

Not shown: a picture, a generated file, or past the length shown per change.

    ↑↓ to move, ↵ to open.