Cannabis Freedom Index

Changelog 9948cf3tree, with Claude Opus 5.5

Country profile: "Cannabis in …" opening, words, history years, quieter datasets

- The opening is about people: who uses it ("1 in N"), use across the region (linked ranking), what people think (Pew, Gallup), cannabis in figures (a few cannabis datasets with where the country stands), in two equal columns for every country. - New `words` field in country files: what people call cannabis, with spelling, language, pronunciation, sources and a link to the atlas entry. Written for Czechia and Canada; rules in DATA-MAINTENANCE.md. - History uses the History page's years (HistoryYears.astro): sticky year and month index, newest or oldest first; the month marker moved into the shared component. - Culture as image cards, datasets as open ruled entries with grey sparklines; menu as a full-width band; Did you know and Datasets before History. - Source cards show the full address and the archived copy; the radar shows the world average alone when its key is hovered.

2 country profiles edited14 files of code, design or docs +623 −322 lines

Countries and states 2 files

Canada countries/ca/index.yaml

sources

Wiktionary — weed (English noun, sense "cannabis") Wiktionary — pot (English, etymology 2, "marijuana") Wiktionnaire — pot (French noun 3, "(Canada) (Familier) Cannabis ; marijuana") Wiktionnaire — herbe (French, sense 5, familiar, "la marijuana") Marihuana Medical Access Regulations, SOR/2001-227 (repealed) 29 unchanged

words

word cannabis · lang English and French · meaning the word of the 2018 Cannabis Act · atlas cannabis · sources act, word weed · lang English · meaning informal · sources wikt_weed, word pot · lang English and Canadian French · meaning informal; 'du pot' in Quebec · atlas pot · sources wikt_pot, wikt_pot_fr, word herbe · lang French · meaning 'grass'; familiar · atlas grass · sources wikt_herbe, word marihuana · lang English · meaning the law's spelling until 2018 · atlas marijuana · sources mmar

Czechia countries/cz.yaml

sources

Wikislovník — tráva (Czech Wiktionary) Wikislovník — marihuana (Czech Wiktionary), with its colloquial and slang synonyms Wikislovník — konopí (Czech Wiktionary) Wikislovník — brko (Czech Wiktionary) 24 unchanged

words

word tráva · lang Czech · say [traːva] · meaning 'grass'; slang · atlas grass · sources wikt_trava, word marihuana · lang Czech · say [marɪɦʊana] · meaning the standard word · atlas marijuana · sources wikt_marihuana, word konopí · lang Czech · say [kɔnɔpiː] · meaning 'hemp', the plant and the drug · atlas cannabis · sources wikt_konopi, word marjánka · lang Czech · meaning colloquial, from marihuana · sources wikt_marihuana, word brko · lang Czech · say [ˈbr̩.kɔ] · meaning a joint; 'feather', slang · atlas joint · sources wikt_brko, word hulení · lang Czech · meaning slang, from hulit 'to smoke' · sources wikt_marihuana

Code, design and documents 14 files

datasets/datasets.yaml +2 −2
@@ -24,6 +24,6 @@
 #             A part of the details whose figure is missing is left out. For a series, `years`
 #             picks the years listed in the details
-#   rail      true for the few datasets also shown in the profile's side column ("Beyond the
-#             law"); every dataset is listed in the profile's Datasets tab
+#   rail      true for the few datasets also shown in the profile's opening ("Cannabis in
+#             …"); every dataset is listed in the profile's Datasets section
 #   map       optional: how one field colours the world map, on a view of its own in `group`
 #             (Society by default): `steps` are where each of the five colours starts; `format`

docs/DATA-MAINTENANCE.md +22 −2
@@ -539,4 +539,24 @@ around them.
   `spotlights`.
 
+### Words for cannabis
+
+`words` (top level, beside `context`) lists what people in the country actually call cannabis,
+shown as cards in the profile's opening, "Cannabis in …". Best known first; four to eight is plenty.
+
+```yaml
+words:
+  - { word: tráva, lang: Czech, say: "[traːva]", meaning: "'grass'; slang", atlas: grass, sources: [wikt_trava] }
+```
+
+- `word` as people write it, in its own script; `lang` the language or variety ("Canadian
+  French"); `say` a pronunciation or romanisation, where the source gives one; `meaning` what the
+  word says and its register (slang, the law's word), in a few words.
+- `atlas` links the word to its Culture/Language entry (`culture/language/<slug>.md`), when there
+  is one for the same word or its direct translation; the build fails if the entry does not exist.
+- Only words used in the country. An atlas entry that lists the country is not a reason to add its
+  word here: 420 is American slang, not a Canadian word for the plant.
+- Each word needs a source: a dictionary of the language (Wiktionary is fine for slang), a law
+  for the official term, or a study of drug slang.
+
 ### Research
 
@@ -818,6 +838,6 @@ group) and are never part of the score.
 
 To add a dataset: put its CSV in `/datasets` and add a definition to `datasets.yaml`; the map
-view follows from that. The profile block is written for each dataset in
-`src/components/CountryDatasets.astro`.
+view follows from that. The profile's opening ("Cannabis in …") is written in `src/components/CountryIntro.astro`;
+every dataset also gets a card in the Datasets section.
 
 ## Quick answers

src/components/CountryArticle.astro +76 −98
@@ -2,5 +2,5 @@
 // Full write-up for one country. Rendered on its own page, and fetched into the
 // drawer on the home page — so there is a single template for both views.
-import { CONTEXT_ICONS, useAges, SEGMENTS, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP, countries, changes, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js";
+import { CONTEXT_ICONS, SEGMENTS, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP, countries, changes, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js";
 import { TIERS, tierOf, tierColor, tierIndex } from "../lib/tiers.js";
 import { unitMap } from "../lib/unit-map.js";
@@ -8,14 +8,12 @@ import { short } from "../lib/short.js";
 import CountryChanges from "./CountryChanges.astro";
 import CountryChart from "./CountryChart.astro";
-import PricePanel from "./PricePanel.astro";
-import CountryDatasets from "./CountryDatasets.astro";
+import CountryIntro from "./CountryIntro.astro";
+import { theName } from "../lib/seo.js";
 import CountryDatasetTab from "./CountryDatasetTab.astro";
 import { datasetsOf } from "../lib/datasets.js";
-import { USE_COMPARISON_NOTE } from "../lib/use-labels.js";
 import SourceTip from "./SourceTip.astro";
 import SourceCitation from "./SourceCitation.astro";
 import Cited from "./Cited.astro";
 import { references, citeParts, citedKeys } from "../lib/cite.js";
-import { SHOW_PRICES } from "../lib/config.js";
 import { PLANTING_STATUS, STATUS_SECTIONS, PLANTING_TRADE } from "../lib/segment-ids.js";
 import { TREATMENT, punishmentText, punishmentExtra } from "../lib/penalties.js";
@@ -24,8 +22,7 @@ import { Icon } from "astro-icon/components";
 import PenaltyTable from "./PenaltyTable.astro";
 import { answersFor } from "../lib/answers.js";
-import CultureList from "./CultureList.astro";
-import { cultureOf } from "../lib/culture.js";
+import { Image } from "astro:assets";
+import { cultureOf, cultureSection } from "../lib/culture.js";
 import { factsOf } from "../lib/facts.js";
-import { priceHistoryFor } from "../lib/prices.js";
 import FactText from "./FactText.astro";
 
@@ -56,4 +53,6 @@ const scored = sub ? [] : history.filter((ch) => ch.score); // the ones that mov
 // folded in the Sources section at the end, under a summary.
 const refs = references("src");
+// the words for cannabis in the opening come first in reading order, so they take the first numbers
+const wordRefs = c.details.words.map((w) => w.src.map((x) => refs.cite(x)));
 const partsOf = (block, refs, where) => citeParts(block.cite.text, refs, (k) => block.cite.keys.includes(k) && block.cite.of(k), block.cite.keys, `countries/${c.slug}.yaml: ${where}`);
 const segParts = d ? d.segments.map((seg, i) => seg && partsOf(seg, refs, SEGMENTS[i].id)) : [];
@@ -75,10 +74,6 @@ const onMap = (help) => `${help} · See the world map`;
 const ctxHas = (key) => Boolean(ctxList?.some((x) => x?.key === key));
 const INCB = ["International Narcotics Control Board — Travellers carrying medicines containing controlled substances", "https://www.incb.org/incb/en/travellers/index.html", null, "https://www.incb.org/incb/en/travellers/index.html"];
-// 48,000,000 → "48 million", 770,000 → "770,000"
-const people = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)} million` : n.toLocaleString("en-GB"));
 const use = sub ? null : c.use;
 const unitUse = sub?.use ?? null; // a state's or province's own survey figure
-// a disputed territory with no survey takes the figure of the country it is part of
-const useFrom = use?.estimate_from ? countries.find((x) => x.id === use.estimate_from.toUpperCase()) : null;
 // Crossing a border with cannabis. This is general guidance, the same rule almost everywhere,
 // worded for the country's status; it is not country-specific research.
@@ -102,7 +97,8 @@ const datasetCount = Object.keys(datasetsOf(c.slug)).length;
 // words and traditions of the cultural atlas that belong to this country; a state shows its country's
 const culture = cultureOf(c);
+const CULTURE_CARDS = 8;
 const facts = sub ? [] : factsOf(c);
-// The figures at a glance: users, how people judge it, how free the country is in general, prices.
-const glance = Boolean(use || unitUse || (!sub && (c.datasets.pew_marijuana || c.datasets.hfi)) || (SHOW_PRICES && !sub && priceHistoryFor(c.id).length));
+// "Cannabis in …": the people before the law, who use it and what they think (CountryIntro.astro)
+const glance = Boolean(use || unitUse);
 // The whole profile is one page; a sticky row of links under the masthead jumps to each part and
 // marks the one in view: [id, label, count]. The ids are the old tab names, so links like #history
@@ -110,13 +106,14 @@ const glance = Boolean(use || unitUse || (!sub && (c.datasets.pew_marijuana || c
 const plural = group ? group.plural.split(" ")[0] : "";
 const sections = [
-  glance && ["overview", "At a glance"],
+  glance && ["overview", "Overview"],
   group && !sub && ["units", plural[0].toUpperCase() + plural.slice(1), group.units.length],
   ["score", group && !sub ? "National estimate" : "Score"],
   ctxList && ["context", "Context", ctxList.filter(Boolean).length + (travel ? 1 : 0)],
-  history.length > 0 && ["history", "History", history.length],
-  group && sub && ["units", `Other ${plural}`, group.units.length - 1],
-  datasetCount > 0 && ["datasets", "Datasets", datasetCount],
   culture.length > 0 && ["culture", "Culture", culture.length],
   facts.length > 0 && ["did-you-know", "Did you know", facts.length],
+  datasetCount > 0 && ["datasets", "Datasets", datasetCount],
+  // the history can run to hundreds of entries, so it comes after the shorter parts
+  history.length > 0 && ["history", "History", history.length],
+  group && sub && ["units", `Other ${plural}`, group.units.length - 1],
   refs.list.length > 0 && ["sources", "Sources", refs.list.length],
 ].filter(Boolean);
@@ -224,52 +221,7 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
 
   {glance && (
-    <section class="c-sec c-glance" id="overview">
-      <h2 class="d-h">At a glance <span>not part of the score{datasetCount > 0 && <> · <a href="#datasets">all {datasetCount} datasets ↓</a></>}</span></h2>
-      <div class="glance-grid">
-        {use && (
-          <dl class="c-use">
-            {use.users != null && (
-              <div>
-                <dt>Cannabis users</dt>
-                <dd>{use.estimate || use.users_approximate ? "about " : ""}{people(use.users)}</dd>
-              </div>
-            )}
-            <div>
-              <dt>Past-year share</dt>
-              <dd>{use.estimate ? "about " : ""}{use.prevalence}%</dd>
-            </div>
-            <p>{use.estimate ? "This site’s estimate" : `UNODC · survey ${use.period ?? use.year}`}</p>
-            <SourceTip><span>Past-year cannabis use among people aged {useAges(use)}. </span>{useFrom
-              ? `This site's figure: there is no survey of ${c.name} alone, so ${useFrom.name}'s rate from its ${useFrom.use.period ?? useFrom.use.year} survey is applied to an estimate of the people aged 15–64 here.`
-              : use.estimate
-              ? `This site's figure: there is no national survey, so the median rate of surveyed countries in ${c.region} is applied to people aged 15–64.`
-              : `People who used cannabis in the past year, from a ${use.period ?? use.year} survey${use.users != null ? `, applied to today's population aged ${useAges({ ages: use.population_ages || use.ages })}` : ""}${use.users_approximate ? ". The population is approximate: the survey ages cut through an age group, or were not recorded and ages 15–64 are used" : ""}${use.source === "earlier" ? ". An earlier figure: UNODC's 2026 table has none for this country" : ""}.`}<br />{USE_COMPARISON_NOTE}<br />{use.source === "wdr2026"
-              ? <><a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a>, annex table 1.2</>
-              : use.source === "earlier"
-                ? <>An earlier UNODC figure, as collected in <a href="https://en.wikipedia.org/wiki/List_of_countries_by_annual_cannabis_use" target="_blank" rel="noopener noreferrer">Wikipedia's list of annual cannabis use by country</a></>
-                : useFrom
-                ? <>{useFrom.name}'s figure from the <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a>; the population aged 15–64 is this place's total times {useFrom.name}'s share of those ages</>
-                : <>A median computed on this site from surveyed countries in {c.region}, using <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a> figures</>}{use.users != null && <>. Population from the <a href="https://data.worldbank.org/" target="_blank" rel="noopener noreferrer">World Bank</a>, aged {useAges({ ages: use.population_ages || use.ages })}</>}. <a href={`${base}sources/#dataset-cannabis_use`}>The whole dataset →</a></SourceTip>
-          </dl>
-        )}
-        {unitUse && (
-          <dl class="c-use">
-            {unitUse.users && (
-              <div>
-                <dt>Cannabis users</dt>
-                <dd>{people(unitUse.users)}</dd>
-              </div>
-            )}
-            <div>
-              <dt>Share aged {unitUse.ages}</dt>
-              <dd>{unitUse.share}%</dd>
-            </div>
-            <p>People who used cannabis in the past year, {unitUse.year}.{unitUse.note && ` ${unitUse.note}`} The survey's own age band, so not directly comparable with the national figures.</p>
-            <SourceTip>{unitUse.source[1] ? <a href={unitUse.source[1]} target="_blank" rel="noopener noreferrer">{unitUse.source[0]}</a> : unitUse.source[0]}</SourceTip>
-          </dl>
-        )}
-        {!sub && <CountryDatasets c={c} />}
-        {SHOW_PRICES && !sub && <PricePanel id={c.id} />}
-      </div>
+    <section class="c-sec c-intro" id="overview">
+      <h2 class="d-h">Cannabis in {theName(sub ? sub.name : c.name)} <span>not part of the score{datasetCount > 0 && <> · <a href="#datasets">all {datasetCount} datasets ↓</a></>}</span></h2>
+      <CountryIntro c={c} sub={sub} wordRefs={wordRefs} />
     </section>
   )}
@@ -347,5 +299,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
             </svg>
             {SEGMENTS.map((s, i) => { const [lx, ly] = radarPt(i, 11.5); return (
-              <a class={`c-radar-label side-${radarSide(i)}`} href={`#seg-${s.id}`} data-seg-link={s.id} style={`left:${lx.toFixed(1)}%;top:${ly.toFixed(1)}%`}>{s.name} <b>{v.s[i]}</b></a>
+              <a class={`c-radar-label side-${radarSide(i)}`} href={`#seg-${s.id}`} data-seg-link={s.id} style={`left:${lx.toFixed(1)}%;top:${ly.toFixed(1)}%`}>{s.name} <b class="own">{v.s[i]}</b><b class="avg">{segAvg[i].toFixed(1)}</b></a>
             ); })}
           </div>
@@ -353,5 +305,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
             <span><i class="sw-shape" style={`--c:${tierColor(v.raw)}`}></i>{sub ? sub.name : c.name}</span>
             {sub && <span><i class="sw-nat"></i>{c.name}</span>}
-            <span><i class="sw-avg"></i>World average</span>
+            <span class="key-avg" tabindex="0" title="Show the world average alone"><i class="sw-avg"></i>World average</span>
           </figcaption>
         </figure>
@@ -437,4 +389,55 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
   )}
 
+  {culture.length > 0 && (
+    <section class="c-sec c-atlas" id="culture">
+      <h2 class="d-h">Culture <span><a href={`${base}culture/`}>the atlas →</a></span></h2>
+      {sub && <p class="d-hint">These belong to the whole country, not to {sub.name} alone.</p>}
… 111 more lines
src/components/CountryChanges.astro +39 −21
@@ -1,11 +1,11 @@
 ---
-// A country's recorded changes, newest first, at the end of the country article.
-// The same ledger rows as the History page: date, what happened, and the score before and after.
+// A country's recorded changes, newest first, in the country article: the same years as the History
+// page (HistoryYears.astro), each year large and sticky beside its entries, a busy year by month.
 // Over them, as on the History page but for this country alone: a low strip with its entries per
 // decade and its own score as a line, and filters by kind and tag that work in place, the bars
 // following the filter.
-import ScoreChange from "./ScoreChange.astro";
+import HistoryYears from "./HistoryYears.astro";
 import { Icon } from "astro-icon/components";
-import { changeDate, articles, HISTORY_FROM, dateTime, lastUpdated, CHANGE_CATEGORIES } from "../lib/countries.js";
+import { HISTORY_FROM, dateTime, lastUpdated, CHANGE_CATEGORIES } from "../lib/countries.js";
 
 // `history`: newest first. `unitLabel`: on a federation's page, the name of its parts ("State"), for
@@ -55,4 +55,9 @@ const unitMap = new Map();
 for (const ch of history) if (ch.unit) unitMap.set(ch.unit.id, { ...ch.unit, n: (unitMap.get(ch.unit.id)?.n ?? 0) + 1 });
 const units = unitLabel ? [...unitMap.values()].sort((a, b) => b.n - a.n || a.name.localeCompare(b.name)) : [];
+// the entries by year, newest first, as on the History page; estimated ones, with no real date, last
+const years = [
+  ...[...new Set(dated.map(yearOfEntry))].map((year) => ({ year, items: dated.filter((ch) => yearOfEntry(ch) === year) })),
+  ...(history.some((ch) => ch.estimate) ? [{ label: "Date not known", items: history.filter((ch) => ch.estimate) }] : []),
+];
 ---
 <div class="c-changes ch-hist">
@@ -129,19 +134,14 @@ const units = unitLabel ? [...unitMap.values()].sort((a, b) => b.n - a.n || a.na
   {more && <p class="ch-more">{more}</p>}
 
-  <ol class="c-ledger">
-    {history.map((ch) => (
-      <li data-kind={ch.category} data-unit={ch.unit?.id ?? ""} data-tags={ch.tags.map((t) => t.key).join(" ")} data-decade={ch.estimate ? undefined : decadeOf(ch)} data-scored={ch.score ? "" : undefined}>
-        <a href={`${base}history/${ch.id}/`}>
-          <time datetime={ch.date}>{changeDate(ch)}</time>
-          <div class="he-body">
-            <p class="he-place">{showUnit && ch.unit && <span class="he-unit">{ch.unit.name}</span>} <span class="he-kind"><Icon name={ch.kind.icon} class="ico" aria-hidden="true" /> {ch.kind.label}</span> <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span> {articles.has(ch.id) && <span class="he-article">Article</span>}</p>
-            <h3>{ch.title}</h3>
-            <p class="he-text">{ch.description}</p>
-          </div>
-          {ch.score ? <ScoreChange score={ch.score} of={scoreLine ? null : `${ch.country?.name ?? "National"}, national`} /> : <span></span>}
-        </a>
-      </li>
-    ))}
-  </ol>
+  {/* the same years as the History page: the year large and sticky beside its entries, a busy year by month */}
+  {years.length > 1 && (
+    <div class="hist-order ch-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-years ch-years">
+    <HistoryYears years={years} here showUnit={showUnit} scoreOf={scoreLine ? null : `${history[0].country?.name ?? "National"}, national`} yearHref={(y) => `${base}history/?from=${y}&country=${history[0].country?.id ?? ""}`} />
+  </div>
 </div>
 
@@ -151,5 +151,5 @@ const units = unitLabel ? [...unitMap.values()].sort((a, b) => b.n - a.n || a.na
   // decade is marked, and clicking it again shows every decade.
   for (const box of document.querySelectorAll(".ch-hist")) {
-    const rows = [...box.querySelectorAll(".c-ledger > li")];
+    const rows = [...box.querySelectorAll(".ch-years li.he")];
     const buttons = [...box.querySelectorAll(".ch-chips button")];
     const bars = [...box.querySelectorAll(".hy-bars a")];
@@ -201,4 +201,11 @@ const units = unitLabel ? [...unitMap.values()].sort((a, b) => b.n - a.n || a.na
     function apply() {
       for (const li of rows) li.hidden = !matches(li) || (decade !== null && li.dataset.decade !== decade);
+      // a month or a year with nothing left drops out, its entry in the month index too
+      for (const m of box.querySelectorAll(".ch-years .hm")) {
+        m.hidden = !m.querySelector("li.he:not([hidden])");
+        const link = box.querySelector(`.hist-mnav a[href="#${m.id}"]`);
+        if (link) link.parentElement.hidden = m.hidden;
+      }
+      for (const y of box.querySelectorAll(".ch-years .hist-year")) y.hidden = !y.querySelector("li.he:not([hidden])");
       for (const x of buttons) x.setAttribute("aria-pressed", String(x.dataset.f === filter));
       const per = new Map(), scoredPer = new Map();
@@ -230,4 +237,15 @@ const units = unitLabel ? [...unitMap.values()].sort((a, b) => b.n - a.n || a.na
       showActive();
     }
+    // newest or oldest first: the years turn round, and the months and entries within each; the
+    // entries with no known date stay at the end
+    const yearsBox = box.querySelector(".ch-years");
+    const orderButtons = [...box.querySelectorAll(".ch-order button")];
+    for (const b of orderButtons) b.addEventListener("click", () => {
+      if (b.getAttribute("aria-pressed") === "true") return;
+      for (const x of orderButtons) x.setAttribute("aria-pressed", String(x === b));
+      const dated = [...yearsBox.querySelectorAll(":scope > .hist-year:not(.hist-undated)")];
+      for (const year of dated) for (const ol of year.querySelectorAll("ol")) ol.append(...[...ol.children].reverse());
+      yearsBox.prepend(...dated.reverse());
+    });
     for (const b of buttons) b.addEventListener("click", () => {
       filter = b.dataset.f === filter ? "" : b.dataset.f;
@@ -239,5 +257,5 @@ const units = unitLabel ? [...unitMap.values()].sort((a, b) => b.n - a.n || a.na
       decade = a.dataset.decade === decade ? null : a.dataset.decade;
       apply();
-      box.querySelector(".c-ledger").scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "nearest" });
+      box.querySelector(".ch-years").scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "nearest" });
     });
   }

src/components/CountryDatasetTab.astro +5 −4
@@ -73,5 +73,5 @@ const groups = GROUPS.map((name) => ({ name, rows: rows.filter((r) => groupOf(r.
               <p class="ds-kicker"><span>{d.credit ?? d.publisher}{year != null && ` · ${year}`}</span></p>
               <h4>{d.name}</h4>
-              <p class:list={["ds-figure", { words: d.fields[headField(d)]?.type === "text" }]}>{stand && <i class="ds-swatch" title={stand.label ? `On the map: ${stand.label}` : undefined}></i>}<b>{head[0]}</b>{head[1] && <span>{head[1]}</span>}</p>
+              <p class:list={["ds-figure", { words: d.fields[headField(d)]?.type === "text" }]} title={stand?.label ? `On the map: ${stand.label}` : undefined}><b>{head[0]}</b>{head[1] && <span>{head[1]}</span>}</p>
               {chart && (
                 <figure class:list={["ds-spark", { many: chart.points.length > MANY }]}>
@@ -90,6 +90,7 @@ const groups = GROUPS.map((name) => ({ name, rows: rows.filter((r) => groupOf(r.
               {stand && (
                 <div class="ds-stand" title={`Higher than ${stand.below}% of the ${stand.n} countries with a figure`}>
+                  <span class="ds-stand-text">Higher than <b>{stand.below}%</b> of {stand.n} countries</span>
                   <span class="ds-stand-track"><i class="ds-stand-mid"></i><i class="ds-stand-me" style={`left:${stand.at * 100}%`}></i></span>
-                  <span class="ds-stand-text"><span>{TONES[d.id]?.[1] ?? "Lowest"}</span><span>Higher than <b>{stand.below}%</b> of {stand.n} countries</span><span>{TONES[d.id]?.[2] ?? "Highest"}</span></span>
+                  <span class="ds-stand-ends"><span>{TONES[d.id]?.[1] ?? "Lowest"}</span><span>{TONES[d.id]?.[2] ?? "Highest"}</span></span>
                 </div>
               )}
@@ -97,6 +98,6 @@ const groups = GROUPS.map((name) => ({ name, rows: rows.filter((r) => groupOf(r.
               {note && (d.id === "cannabis_use" ? <SourceTip>{note}</SourceTip> : <p class="ds-details ds-note">{note}</p>)}
               <p class="ds-links">
-                {mapHref(d) && <a href={mapHref(d)}>See the map <span aria-hidden="true">→</span></a>}
-                {CHART_OF[d.id] && <a href={`${base}charts/#${CHART_OF[d.id]}`}>See the chart <span aria-hidden="true">→</span></a>}
+                {mapHref(d) && <a href={mapHref(d)}>Map</a>}
+                {CHART_OF[d.id] && <a href={`${base}charts/#${CHART_OF[d.id]}`}>Chart</a>}
                 <a href={`${base}sources/#dataset-${d.id}`}>About the data</a>
               </p>

src/components/CountryDatasets.astro +0 −75
@@ -1,75 +0,0 @@
----
-// What the most important outside datasets say about a country (src/lib/datasets.js, `rail` in
-// datasets.yaml), beside the index: how its people judge using marijuana (Pew) and how free it is
-// by the Human Freedom Index. Every dataset is in the profile's Datasets section. Each is placed
-// among the countries the dataset covers. Two tiles in the profile's "At a glance"; nothing here is scored.
-import { places } from "../lib/countries.js";
-import { DATASETS } from "../lib/datasets.js";
-import { viewByMetric } from "../lib/map-views.js";
-import SourceTip from "./SourceTip.astro";
-import { theName } from "../lib/seo.js";
-
-const { c } = Astro.props;
-const base = import.meta.env.BASE_URL;
-const mapHref = (key) => `${base}map/${viewByMetric[`data:${key}`].slug}/`;
-const covered = (key) => places.filter((x) => x.datasets[key]);
-// 1 for the highest value among `list`; ties share a place
-const placeOf = (list, value, own) => 1 + list.filter((x) => value(x) > own).length;
-const ord = (n) => n + (n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th");
-// "the most", "the 2nd most"
-const rankWord = (n, word) => (n === 1 ? word : `${ord(n)} ${word}`);
-const median = (xs) => { const s = [...xs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
-
-const pew = c.datasets.pew_marijuana;
-const PEW = DATASETS.pew_marijuana;
-const pewList = covered("pew_marijuana");
-const pewPlace = pew && placeOf(pewList, (x) => x.datasets.pew_marijuana.unacceptable, pew.unacceptable);
-const pewLeast = pew && 1 + pewList.filter((x) => x.datasets.pew_marijuana.unacceptable < pew.unacceptable).length;
-const pewMedian =median(pewList.map((x) => x.datasets.pew_marijuana.unacceptable));
-// the answers left to right, from approval to disapproval
-const pewParts = pew && [
-  ["Acceptable", pew.acceptable, "var(--t5)"],
-  ["Not a moral issue", pew.not_moral, "var(--t4)"],
-  ["Depends or no answer", pew.depends + pew.unsure, "var(--muted)"],
-  ["Unacceptable", pew.unacceptable, "var(--t1)"],
-];
-
-const hfi = c.datasets.hfi;
-const HFI = DATASETS.hfi;
-// the two indexes ranked among the same places: those the Human Freedom Index covers
-const hfiList = covered("hfi");
-const hfiPlace = hfi && placeOf(hfiList, (x) => x.datasets.hfi.human, hfi.human);
-const cannabisPlace = hfi && placeOf(hfiList, (x) => x.raw, c.raw);
-const gap = hfi ? hfiPlace - cannabisPlace : 0; // above zero: freer for cannabis than in general
-// one short verdict on the gap between the two ranks
-const gapText = !hfi ? "" : Math.abs(gap) < hfiList.length / 8 ? "about as expected"
-  : gap > 0 ? "freer than its general freedom suggests" : "less free than its general freedom suggests";
----
-{(pew || hfi) && (
-  <>
-    {pew && (
-      <div class="cd-block">
-        <h3>{PEW.name}</h3>
-        <p class="cd-big"><b>{pew.unacceptable}%</b> of adults say it is morally unacceptable</p>
-        <div class="cd-stack" role="img" aria-label={pewParts.map(([label, v]) => `${label}: ${v}%`).join(", ")}>
-          {pewParts.map(([label, v, color]) => v > 0 && <i style={`width:${v}%;background:${color}`} title={`${label}: ${v}%`}></i>)}
-        </div>
-        <ul class="cd-key">
-          {pewParts.map(([label, v, color]) => <li><i class="sw" style={`background:${color}`}></i>{label} <b>{v}%</b></li>)}
-        </ul>
-        <p>The {pewPlace <= pewList.length / 2 ? rankWord(pewPlace, "most") : rankWord(pewLeast, "least")} disapproving of the {pewList.length} countries surveyed, where the median is {pewMedian}%.{PEW.notes[c.slug] && ` ${PEW.notes[c.slug]}`}</p>
-        <p class="cd-more"><a href={mapHref("pew_marijuana")}>All {PEW.total} on the map →</a></p>
-        <SourceTip><a href={PEW.url} target="_blank" rel="noopener noreferrer">{PEW.publisher}</a>, {PEW.when}, {pew.sample.toLocaleString("en-GB")} adults (<a href={PEW.data_url} target="_blank" rel="noopener noreferrer">{PEW.data_label}</a>).</SourceTip>
-      </div>
-    )}
-    {hfi && (
-      <div class="cd-block">
-        <h3>{HFI.name}</h3>
-        <p class="cd-big"><b>{hfi.human.toFixed(2)}</b>/10{hfi.human_rank && <> · #{hfi.human_rank} of {HFI.total}</>}</p>
-        <p>Cannabis freedom: {ord(cannabisPlace)} of the same {hfiList.length}, {gapText}.</p>
-        <p class="cd-more"><a href={mapHref("hfi")}>See the map →</a></p>
-        <SourceTip><a href={HFI.url} target="_blank" rel="noopener noreferrer">{HFI.title}</a>, {HFI.when}.</SourceTip>
-      </div>
-    )}
-  </>
-)}

src/components/CountryIntro.astro +201 −0
@@ -0,0 +1,201 @@
+---
+// "Cannabis in <country>": the opening of a profile, about the people rather than the law. How many
+// use it (as "1 in N", with a row of dots), what they think of it (Pew; Gallup for the United
+// States), the words they have for it (`words` in the country file, each linked to its atlas entry
+// where there is one), how use compares across its region, and a few cannabis datasets as one row
+// each (offences, seizures, potency, shops, research: where it stands among the countries). The parts are
+// tiles in two equal columns, the same for every country. Nothing here is scored;
+// every dataset is in the profile's Datasets section.
+import { countries, places, useAges } from "../lib/countries.js";
+import { DATASETS } from "../lib/datasets.js";
+import { viewByMetric } from "../lib/map-views.js";
+import { headline, headField, standing, POLARITY, toneAt } from "../lib/dataset-text.js";
+import { USE_COMPARISON_NOTE } from "../lib/use-labels.js";
+import { SHOW_PRICES } from "../lib/config.js";
+import Cited from "./Cited.astro";
+import SourceTip from "./SourceTip.astro";
+import PricePanel from "./PricePanel.astro";
+import { theName } from "../lib/seo.js";
+import { priceHistoryFor } from "../lib/prices.js";
+
+// `sub`: a state or province, shown with its own survey figure where it has one. `wordRefs`: each
+// word's citation numbers in the profile's references (CountryArticle.astro numbers them first).
+const { c, sub, wordRefs = [] } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const mapHref = (key) => `${base}map/${viewByMetric[`data:${key}`].slug}/`;
+const covered = (key) => places.filter((x) => x.datasets[key]);
+// 1 for the highest value among `list`; ties share a place
+const placeOf = (list, value, own) => 1 + list.filter((x) => value(x) > own).length;
+const ord = (n) => n + (n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th");
+// "the most", "the 2nd most"
+const rankWord = (n, word) => (n === 1 ? word : `${ord(n)} ${word}`);
+const median = (xs) => { const s = [...xs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
+// 48,000,000 → "48 million", 770,000 → "770,000"
+const people = (n) => (n >= 1e6 ? `${+(n / 1e6).toPrecision(2)} million` : n.toLocaleString("en-GB"));
+const name = theName(sub ? sub.name : c.name);
+
+// Who uses it: the national survey, or a state's own
+const use = sub ? null : c.use;
+const unitUse = sub?.use ?? null;
+// a disputed territory with no survey takes the figure of the country it is part of
+const useFrom = use?.estimate_from ? countries.find((x) => x.id === use.estimate_from.toUpperCase()) : null;
+const share = use ? use.prevalence : unitUse?.share;
+const pct = (n) => `${(+n).toFixed(1)}%`; // 26.38 → "26.4%", 9 → "9.0%"
+const ages = use ? useAges(use) : unitUse?.ages;
+const users = use ? use.users : unitUse?.users;
+// "1 in 11"; with fewer than one in thirty the dots would be too many, so a bar stands in for them
+const oneIn = share > 0 ? Math.max(1, Math.round(100 / share)) : null;
+const dots = oneIn && oneIn <= 30 ? oneIn : 0;
+// the median of the surveyed countries in the same region, as a yardstick
+const surveyed = use ? countries.filter((x) => x.region === c.region && !x.use.estimate && x.use.prevalence != null).sort((a, b) => b.use.prevalence - a.use.prevalence || a.name.localeCompare(b.name)) : [];
+const regionMedian = surveyed.length >= 3 ? +median(surveyed.map((x) => x.use.prevalence)).toFixed(1) : null;
+// its place among them, and the five rows around it for a short ranked list
+const regionAt = surveyed.findIndex((x) => x.id === c.id);
+const near = regionAt < 0 ? [] : surveyed.slice(Math.max(0, Math.min(regionAt - 2, surveyed.length - 5)), Math.max(0, Math.min(regionAt - 2, surveyed.length - 5)) + 5);
+const regionTop = surveyed[0]?.use.prevalence ?? 1;
+const youth = sub ? null : c.datasets.youth_use;
+
+// What they think of it
+const pew = sub ? null : c.datasets.pew_marijuana;
+const PEW = DATASETS.pew_marijuana;
+const pewList = covered("pew_marijuana");
+const pewPlace = pew && placeOf(pewList, (x) => x.datasets.pew_marijuana.unacceptable, pew.unacceptable);
+const pewLeast = pew && 1 + pewList.filter((x) => x.datasets.pew_marijuana.unacceptable < pew.unacceptable).length;
+const pewMedian = median(pewList.map((x) => x.datasets.pew_marijuana.unacceptable));
+// the answers left to right, from approval to disapproval
+const pewParts = pew && [
+  ["Acceptable", pew.acceptable, "var(--t5)"],
+  ["Not a moral issue", pew.not_moral, "var(--t4)"],
+  ["Depends or no answer", pew.depends + pew.unsure, "var(--muted)"],
+  ["Unacceptable", pew.unacceptable, "var(--t1)"],
+];
+const gallup = !sub && c.series.gallup_us;
+const GALLUP = DATASETS.gallup_us;
+const gallupNow = gallup && gallup.at(-1), gallupFirst = gallup && gallup[0];
+
+// The words they have for it, as written for the country (a state shows its country's)
+const words = c.details.words;
+
+// Cannabis in figures: the first five cannabis datasets the country has, one row each, the figure
+// and where it stands among the countries the dataset covers (dataset-text.js), linked to its card
+// in Datasets. In this order of interest: enforcement, the market, then science and attention.
+const HIGHLIGHTS = ["cannabis_offences", "police_possession", "seizures", "potency_europe", "prices_europe", "osm_shops", "wastewater", "espad_availability", "treatment", "hemp", "research_papers", "clinical_trials", "wikipedia_views", "news"];
+const highlights = sub ? [] : HIGHLIGHTS.filter((id) => DATASETS[id] && c.series[id]).map((id) => {
+  const d = DATASETS[id], data = c.series[id];
+  const row = Array.isArray(data) ? data.findLast((r) => r[headField(d)] != null) ?? data.at(-1) : data;
+  return { d, head: headline(c, d, row), year: row?.year, stand: standing(c, d) };
+}).filter((h) => h.head[0] && h.stand).slice(0, 5);
+// how many tiles beside the words, for the grid: two equal columns, and with an odd number the
+// last runs across the whole width
+const tiles = 1 + [near.length >= 3, pew, gallup, highlights.length > 0, SHOW_PRICES && !sub && priceHistoryFor(c.id).length > 0].filter(Boolean).length;
+---
+{(use || unitUse) && (
+  <div class={`intro intro-${Math.min(tiles, 5)}`}>
+    <div class="intro-block intro-use">
+      <p class="intro-kicker">Who uses it</p>
+      <p class="intro-big">{use?.estimate ? "Perhaps " : "About "}<b>1 in {oneIn}</b></p>
+      <p class="intro-lede">people aged {ages} in {name} used cannabis in the past year{users != null && <>: {use?.estimate || use?.users_approximate ? "about " : ""}<strong>{people(users)}</strong> people</>}.</p>
+      {dots > 0
+        ? <div class="intro-dots" role="img" aria-label={`One in ${oneIn}`}>{Array.from({ length: dots }, (_, i) => <i class={i === 0 ? "on" : undefined}></i>)}</div>
+        : <div class="intro-share" aria-hidden="true"><i style={`width:${Math.max(1, share)}%`}></i></div>}
+      <dl class="intro-figs">
+        <div><dt>Past year</dt><dd>{pct(share)}</dd></div>
+        {regionMedian != null && <div><dt>{c.region} median</dt><dd>{regionMedian}%</dd></div>}
+        {youth?.past_year != null && <div><dt>Aged 15–16{youth.year ? `, ${youth.year}` : ""}</dt><dd>{pct(youth.past_year)}</dd></div>}
+      </dl>
+      <p class="intro-note">{use
+        ? (use.estimate ? "This site’s estimate: there is no national survey." : `UNODC · survey ${use.period ?? use.year}`)
+        : <>Survey {unitUse.year}, its own age band.{unitUse.note && ` ${unitUse.note}`}</>}</p>
+      {use
+        ? <SourceTip><span>Past-year cannabis use among people aged {useAges(use)}. </span>{useFrom
+              ? `This site's figure: there is no survey of ${c.name} alone, so ${useFrom.name}'s rate from its ${useFrom.use.period ?? useFrom.use.year} survey is applied to an estimate of the people aged 15–64 here.`
+              : use.estimate
+              ? `This site's figure: there is no national survey, so the median rate of surveyed countries in ${c.region} is applied to people aged 15–64.`
+              : `People who used cannabis in the past year, from a ${use.period ?? use.year} survey${use.users != null ? `, applied to today's population aged ${useAges({ ages: use.population_ages || use.ages })}` : ""}${use.users_approximate ? ". The population is approximate: the survey ages cut through an age group, or were not recorded and ages 15–64 are used" : ""}${use.source === "earlier" ? ". An earlier figure: UNODC's 2026 table has none for this country" : ""}.`}<br />{USE_COMPARISON_NOTE}<br />{use.source === "wdr2026"
+              ? <><a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a>, annex table 1.2</>
+              : use.source === "earlier"
+                ? <>An earlier UNODC figure, as collected in <a href="https://en.wikipedia.org/wiki/List_of_countries_by_annual_cannabis_use" target="_blank" rel="noopener noreferrer">Wikipedia's list of annual cannabis use by country</a></>
+                : useFrom
+                ? <>{useFrom.name}'s figure from the <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a>; the population aged 15–64 is this place's total times {useFrom.name}'s share of those ages</>
+                : <>A median computed on this site from surveyed countries in {c.region}, using <a href="https://www.unodc.org/unodc/en/data-and-analysis/world-drug-report-2026-annex.html" target="_blank" rel="noopener noreferrer">UNODC World Drug Report 2026</a> figures</>}{use.users != null && <>. Population from the <a href="https://data.worldbank.org/" target="_blank" rel="noopener noreferrer">World Bank</a>, aged {useAges({ ages: use.population_ages || use.ages })}</>}. <a href={`${base}sources/#dataset-cannabis_use`}>The whole dataset →</a></SourceTip>
+        : <SourceTip>{unitUse.source[1] ? <a href={unitUse.source[1]} target="_blank" rel="noopener noreferrer">{unitUse.source[0]}</a> : unitUse.source[0]}</SourceTip>}
+    </div>
+    {near.length >= 3 && (
+      <div class="intro-block intro-region">
+        <p class="intro-kicker">Use across {c.region}</p>
+        <p class="intro-mid"><b>{ord(regionAt + 1)}</b>highest of {surveyed.length} surveyed countries</p>
+        <ol class="intro-rank">
+          {near.map((x) => (
+            <li class={x.id === c.id ? "on" : undefined} value={surveyed.indexOf(x) + 1}>
+              {x.id === c.id ? <span>{x.flag} {x.name}</span> : <a href={`${base}country/${x.slug}/`}>{x.flag} {x.name}</a>}
+              <i style={`--w:${(x.use.prevalence / regionTop) * 100}%`}></i>
+              <b>{pct(x.use.prevalence)}</b>
+            </li>
+          ))}
+        </ol>
+        <p class="intro-note">Past-year use, latest survey of each. Survey years and ages differ. <a href={`${base}map/${viewByMetric.prevalence.slug}/`}>On the map →</a></p>
+      </div>
+    )}
+    {pew && (
+      <div class="intro-block intro-view">
+        <p class="intro-kicker">What they think of it</p>
+        <p class="intro-q">“Is using marijuana morally acceptable?”</p>
+        <p class="intro-mid"><b>{pew.unacceptable}%</b> say it is morally unacceptable</p>
+        <div class="cd-stack" role="img" aria-label={pewParts.map(([label, v]) => `${label}: ${v}%`).join(", ")}>
+          {pewParts.map(([label, v, color]) => v > 0 && <i style={`width:${v}%;background:${color}`} title={`${label}: ${v}%`}></i>)}
+        </div>
+        <ul class="cd-key">
+          {pewParts.map(([label, v, color]) => <li><i class="sw" style={`background:${color}`}></i>{label} <b>{v}%</b></li>)}
+        </ul>
+        <p class="intro-note">The {pewPlace <= pewList.length / 2 ? rankWord(pewPlace, "most") : rankWord(pewLeast, "least")} disapproving of the {pewList.length} countries surveyed, where the median is {pewMedian}%.{PEW.notes[c.slug] && ` ${PEW.notes[c.slug]}`} <a href={mapHref("pew_marijuana")}>On the map →</a></p>
+        <SourceTip><a href={PEW.url} target="_blank" rel="noopener noreferrer">{PEW.publisher}</a>, {PEW.when}, {pew.sample.toLocaleString("en-GB")} adults (<a href={PEW.data_url} target="_blank" rel="noopener noreferrer">{PEW.data_label}</a>).</SourceTip>
+      </div>
+    )}
+    {gallup && (
+      <div class="intro-block intro-view">
+        <p class="intro-kicker">What they think of it</p>
+        <p class="intro-q">“Should the use of marijuana be made legal?”</p>
+        <p class="intro-mid"><b>{gallupNow.legal}%</b> say yes, up from {gallupFirst.legal}% in {gallupFirst.year}</p>
+        <div class="cd-stack" role="img" aria-label={`Yes: ${gallupNow.legal}%`}>
… 43 more lines
src/components/HistoryList.astro +1 −19
@@ -180,23 +180,5 @@ const scoredAll = feed ? stats.scored : items.filter((ch) => ch.score).length;
   });
 
-  // In a year divided into months, the month index beside it marks the month being read: the last
-  // one whose start has passed the top of the screen.
-  let spying = false;
-  addEventListener("scroll", () => {
-    if (spying) return;
-    spying = true;
-    requestAnimationFrame(() => {
-      spying = false;
-      for (const year of box.querySelectorAll(".hist-year")) {
-        const nav = year.querySelector(".hist-mnav");
-        if (!nav) continue;
-        const r = year.getBoundingClientRect();
-        if (r.bottom < 0 || r.top > innerHeight) continue;
-        const months = [...year.querySelectorAll(".hm")].filter((m) => m.offsetParent);
-        const current = months.filter((m) => m.getBoundingClientRect().top <= 160).at(-1) ?? months[0];
-        for (const a of nav.querySelectorAll("a")) a.toggleAttribute("aria-current", a.hash === `#${current?.id}`);
-      }
-    });
-  }, { passive: true });
+  // (the month index beside a year marks the month being read: the script in HistoryYears.astro)
   const params = new URLSearchParams(location.search);
   const remember = (key, value) => { try { localStorage.setItem(key, value); } catch {} };

src/components/HistoryYears.astro +42 −12
@@ -2,13 +2,19 @@
 // Years of the History ledger, newest first: the year set large, its entries ruled beside it, a
 // busy year divided into months, listed under the year as a sticky index that marks the month in view.
-// Used by the decade and kind pages, and by the year fragments that the continuous list at
-// /history/ loads as the reader scrolls (src/pages/fragments/history/[year].astro).
+// Used by the decade and kind pages, by the year fragments that the continuous list at /history/
+// loads as the reader scrolls (src/pages/fragments/history/[year].astro), and by a country profile
… 78 more lines
src/components/References.astro +1 −1

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

src/content.config.ts +14 −1

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

src/layouts/Base.astro +7 −3

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

src/lib/countries.js +6 −0

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

src/styles/global.css +167 −84

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

    ↑↓ to move, ↵ to open.