Changelog 9484b6e
more charts
14 datasets updated14 files of code, design or docs +39,744 −373 lines
Datasets 14
- datasets/clinicaltrials-cannabis.csv added, 417 rows
- datasets/espad-cannabis-lifetime-trend.csv added, 232 rows
- datasets/euda-cannabis-treatment.csv added, 29 rows
- datasets/eurostat-minimum-wages.csv added, 777 rows
- datasets/gallup-us-legalization.csv added, 28 rows
- datasets/ngram-cannabis-words.csv added, 5,352 rows
- datasets/owid-alcohol-consumption.csv added, 3,891 rows
- datasets/owid-corruption-perceptions.csv added, 2,286 rows
- datasets/owid-most-common-religion.csv added, 382 rows
- datasets/wb-development.csv added, 7,632 rows
- datasets/wb-gdp-per-person.csv added, 6,959 rows
- datasets/wb-government-effectiveness.csv added, 5,351 rows
- datasets/wikidata-cannabis-parties.csv added, 22 rows
- datasets/wikipedia-cannabis-views.csv added, 3,536 rows
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¤cy=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.