Cannabis Freedom Index

Changelog ef93063

more people; culture on map

1 article written5 atlas entries written1 atlas entry edited14 files of code, design or docs +474 −35 lines

Articles and atlas entries 7

Reefer culture/language/reefer.md

text

*Reefer* is American slang for a cannabis cigarette, or for cannabis itself. It belongs t … a Viper" | | Mezz | Cannabis, after Mezz Mezzrow | The last is a person. [Mezz Mezzrow](https:/culture/en.wikipedia.orgpeople/wikimezz-mezzrow/Mezz_Mezzrow), born Milton Mesirow in Chicago, was a clarinettist and saxophonist who organised record … found that marijuana led neither to addiction nor to crime. It had no effect on the law.

Code, design and documents 14 files

source-archive.json +8 −8

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

src/assets/culture/louk-hulsman-1982.jpg +0 −0

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

src/assets/culture/marc-emery-2007.jpg +0 −0

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

src/assets/culture/russell-pasha-1933.jpg +0 −0

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

src/components/AverageChart.astro +11 −1
@@ -5,4 +5,5 @@
 // The plot is a stretched SVG; labels and markers are HTML so they stay readable at any width.
 import { averageHistory, averageHistoryByPopulation, formatDate, dateTime, lastUpdated } from "../lib/countries.js";
+import { TIERS, tierOf, tierColor } from "../lib/tiers.js";
 
 // `note` adds the caveat under the chart.
@@ -45,4 +46,8 @@ function view(range, weighting) {
   const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100;
   const ticks = Array.from({ length: Math.round((yMax - yMin) / tick) + 1 }, (_, i) => yMin + i * tick);
+  // the score tiers behind the line, cut to the visible range, so the reader sees which band the world is in
+  const bands = TIERS.map((t, i) => [Math.max(t.min, yMin), Math.min(i ? TIERS[i - 1].min : 100, yMax), t])
+    .filter(([lo, hi]) => hi > lo)
+    .map(([lo, hi, t]) => ({ name: t.name, color: tierColor(t.min), top: y(hi), height: y(lo) - y(hi) }));
 
   // at most about a dozen year labels, on round steps
@@ -59,5 +64,5 @@ function view(range, weighting) {
   // it lists are read from the dots
   const seek = JSON.stringify({ t0, t1, start, unit: "average", dp: 1, dots: true, y: [yMin, yMax], steps: steps.map((p) => [p.x, p.x, p.before, p.after]) });
-  return { seek, range: range.id, weight: weighting.id, startYear, now, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
+  return { seek, range: range.id, weight: weighting.id, startYear, now, bands, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line };
 }
 
@@ -72,4 +77,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
       <div class="avg-plot" data-seek={v.seek}>
         <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
+          {v.bands.map((b) => <rect class="band" x="0" width="100" y={b.top} height={b.height} style={`fill:${b.color}`} />)}
           {v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)}
           <path class="line" d={v.line} />
@@ -81,4 +87,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
             aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
         ))}
+        {v.bands.filter((b) => b.height >= 9).map((b) => <span class="avg-band" aria-hidden="true" style={`top:${b.top + b.height / 2}%;--c:${b.color}`}>{b.name}</span>)}
         <span class="c-seek" hidden></span>
         <div class="c-seek-tip" hidden></div>
@@ -96,4 +103,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
         <span class="avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}>
           <b>{fmt(v.now)}</b>
+          <span class="avg-tier" style={`--c:${tierColor(v.now)}`}>{tierOf(v.now).name}</span>
           <span class={`delta ${v.delta >= 0 ? "up" : "down"}`}>{v.delta >= 0 ? "+" : "−"}{fmt(Math.abs(v.delta))} since {v.startYear}</span>
         </span>
@@ -118,4 +126,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
       <div class="avg-plot" data-seek={v.seek}>
         <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
+          {v.bands.map((b) => <rect class="band" x="0" width="100" y={b.top} height={b.height} style={`fill:${b.color}`} />)}
           {v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)}
           <path class="line" d={v.line} />
@@ -127,4 +136,5 @@ const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; //
             aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
         ))}
+        {v.bands.filter((b) => b.height >= 9).map((b) => <span class="avg-band" aria-hidden="true" style={`top:${b.top + b.height / 2}%;--c:${b.color}`}>{b.name}</span>)}
         <span class="c-seek" hidden></span>
         <div class="c-seek-tip" hidden></div>

src/components/CountryChart.astro +24 −6
@@ -5,4 +5,5 @@
 // Base.astro, so it also works when the article is loaded into the drawer.
 import { changeDate, dateTime, lastUpdated } from "../lib/countries.js";
+import { TIERS, tierColor } from "../lib/tiers.js";
 
 const { history, context = [] } = Astro.props; // the country's score changes and its other history entries, newest first
@@ -34,4 +35,8 @@ const yMax = Math.min(100, Math.max(20, Math.ceil((top + 5) / tick) * tick)); //
 const y = (v) => (1 - v / yMax) * 100;
 const ticks = Array.from({ length: yMax / tick + 1 }, (_, i) => i * tick);
+// the score tiers as faint bands behind the line, as on the world average chart
+const bands = TIERS.map((t, i) => [t.min, Math.min(i ? TIERS[i - 1].min : 100, yMax), t])
+  .filter(([lo, hi]) => hi > lo)
+  .map(([lo, hi, t]) => ({ color: tierColor(t.min), top: y(hi), height: y(lo) - y(hi) }));
 
 const span = endYear - startYear;
@@ -58,8 +63,20 @@ const rawAt = (px) => {
 };
 // entries that moved no score sit on the line at their date
-const marks = [...context].reverse()
+const context_ = [...context].reverse()
   .filter((ch) => time(ch.date) >= t0 && time(ch.date) <= t1)
   .map((ch) => ({ ch, x: x(time(ch.date)) }))
   .map((m) => ({ ...m, y: y(rawAt(m.x)) }));
+// Dots closer than this (in % of the width) would overlap. A hollow mark that close to a score
+// dot is left out, and a run of close marks becomes one; the seeker still lists every entry.
+const NEAR = 3.5;
+const marks = context_
+  .filter((m) => !points.some((p) => Math.abs(p.x - m.x) < NEAR))
+  .reduce((runs, m) => {
+    const run = runs.at(-1);
+    if (run && m.x - run.at(-1).x < NEAR && Math.abs(m.y - run.at(-1).y) < 4) run.push(m);
+    else runs.push([m]);
+    return runs;
+  }, [])
+  .map((run) => ({ ...run.at(-1), n: run.length })); // at the latest, so the seeker can light it
 // what the seeker needs: the axis, the steps of the line and every entry
 const seek = JSON.stringify({
@@ -68,5 +85,5 @@ const seek = JSON.stringify({
   events: [
     ...points.map((p) => ({ x: p.x, d: changeDate(p.ch), t: p.ch.title, k: p.ch.kind.emoji, s: `${p.ch.score.before} → ${p.ch.score.after}` })),
-    ...marks.map((m) => ({ x: m.x, d: changeDate(m.ch), t: m.ch.title, k: m.ch.kind.emoji })),
+    ...context_.map((m) => ({ x: m.x, d: changeDate(m.ch), t: m.ch.title, k: m.ch.kind.emoji })),
   ].sort((a, b) => a.x - b.x),
 });
@@ -81,8 +98,13 @@ const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L
     <div class="avg-plot" data-seek={seek}>
       <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
+        {bands.map((b) => <rect class="band" x="0" width="100" y={b.top} height={b.height} style={`fill:${b.color}`} />)}
         {ticks.map((t) => <line class="grid" x1="0" x2="100" y1={y(t)} y2={y(t)} />)}
         <path class="line" d={line} />
       </svg>
       <span class={`c-chart-val start ${startBelow ? "below" : ""}`} style={`top:${y(startRaw)}%`} aria-hidden="true">{startScore}</span>
+      {marks.map((m) => (
+        <a class="avg-dot nomove" href={`${base}history/${m.ch.id}/`} style={`left:${m.x}%;top:${m.y}%`}
+          aria-label={m.n > 1 ? `${m.n} entries around ${changeDate(m.ch)}, the latest: ${m.ch.title}. No change in score.` : `${changeDate(m.ch)}: ${m.ch.title}. No change in score.`}></a>
+      ))}
       {points.map((p) => (
         <a class="avg-dot" href={`${base}history/${p.ch.id}/`} style={`left:${p.x}%;top:${p.y}%`}
@@ -91,8 +113,4 @@ const line = `M0,${y(startRaw)}` + points.map((p) => (p.ch.estimate ? `H${p.x0}L
         </a>
       ))}
-      {marks.map((m) => (
-        <a class="avg-dot nomove" href={`${base}history/${m.ch.id}/`} style={`left:${m.x}%;top:${m.y}%`}
-          aria-label={`${changeDate(m.ch)}: ${m.ch.title}. No change in score.`}></a>
-      ))}
       <span class="c-seek" hidden></span>
       <div class="c-seek-tip" hidden></div>

src/components/CountryFacts.astro +31 −0
@@ -0,0 +1,31 @@
+---
+// "Did you know?" at the foot of a country page: every case that belongs to the country, all
+// open across the full width. Nothing is shown for a country without one.
+import { factsOf } from "../lib/facts.js";
+
+const { c } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const facts = factsOf(c);
+---
+{facts.length > 0 && (
+  <section class="dyk dyk-country" aria-label={`Did you know? ${c.name}`}>
+    <div class="dyk-top">
+      <h2>Did you know? <span class="dyk-n">{facts.length}</span></h2>
+      <div class="dyk-nav"><a class="dyk-all-link" href={`${base}did-you-know/`}>All cases →</a></div>
+    </div>
+    <ol class="dyk-facts dyk-all">
+      {facts.map((f) => (
+        <li class="dyk-fact">
+          <p class="dyk-kicker">{f.kicker}</p>
+          <p class="dyk-text">
+            {f.parts.map((part) => typeof part === "string" ? part
+              : part.country ? (part.country.id === c.id ? <b>{part.label}</b> : <a href={`${base}country/${part.country.slug}/`}>{part.label}</a>)
+              : part.entry ? <a href={`${base}${part.entry.url}`}>{part.label}</a>
+              : <a href={`${base}history/${part.change.id}/`}>{part.label}</a>)}
+          </p>
+          {f.culture ? <a class="dyk-more" href={`${base}${f.culture.url}`}>Read the entry →</a> : f.history && <a class="dyk-more" href={`${base}history/${f.history.id}/`}>The full story →</a>}
+        </li>
+      ))}
+    </ol>
+  </section>
+)}

src/components/HomePage.astro +28 −4
@@ -14,4 +14,5 @@ import { SHOW_PRICES } from "../lib/config.js";
 import { homeSeo } from "../lib/seo.js";
 import { viewByMetric } from "../lib/map-views.js";
+import { cultureOf } from "../lib/culture.js";
 import { panelRows, PANEL_FROM } from "../lib/exports.js";
 
@@ -56,6 +57,13 @@ const MED_ORDER = ["yes", "limited", "no"];
 // `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) } }));
+// `culture` is how many atlas entries a country appears in, with the names of the first few
+// (most important first), for the map's Culture view.
+const mapData = places.map((c) => {
+  const { id, num, name, flag, raw, score, rank, s, use, planting, clubs, driving, visitors, rec, med } = c;
+  const atlas = cultureOf(c);
+  return { 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), culture: atlas.length },
+    atlas: atlas.slice(0, 4).map((e) => e.title) };
+});
 // 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".
@@ -73,4 +81,9 @@ const USE_SCALES = {
     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.",
   },
+  culture: {
+    label: "Entries in the cultural atlas",
+    steps: [[0, "None yet"], [1, "1"], [2, "2–3"], [4, "4–6"], [7, "7 or more"]],
+    note: "Words, traditions and people in the cultural atlas that belong to each country. The atlas is growing: a pale country may simply not have been written about yet.",
+  },
 };
 const PRICE_SCALE = {
@@ -214,4 +227,8 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
           <a {...viewAttrs("ratio")}>Share of users ÷ index</a>
         </div>
+        <div class="metric-group">
+          <span>Culture</span>
+          <a {...viewAttrs("culture")}>Atlas entries</a>
+        </div>
         <div class="metric-group">
           <span>Law</span>
@@ -230,5 +247,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
         </div>
       </nav>
-      <p class="sub map-sub" id="mapSub" data-score="Red means prison risk, green means free. Select a country for its full breakdown." data-use="The stronger the blue, the more people use cannabis. Select a country for its full breakdown." data-ratio="The stronger the blue, the further cannabis use runs ahead of the freedom to use it. Select a country for its full breakdown." data-price="Pale yellow means cheaper, violet means more expensive. Countries without a sourced price are hatched." data-planting="Green means legal or sold freely, red means illegal or banned. Hatched countries have not been researched yet. Not part of the score.">Red means prison risk, green means free. Select a country for its full breakdown.</p>
+      <p class="sub map-sub" id="mapSub" data-score="Red means prison risk, green means free. Select a country for its full breakdown." data-use="The stronger the blue, the more people use cannabis. Select a country for its full breakdown." data-ratio="The stronger the blue, the further cannabis use runs ahead of the freedom to use it. Select a country for its full breakdown." data-culture="The stronger the blue, the more of the country's words, customs and people the cultural atlas covers. Select a country for its entries." data-price="Pale yellow means cheaper, violet means more expensive. Countries without a sourced price are hatched." data-planting="Green means legal or sold freely, red means illegal or banned. Hatched countries have not been researched yet. Not part of the score.">Red means prison risk, green means free. Select a country for its full breakdown.</p>
       <div class="card map-card">
         <div class="map-wrap" id="mapWrap">
@@ -539,5 +556,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
       document.querySelectorAll("[data-legend]").forEach((el) => { el.hidden = el.dataset.legend !== shown; });
       const sub = $("#mapSub");
-      sub.textContent = isClubs() ? PLANTING.subs[statusKey()] : isPlant() ? sub.dataset.planting : isPrice() ? sub.dataset.price : metric === "ratio" ? sub.dataset.ratio : isUse() ? sub.dataset.use : sub.dataset.score;
+      sub.textContent = isClubs() ? PLANTING.subs[statusKey()] : isPlant() ? sub.dataset.planting : isPrice() ? sub.dataset.price : metric === "ratio" ? sub.dataset.ratio : metric === "culture" ? sub.dataset.culture : isUse() ? sub.dataset.use : sub.dataset.score;
     }
     // On the home page an option recolours the map in place, and "Expand" then points at that
@@ -621,4 +638,11 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
                     <div class="row"><span>Observation</span><b>${p.date}</b></div>` : ""}`;
         }
+        if (c && metric === "culture") {
+          const n = c.use.culture;
+          return `<strong>${c.flag} ${c.name}</strong>
+            <div class="row"><span>Atlas entries</span><b>${n || "None yet"}</b></div>
+            ${c.atlas.map((t) => `<div class="row"><span>${t}</span></div>`).join("")}
+            ${n > c.atlas.length ? `<div class="row"><span>and ${n - c.atlas.length} more</span></div>` : ""}`;
+        }
         return c && metric === "ratio"
             ? `<strong>${c.flag} ${c.name}</strong>

src/layouts/Base.astro +7 −8
@@ -69,6 +69,8 @@ const schemas = jsonld ? [jsonld].flat() : [];
         <Leaf size={24} />
       </span>
-      Cannabis Freedom Index
+      <span class="brand-name">Cannabis Freedom Index</span>
     </a>
+    {/* On a country page: who this is, shown once its own heading has scrolled away. */}
+    <slot name="here" />
     {/* Search comes first in the bar, with the keys that open it from anywhere. */}
     <button class="icon-btn search-btn" id="searchOpen" aria-label="Search" title="Search (⌘K)" aria-keyshortcuts="Meta+K Control+K">
@@ -427,11 +429,8 @@ const schemas = jsonld ? [jsonld].flat() : [];
     // could never be reached until the page ends.
     (() => {
-      const rails = document.querySelectorAll(".ev-rail, .c-rail");
-      if (!rails.length) return;
-      const fit = () => rails.forEach((r) => r.classList.toggle("too-tall", r.scrollHeight > innerHeight - 92));
-      fit();
-      addEventListener("resize", fit);
-      addEventListener("load", fit);
-      if (window.ResizeObserver) { const ro = new ResizeObserver(fit); rails.forEach((r) => ro.observe(r)); }
+      const here = document.querySelector(".topbar-here"), head = document.querySelector(".d-head");
+      if (!here || !head || !window.IntersectionObserver) return;
+      new IntersectionObserver(([e]) => document.body.classList.toggle("past-head", !e.isIntersecting && e.boundingClientRect.top < 0)).observe(head);
+      here.addEventListener("click", (e) => { e.preventDefault(); scrollTo({ top: 0, behavior: "smooth" }); });
     })();
   </script>

src/lib/facts.js +16 −1
@@ -125,5 +125,5 @@ const fromCulture = [
   { kicker: "Words · Réunion", culture: "language/zamal", text: "On Réunion, part of [[FR|France]], cannabis is [[c:language/zamal|zamal]], and a man losing his temper is said to have ‘his zamal rising’." },
   { kicker: "Words · Malawi", culture: "language/chamba", text: "[[MW|Malawi]] has three famous C's: its fish, its tea and its [[c:language/chamba|chamba]], the country's word for cannabis." },
-  { kicker: "Words · Mezz", culture: "language/reefer", text: "In the jazz slang of the 1930s, cannabis could be called ‘mezz’, after Mezz Mezzrow, a clarinettist at least as well known in the jazz world for supplying it. See [[c:language/reefer|reefer]]." },
+  { kicker: "Words · Mezz", culture: "language/reefer", text: "In the jazz slang of the 1930s, cannabis could be called ‘mezz’, after [[c:people/mezz-mezzrow|Mezz Mezzrow]], a clarinettist at least as well known in the jazz world for supplying it. See [[c:language/reefer|reefer]]." },
   { kicker: "Religion · Brazil", culture: "traditions/santa-maria-in-santo-daime", text: "One branch of [[BR|Brazil]]'s ayahuasca church, Santo Daime, took up cannabis in the 1970s and named it [[c:traditions/santa-maria-in-santo-daime|Santa Maria]], after the Virgin. Its users called themselves marianos." },
   { kicker: "Religion · Réunion", culture: "traditions/zamal-on-reunion", text: "In Tamil temples on Réunion, a possessed priest smokes cannabis the gods ask for, and must show no effect at all: [[c:traditions/zamal-on-reunion|his indifference proves the god is in him]]." },
@@ -143,4 +143,8 @@ const fromCulture = [
   { kicker: "People · India", culture: "people/william-oshaughnessy", text: "[[c:people/william-oshaughnessy|The doctor who brought cannabis into Western medicine]], in Calcutta in 1839, was knighted not for that but for building [[IN|India]]'s telegraph." },
   { kicker: "People · Uruguay", culture: "people/jose-mujica", history: "uruguay-incb-criticism-2013", text: "When the head of the UN drug board said [[UY|Uruguay]]'s cannabis law showed a ‘pirate attitude’, its president, [[c:people/jose-mujica|José Mujica]], told him to stop lying and come and see for himself." },
+  { kicker: "People · Egypt", culture: "people/russell-pasha", history: "league-russell-anslinger-exchange-1938", text: "When Harry Anslinger told the League of Nations in 1938 that marihuana led to murder and rape, [[c:people/russell-pasha|Egypt's own drug police chief]] answered that the hashish addict was a tiresome individual, not a dangerous criminal." },
+  { kicker: "People · Comoros", culture: "people/ali-soilih", history: "comoros-legalisation-1975", text: "The [[KM|Comoros]] [[h:comoros-legalisation-1975|legalised cannabis]] in 1975 under [[c:people/ali-soilih|Ali Soilih]], a revolutionary president who also lowered the voting age to fourteen. He was overthrown and killed in 1978, and the ban came back." },
+  { kicker: "People · Canada", culture: "people/marc-emery", history: "canada-emery-arrest-2005", text: "From 1998 [[c:people/marc-emery|Marc Emery]] declared his mail-order cannabis seed sales to the tax office in [[CA|Canada]], paying nearly 600,000 dollars. Canada never charged him; it extradited him to the United States, which jailed him for five years." },
+  { kicker: "People · United States", culture: "people/mezz-mezzrow", text: "[[c:people/mezz-mezzrow|Mezz Mezzrow]], a jazz clarinettist, was arrested in 1940 with sixty joints at the New York World's Fair. In jail he insisted he was Black, and was moved to the Black block and asked to lead the prison band." },
 ];
 
@@ -177,2 +181,13 @@ export function getFacts() {
   });
 }
+
+// The cases that belong to one country, for the panel at the foot of its page: those that link
+// the country, whose history entry (or a linked one) is set there, or whose atlas entry is kept
+// there alone. Atlas entries shared by many countries would put a case on every one of them.
+export function factsOf(c) {
+  const here = (code) => !!code && [c.id, c.slug].map((x) => String(x).toLowerCase()).includes(String(code).toLowerCase());
+  return getFacts().filter((f) =>
+    f.parts.some((p) => p.country?.id === c.id || here(p.change?.country)) ||
+    here(f.history?.country) ||
+    (f.culture?.countries.length === 1 && f.culture.countries[0].id === c.id));
+}

src/lib/map-views.js +2 −0
@@ -23,4 +23,6 @@ export const MAP_VIEWS = [
   { slug: "use-against-freedom", metric: "ratio", title: "Cannabis use against cannabis freedom, by country",
     lede: "The share of people who use cannabis divided by the country's index score: where use runs far ahead of what the law allows." },
+  { slug: "culture", metric: "culture", title: "Cannabis culture around the world",
+    lede: "How much of each country's cannabis culture the atlas covers: the words for the plant, the customs and faiths it belongs to, and the people who shaped its laws. The stronger the blue, the more entries." },
   { slug: "recreational", metric: "status:rec", title: "Where recreational cannabis is legal",
     lede: "The legal status of cannabis for adults in every country: legal, tolerated, decriminalised, illegal, or punishable by death." },

src/pages/country/[id].astro +4 −0
@@ -1,5 +1,7 @@
 ---
+import { tierColor } from "../../lib/tiers.js";
 import Base from "../../layouts/Base.astro";
 import CountryArticle from "../../components/CountryArticle.astro";
+import CountryFacts from "../../components/CountryFacts.astro";
 import { countries, places } from "../../lib/countries.js";
 import { countrySeo } from "../../lib/seo.js";
@@ -17,4 +19,5 @@ const seo = countrySeo(c, Astro.site);
 ---
 <Base title={c.name} seoTitle={seo.seoTitle} description={seo.description} jsonld={seo.jsonld} type="article">
+  <a slot="here" class="topbar-here" href="#top" style={`--c:${tierColor(c.raw)}`}><span aria-hidden="true">{c.flag}</span><span class="here-name">{c.name}</span><b>{c.score}</b></a>
   <main class="country-page">
     <div class="page-bar">
@@ -23,4 +26,5 @@ const seo = countrySeo(c, Astro.site);
     </div>
     <CountryArticle c={c} />
+    <CountryFacts c={c} />
     <nav class="pager" aria-label="Neighbouring countries by rank">
       {prev

src/pages/country/[id]/[sub].astro +2 −1
@@ -4,5 +4,5 @@ import Base from "../../../layouts/Base.astro";
 import CountryArticle from "../../../components/CountryArticle.astro";
 import { countries, subunitsOf } from "../../../lib/countries.js";
-import { tierOf } from "../../../lib/tiers.js";
+import { tierOf, tierColor } from "../../../lib/tiers.js";
 import { subunitSeo } from "../../../lib/seo.js";
 
@@ -18,4 +18,5 @@ const description = `${sub.name} (${c.name}) scores ${sub.score} out of 100 on t
 ---
 <Base title={`${sub.name}, ${c.name}`} description={description} {...subunitSeo(c, sub, Astro.site)} type="article">
+  <a slot="here" class="topbar-here" href="#top" style={`--c:${tierColor(sub.raw)}`}><span aria-hidden="true">{c.flag}</span><span class="here-name">{sub.name}, {c.name}</span><b>{sub.score}</b></a>
   <main class="country-page">
     <div class="page-bar">

src/styles/global.css +25 −5
@@ -1007,4 +1007,6 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 .c-main { scroll-margin-top: var(--hdr, 57px); }
 .drawer .c-main { scroll-margin-top: 0; }
+/* in one column the tabs follow the score chart: give them room */
+.c-rail + .c-main { margin-top: clamp(28px, 5vw, 44px); }
 .c-tabs { position: sticky; top: var(--hdr, 57px); z-index: 5; display: flex; gap: 4px 26px; overflow-x: auto; scrollbar-width: none; background: var(--bg); border-top: 1px solid var(--text); border-bottom: 1px solid var(--border); }
 .c-tabs::-webkit-scrollbar { display: none; }
@@ -1035,6 +1037,6 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
   .country-page .d-score .tier { font-size: 36px; }
   .country-page .answers { grid-template-columns: repeat(5, minmax(0, 1fr)); }
-  .country-page .c-rail { position: sticky; top: 76px; }
   .country-page .c-main > .d-h:first-child { margin-top: 0; }
+  .country-page .c-rail + .c-main { margin-top: 0; }
 }
 
@@ -1216,5 +1218,5 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 .ev-track span { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--text); }
 .ev .confidence { margin-top: 16px; }
-.ev-rail { position: sticky; top: 76px; display: grid; gap: 40px; }
+.ev-rail { display: grid; gap: 40px; align-content: start; }
 .ev-country { border-top: 1px solid var(--text); padding-top: 12px; display: grid; gap: 12px; }
 .ev-name { font: 400 34px/1 var(--display); display: flex; align-items: baseline; gap: 12px; }
@@ -1244,8 +1246,5 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 @media (max-width: 900px) {
   .ev-top, .ev-body, .ev-body.no-aside { grid-template-columns: minmax(0, 1fr); }
-  .ev-rail { position: static; }
 }
-/* a side pane stays in view only while it fits the window; a taller one scrolls with the page (Base.astro) */
-.ev-rail.too-tall, .country-page .c-rail.too-tall { position: static; }
 
 /* a long read inside a history entry (articles/<id>.md) */
@@ -1930,2 +1929,23 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase
 .cu-dyk { padding-top: clamp(32px, 4vw, 48px); }
 .cu-dyk .dyk { margin-top: 0; }
+/* the country's own cases, above the pager: own word in bold, not a link to the page itself */
+.dyk-country .dyk-text b { font-weight: inherit; }
+.dyk-country + .pager { margin-top: clamp(32px, 5vw, 56px); }
+
+/* On a country page the top bar names the country once its heading has scrolled away (Base.astro). */
+.topbar-here { display: flex; align-items: baseline; gap: 8px; min-width: 0; padding-left: 20px; border-left: 1px solid var(--border); font: 400 21px/1 var(--display); white-space: nowrap; opacity: 0; visibility: hidden; transform: translateY(4px); transition: opacity .2s, transform .2s, visibility .2s; }
+.topbar-here > span:first-child { font-size: 16px; flex: none; }
+.here-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+.topbar-here b { flex: none; font: 500 13px var(--mono); color: var(--text); padding: 2px 6px; border-bottom: 2px solid var(--c); }
+.past-head .topbar-here { opacity: 1; visibility: visible; transform: none; }
+@media (max-width: 899px) {
+  .topbar-here { padding-left: 0; border-left: 0; font-size: 19px; }
+  .past-head .topbar .brand-name { display: none; }
+}
+@media (prefers-reduced-motion: reduce) { .topbar-here { transition: none; } }
+
+/* World average and country charts: the score tiers as faint bands behind the line, named at the left edge, where the line is high */
+.avg-plot .band { fill-opacity: .13; }
+.avg-band { position: absolute; left: 8px; transform: translateY(-50%); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: color-mix(in srgb, var(--c) 70%, var(--text)); pointer-events: none; white-space: nowrap; }
+.avg-tier { font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; padding-left: 8px; border-left: 3px solid var(--c); color: var(--text-2); }
+@media (max-width: 600px) { .avg-band { font-size: 9.5px; } }

    ↑↓ to move, ↵ to open.