Cannabis Freedom Index

Changelog efac45ftree, with Claude Opus 5.5

Region pages: the continents and the EU, each at a glance

/region/ lists the six continents and groupings of countries (the EU to start; a new grouping is one entry in src/lib/regions.js). /region/<slug>/ gives the average score against the world, a map opening on the region, how its countries divide by legal status for adults and patients, its average and score bands over time, every country ranked with its six parts, and the latest history. - AverageChart and countries.js draw the same chart for any group of countries. - The maps' zoom and fit-to-region code is shared (src/lib/static-map.js) by the context, dataset and region pages. - Profiles link their continent and groupings; regions are in the sitemap, search and social cards.

15 files of code, design or docs +389 −67 lines

Code, design and documents 15 files

src/components/AverageChart.astro +16 −10
@@ -12,5 +12,11 @@ import { TIERS, tierOf, tierColor } from "../lib/tiers.js";
 // figures in one row and no card around it (the Charts page).
 // `figures={false}` leaves out the two headline numbers, where the page says it otherwise.
-const { note = false, wide = false, figures = true } = Astro.props;
+// `group` draws a region instead of the world: { name ("Europe"), countries, series } with the
+// series from historyOf() in countries.js.
+const { note = false, wide = false, figures = true, group = null } = Astro.props;
+const members = group?.countries ?? countries;
+const series = group?.series ?? { history: averageHistory, historyByPopulation: averageHistoryByPopulation, bands: bandHistory, bandsByPopulation: bandHistoryByPopulation };
+const who = group?.name ?? "World"; // whose average: "World average score", "Europe average score"
+const people = group ? `of ${group.name}'s people` : "of the world's people";
 const fmt = (v) => v.toFixed(1);
 const time = dateTime;
@@ -22,6 +28,6 @@ const RANGES = [
 ];
 const WEIGHTS = [
-  { id: "country", label: "By country", title: "Every country counts the same", history: averageHistory, bands: bandHistory },
-  { id: "people", label: "By population", title: "Each country counts in proportion to its population", history: averageHistoryByPopulation, bands: bandHistoryByPopulation },
+  { id: "country", label: "By country", title: "Every country counts the same", history: series.history, bands: series.bands },
+  { id: "people", label: "By population", title: "Each country counts in proportion to its population", history: series.historyByPopulation, bands: series.bandsByPopulation },
 ];
 const TIER_ORDER = [...TIERS].reverse(); // severe … free, as in bandHistory
@@ -60,7 +66,7 @@ function view(range, weighting) {
   const bannedNow = banned(rows.at(-1)), bannedThen = banned(rows[0]);
   const sub = { big: `${Math.round(bannedNow * 100)}%`, label: weighting.id === "country" ? "Countries under prohibition or worse" : "People under prohibition or worse",
-    text: weighting.id === "country" ? `${Math.round(bannedNow * countries.length)} of ${countries.length}` : "of the world's people", delta: Math.round((bannedNow - bannedThen) * 100) };
+    text: weighting.id === "country" ? `${Math.round(bannedNow * members.length)} of ${members.length}` : people, delta: Math.round((bannedNow - bannedThen) * 100) };
   // for the readout: each year's shares in tenths of a per cent, and the average in tenths
-  const data = JSON.stringify({ y0, unit: weighting.id === "country" ? countries.length : 0, avg: avgAt, rows: rows.map(([, sh]) => sh.map((v) => Math.round(v * 1000))) });
+  const data = JSON.stringify({ y0, who, unit: weighting.id === "country" ? members.length : 0, avg: avgAt, rows: rows.map(([, sh]) => sh.map((v) => Math.round(v * 1000))) });
   return { range: range.id, weight: weighting.id, startYear: range.startYear, now, delta: now - start, layers, line, years, sub, data };
 }
@@ -74,7 +80,7 @@ const signed = (n, unit = "") => `${n > 0 ? "+" : n < 0 ? "−" : "±"}${Math.ab
   {/* two figures: the world average score, and the share under prohibition or worse */}
   <div class="avg-head" hidden={!figures}>
-    <h2 id="avgTitle" class="visually-hidden">Cannabis freedom in the world over time</h2>
+    <h2 id="avgTitle" class="visually-hidden">Cannabis freedom {group ? `in ${group.name}` : "in the world"} over time</h2>
     <div class="avg-stat">
-      <p class="avg-label">World average score</p>
+      <p class="avg-label">{who} average score</p>
       <p class="avg-now">
         {views.map((v) => (
@@ -116,5 +122,5 @@ const signed = (n, unit = "") => `${n > 0 ? "+" : n < 0 ? "−" : "±"}${Math.ab
       </div>
       <div class="avg-plot" data-bands={v.data}>
-        <svg viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label={`World average score and the share of ${v.weight === "country" ? "countries" : "the world's people"} in each score band, ${v.startYear} to ${endYear}`}>
+        <svg viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label={`${who} average score and the share of ${v.weight === "country" ? "countries" : people.slice(3)} in each score band, ${v.startYear} to ${endYear}`}>
           {v.layers.map((l) => <polygon points={l.points} style={`fill:${l.color}`} />)}
           <path class="avg-over" d={v.line} />
@@ -130,5 +136,5 @@ const signed = (n, unit = "") => `${n > 0 ? "+" : n < 0 ? "−" : "±"}${Math.ab
   </div>
   <p class="avg-legend" aria-hidden="true">
-    <span><i class="avg-over-key"></i>World average score</span>
+    <span><i class="avg-over-key"></i>{who} average score</span>
     {TIERS.map((t) => <span><i style={`background:${tierColor(t.min)}`}></i>{t.name}</span>)}
   </p>
@@ -167,5 +173,5 @@ const signed = (n, unit = "") => `${n > 0 ? "+" : n < 0 ? "−" : "±"}${Math.ab
       year = d.y0 + i;
       const amount = (v) => (d.unit ? `${Math.round((v / 1000) * d.unit)}` : `${(v / 10).toFixed(v < 100 ? 1 : 0)}%`);
-      tip.innerHTML = `<b>${year}</b><span class="tip-avg">World average<em>${(d.avg[i] / 10).toFixed(1)}</em></span>`
+      tip.innerHTML = `<b>${year}</b><span class="tip-avg">${d.who} average<em>${(d.avg[i] / 10).toFixed(1)}</em></span>`
         + NAMES.map((n, k) => [n, shares[k], k]).reverse().filter(([, v]) => v > 0)
           .map(([n, v, k]) => `<span><i style="background:var(--t${k})"></i>${n}<em>${amount(v)}</em></span>`).join("")

src/components/CountryArticle.astro +6 −3
@@ -25,4 +25,5 @@ import { cultureOf, cultureSection } from "../lib/culture.js";
 import { factsOf } from "../lib/facts.js";
 import { neighboursOf } from "../lib/borders.js";
+import { regionsOf, continentSlug } from "../lib/regions.js";
 import FactText from "./FactText.astro";
 
@@ -30,4 +31,6 @@ import FactText from "./FactText.astro";
 const { c, sub } = Astro.props;
 const base = import.meta.env.BASE_URL;
+// the region pages the country belongs to: its continent, then groupings such as the EU
+const regionLinks = regionsOf(c);
 const group = subunitsOf(c.id);
 const v = sub ?? c; // whichever entity's scores are on display
@@ -151,6 +154,6 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
         {/* a state or province is the page's subject: its name is the title, its country the line under it */}
         {sub
-          ? <><h1>{sub.name}</h1><p class="sub">{group.label} of <a href={countryUrl}>{c.name}</a> · {c.region}</p></>
-          : <><h1>{c.name}</h1><p class="sub">{c.region}</p></>}
+          ? <><h1>{sub.name}</h1><p class="sub">{group.label} of <a href={countryUrl}>{c.name}</a> · {regionLinks.map((r, i) => <>{i > 0 && " · "}<a class="c-region" href={`${base}region/${r.slug}/`}>{r.name}</a></>)}</p></>
+          : <><h1>{c.name}</h1><p class="sub">{regionLinks.map((r, i) => <>{i > 0 && " · "}<a class="c-region" href={`${base}region/${r.slug}/`}>{r.name}</a></>)}</p></>}
       </div>
     </div>
@@ -174,5 +177,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
           : c.territory
             ? <p class="sub">Not ranked: {c.territory.label}<br>would rank #{c.worldRank} among countries</p>
-            : <p class="sub">#{c.rank} of {countries.length} worldwide<br>#{c.regionRank} of {c.regionCount} in {c.region}</p>}
+            : <p class="sub">#{c.rank} of {countries.length} worldwide<br>#{c.regionRank} of {c.regionCount} in <a class="c-region" href={`${base}region/${continentSlug(c.region)}/`}>{c.region}</a></p>}
       </div>
     </div>

src/components/RegionMenu.astro +14 −0
@@ -0,0 +1,14 @@
+---
+// The row of regions at the top of /region/ and of each region's page: the continents, then the
+// groupings such as the EU. Styled as the context topics' menu (ContextMenu.astro).
+import { regions } from "../lib/regions.js";
+
+const { current = null } = Astro.props;
+const base = import.meta.env.BASE_URL;
+---
+<nav class="ctxp-menu" aria-label="Regions">
+  <ol>
+    <li><a href={`${base}region/`} aria-current={current ? undefined : "page"}>All regions</a></li>
+    {regions.map((r) => <li><a href={`${base}region/${r.slug}/`} aria-current={r.slug === current ? "page" : undefined}>{r.short}</a></li>)}
+  </ol>
+</nav>

src/lib/countries.js +12 −6
@@ -359,8 +359,9 @@ function scoreAt(c, t) {
 // for earlier years the by-population average uses each country's 1900 population.
 const CHART_FROM = 1860;
-function averageOverTime(weight) {
+function averageOverTime(weight, list = countries) {
+  const ids = new Set(list.map((c) => c.id));
   const at = (t) => {
     let sum = 0, total = 0;
-    for (const c of countries) {
+    for (const c of list) {
       const w = weight(c, t);
       sum += scoreAt(c, t) * w;
@@ -369,5 +370,5 @@ function averageOverTime(weight) {
     return sum / total;
   };
-  const events = changes.filter((ch) => ch.score && !ch.estimate && !ch.country.territory).map((ch) => ({ change: ch, date: ch.date, t: dateTime(ch.date) }));
+  const events = changes.filter((ch) => ch.score && !ch.estimate && !ch.country.territory && ids.has(ch.country.id)).map((ch) => ({ change: ch, date: ch.date, t: dateTime(ch.date) }));
   for (let year = CHART_FROM; year <= +DATA_SNAPSHOT; year++) events.push({ change: null, date: `${year}-01-01`, t: Date.UTC(year, 0, 1) });
   events.sort((a, b) => a.t - b.t);
@@ -378,5 +379,5 @@ function averageOverTime(weight) {
     const w = weight(c, e.t);
     let total = 0;
-    for (const x of countries) total += weight(x, e.t);
+    for (const x of list) total += weight(x, e.t);
     const after = at(e.t);
     return { change: e.change, date: e.date, before: after - ((e.change.raw.after - e.change.raw.before) * w) / total, after };
@@ -389,5 +390,5 @@ export const averageHistoryByPopulation = averageOverTime(populationAt);
 // the world's people) in each tier at the end of that year, as [year, [severe … free]] with shares
 // adding up to 1. Drawn as stacked bands beside the average.
-function bandsOverTime(weight) {
+function bandsOverTime(weight, list = countries) {
   const out = [];
   for (let year = CHART_FROM; year <= +DATA_SNAPSHOT; year++) {
@@ -395,5 +396,5 @@ function bandsOverTime(weight) {
     const shares = new Array(TIERS.length).fill(0);
     let total = 0;
-    for (const c of countries) {
+    for (const c of list) {
       const w = weight(c, t);
       shares[tierIndex(scoreAt(c, t))] += w;
@@ -406,4 +407,9 @@ function bandsOverTime(weight) {
 export const bandHistory = bandsOverTime(() => 1);
 export const bandHistoryByPopulation = bandsOverTime(populationAt);
+// The same four series for a group of countries (a region page): its average and bands over time.
+export const historyOf = (list) => ({
+  history: averageOverTime(() => 1, list), historyByPopulation: averageOverTime(populationAt, list),
+  bands: bandsOverTime(() => 1, list), bandsByPopulation: bandsOverTime(populationAt, list),
+});
 
 // ── History pages ──

src/lib/regions.js +49 −0
@@ -0,0 +1,49 @@
+// Regions as pages of their own (/region/ and /region/<slug>/): the six continents every profile
+// names under `region:`, and groupings of countries such as the European Union, listed here by
+// country file name. For each: its countries and territories, the figures a page opens with, and
+// its average score and score bands over time.
+import { countries, places, changes, historyOf, worldAvg, REC_ORDER, MED_LABEL } from "./countries.js";
+
+// Groupings beyond the continents. `members` are country file names; a new grouping needs only
+// an entry here. EU: the 27 member states since 2020.
+const GROUPINGS = [
+  {
+    slug: "eu", name: "European Union", short: "EU", of: "Europe",
+    about: "The 27 member states of the European Union. EU farm rules set a THC limit of 0.3% for hemp, while each member state writes its own criminal law on cannabis.",
+    members: ["at", "be", "bg", "hr", "cy", "cz", "dk", "ee", "fi", "fr", "de", "gr", "hu", "ie", "it", "lv", "lt", "lu", "mt", "nl", "pl", "pt", "ro", "sk", "si", "es", "se"],
+  },
+];
+
+const slugOf = (name) => name.toLowerCase().replace(/\W+/g, "-");
+const CONTINENTS = [...new Set(countries.map((c) => c.region))].sort().map((name) => ({ slug: slugOf(name), name, short: name, kind: "continent" }));
+
+const median = (vs) => { const s = [...vs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
+
+function build(r, list) {
+  const members = list.filter((c) => !c.territory).sort((a, b) => b.raw - a.raw || a.name.localeCompare(b.name));
+  const territories = list.filter((c) => c.territory).sort((a, b) => a.name.localeCompare(b.name));
+  const ids = new Set(list.map((c) => c.id));
+  const population = members.reduce((n, c) => n + c.population, 0);
+  const users = members.reduce((n, c) => n + (c.use?.users ?? 0), 0);
+  const avg = members.reduce((n, c) => n + c.raw, 0) / members.length;
+  const avgByPeople = members.reduce((n, c) => n + c.raw * c.population, 0) / population;
+  const count = (key, value) => members.filter((c) => c[key] === value).length;
+  return {
+    ...r, members, territories, all: [...members, ...territories], population, users, avg, avgByPeople, median: median(members.map((c) => c.raw)),
+    vsWorld: avg - worldAvg,
+    rec: REC_ORDER.map((k) => ({ key: k, n: count("rec", k) })).filter((x) => x.n),
+    med: Object.keys(MED_LABEL).map((k) => ({ key: k, n: count("med", k) })).filter((x) => x.n),
+    // the latest history entries in the region, newest first
+    latest: changes.filter((ch) => ch.country && ids.has(ch.country.id) && !ch.estimate).slice(0, 12),
+    series: historyOf(members),
+  };
+}
+
+export const regions = [
+  ...CONTINENTS.map((r) => build(r, places.filter((c) => c.region === r.name))),
+  ...GROUPINGS.map((g) => build({ ...g, kind: "grouping" }, places.filter((c) => g.members.includes(c.slug)))),
+];
+export const regionBySlug = Object.fromEntries(regions.map((r) => [r.slug, r]));
+// the region page of a profile's continent, and of each grouping a country belongs to
+export const regionsOf = (c) => regions.filter((r) => r.all.some((x) => x.id === c.id));
+export const continentSlug = slugOf;

src/lib/search-icons.js +1 −1
@@ -13,5 +13,5 @@ export const PAGE_ICONS = {
   "did-you-know/": "iconoir:light-bulb", "survey/": "iconoir:graph-up", "survey/take/": "iconoir:edit-pencil",
   "culture/": "iconoir:palette", "culture/people/": "iconoir:community", "culture/stories/": "iconoir:book", "sources/": "iconoir:bookmark-book", "research/": "iconoir:percentage-circle",
-  "glossary/": "iconoir:book-stack", "context/": "iconoir:list", "datasets/": "iconoir:database", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image",
+  "glossary/": "iconoir:book-stack", "context/": "iconoir:list", "datasets/": "iconoir:database", "region/": "iconoir:globe", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image",
 };
 // pages that come in families: a part of the cultural atlas, a map view, a history period, a history tag, an exhibition

src/lib/static-map.js +30 −0
@@ -0,0 +1,30 @@
+// The world maps drawn at build time on the context, dataset and region pages (siteMap() in
+// world-map.js): zoom and pan by wheel, drag or the + − ⟲ buttons, as on the home page's map, with
+// the small countries' dots keeping their size. A map whose coloured places sit in one region
+// (Europe, the United States) opens on them, and reset returns there. Imported by a page's script;
+// works on every `[data-ctxp-map]` figure.
+import { select } from "d3-selection";
+import { zoom, zoomIdentity } from "d3-zoom";
+import "d3-transition";
+
+for (const figure of document.querySelectorAll("[data-ctxp-map]")) {
+  const svg = select(figure.querySelector("svg"));
+  const g = svg.select(".ctxp-zoom");
+  const [, , w, h] = figure.querySelector("svg").getAttribute("viewBox").split(" ").map(Number);
+  const dots = g.selectAll("circle").each(function () { this.dataset.r = this.getAttribute("r"); });
+  // the box around the coloured places, if it is much smaller than the world. A country spread
+  // over oceans (France with its overseas regions) is left out of the box.
+  const boxes = g.selectAll(".has-data").nodes().map((n) => n.getBBox());
+  const box = (boxes.filter((b) => b.width < w * 0.12).length ? boxes.filter((b) => b.width < w * 0.12) : boxes)
+    .reduce((a, b) => (a ? { x0: Math.min(a.x0, b.x), y0: Math.min(a.y0, b.y), x1: Math.max(a.x1, b.x + b.width), y1: Math.max(a.y1, b.y + b.height) } : { x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height }), null);
+  const k = box ? Math.min(6, 0.85 / Math.max((box.x1 - box.x0) / w, (box.y1 - box.y0) / h)) : 1;
+  const home = k > 1.6 ? zoomIdentity.translate(w / 2, h / 2).scale(k).translate(-(box.x0 + box.x1) / 2, -(box.y0 + box.y1) / 2) : zoomIdentity;
+  const z = zoom().scaleExtent([1, 10]).translateExtent([[0, 0], [w, h]]).on("zoom", (e) => {
+    g.attr("transform", e.transform);
+    dots.attr("r", function () { return this.dataset.r / Math.sqrt(e.transform.k); });
+  });
+  svg.call(z);
+  z.transform(svg, home);
+  const act = { in: () => z.scaleBy(svg.transition().duration(250), 1.8), out: () => z.scaleBy(svg.transition().duration(250), 1 / 1.8), reset: () => z.transform(svg.transition().duration(250), home) };
+  figure.querySelectorAll("[data-zoom]").forEach((b) => b.addEventListener("click", act[b.dataset.zoom]));
+}

src/pages/context/[topic].astro +1 −19
@@ -162,23 +162,5 @@ const description = `${topic.name} in every country: ${topic.help.charAt(0).toLo
 <script>
   import "../../lib/unit-map-tip.js";
-  import { select } from "d3-selection";
-  import { zoom, zoomIdentity } from "d3-zoom";
-  import "d3-transition";
-
-  // zoom and pan, by wheel, drag or the buttons, as on the home page's map
-  for (const figure of document.querySelectorAll("[data-ctxp-map]")) {
-    const svg = select(figure.querySelector("svg"));
-    const g = svg.select(".ctxp-zoom");
-    const [, , w, h] = figure.querySelector("svg").getAttribute("viewBox").split(" ").map(Number);
-    // the dots of the small countries keep their size as the map grows
-    const dots = g.selectAll("circle").each(function () { this.dataset.r = this.getAttribute("r"); });
-    const z = zoom().scaleExtent([1, 10]).translateExtent([[0, 0], [w, h]]).on("zoom", (e) => {
-      g.attr("transform", e.transform);
-      dots.attr("r", function () { return this.dataset.r / Math.sqrt(e.transform.k); });
-    });
-    svg.call(z);
-    const act = { in: () => z.scaleBy(svg.transition().duration(250), 1.8), out: () => z.scaleBy(svg.transition().duration(250), 1 / 1.8), reset: () => z.transform(svg.transition().duration(250), zoomIdentity) };
-    figure.querySelectorAll("[data-zoom]").forEach((b) => b.addEventListener("click", act[b.dataset.zoom]));
-  }
+  import "../../lib/static-map.js";
 
   // The table shows the places with a section by default; the box filters by country, region or

src/pages/datasets/[dataset].astro +1 −27
@@ -199,31 +199,5 @@ const description = `${d.name}, country by country: ${d.about.split(/(?<=\.)\s/)
 <script>
   import "../../lib/unit-map-tip.js";
-  import { select } from "d3-selection";
-  import { zoom, zoomIdentity } from "d3-zoom";
-  import "d3-transition";
-
-  // zoom and pan, by wheel, drag or the buttons, as on the context pages
-  for (const figure of document.querySelectorAll("[data-ctxp-map]")) {
-    const svg = select(figure.querySelector("svg"));
-    const g = svg.select(".ctxp-zoom");
-    const [, , w, h] = figure.querySelector("svg").getAttribute("viewBox").split(" ").map(Number);
-    const dots = g.selectAll("circle").each(function () { this.dataset.r = this.getAttribute("r"); });
-    // a dataset that covers one region (Europe, the United States) opens on it: the box around
-    // the places with a figure, if it is much smaller than the world; reset returns there. A
-    // country spread over oceans (France with its overseas regions) is left out of the box.
-    const boxes = g.selectAll(".has-data").nodes().map((n) => n.getBBox());
-    const box = (boxes.filter((b) => b.width < w * 0.12).length ? boxes.filter((b) => b.width < w * 0.12) : boxes)
-      .reduce((a, b) => (a ? { x0: Math.min(a.x0, b.x), y0: Math.min(a.y0, b.y), x1: Math.max(a.x1, b.x + b.width), y1: Math.max(a.y1, b.y + b.height) } : { x0: b.x, y0: b.y, x1: b.x + b.width, y1: b.y + b.height }), null);
-    const k = box ? Math.min(6, 0.85 / Math.max((box.x1 - box.x0) / w, (box.y1 - box.y0) / h)) : 1;
-    const home = k > 1.6 ? zoomIdentity.translate(w / 2, h / 2).scale(k).translate(-(box.x0 + box.x1) / 2, -(box.y0 + box.y1) / 2) : zoomIdentity;
-    const z = zoom().scaleExtent([1, 10]).translateExtent([[0, 0], [w, h]]).on("zoom", (e) => {
-      g.attr("transform", e.transform);
-      dots.attr("r", function () { return this.dataset.r / Math.sqrt(e.transform.k); });
-    });
-    svg.call(z);
-    z.transform(svg, home);
-    const act = { in: () => z.scaleBy(svg.transition().duration(250), 1.8), out: () => z.scaleBy(svg.transition().duration(250), 1 / 1.8), reset: () => z.transform(svg.transition().duration(250), home) };
-    figure.querySelectorAll("[data-zoom]").forEach((b) => b.addEventListener("click", act[b.dataset.zoom]));
-  }
+  import "../../lib/static-map.js";
 
   // The table filters by any word in a row, and sorts by country, figure or year: a second click

src/pages/og/[...path].png.js +5 −0
@@ -10,4 +10,5 @@ import { CULTURE_SECTIONS, cultureEntries, cultureIn, cultureSection } from "../
 import { contextTopics } from "../../lib/contexts.js";
 import { datasetPages } from "../../lib/dataset-pages.js";
+import { regions } from "../../lib/regions.js";
 import { survey, results } from "../../lib/survey.js";
 import { getFacts } from "../../lib/facts.js";
@@ -90,4 +91,8 @@ cards.context = page("Beyond the score", "What the score does not show, compared
 for (const t of contextTopics) cards[`context/${t.slug}`] = page(t.name, sentence(t.help),
   [[num(t.filled), "PROFILES WITH THIS SECTION"], [num(t.notApplicable), "EXPLAINED AS NOT APPLICABLE"], [num(t.missing), "NOT RESEARCHED YET"]], `CONTEXT · ${BRAND}`);
+// the regions, continents and groupings such as the EU
+cards.region = page("Regions", "How free cannabis is continent by continent, and in groups of countries such as the European Union.", [[String(regions.length), "REGIONS"], [worldAvg.toFixed(1), "WORLD AVERAGE"]], `REGIONS · ${BRAND}`);
+for (const r of regions) cards[`region/${r.slug}`] = page(`Cannabis in ${r.name}`, sentence(`${r.members[0].name} is the freest, ${r.members.at(-1).name} the harshest`),
+  [[r.avg.toFixed(1), "AVERAGE SCORE"], [num(r.members.length), "COUNTRIES"], [String(r.rec.find((x) => x.key === "legal")?.n ?? 0), "LEGAL FOR ADULTS"]], `REGIONS · ${BRAND}`);
 // the outside datasets, each with a map and every place's figure
 cards.datasets = page("Datasets", "What others count about cannabis, country by country: use, offences, prices, potency, opinion and freedom.", [[String(datasetPages.length), "DATASETS"], [num(datasetFigures), "COUNTRY FIGURES"]], `DATASETS · ${BRAND}`);

src/pages/region/[region].astro +169 −0
@@ -0,0 +1,169 @@
+---
+// One region, e.g. /region/europe/ or /region/eu/: how free cannabis is there at a glance, a map
+// opening on the region, how its countries divide by legal status, its average and score bands
+// over time, every country ranked, and the latest history. The data is in src/lib/regions.js.
+import Base from "../../layouts/Base.astro";
+import RegionMenu from "../../components/RegionMenu.astro";
+import AverageChart from "../../components/AverageChart.astro";
+import ScoreChange from "../../components/ScoreChange.astro";
+import { regions } from "../../lib/regions.js";
+import { siteMap } from "../../lib/world-map.js";
+import { tierColor, tierOf } from "../../lib/tiers.js";
+import { SEGMENTS, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP, worldAvg, changeDate, countries } from "../../lib/countries.js";
+
+export function getStaticPaths() {
+  return regions.map((region) => ({ params: { region: region.slug }, props: { region } }));
+}
+
+const { region: r } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const map = siteMap();
+const W = map.width, H = map.height;
+const inRegion = new Map(r.all.map((c) => [c.id, c]));
+const num = (n) => n.toLocaleString("en-GB");
+const compact = (n) => (n >= 1e9 ? `${+(n / 1e9).toPrecision(3)} billion` : n >= 1e6 ? `${+(n / 1e6).toPrecision(3)} million` : num(n));
+const fmt = (v) => v.toFixed(1);
+const freest = r.members[0], harshest = r.members.at(-1);
+const legal = r.rec.find((x) => x.key === "legal")?.n ?? 0;
+const medical = r.med.find((x) => x.key === "yes")?.n ?? 0;
+const worldRank = (c) => countries.indexOf(c) + 1;
+const figures = [
+  [fmt(r.avg), `average score · world ${fmt(worldAvg)}`],
+  [num(r.members.length), r.territories.length ? `countries, and ${r.territories.length} territories` : "countries"],
+  [compact(r.population), "people"],
+  [num(legal), `legal for adults · ${medical} with a medical route`],
+];
+const lede = `${r.about ?? `${r.members.length} countries${r.territories.length ? ` and ${r.territories.length} territories` : ""} in ${r.name}.`} The average score is ${fmt(r.avg)} of 100, ${Math.abs(r.vsWorld) < 1 ? "about the world average" : r.vsWorld > 0 ? `${fmt(r.vsWorld)} above the world average` : `${fmt(-r.vsWorld)} below the world average`}; ${freest.name} is the freest and ${harshest.name} the harshest.`;
+const description = `Cannabis laws in ${r.name}: every country's score, legal status for adults and patients, a map and the latest changes.`;
+const bar = (list, total) => list.map((x) => ({ ...x, w: (x.n / total) * 100 }));
+const REC_TIER = { legal: 5, partial: 4, decrim: 3, illegal: 1, death: 0 }, MED_TIER = { yes: 5, limited: 3, no: 0 };
+---
+<Base title={`Cannabis in ${r.name}: laws country by country`} description={description}>
+  <main class="changes-page ctxp rgp">
+    <RegionMenu current={r.slug} />
+    <section class="hero hero-sm">
+      <p class="eyebrow"><a href={`${base}region/`}>Regions</a> <span>{r.kind === "grouping" ? `a group of countries${r.of ? ` in ${r.of}` : ""}` : "continent"}</span></p>
+      <h1>{r.name}</h1>
+      <p class="lede">{lede}</p>
+    </section>
+
+    <dl class="hist-stats ctxp-stats">
+      {figures.map(([n, label]) => <div><dt>{label}</dt><dd>{n}</dd></div>)}
+    </dl>
+
+    <section class="section ctxp-map-sec" aria-labelledby="rgpMap">
+      <div class="section-head"><div>
+        <h2 id="rgpMap">{r.name} on the map</h2>
+        <p class="sub">Each country coloured by its score: red means prison risk, green means free. Select a country for its profile.</p>
+      </div></div>
+      <figure class="card map-card map-wrap ctxp-map" data-ctxp-map data-map-tips>
+        <svg viewBox={`0 0 ${W} ${H}`} role="group" aria-label={`Map of ${r.name}, coloured by score`}>
+          <g class="ctxp-zoom">
+            <path class="sphere" d={map.sphere} />
+            {map.shapes.map((s) => {
+              const c = s.c ? inRegion.get(s.c.id) : null;
+              return c
+                ? <a href={`${base}country/${c.slug}/`} data-tip={c.id} aria-label={`${c.name}: ${c.score}`}><path class="country-shape has-data" d={s.d} style={`fill:${tierColor(c.raw)}`} /></a>
+                : <path class="country-shape land-only" d={s.d} style="fill:var(--land)" />;
+            })}
+            {map.dots.map((d) => {
+              const c = inRegion.get(d.c.id);
+              return c && <a href={`${base}country/${c.slug}/`} data-tip={c.id} aria-hidden="true" tabindex="-1"><circle class="country-shape has-data tiny-dot" cx={d.x} cy={d.y} r={map.r} style={`fill:${tierColor(c.raw)}`} /></a>;
+            })}
+          </g>
+        </svg>
+        <div class="map-zoom">
+          <button type="button" class="icon-btn" data-zoom="in" aria-label="Zoom in">+</button>
+          <button type="button" class="icon-btn" data-zoom="out" aria-label="Zoom out">−</button>
+          <button type="button" class="icon-btn" data-zoom="reset" aria-label="Reset zoom">⟲</button>
+        </div>
+        <div class="tooltip" hidden></div>
+        {r.all.map((c) => (
+          <template data-tip-for={c.id}>
+            <div class="tip-head"><strong><span class="tip-flag">{c.flag}</span>{c.name}</strong><span class="tip-chip" style={`--chip:${tierColor(c.raw)}`}>{c.score}</span></div>
+            <div class="tip-meta"><span class={`chip s-${c.rec}`}>{REC_LABEL[c.rec]}</span> <span class={`chip s-${c.med}`}>Medical: {MED_LABEL[c.med]}</span></div>
+            <p class="tip-note">{c.note}</p>
+            <div class="tip-foot">Click for the profile</div>
+          </template>
+        ))}
+      </figure>
+    </section>
+
+    <section class="section" aria-labelledby="rgpStatus">
+      <div class="section-head"><div><h2 id="rgpStatus">How the {r.members.length} countries divide</h2></div></div>
+      <div class="rgp-splits">
+        {[["For adults", r.rec, REC_LABEL, REC_HELP, REC_TIER], ["For patients", r.med, MED_LABEL, MED_HELP, MED_TIER]].map(([label, list, LABEL, HELP, TIER]) => (
+          <div>
+            <h3 class="rgp-split-h">{label}</h3>
+            <p class="rgp-split" role="img" aria-label={list.map((x) => `${LABEL[x.key]}: ${x.n}`).join(", ")}>
+              {bar(list, r.members.length).map((x) => <i style={`flex:${x.n};background:var(--t${TIER[x.key]})`} title={`${LABEL[x.key]}: ${x.n}`}></i>)}
+            </p>
+            <ul class="rgp-split-key">
+              {list.map((x) => (
+                <li title={HELP[x.key]}>
+                  <span class={`chip s-${x.key}`}>{LABEL[x.key]}</span> <b>{x.n}</b>
+                  <small>{r.members.filter((c) => (label === "For adults" ? c.rec : c.med) === x.key).slice(0, 6).map((c) => c.name).join(", ")}{x.n > 6 ? "…" : ""}</small>
+                </li>
+              ))}
+            </ul>
+          </div>
+        ))}
+      </div>
+    </section>
+
+    <section class="section" aria-label="Over time">
+      <div class="section-head"><div><h2>{r.name} over time</h2><p class="sub">The average score and the share of countries, or of people, in each band, rebuilt from the recorded history.</p></div></div>
+      <AverageChart wide group={{ name: r.short === "EU" ? "EU" : r.name, countries: r.members, series: r.series }} />
+    </section>
+
+    <section class="section" aria-labelledby="rgpTable">
+      <div class="section-head"><div>
+        <h2 id="rgpTable">Every country, ranked</h2>
+        <p class="sub">The index score from 0 to 100 and its six parts from 0 to 10.{r.territories.length > 0 && " Territories follow, unranked."}</p>
+      </div></div>
+      <div class="rgp-table-wrap">
+        <table class="src-table rgp-table">
+          <thead><tr>
+            <th class="num">#</th><th>Country</th><th class="num">Score</th><th>Adults</th><th>Patients</th>
+            {SEGMENTS.map((s) => <th class="num rgp-seg" title={s.name}>{s.name.replace(/^Punishment & /, "").replace(/^Personal /, "").replace(/ & gifting$/, "")}</th>)}
+          </tr></thead>
+          <tbody>
+            {r.all.map((c, i) => (
+              <tr class:list={{ "rgp-territory": c.territory }}>
+                <td class="num rgp-rank">{c.territory ? "–" : i + 1}<small>{!c.territory && `#${worldRank(c)}`}</small></td>
+                <td class="ctxp-country"><a href={`${base}country/${c.slug}/`}><span aria-hidden="true">{c.flag}</span> {c.name}</a><small>{c.territory ? c.territory.label : tierOf(c.raw).name}</small></td>
+                <td class="num"><span class="pill" style={`--c:${tierColor(c.raw)}`}>{c.score}</span></td>
+                <td><span class={`chip s-${c.rec}`} title={REC_HELP[c.rec]}>{REC_LABEL[c.rec]}</span></td>
+                <td><span class={`chip s-${c.med}`} title={MED_HELP[c.med]}>{MED_LABEL[c.med]}</span></td>
+                {c.s.map((v) => <td class="num rgp-seg" style={`--c:${tierColor(v * 10)}`}><i></i>{v}</td>)}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+      <p class="rgp-foot">The second number under the rank is the country's place worldwide, of {countries.length}.</p>
+    </section>
+
+    {r.latest.length > 0 && (
+      <section class="section" aria-labelledby="rgpLatest">
+        <div class="section-head"><div><h2 id="rgpLatest">Latest in {r.name}</h2><p class="sub">The newest entries in the history. <a href={`${base}history/`}>All history&nbsp;→</a></p></div></div>
+        <ol class="rgp-latest">
+          {r.latest.map((ch) => (
+            <li>
+              <span class="rgp-date">{changeDate(ch)}</span>
+              <div>
+                <a class="rgp-entry" href={`${base}history/${ch.id}/`}><span class="rgp-flag" aria-hidden="true">{ch.country.flag}</span>{ch.title}</a>
+                {ch.score && ch.score.before !== ch.score.after && <ScoreChange score={ch.score} of={ch.country.name} />}
+              </div>
+            </li>
+          ))}
… 11 more lines
src/pages/region/index.astro +45 −0
@@ -0,0 +1,45 @@
+---
+// Regions: the continents and groupings such as the EU, each with its average score, how its
+// countries divide by score band, and a page of its own (/region/<slug>/). The data is in
+// src/lib/regions.js.
+import Base from "../../layouts/Base.astro";
+import RegionMenu from "../../components/RegionMenu.astro";
+import { regions } from "../../lib/regions.js";
+import { TIERS, tierColor, tierOf } from "../../lib/tiers.js";
+import { worldAvg } from "../../lib/countries.js";
+
+const base = import.meta.env.BASE_URL;
+const fmt = (v) => v.toFixed(1);
+// how a region's countries divide by score band, strictest first
+const bands = (r) => [...TIERS].reverse().map((t) => ({ t, n: r.members.filter((c) => tierOf(c.raw) === t).length })).filter((x) => x.n);
+---
+<Base title="Regions" description="Cannabis laws by region: Europe, the European Union, Africa, Asia, the Americas and Oceania, each with its countries' scores, legal status, a map and the latest changes.">
+  <main class="changes-page ctxp rgp">
+    <RegionMenu />
+    <section class="hero hero-sm">
+      <p class="eyebrow">Regions <span>world average {fmt(worldAvg)}</span></p>
+      <h1>Regions</h1>
+      <p class="lede">How free cannabis is continent by continent, and in groups of countries that share rules, such as the European Union. Each region has a page with a map, how its countries divide by legal status, its average over time and every country ranked.</p>
+    </section>
+
+    {[["Continents", "continent"], ["Groups of countries", "grouping"]].map(([title, kind]) => (
+      <section class="section">
+        <div class="section-head"><div><h2>{title}</h2></div></div>
+        <ol class="ctxp-topics rgp-list">
+          {regions.filter((r) => r.kind === kind).map((r) => (
+            <li>
+              <a href={`${base}region/${r.slug}/`}>
+                <span class="ctxp-topic-name">{r.name}</span>
+                <span class="ctxp-topic-help">{r.members.length} countries · freest {r.members[0].name}, harshest {r.members.at(-1).name}</span>
+                <span class="ctxp-split" role="img" aria-label={bands(r).map((x) => `${x.t.name}: ${x.n}`).join(", ")}>
+                  {bands(r).map((x) => <i style={`flex:${x.n};background:${tierColor(x.t.min)}`} title={`${x.t.name}: ${x.n}`}></i>)}
+                </span>
+                <span class="ctxp-topic-n">Average <b style={`color:${tierColor(r.avg)}`}>{fmt(r.avg)}</b> · {r.vsWorld >= 0 ? "+" : "−"}{fmt(Math.abs(r.vsWorld))} on the world</span>
+              </a>
+            </li>
+          ))}
+        </ol>
+      </section>
+    ))}
+  </main>
+</Base>

src/pages/search.json.js +4 −1
@@ -8,4 +8,5 @@
 import { contextTopics } from "../lib/contexts.js";
 import { datasetPages } from "../lib/dataset-pages.js";
+import { regions } from "../lib/regions.js";
 import { getCollection } from "astro:content";
 import { termAnchor } from "../lib/terms.js";
@@ -27,8 +28,10 @@ const rows = [
     ["History", "Laws, rulings and treaties", "history/"], ["Can I?", "Plain answers per country", "can-i/"], ["Compare", "Countries side by side", "compare/"],
     ["Download the data", "Scores, history and yearly panel", "data/"], ["Score changes", "Every score change", "score-changes/"], ["Changelog", "Every change to the site, data and text", "changelog/"],
-    ["Articles", "The longer reads on single events", "articles/"], ["About", "What the project is, its aims and commitments", "about/"], ["Did you know?", "Curious cases from the index", "did-you-know/"], ["State of Cannabis", "The yearly survey and its results", "survey/"], ["Take the survey", "Anonymous, about ten minutes", "survey/take/"], ["Culture", "A cultural atlas of cannabis", "culture/"], ["Culture: People", "Officials, preachers, scientists and presidents", "culture/people/"], ["Culture: Stories", "The people behind the laws", "culture/stories/"], ["Sources", "Every source the index cites", "sources/"], ["Research progress", "How much of each country is researched, and what is missing", "research/"], ["Glossary", "The words of cannabis law, explained", "glossary/"], ["Charts", "The history and the scores in charts", "charts/"], ["World map", "Every country coloured by its score, full screen", "map/"], ["Map poster", "The world map as a printable poster", "map/poster/"], ["Context", "Beyond the score: each topic compared across countries", "context/"], ["Datasets", "What others count: use, offences, prices, opinion and more, by country", "datasets/"],
+    ["Articles", "The longer reads on single events", "articles/"], ["About", "What the project is, its aims and commitments", "about/"], ["Did you know?", "Curious cases from the index", "did-you-know/"], ["State of Cannabis", "The yearly survey and its results", "survey/"], ["Take the survey", "Anonymous, about ten minutes", "survey/take/"], ["Culture", "A cultural atlas of cannabis", "culture/"], ["Culture: People", "Officials, preachers, scientists and presidents", "culture/people/"], ["Culture: Stories", "The people behind the laws", "culture/stories/"], ["Sources", "Every source the index cites", "sources/"], ["Research progress", "How much of each country is researched, and what is missing", "research/"], ["Glossary", "The words of cannabis law, explained", "glossary/"], ["Charts", "The history and the scores in charts", "charts/"], ["World map", "Every country coloured by its score, full screen", "map/"], ["Map poster", "The world map as a printable poster", "map/poster/"], ["Context", "Beyond the score: each topic compared across countries", "context/"], ["Datasets", "What others count: use, offences, prices, opinion and more, by country", "datasets/"], ["Regions", "Cannabis laws continent by continent, and in the EU", "region/"],
   ].map(([n, s, u]) => ({ t: "p", n, s, u, ic: PAGE_ICONS[u] })),
   // the context topics, each compared across every country
   ...contextTopics.map((t) => ({ t: "p", n: `Context: ${t.name}`, s: t.help, u: `context/${t.slug}/`, ic: PAGE_ICONS["context/"] })),
+  // the regions: continents and groupings such as the EU
+  ...regions.map((r) => ({ t: "p", n: `Region: ${r.name}`, s: `${r.members.length} countries · average score ${r.avg.toFixed(1)}`, u: `region/${r.slug}/`, ic: PAGE_ICONS["region/"] })),
   // the outside datasets, each across every country
   ...datasetPages.map((p) => ({ t: "p", n: `Dataset: ${p.d.name}`, s: `${p.d.credit ?? p.d.publisher} · ${p.d.when}`, u: `datasets/${p.slug}/`, ic: PAGE_ICONS["datasets/"] })),

src/pages/sitemap.xml.js +3 −0
@@ -17,4 +17,5 @@ import { CULTURE_SECTIONS, cultureEntries } from "../lib/culture.js";
 import { contextTopics } from "../lib/contexts.js";
 import { datasetPages } from "../lib/dataset-pages.js";
+import { regions } from "../lib/regions.js";
 import { commits, pageCount, pageOf } from "../lib/gitlog.js";
 import { lastChanged } from "../lib/lastmod.js";
@@ -40,4 +41,6 @@ export function GET({ site }) {
     ["datasets/", latest],
     ...datasetPages.map((p) => [`datasets/${p.slug}/`, latest]),
+    ["region/", latest],
+    ...regions.map((r) => [`region/${r.slug}/`, latest]),
     // profiles
     ...places.map((c) => [`country/${c.slug}/`, c.details.reviewed]),

src/styles/global.css +33 −0
@@ -3578,2 +3578,35 @@ a.ctx-topic:hover { text-decoration: underline; text-decoration-thickness: 1px;
   .metric-rows a.metric-page { margin-left: 0; }
 }
+/* ── Regions (/region/, /region/<slug>/): the context pages' layout, with these additions ── */
+.rgp-list .ctxp-topic-n b { font-weight: 500; }
+.rgp-splits { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 40px; }
+.rgp-split-h { margin: 0 0 8px; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.rgp-split { display: flex; height: 10px; margin: 0 0 12px; overflow: hidden; border-radius: 3px; }
+.rgp-split i + i { box-shadow: inset 1px 0 0 var(--bg); }
+.rgp-split-key { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.rgp-split-key li { display: grid; grid-template-columns: auto auto minmax(0, 1fr); align-items: baseline; gap: 2px 10px; font-size: 13.5px; }
+.rgp-split-key b { font-weight: 500; }
+.rgp-split-key small { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rgp-table-wrap { overflow-x: auto; }
+.rgp-table th, .rgp-table td { padding: 9px 12px 9px 0; vertical-align: middle; }
+.rgp-table .ctxp-country { min-width: 11rem; }
+.src-table.rgp-table .num { padding: 9px 14px 9px 0; text-align: left; }
+.rgp-rank { width: 2.6rem; }
+.rgp-rank small { display: block; font: 400 10px var(--mono); color: var(--muted); }
+.rgp-seg { width: 4.2rem; font: 400 12.5px var(--mono); white-space: nowrap; }
+th.rgp-seg { font-size: 10px; text-transform: uppercase; letter-spacing: .03em; }
+td.rgp-seg i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 2px; background: var(--c); }
+.rgp-territory td { color: var(--muted); }
+.rgp-foot { margin: 10px 0 0; color: var(--muted); font-size: 12.5px; }
+.rgp-latest { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--text); }
+.rgp-latest li { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 4px 20px; padding: 12px 0; border-bottom: 1px solid var(--border); }
+.rgp-date { font: 400 11px var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding-top: 3px; }
+.rgp-entry { color: var(--text); font-size: 15px; line-height: 1.4; }
+.rgp-flag { margin-right: 8px; }
+.rgp-entry:hover { text-decoration: underline; text-underline-offset: 3px; }
+.rgp-latest .change-score { margin: 6px 0 0; }
+@media (max-width: 700px) {
… 5 more lines

    ↑↓ to move, ↵ to open.