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