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 linessrc/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 linessrc/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.