Code, design and documents 19 files
docs/COUNTRY-CONTEXTS.md +38 −20
@@ -46,7 +46,8 @@ about the country.
| 11 | `arrests` | Arrests and prisoners | Unchanged |
| 12 | `consequences` | Beyond the penalty | Renamed: records, drug registers, lost licences, tests at work, deportation |
+| — | `culture` (new, optional) | Culture and traditions | How cannabis sits in the country's life; links the Culture entries |
| 13 | `genetics` (new, optional) | Genetics and heritage | Local plants and who keeps them |
-| 14 | `research` (new, optional) | Research | Whether scientists can study cannabis, and what research from here gave the world |
-| 15 | `local` (new, optional) | Local differences | Places where the rules, or life under them, differ from the national picture |
+| 14 | `research` (new, optional) | Research | Scientific research on cannabis in the country |
+| 15 | `spotlights` (new, optional) | Country spotlights | A list: what makes cannabis different here, each with a title |
`products` is removed once its content has moved into `recreational`, `medical` and `hemp`.
@@ -100,33 +101,50 @@ and what bans, eradication or imported hybrids did to them.
### `research`: Research
-Optional. Write it where there is something to say. It covers two things. First, whether a
-university can get a licence and plants to study (blocked in many countries, a single farm in
-the U.S. for decades). Second, what research from here gave the world: Mechoulam and THC in
-Israel, Hanuš in Czechoslovakia, the Swiss pilot trials, O'Shaughnessy in Calcutta. Discoveries
-link to their History entries and researchers to Culture/People.
+Optional. Scientific research on cannabis done in the country: who studies it, notable studies
+and what they found, landmark discoveries (Mechoulam and THC in Israel, Hanuš in
+Czechoslovakia, O'Shaughnessy in Calcutta), and whether the law lets researchers get cannabis to
+study. The paper and trial counts in `datasets/openalex-cannabis-papers.csv` and
+`datasets/clinicaltrials-cannabis.csv` give a first line. Discoveries link to their History
+entries and researchers to Culture/People. Policy monitoring belongs here only where it is real
+science (lab testing, controlled trials), not routine counting.
-### `local`: Local differences
+### `spotlights`: Country spotlights
-Optional, and the only new section that is a list, because a country can have several such
-places:
+Optional. The only section that is a list: one to three things that make cannabis different in
+this country, each with a short title. They follow the editorial test from #224: would a reader
+learn something important, specific and surprising that the scores and the other sections do not
+tell them?
```yaml
-local:
- - place: Christiania, Copenhagen
+spotlights:
+ - title: The back door
+ text: >-
+ A coffeeshop may sell cannabis … [@gov,tri26]
+ sources: [gov, tri26]
+ - title: A different rule in every town
text: >-
…
sources: [...]
- - place: …
```
-Use it for places that are not a legal unit of their own (federal states already have
-`subunits`), or where practice differs from the law. Examples: Pusher Street, Barcelona's clubs,
-the Rif, the Bekaa, Malana, Dutch border towns, Swiss pilot cities, U.S. tribal lands. Each
-entry says how the place differs from the national rule, and never presents one city as the
-country.
+A spotlight can be a place (Christiania, the Rif, the Bekaa, U.S. tribal lands), an institution
+(Uruguay's state registry, Brazil's patient associations) or a contradiction (use is legal but
+buying is not). Never present one city as the country, and link to Culture or History where the
+subject has an entry.
+
+## On the page
+
+- Every section has a small **?** beside its heading. Hovering or focusing it shows what the
+ section covers (`CONTEXT` in `src/lib/countries.js`).
+- The **Recreational** and **Medical** status chips at the top of the page link to their
+ sections, and each section shows its chip again.
+- Spotlights show as titled items inside one *Country spotlights* section.
## How sections stay distinct
- A section never repeats a scored segment; it explains what the score leaves out.
+- Texts link generously with Markdown, `[name](url)`: dated events to their history entries,
+ people and traditions to Culture, and named organisations to their websites. The rules are in
+ `docs/DATA-MAINTENANCE.md`, "Writing context sections well". A link is not a source.
- A custom or a word belongs in Culture, and a dated event in `changes.yaml`. The section links
to them.
@@ -140,9 +158,9 @@ country.
*Commercial production* → *Cannabis industry*.
The API lists `context` in this order, and each entry carries its `key`.
-2. **Schema:** add `recreational`, `hemp`, `genetics`, `research` and `local` as optional, and
+2. **Schema:** add `recreational`, `hemp`, `genetics`, `research` and `spotlights` as optional, and
keep `products` valid while countries move over.
3. **Data, in batches by region:** move `products` into `recreational`, `medical` and `hemp`,
and widen `production` into the industry section, keeping every source and citation. Write `genetics`, `research` and
- `local` where there is something real to say.
+ `spotlights` where there is something real to say.
4. **Clean-up:** once no country uses `products`, remove it and make
`recreational` and `hemp` required.
docs/DATA-MAINTENANCE.md +234 −0
@@ -327,4 +327,238 @@ table, the per-country JSON and `scores.csv` (columns `seeds`, `seeds_trade`, `c
all read from there, together with `PLANTING_TRADE`.
+## Writing context sections well
+
+The *More context* tab is where a reader learns how cannabis actually works in a country, beyond
+the six scores. Every section is unscored. This part says what each section holds and how to
+write it; the plain-language rules in `docs/WRITING.md` apply throughout. The Netherlands, Czechia
+and Germany are written this way and can serve as models.
+
+### The sections, in page order
+
+| Key | Shown as | Holds | Required? |
+|---|---|---|---|
+| `death_penalty` | Death penalty | Whether the law allows execution for cannabis, and whether it is carried out | Where the law provides for it |
+| `recreational` | Recreational use | How adults actually get and use cannabis, legally or not: shops, clubs, home growing, dealers, which products, typical price and strength | Yes, for every country (replacing `products`) |
+| `medical` | Medical cannabis | The whole patient story: who qualifies, who prescribes, which forms, cost and insurance, where it is made | Yes |
+| `clubs` | Cannabis clubs | Members' clubs and their footing, with a `status` | When researched |
+| `planting` | Seeds and clones | Seed and cutting law, with four statuses | When researched |
+| `hemp` | Hemp and CBD | The THC limit, who may grow hemp, how much is grown, CBD sales, loophole products such as HHC | Yes, for every country |
+| `production` | Cannabis industry | The legal commercial sector: who may grow, process and sell for profit, licences, size, imports and exports | Yes |
+| `black_market` | Black market | The illegal market: size, origin, prices, how openly it is sold | When researched |
+| `visitors` | Visitors and foreigners | Whether a non-resident can get cannabis legally, and what happens to a foreigner caught | When researched |
+| `driving` | Driving | What the law needs to convict a driver, with a `status` | When researched |
+| `arrests` | Arrests and prisoners | Published counts, with year and who counted | When researched |
+| `consequences` | Beyond the penalty | What follows a person beyond the punishment: records, registers, licences, jobs, deportation | Yes |
+| `culture` | Culture and traditions | How cannabis sits in the country's life: customs, faith, words, music and film, festivals, famous figures, attitudes (polls), and what prohibition did to them | Recommended wherever a tradition or Culture entry exists |
+| `genetics` | Genetics and heritage | Local cannabis plants, who keeps them, what the law did to them | Only where a real account exists |
+| `research` | Research | Scientific research in the country: who studies cannabis, what they found, what the law allows researchers | Where there is something to say |
+| `spotlights` | Country spotlights | One to three things that make cannabis different here, each with a title | Only where something passes the test below |
+
+`products` is being folded into `recreational`, `medical` and `hemp`; when you revise a country,
+move its content and delete the section. The schema accepts both during the move.
+
+### Statuses for hemp, industry and research
+
+`hemp`, `production` and `research` may carry a `status`, like `clubs` or `driving`. It shows as a
+chip on the country page, colours a world-map view and fills a rankings column. It is optional
+while countries are researched; set it only when the section's text states the fact it rests on.
+
+```yaml
+ hemp:
+ status: standard # high (up to 1% THC) | standard (0.3%) | strict (below 0.3%) | none (no exception for hemp)
+ production:
+ status: medical # open (licensed adult market) | state (state-run) | pilot (trials only) | medical (medicine, research or export only) | none
+ research:
+ status: licensed # open | licensed (special licence or permit) | restricted (synthetic or narrow routes only) | blocked
+```
+
+- `hemp` is the THC line in law, not how big the hemp industry is.
+- `production` describes who may produce for sale. A country with both a pilot for adults and a
+ medical industry is `pilot`; one with only medical licences is `medical`.
+- `research` needs a stated route: a permit, a licence, a state agency that supplies researchers.
+ If the text does not say how researchers get cannabis, leave the status out.
+
+### How to write a section
+
+- **Start with what a person meets.** The first sentence answers the section's question for an
+ ordinary adult in that country ("There is still nowhere to buy cannabis legally, so…"). Law
+ numbers and institutions come after, and only when they help.
+- **Three to seven sentences.** Long enough to explain, short enough to read on a phone. If a
+ subject needs more, it probably needs a history article or a Culture entry, linked from here.
+- **The whole country.** Describe the national picture. A city or region goes in `spotlights`,
+ or in the text only as an example, named as such.
+- **One section, one subject.** Don't repeat a scored segment or another section: `recreational`
+ is the user's side, `production` the businesses behind the supply, `black_market` the illegal
+ trade. Point to the other section instead of retelling it.
+- **Numbers with their frame.** Every figure needs its year, its unit and what it counts:
+ "registered users", "approved associations", "tonnes imported". Say "about" when the source
+ rounds. A figure from `datasets/` can be quoted with its year; the page also shows the dataset,
+ so don't build a paragraph on a number that the next refresh will change.
+- **Proposals are not law.** A bill, a government plan or an announcement is described as such,
+ with its date and where it stands ("none of this had been adopted").
+- **Cite every sentence** with inline marks, `[@key]`, as in the segments. Every key in the
+ section's `sources` must be cited in its text, or the build fails.
+- **Prefer primary sources**: the statute, the regulator, the statistics office, the official
+ evaluation. A good newspaper is fine for practice and for what officials said. Wikipedia is a
+ last resort and never the only source for a figure.
+
+### Links
+
+Texts may contain Markdown links, `[name](url)`, and should use them generously: a reader who
+meets a name, a body or a dated change should be one click from more. Link, in this order of
+preference:
+
+- **A dated event that has a history entry**: a law, ruling, report or ban mentioned with its
+ date. Put the link on the words that name the change, for example
+ `[From 30 July 2026](/history/germany-flower-reimbursement-ended-2026/)` or
+ `[legal since 1 July 2024](/history/germany-cultivation-associations-2024/)`. If the event has
+ no entry yet, write one in `changes.yaml` (it is found history anyway) and link it.
+- **A person, tradition or word that has a Culture entry**, for example
+ `[Lumír Hanuš](/culture/people/lumir-hanus/)`.
+- **An organisation, authority or project the text names**: link its own website the first time
+ it appears, for example `[Federal Office for Agriculture and Food](https://www.ble.de/)`,
+ `[EKOCAN project](https://www.uke.de/ekocan)`. Use the official home page or project page, not a
+ news story about it. External links open in a new tab and show a small ↗.
+
+Rules:
+
+- A link is not a source. The sentence still needs its `[@key]`.
+- Link a target once per section, on its first mention, and on a few words, never a whole
+ sentence.
+- Internal links start with `/` and must point at a page that exists: check the history id in
+ `changes.yaml` or the Culture file name.
+- Don't link generic words (police, pharmacy, government) or a source that is already in the
+ references.
+- When you revise any section, look for names, bodies and dated changes that could be linked.
+
+### Country spotlights
+
+`spotlights` is a list. Each item has a short `title`, a `text` written like any section, and its
+own `sources`:
+
+```yaml
+spotlights:
+ - title: Grow it, but don't share it
+ text: >-
+ Since 2026 an adult may grow three plants … [@z167,z40]
+ sources: [z167, z40]
+```
+
+- **The test:** would a reader learn something important, specific and surprising that the
+ scores and the other sections do not tell them? If not, leave it out. Most countries will have
+ none to three, and none is better than filler.
+- **Good subjects:** a contradiction in the system (use is legal but buying is not), a place that
+ differs from the national rule (Christiania, Bavaria, the ten Dutch experiment towns), an
+ institution found nowhere else (Uruguay's registry, Malta's associations), a market the law
+ created by accident (Germany's prescription boom).
+- **Titles** are a few plain words that say the point, not a label: "The back door", "Twenty-one,
+ not eighteen".
+- A spotlight may draw on facts from other sections, but it tells them as one story with its
+ consequence for people.
+
+### Culture and traditions
+
+`culture` gives the context around the Culture entries that belong to the country. Those already
+show as a list of cards on the page; this section tells the story that connects them, and the law
+around them.
+
+- **Link every Culture entry it mentions**, and the culture-category history entries
+ (`/history/<id>/`). Don't retell an entry; give it a sentence or two of context and let the link
+ do the rest.
+- **Mention what has no entry yet**: a festival, a musician, a movement, a word. Each one is a lead
+ for a future Culture entry; note it in the research log or write the entry.
+- **Cover the range**, as far as sources allow: old customs (ritual, folk medicine, hemp crafts and
+ food), modern culture (words, music, film, festivals, marches), well-known figures, and how
+ people see it today. A poll needs its year, its question and who asked.
+- **Show the link to the law** where there is one: what prohibition did to a custom, how a
+ tradition shaped the rules (the coffeeshop, Rastafari, bhang shops).
+- The plant itself belongs in `genetics`, scientific work in `research`, and one striking story in
+ `spotlights`.
+
+### Research
+
+`research` is about science, not policy monitoring: universities and institutes, notable studies
+and what they found, landmark discoveries (link them to their history entries), clinical trials,
+and whether the law lets researchers obtain cannabis. The paper and trial counts in
+`datasets/openalex-cannabis-papers.csv` and `datasets/clinicaltrials-cannabis.csv` give a first
+line; cite them with the dataset as the source. An official evaluation of a cannabis law belongs
+here when it is run as a study, with its findings.
… 80 more linessrc/components/Cited.astro +9 −2
@@ -1,8 +1,15 @@
---
// A text with its citations as superscript numbers linked to a list of references (src/lib/cite.js).
-// `parts` comes from citeParts(); the text itself is plain, so it is written out as it is.
+// `parts` comes from citeParts(). The text is plain apart from Markdown links, [name](url): a full
+// URL opens in a new tab, and one starting with "/" is a page of this site and gets the base path.
+import { linkParts } from "../lib/cite.js";
const { parts } = Astro.props;
+const base = import.meta.env.BASE_URL.replace(/\/?$/, "/");
---
{parts.map((p) => p.refs
? p.refs.map((r) => <sup class="cite"><a href={`#${r.id}`} aria-label={`Source ${r.n}`}>[{r.n}]</a></sup>)
- : p.t)}
+ : linkParts(p.t).map((x) => x.href
+ ? (x.href.startsWith("/")
+ ? <a href={base + x.href.slice(1)}>{x.t}</a>
+ : <a href={x.href} target="_blank" rel="noopener noreferrer">{x.t}</a>)
+ : x.t))}
src/components/CountryArticle.astro +51 −10
@@ -20,4 +20,6 @@ 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";
+import { viewByMetric } from "../lib/map-views.js";
import { answersFor } from "../lib/answers.js";
import CultureList from "./CultureList.astro";
@@ -56,5 +58,12 @@ const unitParts = sub ? sub.segCite.map((text, i) => citeParts(text, unitRefs,
citedKeys(text) ?? sub.sources.map((_, i) => String(i + 1)),
`countries/${c.slug}/${sub.id}.yaml: ${SEGMENTS[i].id}`)) : [];
-const ctxParts = d ? Object.fromEntries(d.context.filter(Boolean).map((x) => [x.key, partsOf(x, ctxRefs, x.key)])) : {};
+const ctxParts = d ? Object.fromEntries(d.context.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, ctxRefs, `${x.key}[${i}]`)) : partsOf(x, ctxRefs, x.key)])) : {};
+// a maximum fine in the profile's currency: "€5,500", "$300"
+const money = (n, currency) => new Intl.NumberFormat("en-GB", { style: "currency", currency, maximumFractionDigits: 0 }).format(n);
+// a chip inside a context section leads to the world map coloured by the same status
+const mapOf = (metric) => (viewByMetric[metric] ? `${base}map/${viewByMetric[metric].slug}/` : undefined);
+const onMap = (help) => `${help} · See the world map`;
+// the recreational and medical status chips lead to their own context sections, where those exist
+const ctxHas = (key) => Boolean(d?.context.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"
@@ -141,6 +150,10 @@ const culture = cultureOf(c);
<p class="d-note">{v.note}</p>
<div class="d-chips">
- <span class={`chip s-${v.rec}`} title={REC_HELP[v.rec]}>Recreational: {REC_LABEL[v.rec]}</span>
- <span class={`chip s-${v.med}`} title={MED_HELP[v.med]}>Medical: {MED_LABEL[v.med]}</span>
+ {ctxHas("recreational")
+ ? <a class={`chip s-${v.rec}`} href="#seg-ctx-recreational" data-seg-link="ctx-recreational" title={REC_HELP[v.rec]}>Recreational: {REC_LABEL[v.rec]}</a>
+ : <span class={`chip s-${v.rec}`} title={REC_HELP[v.rec]}>Recreational: {REC_LABEL[v.rec]}</span>}
+ {ctxHas("medical")
+ ? <a class={`chip s-${v.med}`} href="#seg-ctx-medical" data-seg-link="ctx-medical" title={MED_HELP[v.med]}>Medical: {MED_LABEL[v.med]}</a>
+ : <span class={`chip s-${v.med}`} title={MED_HELP[v.med]}>Medical: {MED_LABEL[v.med]}</span>}
</div>
{d && d.context.some((x) => x && (x.status || x.seeds)) && (
@@ -334,4 +347,19 @@ const culture = cultureOf(c);
<div class="why">
<p><Cited parts={segParts[i]} /></p>
+ {seg.penalties && (
+ <table class="pen">
+ <thead><tr><td>Amount</td><td>What happens</td><td>Punishment</td><td>Fine, at most</td></tr></thead>
+ <tbody>
+ {seg.penalties.rows.map((r) => (
+ <tr>
+ <td data-label="Amount">{r.amount}{r.note && <span class="pen-note">{r.note}</span>}</td>
+ <td data-label="What happens"><span class={`pen-t s-${TREATMENT[r.treatment].tone}`} title={TREATMENT[r.treatment].help}>{TREATMENT[r.treatment].label}</span>{r.term && <span class="pen-note">{r.term}</span>}</td>
+ <td data-label="Punishment" class:list={{ "pen-death": r.death }}>{punishmentText(r) ?? "—"}{punishmentExtra(r) && <span class="pen-note">{punishmentExtra(r)}</span>}</td>
+ <td data-label="Fine, at most">{r.fine ? money(r.fine, seg.penalties.currency) : "—"}</td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
</div>
)}
@@ -348,19 +376,32 @@ const culture = cultureOf(c);
{d.context.filter(Boolean).map((x) => (
<div class="segrow" data-seg={`ctx-${x.key}`} id={`seg-ctx-${x.key}`}>
- <div class="segrow-top"><span>{x.name === "Death penalty" ? "☠️ " : ""}{x.name}</span></div>
+ <div class="segrow-top"><span>{x.name === "Death penalty" ? "☠️ " : ""}{x.name}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div>
+ {x.key === "recreational" && (
+ <div class="d-chips"><a class={`chip s-${v.rec}`} href={mapOf("status:rec")} title={onMap(REC_HELP[v.rec])}>Recreational: {REC_LABEL[v.rec]}</a></div>
+ )}
+ {x.key === "medical" && (
+ <div class="d-chips"><a class={`chip s-${v.med}`} href={mapOf("status:med")} title={onMap(MED_HELP[v.med])}>Medical: {MED_LABEL[v.med]}</a></div>
+ )}
{x.status && (
<div class="d-chips">
- <span class={`chip s-${STATUS_SECTIONS[x.kind].statuses[x.status].tone}`} title={STATUS_SECTIONS[x.kind].statuses[x.status].help}>{STATUS_SECTIONS[x.kind].chip}: {STATUS_SECTIONS[x.kind].statuses[x.status].label}</span>
+ <a class={`chip s-${STATUS_SECTIONS[x.kind].statuses[x.status].tone}`} href={mapOf(`status:${x.kind}`)} title={onMap(STATUS_SECTIONS[x.kind].statuses[x.status].help)}>{STATUS_SECTIONS[x.kind].chip}: {STATUS_SECTIONS[x.kind].statuses[x.status].label}</a>
</div>
)}
{x.seeds && (
<div class="d-chips">
- <span class={`chip s-${PLANTING_STATUS[x.seeds].tone}`} title={PLANTING_STATUS[x.seeds].help[0]}>Seeds: {PLANTING_STATUS[x.seeds].label}</span>
- <span class={`chip s-${PLANTING_TRADE[x.seeds_trade].tone}`} title={PLANTING_TRADE[x.seeds_trade].help[0]}>Seed sales: {PLANTING_TRADE[x.seeds_trade].label}</span>
- <span class={`chip s-${PLANTING_STATUS[x.clones].tone}`} title={PLANTING_STATUS[x.clones].help[1]}>Clones: {PLANTING_STATUS[x.clones].label}</span>
- <span class={`chip s-${PLANTING_TRADE[x.clones_trade].tone}`} title={PLANTING_TRADE[x.clones_trade].help[1]}>Clone sales: {PLANTING_TRADE[x.clones_trade].label}</span>
+ <a class={`chip s-${PLANTING_STATUS[x.seeds].tone}`} href={mapOf("plant:seeds")} title={onMap(PLANTING_STATUS[x.seeds].help[0])}>Seeds: {PLANTING_STATUS[x.seeds].label}</a>
+ <a class={`chip s-${PLANTING_TRADE[x.seeds_trade].tone}`} href={mapOf("plant:seeds_trade")} title={onMap(PLANTING_TRADE[x.seeds_trade].help[0])}>Seed sales: {PLANTING_TRADE[x.seeds_trade].label}</a>
+ <a class={`chip s-${PLANTING_STATUS[x.clones].tone}`} href={mapOf("plant:clones")} title={onMap(PLANTING_STATUS[x.clones].help[1])}>Clones: {PLANTING_STATUS[x.clones].label}</a>
+ <a class={`chip s-${PLANTING_TRADE[x.clones_trade].tone}`} href={mapOf("plant:clones_trade")} title={onMap(PLANTING_TRADE[x.clones_trade].help[1])}>Clone sales: {PLANTING_TRADE[x.clones_trade].label}</a>
</div>
)}
- <p class="ctx"><Cited parts={ctxParts[x.key]} /></p>
+ {x.items
+ ? x.items.map((item, i) => (
+ <div class="ctx-spot">
+ <h3 class="ctx-spot-h">{item.title}</h3>
+ <p class="ctx"><Cited parts={ctxParts[x.key][i]} /></p>
+ </div>
+ ))
+ : <p class="ctx"><Cited parts={ctxParts[x.key]} /></p>}
</div>
))}
src/components/HomePage.astro +6 −4
@@ -92,7 +92,9 @@ const MED_ORDER = ["yes", "limited", "no"];
import { useFreedomRatio } from "../lib/use-freedom-ratio.js";
const mapData = places.map((c) => {
- const { id, num, name, flag, raw, score, rank, s, use, planting, clubs, driving, visitors, rec, med } = c;
+ const { id, num, name, flag, raw, score, rank, s, use, planting, rec, med } = c;
const atlas = cultureOf(c);
- return { id, num, name, flag, raw, score, rank, s, planting, clubs, driving, visitors, rec, med, fe: estimateNote(c), prices: SHOW_PRICES ? flatLatestPricesFor(id) : {},
+ // one value per section in STATUS_SECTIONS (clubs, driving, visitors, hemp, production, research)
+ const statuses = Object.fromEntries(Object.keys(STATUS_SECTIONS).map((key) => [key, c[key]]));
+ return { id, num, name, flag, raw, score, rank, s, planting, ...statuses, rec, med, fe: estimateNote(c), prices: SHOW_PRICES ? flatLatestPricesFor(id) : {},
use: { ...use, ages: useAges(use), ratio: useFreedomRatio(use.prevalence, raw), culture: atlas.length },
atlas: atlas.slice(0, 4).map((e) => e.title), ds: datasetMapValues(c) };
@@ -131,5 +133,5 @@ const PLANTING_COLOR = { legal: 5, conditional: 4, licensed: 4, grey: 3, illegal
const plantingScale = (statuses) => Object.fromEntries(Object.entries(statuses).map(([k, v]) => [k, { label: v.label, help: v.help, color: `var(--t${PLANTING_COLOR[k]})` }]));
const PLANTING_SCALES = { status: plantingScale(PLANTING_STATUS), trade: plantingScale(PLANTING_TRADE) };
-// Sections with one status (clubs, driving, visitors) on the map: one option each.
+// Sections with one status (clubs, driving, visitors, hemp, production, research) on the map: one option each.
// The two headline statuses of every profile, offered on the map the same way. `complete` marks
// sections every country has, so their legend needs no "not researched" entry.
@@ -557,5 +559,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments",
const plantOf = (c, field = plantField()) => plantScale(field)[c.planting?.[field]];
const plantColor = (c) => plantOf(c)?.color ?? "url(#hatch)";
- const isClubs = () => metric.startsWith("status:"); // coloured by a section's one status: clubs, driving, visitors
+ const isClubs = () => metric.startsWith("status:"); // coloured by a section's one status (STATUS_SECTIONS)
const statusKey = () => metric.split(":")[1];
const clubsOf = (c) => PLANTING.sections[statusKey()].scale[c[statusKey()]];
src/components/SourceTip.astro +6 −3
@@ -3,8 +3,11 @@
// click or tap (for touch screens; the script in Base.astro toggles it and closes it again). The
// slot is the source text, with its links. The parent needs `position: relative`.
+// `label` names the note ("Source" by default); `inline` sets the "i" in the line of text, beside a
+// heading, instead of in the parent's corner.
import { Icon } from "astro-icon/components";
+const { label = "Source", inline = false } = Astro.props;
---
-<span class="src-tip">
- <button type="button" class="src-tip-btn" aria-label="Source" aria-expanded="false"><Icon name="iconoir:info-circle" aria-hidden="true" /></button>
- <span class="src-tip-pop" role="tooltip"><b>Source</b> <slot /></span>
+<span class:list={["src-tip", { "src-tip-inline": inline }]}>
+ <button type="button" class="src-tip-btn" aria-label={label} aria-expanded="false"><Icon name="iconoir:info-circle" aria-hidden="true" /></button>
+ <span class="src-tip-pop" role="tooltip"><b>{label}</b> <slot /></span>
</span>
src/content.config.ts +36 −10
@@ -5,5 +5,6 @@ import { glob, file } from "astro/loaders";
import { z } from "astro/zod";
import { isMixedCannabisPrice } from "./lib/price-selection.js";
-import { SEGMENT_IDS, CONTEXT_IDS, CHANGE_CATEGORIES, CHANGE_TAGS, PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "./lib/segment-ids.js";
+import { TREATMENT, parseAmount, parseDuration } from "./lib/penalties.js";
+import { SEGMENT_IDS, CONTEXT_IDS, OPTIONAL_CONTEXT_IDS, sectionSources, CHANGE_CATEGORIES, CHANGE_TAGS, PLANTING_STATUS, PLANTING_TRADE, STATUS_SECTIONS } from "./lib/segment-ids.js";
const score = z.number().int().min(0).max(10);
@@ -26,4 +27,25 @@ const source = z.object({
const webSource = source.refine((s) => Boolean(s.url), { message: "price sources need a URL" });
const cited = z.object({ text: z.string().min(1), sources: z.array(z.string()) }).strict();
+// a penalty step of a segment (src/lib/penalties.js): an amount ("30 g", "1 oz", "5 plants") or a
+// `label` for a step that is not about an amount, what the law does, and its maximum prison and fine
+const amount = z.string().refine((s) => parseAmount(s), { message: 'an amount such as "5 g", "1 oz" or "5 plants"' });
+const penalty = z.object({
+ upto: amount.optional(), over: amount.optional(), label: z.string().min(1).optional(),
+ repeat: z.number().int().min(2).optional(),
+ treatment: z.enum(Object.keys(TREATMENT) as [string, ...string[]]),
+ term: z.string().min(1).optional(),
+ prison: z.string().refine((s) => parseDuration(s), { message: 'a duration such as "5 days" or "2 years"' }).optional(),
+ prison_min: z.string().refine((s) => parseDuration(s), { message: 'a duration such as "6 months"' }).optional(),
+ caning: z.tuple([z.number().int().positive(), z.number().int().positive()]).optional(), // [fewest, most] strokes
+ death: z.literal(true).optional(),
+ fine: z.number().positive().optional(),
+ note: z.string().min(1).optional(),
+}).strict().refine((r) => r.upto || r.over || r.label, { message: "a penalty step needs upto, over or a label" })
+ .refine((r) => !r.prison_min || r.prison, { message: "prison_min needs prison, the maximum" });
+// a segment: its cited text, and for possession and cultivation optionally the penalty steps
+const segment = cited.extend({ currency: z.string().regex(/^[A-Z]{3}$/).optional(), penalties: z.array(penalty).min(1).optional() })
+ .refine((s) => !s.penalties?.some((r) => r.fine) || s.currency, { message: "penalties with a fine need a currency" });
+// a country spotlight: one thing that makes cannabis different there, with a short title
+const spotlight = z.object({ title: z.string().min(1), text: z.string().min(1), sources: z.array(z.string()) }).strict();
// seeds and clones: the same cited text, plus for each a status for having and planting them
// (PLANTING_STATUS) and one for selling or handing them on (PLANTING_TRADE)
@@ -32,6 +54,10 @@ const plantingTrade = z.enum(Object.keys(PLANTING_TRADE) as [string, ...string[]
const planting = z.object({ seeds: plantingStatus, seeds_trade: plantingTrade, clones: plantingStatus, clones_trade: plantingTrade, text: z.string().min(1), sources: z.array(z.string()) }).strict();
// clubs, driving, visitors: the cited text plus one status from that section's vocabulary (STATUS_SECTIONS)
-const statusSections = Object.fromEntries(Object.entries(STATUS_SECTIONS).map(([key, section]) => [key,
- z.object({ status: z.enum(Object.keys(section.statuses) as [string, ...string[]]), text: z.string().min(1), sources: z.array(z.string()) }).strict().optional()]));
+// the newer ones (`optional` in STATUS_SECTIONS) may leave the status out; a standard section (production) stays required
+const statusSections = Object.fromEntries(Object.entries(STATUS_SECTIONS).map(([key, section]) => {
+ const status = z.enum(Object.keys(section.statuses) as [string, ...string[]]);
+ const block = z.object({ status: section.optional ? status.optional() : status, text: z.string().min(1), sources: z.array(z.string()) }).strict();
+ return [key, (CONTEXT_IDS as string[]).includes(key) ? block : block.optional()];
+}));
const status = {
// "death": illegal, and the law allows the death penalty for cannabis offences, in practice trafficking.
@@ -86,10 +112,10 @@ const countries = defineCollection({
scores: perSegment(score),
answers,
- segments: perSegment(cited),
- // the four standard sections, plus `death_penalty` where the law provides for one and,
+ segments: perSegment(segment),
+ // the three standard sections, plus `death_penalty` where the law provides for one and,
// where they have been researched, `planting` (seeds and clones), the sections with a status
- // (`clubs`, `driving`, `visitors`), `black_market` (the illegal market) and `arrests`
- // (arrests and prisoners, as far as the country publishes them)
- context: z.object({ death_penalty: cited.optional(), planting: planting.optional(), ...statusSections, black_market: cited.optional(), arrests: cited.optional(), ...Object.fromEntries(CONTEXT_IDS.map((id) => [id, cited])) }).strict(),
+ // (`clubs`, `driving`, `visitors`), the plain optional ones (OPTIONAL_CONTEXT_IDS) and the
+ // list of `spotlights`
+ context: z.object({ death_penalty: cited.optional(), planting: planting.optional(), ...Object.fromEntries(OPTIONAL_CONTEXT_IDS.map((id) => [id, cited.optional()])), spotlights: z.array(spotlight).min(1).optional(), ...Object.fromEntries(CONTEXT_IDS.map((id) => [id, cited])), ...statusSections }).strict(),
sources: z.record(z.string(), source),
subunits: z
@@ -109,7 +135,7 @@ const countries = defineCollection({
ctx.addIssue({ code: "custom", message: 'a country marked "recreational: death" needs a context.death_penalty section' });
}
- const blocks = { ...data.segments, ...data.context } as Record<string, { sources: string[] }>;
+ const blocks = { ...data.segments, ...data.context } as Record<string, unknown>;
for (const [name, block] of Object.entries(blocks)) {
- for (const key of block.sources) {
+ for (const key of sectionSources(block)) {
if (!(key in data.sources)) ctx.addIssue({ code: "custom", message: `"${name}" cites unknown source "${key}"` });
}
src/lib/citations.js +2 −1
@@ -9,4 +9,5 @@ import { places, changes, SEGMENTS, CONTEXT } from "./countries.js";
import { cultureEntries, cultureSection } from "./culture.js";
import { archivedUrl } from "./archive.js";
+import { sectionSources } from "./segment-ids.js";
const placeOf = new Map(places.map((c) => [c.slug, c]));
@@ -24,5 +25,5 @@ for (const { id, data } of await getCollection("countries")) {
// which sections cite each source key
const at = new Map();
- for (const [name, block] of Object.entries({ ...data.segments, ...data.context })) for (const key of block?.sources ?? []) at.set(key, [...(at.get(key) ?? []), SECTION_NAME.get(name) ?? name]);
+ for (const [name, block] of Object.entries({ ...data.segments, ...data.context })) for (const key of sectionSources(block)) at.set(key, [...(at.get(key) ?? []), SECTION_NAME.get(name) ?? name]);
for (const [key, s] of Object.entries(data.sources)) cites.push({ s, where: slug, owner: c && countryOwner(c), at: at.get(key) ?? [] });
for (const s of data.subunits?.sources ?? []) cites.push({ s, where: slug, owner: c && countryOwner(c), at: [`${data.subunits.plural.replace(/^./, (x) => x.toUpperCase())} overview`] });
src/lib/cite.js +20 −2
@@ -10,6 +10,24 @@
export const CITE_MARK = /\s?\[@([^\]]+)\]/g;
-// the text without its marks, for every place that shows it as plain text (lists, previews, search)
-export const stripCites = (text) => (text ?? "").replace(CITE_MARK, "");
+// A Markdown link in a text, [name](url), to a full URL or a page of this site ("/culture/…").
+// A citation mark is never one: its brackets start with "@" and are not followed by "(".
+export const LINK_MARK = /\[([^\]@][^\]]*)\]\(((?:https?:\/\/|\/)[^)\s]+)\)/g;
+
+// a plain stretch of text split into text and links: [{ t }, { t, href }, …]
+export function linkParts(text) {
+ const out = [];
+ let last = 0;
+ for (const m of (text ?? "").matchAll(LINK_MARK)) {
+ if (m.index > last) out.push({ t: text.slice(last, m.index) });
+ out.push({ t: m[1], href: m[2] });
+ last = m.index + m[0].length;
+ }
+ if (last < (text ?? "").length) out.push({ t: text.slice(last) });
+ return out;
+}
+
+// the text without its marks, and its links reduced to their names, for every place that shows it
+// as plain text (lists, previews, search)
+export const stripCites = (text) => (text ?? "").replace(CITE_MARK, "").replace(LINK_MARK, "$1");
// the keys a text cites, in order of first appearance; null when it has no marks at all
src/lib/countries.js +33 −4
@@ -7,4 +7,5 @@ import { stripCites } from "./cite.js";
import { datasetsOf, latestOf } from "./datasets.js";
import { TIERS, tierIndex } from "./tiers.js";
+import { penaltyRow, penaltySummary } from "./penalties.js";
export { CHANGE_CATEGORIES, CHANGE_TAGS };
@@ -31,6 +32,25 @@ export const MED_HELP = {
// The order goes from how people get and use cannabis, through the market, to the police
// (docs/COUNTRY-CONTEXTS.md).
-export const CONTEXT = [["death_penalty", "Death penalty"], ["medical", "Medical cannabis"], ["clubs", "Cannabis clubs"], ["planting", "Seeds and clones"], ["products", "Products"], ["production", "Cannabis industry"], ["black_market", "Black market"], ["visitors", "Visitors and foreigners"], ["driving", "Driving"], ["arrests", "Arrests and prisoners"], ["consequences", "Beyond the penalty"]];
+export const CONTEXT = [
+ ["death_penalty", "Death penalty", "Whether the law allows execution for cannabis offences, and whether it is carried out"],
+ ["recreational", "Recreational use", "How adults actually get and use cannabis here, legally or not"],
+ ["medical", "Medical cannabis", "How patients get cannabis as medicine: who qualifies, who prescribes, what forms there are and what it costs"],
+ ["clubs", "Cannabis clubs", "Whether members' clubs that grow and share cannabis exist, and on what footing"],
+ ["planting", "Seeds and clones", "Whether seeds and cuttings may be bought, kept, planted and sold"],
+ ["products", "Products", "Which cannabis products are on sale; being folded into the recreational, medical and hemp sections"],
+ ["hemp", "Hemp and CBD", "Low-THC hemp for fibre, food and CBD, and loophole products such as HHC"],
+ ["production", "Cannabis industry", "The legal commercial sector: who may grow, process and sell for profit, and on what terms"],
+ ["black_market", "Black market", "The illegal market: how big it is, where the cannabis comes from, prices and how openly it is sold"],
+ ["visitors", "Visitors and foreigners", "Whether someone who does not live here can get cannabis legally, and what happens to a foreigner who is caught"],
+ ["driving", "Driving", "What the law needs to convict a driver who has used cannabis"],
+ ["arrests", "Arrests and prisoners", "Published counts of arrests, offences, convictions and prisoners"],
+ ["consequences", "Beyond the penalty", "What follows a person beyond the punishment itself: records, drug registers, lost licences, jobs, deportation"],
+ ["culture", "Culture and traditions", "How cannabis sits in the country's life: customs, faith, words, music and film, festivals and attitudes, and what prohibition did to them"],
+ ["genetics", "Genetics and heritage", "Local cannabis plants, the people who keep them, and what bans and new laws did to them"],
+ ["research", "Research", "Scientific research on cannabis in the country: who studies it, what they found, and what the law allows"],
+ ["spotlights", "Country spotlights", "What makes cannabis different here: places, institutions and practices the scores do not show"],
+];
export const CONTEXT_NAMES = CONTEXT.map(([, name]) => name);
+export const CONTEXT_HELP = Object.fromEntries(CONTEXT.map(([key, , help]) => [key, help]));
// File values → short codes used in class names and filters.
@@ -39,4 +59,9 @@ const MED_CODE = { legal: "yes", limited: "limited", none: "no" };
const isoDate = (d) => d.toISOString().slice(0, 10);
const pair = sourceTuple;
+// the spotlights as one cited block (for the API, comparisons and plain-text lists) plus each item
+const spotlightsOf = (list, cited) => ({
+ ...cited({ text: list.map((x) => `${x.title}. ${x.text}`).join(" "), sources: [...new Set(list.flatMap((x) => x.sources))] }),
+ items: list.map((x) => ({ title: x.title, ...cited(x) })),
+});
const plantingOf = ({ seeds, seeds_trade, clones, clones_trade }) => ({ seeds, seeds_trade, clones, clones_trade });
@@ -110,10 +135,14 @@ const everyPlace = (await getCollection("countries"))
details: {
reviewed: isoDate(data.updated),
- segments: SEGMENTS.map((seg) => cited(data.segments[seg.id])), // in SEGMENTS order
- context: CONTEXT.map(([key, name]) => (data.context[key] ? { key, name, ...cited(data.context[key]), ...(key === "planting" && plantingOf(data.context[key])), ...(STATUS_SECTIONS[key] && { kind: key, status: data.context[key].status }) } : null)), // null: no such section
+ // in SEGMENTS order; possession and cultivation may carry penalty steps (penalties.js)
+ segments: SEGMENTS.map((seg) => {
+ const block = data.segments[seg.id], rows = block.penalties?.map(penaltyRow);
+ return { ...cited(block), ...(rows && { penalties: { rows, currency: block.currency ?? null, summary: penaltySummary(rows) } }) };
+ }),
+ context: CONTEXT.map(([key, name, help]) => (data.context[key] ? { key, name, help, ...(key === "spotlights" ? spotlightsOf(data.context.spotlights, cited) : cited(data.context[key])), ...(key === "planting" && plantingOf(data.context[key])), ...(STATUS_SECTIONS[key] && { kind: key, status: data.context[key].status }) } : null)), // null: no such section
sources: Object.values(data.sources).map(pair),
},
planting: data.context.planting ? plantingOf(data.context.planting) : null,
- // one status per section in STATUS_SECTIONS (clubs, driving, visitors), null until researched
+ // one status per section in STATUS_SECTIONS (clubs, driving, visitors, hemp, production, research), null until researched
...Object.fromEntries(Object.keys(STATUS_SECTIONS).map((key) => [key, data.context[key]?.status ?? null])),
subunitMeta: data.subunits ?? null,
src/lib/culture.js +12 −0
@@ -5,4 +5,5 @@ import { getCollection } from "astro:content";
import { places, changes } from "./countries.js";
import { sourceTuple } from "./sources.js";
+import { LINK_MARK } from "./cite.js";
// The parts of the atlas, in menu order. `entries: true` means the section is made of files in
@@ -58,4 +59,15 @@ for (const e of cultureEntries) for (const [, path] of (e.entry.body ?? "").matc
if (!cultureEntries.some((x) => x.url === path + "/")) throw new Error(`culture/${e.entry.id}.md: the text links to /${path}/, which does not exist`);
}
+// Links in a country profile's segment and context texts to a history entry or a culture entry
+// must lead somewhere too (docs/DATA-MAINTENANCE.md, Links)
+const historyIds = new Set(changes.map((ch) => ch.id));
+for (const c of places) {
+ const blocks = [...c.details.segments, ...c.details.context.filter(Boolean).flatMap((x) => x.items ?? [x])];
… 9 more linessrc/lib/exports.js +3 −3
Not shown: a picture, a generated file, or past the length shown per change.
src/lib/map-views.js +1 −1
Not shown: a picture, a generated file, or past the length shown per change.
src/lib/penalties.js +85 −0
Not shown: a picture, a generated file, or past the length shown per change.
src/lib/research.js +4 −2
Not shown: a picture, a generated file, or past the length shown per change.
src/lib/segment-ids.js +39 −1
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/api/[id].json.js +1 −1
Not shown: a picture, a generated file, or past the length shown per change.
src/pages/map-notes.json.js +3 −2
Not shown: a picture, a generated file, or past the length shown per change.
src/styles/global.css +32 −0
Not shown: a picture, a generated file, or past the length shown per change.