Cannabis Freedom Index

Changelog f3a0ef9

yaay

55 history entries added37 history entries edited2 scores changed90 articles written33 files of code, design or docs +14,177 −1,433 lines

Score changes 2 places

  1. Chile2426+2enforcement 3 → 4
  2. Bosnia and Herzegovina1211−1access 1 → 0

History changes.yaml

Added 55

Edited 37

Chile lets trafficking penalties reach small amounts

date

2026-0405-23

description

A reform of Law 2021.000817, passed by Congressthe Chamber of Deputies on 23 March by 130 votes to 12 and in force since Aprilpublished on 23 May, shifts the law's test from the quantity seizedadds a rule to the potential harm of the substanceLaw 20.000: trafficking penalties can apply to small amountsquantities of substances that a drug judgedregulation lists as capable of serious harm are punished as trafficking. The ministry has six months to write that list. Cannabis is not named, a class in whichbut the regulations place cannabis. On 2 Mayexisting decree classes it with the Suprememost harmful drugs, and 37 deputies challenged the rule at the Constitutional Court still downgraded one micro-trafficking case to personal use for lack of proof of intentthat reason.

before

possession 3 · cultivation 2 · enforcement 4 · sharing 1 · consumption 3 · access 1

sources

Diario Oficial de la República de Chile — Ley Núm. 21.817 (23 May 2026) Cámara de Diputadas y Diputados — Respaldan agravantes a ley sobre tráfico de estupefacientes (23 March 2026) 1 unchanged

Czechia legalises possession and home growing

description

AAct No. 270/2025, a reform of the criminal -code reform, took effect on 1 January. Adults 21 and over may grow three plants and keep 100 g at home or 25 g in public; before, small amounts were only an administrative offence. Four or five plants, or up to twice those amounts, is an administrative offence, and more is a crime. Shops and clubs are still not allowed.

Bosnia and Herzegovina approves cannabis-based medicines

date

2025-12-29

description

Medical use was approvedThe Council of Ministers unanimously moved cannabis, with the Agency for Medicinesits resin, extracts and Medical Devices overseeing import and distribution. The rules allow only pharmaceutical preparationstinctures from the table of prohibited substances to the table of substances under strict control, so that cannabis-based medicines can be prescribed by a doctor; growing. Growing cannabis for medicine stays prohibited. Prescribing depends on rulebooks that had still not been amended in July 2026, so no patient had been treated and the score has not moved.

before

possession 1 · cultivation 1 · enforcement 2 · sharing 1 · consumption 1 · access 0

sources

Paragraf.ba — Council of Ministers amends the list of narcotic drugs to allow medical cannabis (31 December 2025) Paragraf.ba — Deadline agreed for amending the rulebook on cannabis-based medicines (14 July 2026) 1 unchanged

Czechia raises the hemp limit to 1% THC

description

Cannabis with up to 1% THC, upThe amendment raised the threshold for technical hemp from 0.3% to 1% THC, stopped counting as a controlled drug. It is the highest limit in the European Unionremoved its permit requirement and opened the waysimplified proof of compliance for low-THC flower and extractsgrowers. Farms larger than 100 square metres still had to be sold freelyreport specified information to customs.

The United Kingdom allows medical cannabis

before

possession 1 · cultivation 1 · enforcement 3 · sharing 1 · consumption 1 · access 01

sources

Forces of Necessity: Lay Advocacy and the Remedicalization of Cannabis in the UK, 1973–2004 2 unchanged

Uruguay becomes the first country to legalise

description

President Mujica signed Law 19.172, passed by the Senate on 10 December by 16 votes to 13, which put the whole chain under state control. Registered residents choose one of three ro … o 40 g a month in pharmacies. Possession for personal use had not been a crime since 1974.

Switzerland introduces a CHF 100 fine

description

Under an amendment passed in 2012, adults caught using cannabis with up to 10 g on them pay a fixed CHF 100 fine and are not prosecutedinstead of entering ordinary criminal proceedings. Courts later held that simply possessing up toThe amendment also defined 10 g as a minor quantity; courts later clarified that possession alone within the limit is not punishable at all.

sources

Swiss Federal Council — Fixed fine for cannabis consumption Federal Office of Public Health — Evaluation of fixed fines for cannabis consumption ch.ch (Swiss authorities) — Cannabis Wikipedia — Cannabis in Switzerland

Mexico stops prosecuting up to 5 g

sources

Diario Oficial de la Federación — Drug-law reform decree of 20 August 2009 CMS Expert Guide — Cannabis law and legislation in Mexico Wikipedia — Cannabis in Mexico

Germany's Constitutional Court limits prosecution of small amounts

description

The court upheld the ban on cannabis but said that cases involving a small amount for occasional personal use and no danger to others should generally not be prosecuted. Each state then set its own threshold, mostly around 6 gIt required enforcement authorities in the states to apply that principle in an essentially uniform way.

sources

Federal Constitutional Court — Order of 9 March 1994, 2 BvL 43/92 and others Wikipedia — Cannabis in Germany Wikipedia (German) — Cannabis-Beschluss

France makes private use a crime

sources

Légifrance — Law No. 70-1320 of 31 December 1970 CNCDH — Opinion on drug use and human rights Wikipedia — Cannabis in France

Nigeria's military government issues the Indian Hemp Decree

description

Months after taking power, the military government replaced colonial-era fines with some of the harshest penalties in the world: at least ten years in prisonthe death penalty for possessioncultivation, and death or 21 years for growingimportation, and severe penalties for use and possession. The death penalty was later removed, but long mandatory sentences remain.

sources

The Origins of Cannabis Prohibition in Nigeria and the Indian Hemp Decree of 1966 1 unchanged

Iran declares cannabis illegal

description

The government declared cannabis an illegal substance inIn May 1959, aheadthe government added cannabis and its derivatives to the list of signingillegal drugs and applied the 1961 Single Convention. The same source says a 1926 penal law had already set prison terms and finessanctions used for cannabis use, so the first restriction may be olderopium.

sources

High on Modernity: Cannabis and the Quest for the Sublime in Iran Sensi Seeds — Cannabis in Iran: laws, use and history

checked

true

Afghanistan adopts another nominal cannabis ban

title

Afghanistan makesadopts another nominal cannabis nominally illegalban

description

The banA 1957 measure, reportedly adopted under American pressure, was not enforced, and hashish stayed openly available until the crackdown of 1973. It was not the country's first legal restriction: the 1924–1925 penal code had already forbidden domestic production and use.

sources

Smuggler's Paradise: The Hash Trade and Drug Control in the Building of the Afghan State, ca. 1923–1974 1 unchanged

The Netherlands bans possession of cannabis

description

Import and export had been restricted since 1928. An amendment to the Opium Act now made possessing, producing and, selling and using cannabis offences too, with a maximum four-year prison term, just over twenty years before the country became known for tolerating it.

sources

Cannabis, Counterculture, and Criminals: The Rise of Smuggling in the Netherlands, ca. 1962–1976 1 unchanged

Mauritius prohibits cannabis again

category

otherban

title

Mauritius rewrites its Dangerous Drugs Ordinanceprohibits cannabis again

description

Ordinance No. 55 of 1950 regulated the importationprohibited buying, exportationselling, manufacturesupplying, sale and use of opium and other dangerous drugs. It definedprocuring, distributing or possessing gandia as bhang, hashish and all narcotic parts of the Cannabis sativa plantcharras, and hashishseparately prohibited planting, growing or charras as its resincultivating Cannabis sativa. This ended the legal regime created in 1883.

before

possession 10 · cultivation 1 · enforcement 10 · sharing 10 · consumption 10 · access 8

Mexico suspends its health-based drug rule

description

Five months after it began, theMexico suspended a regulation that had let doctors prescribe drugscontrolled doses to users was suspended under pressure frompeople dependent on drugs. The programme principally supplied morphine; the United States, which had stopped exports of medical narcotics to Mexicocontemporary cannabis proposals described in the historical literature never became law. Possession went back to being a crime.

before

possession 3 · cultivation 1 · enforcement 4 · sharing 1 · consumption 3 · access 2

sources

Reefer Madness Past and Present: Dr. Leopoldo Salazar Viniegra, Mexico, and the United States 2 unchanged

Mexico briefly treats drug use as a health matter

description

Under President Cárdenas a new federal regulation let doctors prescribe drugscontrolled doses to userspeople dependent on drugs and set upcreated a state dispensaries, taking users out of criminal lawmorphine programme. It did not legalise cannabis: the historian Isaac Campos writes that the government's marijuana initiative never advanced beyond research and publicity. The United States responded by cutting off medical drug exports, andpressured Mexico suspendedto suspend the rule within months.

before

possession 1 · cultivation 1 · enforcement 2 · sharing 1 · consumption 1 · access 0

sources

Reefer Madness Past and Present: Dr. Leopoldo Salazar Viniegra, Mexico, and the United States 2 unchanged

Nigeria passes the Dangerous Drugs Act

category

banother

description

The British colonial government replaced its 1927 Dangerous Drugs Ordinance with a slightly amended law. The earlier ordinance had already restricted cannabis for the first time, with finesuse and short prison termstrade to medical and scientific purposes; the 1935 version remained in force until the late 1980s.

before

possession 10 · cultivation 10 · enforcement 10 · sharing 10 · consumption 10 · access 8

sources

The Origins of Cannabis Prohibition in Nigeria and the Indian Hemp Decree of 1966 1 unchanged

Canada places cannabis under its narcotics law

title

Canada bansplaces cannabis under its narcotics law

description

Cannabis was added to the schedule of the Opium and Narcotic Drug Act without debate in Parliamentwith almost no parliamentary explanation. NobodyArchival research has not established why: there was almost nonon-medical use was virtually unknown in Canada, and the first seizure was not until 1932considered a problem. Cultivation was prohibited separately in 1938.

sources

Senate of Canada — Cannabis: Our Position for a Canadian Public Policy, Volume II Wikipedia — Cannabis in Canada

Mexico bans marijuana

description

A federal health decree prohibited growing and sellingremoved marijuana from the medicines recognised by the sanitary code and penalised unauthorised sale and possession with a fine or, seventeen years beforeif the United States acted. It had been restrictedfine could not be paid, fifteen days in Mexico City sincejail. Its title also placed cultivation under the 1880snew national policy.

before

possession 10 · cultivation 10 · enforcement 10 · sharing 10 · consumption 10 · access 81

sources

A Historical Approach to the Criminalization of Marijuana Use in Mexico 1 unchanged

Jamaica passes the Ganja Law

sources

Ganja Madness: Cannabis, Insanity, and Indentured Labor in British Guiana and Trinidad, 1881–1912 1 unchanged

British Guiana regulates the sale of bhang

description

An ordinance to regulateOrdinance No. 22 licensed the sale of opiumganja and bhang restricted. The chapter describes the measure as anticipatory: officials introduced it before cannabis to licensed sellerswas treated as a significant local problem.

sources

Ganja Madness: Cannabis, Insanity, and Indentured Labor in British Guiana and Trinidad, 1881–1912 1 unchanged

Mauritius bans cannabis

description

The British colonyOrdinance No. 2 prohibited cannabis, which had arrived with Indian indentured labourers. It amid official concern that it was among the first colonial bansused in robberies. An 1848 law later added further restrictions on possession of "gandia" and other poisons.

sources

Ganja Madness: Cannabis, Insanity, and Indentured Labor in British Guiana and Trinidad, 1881–1912 2 unchanged

Countries and states 2 files

Bosnia and Herzegovina countries/ba.yaml

access 1 → 0

summary

Illegal; personal possession is generally fined as a misdemeanour while growing and dealing are crimes. SinceA December 2025 doctors can prescribedecision opened the way to cannabis-based pharmaceutical preparationsmedicines, but the prescribing rules it depends on had not been adopted by July 2026.

segments › access › text

No legal source for recreational useyet. SinceOn 29 December 2025 a doctor can prescribethe Council of Ministers moved cannabis, its resin, extracts and tinctures from the table of prohibited substances to the table of substances under strict control, so that cannabis-based pharmaceutical preparationsmedicines can be prescribed. Prescribing still depends on entity rulebooks that had not been amended by July 2026, which are importedand without them the medicines agency cannot send the UN control board the estimate it needs before imports can be approved.

segments › access › sources

gi, wikipara25, para26

context › medical › text

Medical use wasThe Council of Ministers approved inmedical use on 29 December 2025, after a decade of campaigning that began with a state task force in 2016. … are allowed; the Agency for Medicines and Medical Devices oversees import and distribution. Growing, and growing cannabis for medicine remains prohibited. No patient could be treated by July 2026: the rulebook that is to set indications, prescribing doctors, registers and doses had not been amended, and the Federation's health ministry was given until 1 August 2026 to draft the changes.

context › medical › sources

gi, para25, para26, wiki

sources

Paragraf.ba — Council of Ministers amends the list of narcotic drugs to allow medical cannabis (31 December 2025) Paragraf.ba — Deadline agreed for amending the rulebook on cannabis-based medicines (14 July 2026) 5 unchanged

Chile countries/cl.yaml

enforcement 3 → 4

summary

Private personal use is not punishable and courts accept personal cultivation as a defence. Public use is a minor offence. A 2026 reform letslaw could let trafficking penalties reach small amounts once its list of substances is written.

segments › enforcement › text

Private users are not punished, but the lack of thresholds means people are still arrested and charged with micro-trafficking, which carries prison of 541 days to five years. A reform in force since AprilIn May 2026 lets trafficking penalties applythe Supreme Court replaced such a sentence, for 21 g, with a fine, holding that possession alone does not show an intention to deal. Law 21.817, published on 23 May 2026, will punish small amounts when the substance is judgedas trafficking for substances that a regulation lists as capable of serious harm, so quantity protects users less than before. It is under challenge at the Constitutional Court, and in MayThe ministry has until late November 2026 the Supreme Court still required evidence of intent to trafficwrite that list; whether cannabis will be on it had not been settled when this was written, and this score would fall if it is.

segments › enforcement › sources

ep26, bio26, tni, ley, ley21817, dcs26

sources

Diario Oficial de la República de Chile — Ley Núm. 21.817 (23 May 2026) Diario Constitucional — Corte Suprema recalifica microtráfico como consumo personal y sustituye condena por multa (6 May 2026) 8 unchanged

Articles and atlas entries 90

Code, design and documents 33 files

.gitignore +1 −0
@@ -4,2 +4,3 @@ dist/
 .claude/
 .env
+tmp/

README.md +11 −1
@@ -65,5 +65,5 @@ sources:
 ```
 
-Please include a source link. Or just [open an issue](https://github.com/burningtree/cannabis-freedom-index/issues).
+Please include a source link or a complete bibliographic citation. Or just [open an issue](https://github.com/burningtree/cannabis-freedom-index/issues).
 
 ### Contributing flower prices
@@ -80,4 +80,14 @@ example, source-quality rules, currency conversion and validation checklist.
 Scores are editorial estimates, not legal advice. Check current local law before travelling with, buying or growing cannabis.
 
+## Data for research
+
+- `/data/panel.csv`: a score for every place in every year since 1900, with the six segment scores, rebuilt from the history. The `basis` column separates recorded years from assumptions.
+- `/data/history.csv`, `/data/scores.csv`: every history entry with its sources, and today's scores with ISO numeric codes for joining.
+- `source-archive.json`: copies of the sources in the Internet Archive, kept up to date with `node scripts/archive-sources.cjs`.
+
+- `/llms.txt` and `/llms-full.txt`: a guide for language models, and the methodology with every country profile as one Markdown file. Both are generated from the data at build time (`src/lib/llms.js`).
+
+See the [data page](https://cannabisfreedom.fyi/data/) for the columns and how to cite.
+
 ## Licence
 

docs/DATA-MAINTENANCE.md +94 −0
@@ -304,4 +304,27 @@ all read from there, together with `PLANTING_TRADE`.
 ```
 
+Sources do not have to be webpages. For a book or a chapter, omit `url` and give enough
+bibliographic information for a reader to find the passage:
+
+```yaml
+  sources:
+    - type: chapter
+      title: "Ganja Madness: Cannabis, Insanity, and Indentured Labor in British Guiana and Trinidad, 1881–1912"
+      authors: [Jamie Banks]
+      container: "Cannabis: Global Histories"
+      editors: [Lucas Richert, James H. Mills]
+      publisher: MIT Press
+      year: 2021
+      isbn: "9780262366137"
+      locator: "pp. 57–80"
+```
+
+Use `type: book` for a whole book and `type: chapter` for a chapter in an edited volume. The
+other accepted types are `web`, `journal`, `report`, `law` and `other`. `authors`, `editors`,
+`container`, `publisher`, `year`, `isbn` and `locator` are optional, but include every field the
+source provides and make `locator` as precise as practical. Print sources render as citations
+rather than dead links and have no Internet Archive copy. Price observations remain web-only and
+still require a URL.
+
 - **`before`** is the six scores *before* the event. The scores after it are the `before` of the
   same country's next scored entry, or the country's current scores. So the chain must be
@@ -506,2 +529,73 @@ After each pass, say: how many entries were added or corrected, which profiles a
 changed and why, which score timelines were re-based, which claims rest on sources that were read versus only summarised, and what
 could not be confirmed. Nothing is committed or deployed unless the user asks.
+
+## Long reads for history entries
+
+An important history entry can have a full article. The entry in `changes.yaml` stays as it is
+(title, short description, sources); the article is a Markdown file named after the entry's id:
+
+```
+articles/russia-griner-sentence-2022.md
+```
+
+```markdown
+---
+image:                       # optional lead picture
+  src: https://upload.wikimedia.org/…/1280px-Example.jpg   # or a path under /public
+  alt: What the picture shows
+  caption: One line under the picture.
+  credit: Author, licence, via Wikimedia Commons
+  credit_url: https://commons.wikimedia.org/wiki/File:Example.jpg
+model: opus-5.5              # which model wrote it, with its effort where known (gpt-5.6-sol-medium)
+written: 2026-10-11          # the day it was written
+sources:                     # added to the entry's own sources
+  - title: Publisher — headline (date)
+    url: https://…
+  - type: book              # print sources use the same structure as changes.yaml
+    title: Book title
+    authors: [Author Name]
+    publisher: Publisher
+    year: 2021
+    isbn: "9780000000000"
+    locator: "pp. 10–14"
+---
+
+The story in Markdown: paragraphs, `## Subheadings`, `> quotes`, lists, links and pictures.
+```
+
+Rules: write only what the listed sources say. Keep it compact: about 350 to 500 words, a short
+opening, three to five short sections and at most one small table. Side events belong in their
+own `changes.yaml` entries, linked from the article, not narrated in it. A picture is optional and most articles should
+have none: use one only when it shows the subject itself (the person, the place, the object), never
+a parliament or court building as a stand-in. Use pictures whose licence allows it and always
+give the credit; link to country profiles as `/country/ru/` and to other entries as
+`/history/<id>/`. The build fails if the file name matches no entry. Entries with an article get
+an "Article" tag in the history lists.
+
+## Data for research: the yearly panel and archived sources
+
+Two things make the data usable beyond the site.
+
+**The panel** (`/data/panel.csv` and `.json`) is generated at build time from `changes.yaml`: one
+row per place and year since 1900, with the six segment scores and the index score in force on
+31 December. Nothing is maintained by hand. The column `basis` marks each row as `recorded`,
+`interpolated` (inside the window of an estimated first restriction) or `baseline` (before the
+place's first recorded change). It gets better only by improving the history: replace estimates
+with dated entries, and check that every `before` matches the entry that precedes it
+(`node scripts/data-status.cjs chain`). The code is in `src/lib/exports.js`.
+
+**Archived web sources.** `source-archive.json` maps a source address to its copy in the Internet
+Archive. The site shows an "archived" link beside the source on history pages, and the history
+download carries the copies in `source_archive_urls`. Books and other sources without URLs keep
+their bibliographic citation in `source_citations` and are deliberately skipped by the archive
+script. The file is written by a script, never by hand:
+
+```
+node scripts/archive-sources.cjs status      # how many sources have a recorded copy
+node scripts/archive-sources.cjs check 200   # record the copies the Archive already holds
+node scripts/archive-sources.cjs save 20     # ask the Archive to save what it does not hold
+```
+
+The number caps one run. `check` only reads; `save` asks the Internet Archive to fetch the page,
+which it limits to a few requests a minute, so run it in small batches. Run `check` after adding
+sources, and `save` for the ones that come back without a copy.

scripts/archive-sources.cjs +76 −0
@@ -0,0 +1,76 @@
+// Archived copies of the sources: node scripts/archive-sources.cjs [status|check|save] [limit]
+// Run from the project root. Needs js-yaml (already in node_modules) and Node 18+ (fetch).
+//
+//   status  how many source addresses there are and how many have a recorded copy (no network)
+//   check   ask the Internet Archive which sources it already holds, and record those copies
+//   save    ask the Internet Archive to save the sources it does not hold yet, then record them
+//
+// `limit` caps how many addresses one run handles (default 50), so a run stays short and polite;
+// run it again to continue. Results go to source-archive.json, which the site reads:
+//   { "<url>": { "archived": "https://web.archive.org/web/<timestamp>/<url>", "checked": "YYYY-MM-DD" } }
+// An address with no copy is recorded with "archived": null and is tried again after 30 days.
+const fs = require("fs"), path = require("path"), y = require("js-yaml");
+const FILE = "source-archive.json";
+const archive = fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : {};
+const what = process.argv[2] || "status";
+const limit = +process.argv[3] || 50;
+const today = new Date().toISOString().slice(0, 10);
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// every source address in the data: history entries, country profiles and articles
+const urls = new Set();
+for (const e of y.load(fs.readFileSync("changes.yaml", "utf8"))) for (const s of e.sources || []) if (s.url) urls.add(s.url);
+const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((f) => (f.isDirectory() ? walk(path.join(dir, f.name)) : [path.join(dir, f.name)]));
+for (const f of walk("countries").filter((f) => f.endsWith(".yaml"))) {
+  const d = y.load(fs.readFileSync(f, "utf8"));
+  for (const s of Object.values(d.sources || {})) if (s.url) urls.add(s.url);
+  for (const s of d.subunits?.sources || []) if (s.url) urls.add(s.url);
+}
+for (const f of fs.existsSync("articles") ? walk("articles").filter((f) => f.endsWith(".md")) : []) {
+  const head = fs.readFileSync(f, "utf8").match(/^---\n([\s\S]*?)\n---/);
+  for (const s of (head && y.load(head[1])?.sources) || []) if (s.url) urls.add(s.url);
+}
+const all = [...urls].filter((u) => /^https?:\/\//.test(u) && !u.includes("web.archive.org"));
+const stale = (u) => !archive[u] || (!archive[u].archived && Date.now() - Date.parse(archive[u].checked) > 30 * 864e5);
+const have = all.filter((u) => archive[u]?.archived).length;
+console.log(`sources: ${all.length} addresses | with a recorded copy ${have} | without ${all.length - have}`);
+
+// written to a temporary file first and then renamed, so a full disk or a crash cannot leave half a file
+const write = () => {
+  fs.writeFileSync(FILE + ".tmp", JSON.stringify(Object.fromEntries(Object.entries(archive).sort(([a], [b]) => a.localeCompare(b))), null, 1) + "\n");
+  fs.renameSync(FILE + ".tmp", FILE);
+};
+// the newest copy the Internet Archive holds, or null
+async function lookup(url) {
+  const r = await fetch(`https://archive.org/wayback/available?url=${encodeURIComponent(url)}`);
+  if (!r.ok) throw new Error(`availability ${r.status}`);
+  const snap = (await r.json()).archived_snapshots?.closest;
+  return snap?.available && String(snap.status).startsWith("2") ? snap.url.replace(/^http:/, "https:") : null;
+}
+
+(async () => {
+  if (what === "status") return;
+  if (!["check", "save"].includes(what)) return console.log("unknown command: use status, check or save");
+  const todo = all.filter(what === "check" ? stale : (u) => !archive[u]?.archived).slice(0, limit);
+  let found = 0, failed = 0;
+  for (const url of todo) {
+    try {
+      let copy = await lookup(url);
+      if (!copy && what === "save") {
+        // Save Page Now: without an account the Archive accepts only a few requests a minute
+        const r = await fetch(`https://web.archive.org/save/${url}`, { redirect: "follow" });
+        console.log(`  save ${r.status} ${url}`);
+        await sleep(15000);
+        copy = await lookup(url);
+      }
+      archive[url] = { archived: copy, checked: today };
+      if (copy) found++;
+    } catch (e) {
+      failed++;
+      console.log(`  failed ${url}: ${e.message}`);
+    }
+    write();
+    await sleep(what === "check" ? 1200 : 1000);
+  }
+  console.log(`${what}: handled ${todo.length} | copies recorded ${found} | failed ${failed} | still to do ${all.filter(what === "check" ? stale : (u) => !archive[u]?.archived).length}`);
+})();

source-archive.json +5914 −0

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

src/components/Articles.astro +20 −0
@@ -0,0 +1,20 @@
+---
+// The history entries that have a full article, oldest first: the list on /articles/.
+import { changes, articles } from "../lib/countries.js";
+
+const base = import.meta.env.BASE_URL;
+const items = changes.filter((ch) => articles.has(ch.id)).sort((x, y) => x.date.localeCompare(y.date));
+---
+<section class="reads" id="articles">
+  <ol>
+    {items.map((ch) => (
+      <li>
+        <a href={`${base}history/${ch.id}/`}>
+          <time datetime={ch.date}>{ch.date.slice(0, 4)}</time>
+          <h3>{ch.title}</h3>
+          <p>{ch.place.flag} {ch.place.name}{articles.get(ch.id).data.model && <span> · {articles.get(ch.id).data.model}</span>}</p>
+        </a>
+      </li>
+    ))}
+  </ol>
+</section>

src/components/CountryArticle.astro +6 −11
@@ -7,4 +7,5 @@ import CountryChanges from "./CountryChanges.astro";
 import CountryChart from "./CountryChart.astro";
 import PricePanel from "./PricePanel.astro";
+import SourceCitation from "./SourceCitation.astro";
 import { SHOW_PRICES } from "../lib/config.js";
 import { PLANTING_STATUS, STATUS_SECTIONS, PLANTING_TRADE } from "../lib/segment-ids.js";
@@ -161,5 +162,5 @@ const answers = answersFor(v.s);
         <h2 class="d-h">Sources</h2>
         <ul class="srcs">
-          {sub.sources.map(([label, url]) => <li><a href={url} target="_blank" rel="noopener noreferrer">{label}</a></li>)}
+          {sub.sources.map((source) => <SourceCitation source={source} />)}
         </ul>
       </section>
@@ -184,7 +185,5 @@ const answers = answersFor(v.s);
               <p>{seg.t}</p>
               <ul class="srcs">
-                {seg.src.map(([label, url]) => (
-                  <li><a href={url} target="_blank" rel="noopener noreferrer">{label}</a></li>
-                ))}
+                {seg.src.map((source) => <SourceCitation source={source} />)}
               </ul>
             </div>
@@ -211,5 +210,5 @@ const answers = answersFor(v.s);
         {!sub && (
           <ul class="srcs">
-            {group.sources.map(([label, url]) => <li><a href={url} target="_blank" rel="noopener noreferrer">{label}</a></li>)}
+            {group.sources.map((source) => <SourceCitation source={source} />)}
           </ul>
         )}
@@ -238,7 +237,5 @@ const answers = answersFor(v.s);
             <p class="ctx">{x.t}</p>
             <ul class="srcs">
-              {x.src.map(([label, url]) => (
-                <li><a href={url} target="_blank" rel="noopener noreferrer">{label}</a></li>
-              ))}
+              {x.src.map((source) => <SourceCitation source={source} />)}
             </ul>
           </div>
@@ -258,7 +255,5 @@ const answers = answersFor(v.s);
           <h2 class="d-h">All sources</h2>
           <ol>
-            {d.sources.map(([label, url]) => (
-              <li><a href={url} target="_blank" rel="noopener noreferrer">{label}</a></li>
-            ))}
+            {d.sources.map((source) => <SourceCitation source={source} />)}
           </ol>
         </section>

src/components/CountryChanges.astro +12 −13
@@ -1,8 +1,7 @@
 ---
 // A country's recorded changes, newest first, at the end of the country article.
-// Drawn as the same timeline as the History page.
+// The same ledger rows as the History page: date, what happened, and the score before and after.
 import ScoreChange from "./ScoreChange.astro";
-import { changeDate } from "../lib/countries.js";
-import { tierColor } from "../lib/tiers.js";
+import { changeDate, articles } from "../lib/countries.js";
 
 const { history } = Astro.props;
@@ -11,15 +10,15 @@ const base = import.meta.env.BASE_URL;
 <div class="c-changes">
   <h2 class="d-h">History <span>{history.filter((ch) => ch.confidence.key === "checked").length} of {history.length} checked against a source</span></h2>
-  <ol class="chl-list">
+  <ol class="c-ledger">
     {history.map((ch) => (
-      <li class="chl-item" style={`--c:${ch.score ? tierColor(ch.score.after) : "var(--muted)"}`}>
-        <a class="card chl-card" href={`${base}history/${ch.id}/`}>
-          <p class="change-meta">
-            <time datetime={ch.date}>{changeDate(ch)}</time>
-            <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span>
-          </p>
-          <h3><span class="kind" title={ch.kind.label} aria-hidden="true">{ch.kind.emoji}</span><span class="visually-hidden">{ch.kind.label}: </span>{ch.title}</h3>
-          <p class="chl-text">{ch.description}</p>
-          {ch.score && <ScoreChange score={ch.score} />}
+      <li>
+        <a href={`${base}history/${ch.id}/`}>
+          <time datetime={ch.date}>{changeDate(ch)}</time>
+          <div class="he-body">
+            <p class="he-place"><span class="he-kind"><span aria-hidden="true">{ch.kind.emoji}</span> {ch.kind.label}</span> <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span></p>
+            <h3>{ch.title}{articles.has(ch.id) && <span class="he-article">Article</span>}</h3>
+            <p class="he-text">{ch.description}</p>
+          </div>
+          {ch.score ? <ScoreChange score={ch.score} /> : <span></span>}
         </a>
       </li>

src/components/HistoryList.astro +101 −54
@@ -1,8 +1,9 @@
 ---
 // One page of the History list, newest first and grouped by year. It shows either a period
-// (`period`) or every entry of one kind (`kind`, a key of CHANGE_CATEGORIES). The period links
-// and the kind filters above the list lead to the other pages.
+// (`period`) or every entry of one kind (`kind`, a key of CHANGE_CATEGORIES).
+// Top to bottom: the history in numbers, the periods as a small histogram that doubles as
+// navigation, the kinds as filters, then the entries as a ledger with the year set large beside them.
 import ScoreChange from "./ScoreChange.astro";
-import { changes, places, historyPeriods, formatDate, CHANGE_CATEGORIES } from "../lib/countries.js";
+import { changes, articles, places, historyPeriods, formatDate, CHANGE_CATEGORIES } from "../lib/countries.js";
 import { tierColor } from "../lib/tiers.js";
 
@@ -38,12 +39,29 @@ const stats = {
   fewest: places.map((c) => [c, perPlace.get(c) ?? 0]).sort((x, y) => x[1] - y[1] || x[0].name.localeCompare(y[0].name)).slice(0, 8),
 };
+// the tallest period sets the scale of the histogram
+const most = Math.max(...historyPeriods.map((p) => p.items.length));
 ---
-<nav class="periods" aria-label="Periods">
-  {historyPeriods.map((p) => (
-    <a href={href(p)} aria-current={p === period ? "page" : undefined}>{p.label} <small>{p.items.length}</small></a>
+<div class="hist">
+<dl class="hist-stats" aria-label="History in numbers">
+  <div><dt>Entries in total</dt><dd>{num(stats.total)}</dd></div>
+  <div><dt>{kind ? "Of this kind" : "In this period"}</dt><dd>{num(items.length)}</dd></div>
+  <div><dt>Moved a score</dt><dd>{num(stats.scored)}</dd></div>
+  <div><dt>Context only</dt><dd>{num(stats.context)}</dd></div>
+  <div><dt>Countries and territories</dt><dd>{num(stats.places)}</dd></div>
+  <div><dt>Tied to no country</dt><dd>{num(stats.international)}</dd></div>
+</dl>
+<p class="hist-note">{num(stats.checked)} of the {num(dated.length)} dated entries ({Math.round((stats.checked / dated.length) * 100)}%) were checked against a source that was read. Another {num(stats.estimated)} are estimated first bans with no known date; they are not listed here. The oldest entry is from <a href={`${base}history/${stats.oldest.id}/`}>{stats.oldest.date.slice(0, 4)}</a>, the newest from <a href={`${base}history/${stats.newest.id}/`}>{formatDate(stats.newest.date)}</a>.</p>
+
+<nav class="hist-periods" aria-label="Periods">
+  {[...historyPeriods].reverse().map((p) => (
+    <a href={href(p)} aria-current={p === period ? "page" : undefined} title={`${p.label}: ${p.items.length} entries`}>
+      <span class="hp-n">{p.items.length}</span>
+      <i style={`height:${Math.max(3, (p.items.length / most) * 100)}%`}></i>
+      <span class="hp-label">{p.label}</span>
+    </a>
   ))}
 </nav>
 
-<nav class="kinds" aria-label="Filter by kind of event">
+<nav class="hist-kinds" aria-label="Filter by kind of event">
   <a href={`${base}history/`} aria-current={kind ? undefined : "true"}>All kinds</a>
   {Object.entries(CHANGE_CATEGORIES).map(([key, k]) => (
@@ -54,20 +72,30 @@ const stats = {
 </nav>
 
-<div class="change-layout hist-layout">
-<div class="chlog">
+<div class="hist-tools">
+  <div class="hist-order" role="group" aria-label="Order of the entries">
+    <button type="button" data-order="newest" aria-pressed="true">Newest first</button>
+    <button type="button" data-order="oldest" aria-pressed="false">Oldest first</button>
+  </div>
+  <div class="hist-order hist-only" role="group" aria-label="Which entries to show">
+    <button type="button" data-only="all" aria-pressed="true">All entries <small>{items.length}</small></button>
+    <button type="button" data-only="scored" aria-pressed="false">Score changes only <small>{items.filter((ch) => ch.score).length}</small></button>
+  </div>
+</div>
+
+<div class="hist-years" data-order="newest" data-only="all">
   {years.map(({ year, items }) => (
-    <section aria-labelledby={`y${year}`}>
-      <h2 class="chl-year" id={`y${year}`}>{year}</h2>
-      <ol class="chl-list">
+    <section class="hist-year" aria-labelledby={`y${year}`} data-scored={items.some((ch) => ch.score) ? "" : undefined}>
+      <h2 id={`y${year}`}>{year}<small class="n-all">{items.length === 1 ? "1 entry" : `${items.length} entries`}</small><small class="n-scored">{items.filter((ch) => ch.score).length === 1 ? "1 score change" : `${items.filter((ch) => ch.score).length} score changes`}</small></h2>
+      <ol>
         {items.map((ch) => (
-          <li class="chl-item" style={`--c:${ch.score ? tierColor(ch.score.after) : "var(--muted)"}`}>
-            <a class="card chl-card" href={`${base}history/${ch.id}/`}>
-              <p class="change-meta">
-                <time datetime={ch.date}>{formatDate(ch.date)}</time>
-                <span>{ch.place.flag} {ch.place.name} <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span></span>
-              </p>
-              <h3><span class="kind" title={ch.kind.label} aria-hidden="true">{ch.kind.emoji}</span><span class="visually-hidden">{ch.kind.label}: </span>{ch.title}</h3>
-              <p class="chl-text">{ch.description}</p>
-              {ch.score && <ScoreChange score={ch.score} />}
+          <li data-scored={ch.score ? "" : undefined}>
+            <a href={`${base}history/${ch.id}/`}>
+              <time datetime={ch.date}>{ch.date.length === 4 ? "" : formatDate(ch.date).replace(` ${year}`, "")}</time>
+              <div class="he-body">
+                <p class="he-place">{ch.place.flag} {ch.place.name} <span class="he-kind"><span aria-hidden="true">{ch.kind.emoji}</span> {ch.kind.label}</span> <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span></p>
+                <h3>{ch.title}{articles.has(ch.id) && <span class="he-article">Article</span>}</h3>
+                <p class="he-text">{ch.description}</p>
+              </div>
+              {ch.score ? <ScoreChange score={ch.score} /> : <span></span>}
             </a>
           </li>
@@ -78,37 +106,4 @@ const stats = {
 </div>
 
-<aside class="change-aside hist-aside" aria-label="History in numbers">
-  <div class="card ca-card">
-    <h2 class="hs-title">History in numbers</h2>
-    <dl class="hs-grid">
-      <div><dt>Entries in total</dt><dd>{num(stats.total)}</dd></div>
-      <div><dt>{kind ? "Of this kind" : "In this period"}</dt><dd>{num(items.length)}</dd></div>
-      <div><dt>Moved a score</dt><dd>{num(stats.scored)}</dd></div>
-      <div><dt>Context only</dt><dd>{num(stats.context)}</dd></div>
-      <div><dt>Countries and territories</dt><dd>{num(stats.places)}</dd></div>
-      <div><dt>Tied to no country</dt><dd>{num(stats.international)}</dd></div>
-    </dl>
-    <p class="ca-note">{num(stats.checked)} of the {num(dated.length)} dated entries ({Math.round((stats.checked / dated.length) * 100)}%) were checked against a source that was read. Another {num(stats.estimated)} are estimated first bans with no known date; they are not listed here.</p>
-    <p class="ca-note">The oldest entry is from <a href={`${base}history/${stats.oldest.id}/`}>{stats.oldest.date.slice(0, 4)}</a>, the newest from <a href={`${base}history/${stats.newest.id}/`}>{formatDate(stats.newest.date)}</a>.</p>
-  </div>
-  <div class="ca-more hs-top">
-    <h2>Most entries</h2>
-    <ol>
-      {stats.top.map(([c, n]) => (
-        <li><a href={`${base}country/${c.slug}/`}><b>{c.flag} {c.name}</b><time>{n} entries</time></a></li>
-      ))}
-    </ol>
-  </div>
-  <div class="ca-more hs-top">
-    <h2>Fewest entries</h2>
-    <ol>
-      {stats.fewest.map(([c, n]) => (
-        <li><a href={`${base}country/${c.slug}/`}><b>{c.flag} {c.name}</b><time>{n === 1 ? "1 entry" : `${n} entries`}</time></a></li>
-      ))}
-    </ol>
-  </div>
-</aside>
-</div>
-
 {period && <nav class="pager chl-pager" aria-label="Other periods">
   {newer
@@ -119,2 +114,54 @@ const stats = {
     : <span></span>}
 </nav>}
+
+<div class="hist-tops">
+  {[["Most entries", stats.top], ["Fewest entries", stats.fewest]].map(([label, list]) => (
+    <section aria-label={label}>
+      <h2>{label}</h2>
+      <ol>
+        {list.map(([c, n]) => (
+          <li><a href={`${base}country/${c.slug}/`}><span>{c.flag} {c.name}</span><b>{n}</b></a></li>
+        ))}
+      </ol>
+    </section>
+  ))}
+</div>
+</div>
+
+<script>
+  // Reading order: the page is written newest first; "Oldest first" turns the years and the
+  // entries inside each year around, to read the period as it happened. The choice is kept in
+  // this browser, and ?order=oldest in the address asks for it directly.
+  const years = document.querySelector(".hist-years");
+  const buttons = [...document.querySelectorAll(".hist-order button[data-order]")];
+  function setOrder(order) {
+    if (years.dataset.order !== order) {
+      years.append(...[...years.children].reverse());
+      for (const list of years.querySelectorAll("ol")) list.append(...[...list.children].reverse());
+      years.dataset.order = order;
+    }
… 26 more lines
src/components/HomePage.astro +812 −0
@@ -0,0 +1,812 @@
+---
+import Base from "../layouts/Base.astro";
+import DidYouKnow from "../components/DidYouKnow.astro";
+import Changes from "../components/Changes.astro";
+import ProjectNumbers from "../components/ProjectNumbers.astro";
+import AverageChart from "../components/AverageChart.astro";
+import { SEGMENTS, DATA_SNAPSHOT, REC_LABEL, REC_ORDER, REC_HELP, MED_LABEL, MED_HELP, countries, regions, worldAvg, lastUpdated, formatDate, subunitsOf, places, trendOf, changeDate, TREND_YEARS } from "../lib/countries.js";
+import { TIERS, tierColor, tierOf } from "../lib/tiers.js";
+import { PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "../lib/segment-ids.js";
+import { PRICE_MARKETS, PRICE_MARKET_LABEL, flatLatestPricesFor, latestPricesFor } from "../lib/prices.js";
+import { SHOW_PRICES } from "../lib/config.js";
+import { homeSeo } from "../lib/seo.js";
+import { viewByMetric } from "../lib/map-views.js";
+
+// The home page, or, with `view`, just the map on a page of its own (see /map/).
+const { view = null } = Astro.props;
+
+const base = import.meta.env.BASE_URL;
+// where each map option lives: its own page, or the home page for the overall index
+const viewHref = (metric) => `${base}map/${viewByMetric[metric].slug ? viewByMetric[metric].slug + "/" : ""}`;
+const current = view ? view.metric : "index";
+// one option of the map: on the home page it recolours the map in place; on a map page it is a
+// plain link to that view's page
+const viewAttrs = (metric) => ({ href: viewHref(metric), "data-metric": metric, "aria-current": current === metric ? "true" : undefined });
+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 15–64 used cannabis in the past year (${c.use.year} survey)`;
+const stats = [
+  [places.length, "territories scored"],
+  [count((c) => c.raw >= 60), "reach the “Free” tier (60+)"],
+  [count((c) => c.raw >= 40), "score 40+: legal to possess and grow, with limits"],
+  [count((c) => c.raw < 15), "score under 15: prison is a real risk"],
+  [count((c) => c.rec === "death"), "keep the death penalty for cannabis offences", "danger"],
+];
+const legend = [...TIERS].reverse().map((t, i, arr) => ({
+  name: t.name, min: t.min, max: i < arr.length - 1 ? arr[i + 1].min - 1 : 100, i,
+}));
+const MED_ORDER = ["yes", "limited", "no"];
+// What the browser needs for the map: scores only, no prose.
+// `ratio` is the share of people who use cannabis divided by the index score: how far use runs
+// ahead of freedom. A score below 1 counts as 1, so that a zero does not divide.
+const mapData = places.map(({ id, num, name, flag, raw, score, rank, s, use, planting, clubs, driving, visitors, rec, med }) =>
+  ({ id, num, name, flag, raw, score, rank, s, planting, clubs, driving, visitors, rec, med, prices: SHOW_PRICES ? flatLatestPricesFor(id) : {}, use: { ...use, ratio: +(use.prevalence / Math.max(raw, 1)).toFixed(2) } }));
+// States, provinces and territories of the federations, for the map's "show states" switch.
+// Keyed like the shapes in src/data/subunits.geo.json: "us/ca".
+const unitData = Object.fromEntries(countries.flatMap((c) => {
+  const group = subunitsOf(c.id);
+  return group ? group.units.map((u) => [`${c.slug}/${u.id}`, { id: u.id, country: c.id, name: u.name, raw: u.raw, score: u.score, s: u.s, rank: u.rank, of: group.units.length }]) : [];
+}));
+// 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"]] },
+  prevalence: { label: "Share who use cannabis", steps: [[0, "Under 1%"], [1, "1–3%"], [3, "3–7%"], [7, "7–15%"], [15, "15% or more"]] },
+  ratio: {
+    label: "Use relative to freedom",
+    steps: [[0, "Under 0.15"], [0.15, "0.15–0.3"], [0.3, "0.3–0.5"], [0.5, "0.5–1"], [1, "1 or more"]],
+    note: "Share of people aged 15–64 who use cannabis, divided by the index score. A high value means many people use cannabis where the law gives them little freedom to.",
+  },
+};
+const PRICE_SCALE = {
+  steps: [[0, "Under $5/g"], [5, "$5–10/g"], [10, "$10–15/g"], [15, "$15–20/g"], [20, "$20/g or more"]],
+  note: "Latest sourced flower price, normalised to US dollars per gram. Street and medical markets are kept separate.",
+};
+// Seeds and clones on the map: one option per field, coloured by the status' tone.
+const PLANTING_FIELDS = { seeds: "Seeds", seeds_trade: "Seed sales", clones: "Clones", clones_trade: "Clone sales" };
+// Each status gets its own step of the tier colours, so that no two legend entries look alike.
+const PLANTING_COLOR = { legal: 5, licensed: 4, grey: 3, illegal: 0, open: 5, regulated: 4, gift: 3, unclear: 2, banned: 0 };
+const plantingScale = (statuses) => Object.fromEntries(Object.entries(statuses).map(([k, v]) => [k, { label: v.label, color: `var(--t${PLANTING_COLOR[k]})` }]));
+const PLANTING_SCALES = { status: plantingScale(PLANTING_STATUS), trade: plantingScale(PLANTING_TRADE) };
+// Sections with one status (clubs, driving, visitors) on the map: one option each.
+// The two headline statuses of every profile, offered on the map the same way. `complete` marks
+// sections every country has, so their legend needs no "not researched" entry.
+const LAW_SECTIONS = {
+  rec: { name: "Recreational status", complete: true, statuses: Object.fromEntries(REC_ORDER.map((k) => [k, { label: REC_LABEL[k], help: REC_HELP[k] }])),
+    tiers: { legal: 5, partial: 4, decrim: 3, illegal: 1, death: 0 },
+    sub: "Green means adults may legally have cannabis, yellow that small amounts bring at most a fine, red that it is a crime; the darkest red marks laws with a death penalty.",
+    note: "The legal status of cannabis for adults. The index score measures much more than this one label." },
+  med: { name: "Medical status", complete: true, statuses: Object.fromEntries(MED_ORDER.map((k) => [k, { label: MED_LABEL[k], help: MED_HELP[k] }])),
+    tiers: { yes: 5, limited: 3, no: 0 },
+    sub: "Green means a medical cannabis programme is legal, yellow that access is limited, red that there is none.",
+    note: "Whether patients can get cannabis legally. Medical programmes are not part of the score." },
+};
+const MAP_SECTIONS = { ...LAW_SECTIONS, ...STATUS_SECTIONS };
+const STATUS_SCALES = Object.fromEntries(Object.entries(MAP_SECTIONS).map(([key, section]) => [key, { name: section.name,
+  scale: Object.fromEntries(Object.entries(section.statuses).map(([k, v]) => [k, { label: v.label, help: v.help, color: `var(--t${section.tiers[k]})` }])) }]));
+const streetFlower = (c) => latestPricesFor(c.id).street;
+
+// Rankings columns the reader can switch on and off. `on` marks the ones shown by default.
+// Extra columns sort on a number kept on the row: higher means freer (or bigger), -1 means no data.
+const order = (keys) => Object.fromEntries(keys.map((k, i) => [k, keys.length - i]));
+const STATUS_RANK = { ...order(Object.keys(PLANTING_STATUS)), ...order(["open", "regulated", "gift", "unclear", "banned"]) };
+const statusCol = (key, label, vocab, helpIndex) => ({
+  key, label, group: "Context",
+  sort: (c) => STATUS_RANK[c.planting?.[key]] ?? -1,
+  chip: (c) => c.planting && { ...vocab[c.planting[key]], help: vocab[c.planting[key]].help[helpIndex] },
+});
+const BASE_COLUMNS = [
+  { key: "region", label: "Region" }, { key: "users", label: "Users" }, { key: "share", label: "Share of adults" },
+  ...(SHOW_PRICES ? [{ key: "price", label: "Price per gram" }] : []),
+  { key: "rec", label: "Recreational" }, { key: "med", label: "Medical" },
+];
+const EXTRA_COLUMNS = [
+  ...SEGMENTS.map((s, i) => ({ key: `s${i}`, label: s.name, group: "Score segments", sort: (c) => c.s[i], score: (c) => c.s[i] })),
+  { key: "pop", label: "Population", group: "People", sort: (c) => c.population, text: (c) => compact(c.population) },
+  statusCol("seeds", "Seeds", PLANTING_STATUS, 0),
+  statusCol("seeds_trade", "Seed sales", PLANTING_TRADE, 0),
+  statusCol("clones", "Clones", PLANTING_STATUS, 1),
+  statusCol("clones_trade", "Clone sales", PLANTING_TRADE, 1),
+  ...Object.entries(STATUS_SECTIONS).map(([key, section]) => ({ key, label: section.name, group: "Context",
+    sort: (c) => order(Object.keys(section.statuses))[c[key]] ?? -1, chip: (c) => c[key] && section.statuses[c[key]] })),
+];
+const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", "People", "Context"].map((g) => [g, EXTRA_COLUMNS.filter((x) => x.group === g)])];
+---
+<Base
+  title={view ? view.title : undefined}
+  description={view ? view.lede : `Where is cannabis legal? Cannabis laws in ${countries.length} countries, each scored from 0 to 100 for possession, home growing, penalties and legal access, with a world map, rankings${SHOW_PRICES ? ", retail prices" : ""} and legal history.`}
+  jsonld={view ? undefined : homeSeo(Astro.site, countries.length)}>
+  <main id="top" class:list={{ "map-page": view }}>
+    {view && (
… 691 more lines
src/components/ProjectNumbers.astro +40 −0

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

src/components/SourceCitation.astro +13 −0

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

src/content.config.ts +32 −3

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

src/layouts/Base.astro +2 −2

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

src/lib/archive.js +8 −0

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

src/lib/countries.js +6 −1

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

src/lib/exports.js +53 −2

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

src/lib/llms.js +88 −0

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

src/lib/map-views.js +45 −0

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

src/lib/sources.js +27 −0

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

src/pages/articles/index.astro +17 −0

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

src/pages/data/index.astro +20 −2

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

src/pages/data/panel.csv.js +4 −0

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

src/pages/data/panel.json.js +4 −0

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

src/pages/history/[id].astro +98 −71

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

src/pages/index.astro +2 −775

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

src/pages/llms-full.txt.js +4 −0

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

src/pages/llms.txt.js +4 −0

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

src/pages/map/[view].astro +11 −0

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

src/pages/map/index.astro +6 −0

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

src/pages/robots.txt.js +2 −2

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

src/pages/sitemap.xml.js +2 −0

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

src/styles/global.css +250 −6

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

    ↑↓ to move, ↵ to open.