Code, design and documents 12 files
README.md +1 −1
@@ -36,5 +36,5 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag
| Path | What |
|---|---|
-| `countries/de.yaml` | Everything about one country: scores, summary, explanations, sources |
+| `countries/de.yaml` | Everything about one country: scores, summary, explanations, sources, and optionally its seed and clone law (unscored) |
| `countries/us/` | A federation: `index.yaml` for the country, one file per state |
| `segments.yaml` | The six segments, their weights and level descriptions |
docs/DATA-MAINTENANCE.md +68 −1
@@ -26,4 +26,6 @@ Flower prices are a fourth, independent data set. They provide market context bu
the freedom score.
+Seed and clone law is an optional, unscored section of the profile (see "Seeds and clones").
+
## Where things are
@@ -36,5 +38,5 @@ the freedom score.
| `src/content.config.ts` | The schema. The build fails with a clear message when data breaks it |
| `scripts/history-batch.cjs` | Applies a batch of history edits from a JSON file (see below) |
-| `scripts/data-status.cjs` | Counts; lists of what is unchecked, estimated, thin or weakly sourced; and a check of every country's score timeline |
+| `scripts/data-status.cjs` | Counts; lists of what is unchecked, estimated, thin or weakly sourced; which profiles have a seeds-and-clones section; and a check of every country's score timeline |
Run everything from the project root. `npm run build` validates all data; run it after every
@@ -136,4 +138,69 @@ by the ECB, cite the alternative exchange-rate source in the observation note.
map modes.
+## Seeds and clones
+
+Seed and clone law often differs from the plant's. The 1961 Single Convention leaves seeds out of
+its definition of cannabis, so many countries that ban growing still let seeds be sold, while
+clones are living plants and usually fall under the cultivation rules. The profile records this in
+an optional section under `context`. **It is never part of the score** and no history `before`
+changes because of it.
+
+```yaml
+context:
+ planting:
+ seeds: grey # may an adult have and plant them: legal | grey | licensed | illegal
+ seeds_trade: open # may they be sold or handed on: open | regulated | gift | unclear | banned
+ clones: illegal # same four values as seeds
+ clones_trade: banned # same five values as seeds_trade
+ text: >-
+ The Misuse of Drugs Act's definition of cannabis leaves out the seed, so buying and holding
+ seeds is not an offence. Cultivating any plant of the genus Cannabis is unlawful without a
+ licence, which covers germinating seeds and keeping cuttings.
+ sources:
+ - mda # a key defined under the profile's `sources`
+```
+
+| Value | Seeds | Clones |
+|---|---|---|
+| `legal` | Adults may buy, hold and sow them | Adults may buy and keep cuttings |
+| `grey` | Sold or held openly, but germinating is an offence, or the law is silent | Tolerated, or legal only until the plant flowers |
+| `licensed` | Only for licensed growers (hemp, medical, registered clubs) | Same |
+| `illegal` | Treated as cannabis or expressly banned | Treated as plants under the cultivation ban |
+
+Having them and trading them are separate questions, so each gets its own status. Seeds are often
+lawful to sell where sowing them is not, and a country that lets adults grow may still allow no one
+to sell them a plant.
+
+| Trade value | Meaning |
+|---|---|
+| `open` | Anyone may sell them, in shops or online |
+| `regulated` | Only licensed sellers, clubs or the state may sell or hand them out, often with limits |
+| `gift` | May be given away between adults, but not sold |
+| `unclear` | The law does not say whether they may be sold |
+| `banned` | Selling or handing them on is an offence |
+
+How to decide:
+
+- Read the statute's **definition of cannabis**. If it excludes the seed and nothing else bans
+ seeds, seeds are `grey` where growing is illegal and `legal` where adults may grow.
+- For clones, read the **cultivation offence** and any rule on plants that are not flowering. A
+ cutting is a plant unless the law says otherwise.
+- For the trade status, look for who may sell: nobody (`banned`), a licence holder or club
+ (`regulated`), anyone (`open`). Where seeds are simply outside the definition of cannabis and no
+ other rule restricts them, the trade is `open`. Use `unclear` when the law allows having them but
+ is silent on selling, and say so in the text.
+- Keep it consistent with the profile's cultivation segment: if a few plants are tolerated, clones
+ are `grey`, not `legal`.
+- Leave the section out when no source was read. Do not guess from neighbouring countries.
+
+A law that changes seed or clone rules goes into `changes.yaml` as a context entry (category
+`other`, or `easing` / `tightening` when it clearly is one), with no `before`.
+
+`node scripts/data-status.cjs planting` shows which profiles have the section and lists the rest by
+score, highest first. State and province files do not carry it yet. The statuses and their labels
+are defined once in `PLANTING_STATUS` in `src/lib/segment-ids.js`; the country page, the compare
+table, the per-country JSON and `scores.csv` (columns `seeds`, `seeds_trade`, `clones`, `clones_trade`)
+all read from there, together with `PLANTING_TRADE`.
+
## How a history entry works
scripts/data-status.cjs +10 −1
@@ -1,3 +1,3 @@
-// Where the data stands: node scripts/data-status.cjs [unchecked|estimates|thin|weak|chain [id]]
+// Where the data stands: node scripts/data-status.cjs [unchecked|estimates|thin|weak|planting|chain [id]]
// Run from the project root. Needs js-yaml (already in node_modules).
const fs = require("fs"), y = require("js-yaml");
@@ -14,4 +14,13 @@ console.log(`history: ${changes.length} entries | checked ${changes.filter((e) =
if (what === "unchecked") for (const e of unchecked) console.log([e.country || "--", e.date, e.id, e.sources[0].url].join(" | "));
if (what === "estimates") console.log(estimates.map((e) => `${e.country}${e.window ? `[${e.window}]` : ""}`).join(" "));
+if (what === "planting") {
+ // Seeds and clones: which profiles have the section, and which are still to do, best score first.
+ const W = Object.fromEntries(y.load(fs.readFileSync("segments.yaml", "utf8")).map((s) => [s.id, s.weight]));
+ const total = (id) => Object.entries(country[id].scores).reduce((a, [k, v]) => a + (W[k] * v) / 10, 0);
+ const has = ids.filter((id) => country[id].context.planting);
+ console.log(`seeds and clones: ${has.length} of ${ids.length} profiles`);
+ for (const id of has) console.log(` ${id} ${country[id].name}: seeds ${country[id].context.planting.seeds} / sale ${country[id].context.planting.seeds_trade}, clones ${country[id].context.planting.clones} / sale ${country[id].context.planting.clones_trade}`);
+ console.log("to do:", ids.filter((id) => !country[id].context.planting).sort((a, b) => total(b) - total(a)).map((id) => `${id}:${country[id].name}`).join(", "));
+}
if (what === "thin") {
const n = {};
src/components/CountryArticle.astro +9 −0
@@ -8,4 +8,5 @@ import CountryChart from "./CountryChart.astro";
import PricePanel from "./PricePanel.astro";
import { SHOW_PRICES } from "../lib/config.js";
+import { PLANTING_STATUS, PLANTING_TRADE } from "../lib/segment-ids.js";
import { answersFor } from "../lib/answers.js";
@@ -212,4 +213,12 @@ const circ = 2 * Math.PI * r;
<div class="segrow">
<div class="segrow-top"><span>{x.name === "Death penalty" ? "☠️ " : ""}{x.name}</span></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>
+ </div>
+ )}
<p class="ctx">{x.t}</p>
<ul class="srcs">
src/content.config.ts +9 −3
@@ -4,5 +4,5 @@ import { defineCollection } from "astro:content";
import { glob, file } from "astro/loaders";
import { z } from "astro/zod";
-import { SEGMENT_IDS, CONTEXT_IDS, CHANGE_CATEGORIES } from "./lib/segment-ids.js";
+import { SEGMENT_IDS, CONTEXT_IDS, CHANGE_CATEGORIES, PLANTING_STATUS, PLANTING_TRADE } from "./lib/segment-ids.js";
const score = z.number().int().min(0).max(10);
@@ -11,4 +11,9 @@ const perSegment = <T extends z.ZodTypeAny>(value: T) =>
const source = z.object({ title: z.string().min(1), url: z.string().url() }).strict();
const cited = z.object({ 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)
+const plantingStatus = z.enum(Object.keys(PLANTING_STATUS) as [string, ...string[]]);
+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();
const status = {
// "death": illegal, and the law allows the death penalty for cannabis offences, in practice trafficking.
@@ -52,6 +57,7 @@ const countries = defineCollection({
scores: perSegment(score),
segments: perSegment(cited),
- // the four standard sections, plus `death_penalty` where the law provides for one
- context: z.object({ death_penalty: cited.optional(), ...Object.fromEntries(CONTEXT_IDS.map((id) => [id, cited])) }).strict(),
+ // the four standard sections, plus `death_penalty` where the law provides for one and
+ // `planting` (seeds and clones) where it has been researched
+ context: z.object({ death_penalty: cited.optional(), planting: planting.optional(), ...Object.fromEntries(CONTEXT_IDS.map((id) => [id, cited])) }).strict(),
sources: z.record(z.string(), source),
subunits: z
src/lib/countries.js +6 −3
@@ -18,6 +18,7 @@ export const REC_HELP = {
export const MED_LABEL = { yes: "Legal", limited: "Limited", no: "None" };
// Explanations that exist for each country but are not part of the score.
-// "Death penalty" comes first and exists only for countries whose law provides for it
-const CONTEXT = [["death_penalty", "Death penalty"], ["production", "Commercial production"], ["products", "Products"], ["medical", "Medical use"], ["consequences", "Other consequences"]];
+// "Death penalty" comes first and exists only for countries whose law provides for it;
+// "Seeds and clones" exists only where it has been researched and carries a status for each.
+const CONTEXT = [["death_penalty", "Death penalty"], ["production", "Commercial production"], ["products", "Products"], ["planting", "Seeds and clones"], ["medical", "Medical use"], ["consequences", "Other consequences"]];
export const CONTEXT_NAMES = CONTEXT.map(([, name]) => name);
@@ -27,4 +28,5 @@ const MED_CODE = { legal: "yes", limited: "limited", none: "no" };
const isoDate = (d) => d.toISOString().slice(0, 10);
const pair = (s) => [s.title, s.url];
+const plantingOf = ({ seeds, seeds_trade, clones, clones_trade }) => ({ seeds, seeds_trade, clones, clones_trade });
// ── Segments ──────────────────────────────────────────
@@ -69,7 +71,8 @@ const everyPlace = (await getCollection("countries"))
reviewed: isoDate(data.updated),
segments: SEGMENTS.map((seg) => cited(data.segments[seg.id])), // in SEGMENTS order
- context: CONTEXT.map(([key, name]) => (data.context[key] ? { name, ...cited(data.context[key]) } : null)), // null: no such section
+ context: CONTEXT.map(([key, name]) => (data.context[key] ? { name, ...cited(data.context[key]), ...(key === "planting" && plantingOf(data.context[key])) } : null)), // null: no such section
sources: Object.values(data.sources).map(pair),
},
+ planting: data.context.planting ? plantingOf(data.context.planting) : null,
subunitMeta: data.subunits ?? null,
};
src/lib/exports.js +4 −0
@@ -19,4 +19,8 @@ export const scoreRows = places.map((c) => ({
recreational: REC_LABEL[c.rec],
medical: MED_LABEL[c.med],
+ seeds: c.planting?.seeds ?? "", // empty until researched; see PLANTING_STATUS and PLANTING_TRADE
+ seeds_trade: c.planting?.seeds_trade ?? "",
+ clones: c.planting?.clones ?? "",
+ clones_trade: c.planting?.clones_trade ?? "",
population: c.population,
users: c.use.users,
src/lib/segment-ids.js +17 −1
@@ -3,4 +3,20 @@
export const SEGMENT_IDS = ["possession", "cultivation", "enforcement", "sharing", "consumption", "access"];
export const CONTEXT_IDS = ["production", "products", "medical", "consequences"];
+// Seeds and clones: an optional context section. Not part of the score. Each has two statuses:
+// whether an adult may have and plant them (PLANTING_STATUS) and whether they may be sold or
+// handed on (PLANTING_TRADE). `help` is [what it means for seeds, what it means for clones].
+export const PLANTING_STATUS = {
+ legal: { label: "Legal", tone: "legal", help: ["Adults may buy, hold and sow them", "Adults may buy and keep cuttings"] },
+ grey: { label: "Grey area", tone: "decrim", help: ["Sold or held openly, but germinating them is an offence or the law is silent", "Tolerated, or legal only until the plant flowers"] },
+ licensed: { label: "Licence only", tone: "partial", help: ["Only for licensed growers, such as hemp, medical or registered clubs", "Only for licensed growers, such as hemp, medical or registered clubs"] },
+ illegal: { label: "Illegal", tone: "illegal", help: ["Treated as cannabis or expressly banned", "Treated as plants under the cultivation ban"] },
+};
+export const PLANTING_TRADE = {
+ open: { label: "Sold freely", tone: "legal", help: ["Anyone may sell them, in shops or online", "Anyone may sell them, in shops or online"] },
+ regulated: { label: "Regulated sellers only", tone: "partial", help: ["Only licensed sellers, clubs or the state may sell or hand them out, often with limits", "Only licensed sellers, clubs or the state may sell or hand them out, often with limits"] },
+ gift: { label: "Gifts only", tone: "decrim", help: ["May be given away between adults, but not sold", "May be given away between adults, but not sold"] },
+ unclear: { label: "Not settled", tone: "decrim", help: ["The law does not say whether they may be sold", "The law does not say whether they may be sold"] },
+ banned: { label: "Banned", tone: "illegal", help: ["Selling or handing them on is an offence", "Selling or handing them on is an offence"] },
+};
// What kind of event a History entry is, in the order the legend lists them.
export const CHANGE_CATEGORIES = {
@@ -13,4 +29,4 @@ export const CHANGE_CATEGORIES = {
debate: { emoji: "🗳️", label: "Debate", help: "Reports, commissions, votes and plans that changed no rule by themselves" },
international: { emoji: "🌐", label: "International", help: "Treaties and decisions of international bodies" },
- other: { emoji: "📜", label: "Other law", help: "Other laws and rulings, including hemp and CBD rules" },
+ other: { emoji: "📜", label: "Other law", help: "Other laws and rulings, including hemp, CBD and seed rules" },
};
src/pages/api/[id].json.js +1 −1
@@ -11,5 +11,5 @@ export function getStaticPaths() {
export function GET({ props }) {
const { c } = props;
- const entry = ({ t, src }) => ({ t, src });
+ const entry = ({ t, src, seeds, seeds_trade, clones, clones_trade }) => ({ t, src, ...(seeds && { seeds, seeds_trade, clones, clones_trade }) });
const body = {
id: c.id,
src/pages/compare.astro +7 −1
@@ -4,4 +4,5 @@ import { SEGMENTS, CONTEXT_NAMES, REC_LABEL, MED_LABEL, countries, places } from
import { flatLatestPricesFor } from "../lib/prices.js";
import { SHOW_PRICES } from "../lib/config.js";
+import { PLANTING_STATUS, PLANTING_TRADE } from "../lib/segment-ids.js";
const light = places.map(({ id, slug, name, flag, region, raw, score, rank, s, rec, med }) =>
@@ -10,4 +11,6 @@ const meta = {
segments: SEGMENTS.map((s) => ({ name: s.name, weight: s.weight })),
context: CONTEXT_NAMES,
+ planting: PLANTING_STATUS,
+ plantingTrade: PLANTING_TRADE,
rec: REC_LABEL,
med: MED_LABEL,
@@ -110,5 +113,8 @@ const presets = [
? `<ul class="srcs">${entry.src.map(([label, url]) => `<li><a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${esc(label)}</a></li>`).join("")}</ul>`
: "";
- const prose = (entry) => entry ? `<p class="cmp-text">${esc(entry.t)}</p>${sources(entry)}` : "";
+ // seeds and clones carry a status chip each, above the text
+ const plantingChip = (what, set, key, i) => `<span class="chip s-${set[key].tone}" title="${esc(set[key].help[i])}">${what}: ${set[key].label}</span>`;
+ const statusChips = (entry) => entry.seeds ? `<div class="d-chips">${plantingChip("Seeds", META.planting, entry.seeds, 0)}${plantingChip("Seed sales", META.plantingTrade, entry.seeds_trade, 0)}${plantingChip("Clones", META.planting, entry.clones, 1)}${plantingChip("Clone sales", META.plantingTrade, entry.clones_trade, 1)}</div>` : "";
+ const prose = (entry) => entry ? `${statusChips(entry)}<p class="cmp-text">${esc(entry.t)}</p>${sources(entry)}` : "";
const priceCell = (p) => p
? `<div class="cmp-val"><b>$${p.usd.toFixed(2)}</b><span>/g</span></div><div class="cmp-sub">${p.price} ${p.currency}/g · ${p.date}</div>`
src/pages/data/index.astro +1 −1
@@ -9,5 +9,5 @@ const base = import.meta.env.BASE_URL;
const repo = "https://github.com/burningtree/cannabis-freedom-index";
const files = [
- { name: "scores", title: "Scores", rows: scoreRows, about: "One row per country or disputed territory: index score, the six segment scores, rank, tier, legal status, population and estimated cannabis users." },
+ { name: "scores", title: "Scores", rows: scoreRows, about: "One row per country or disputed territory: index score, the six segment scores, rank, tier, legal status, seed and clone status where researched, population and estimated cannabis users." },
{ name: "history", title: "History", rows: historyRows, about: "One row per history entry: date, country, category, title, description, the score before and after, how far it has been checked, and its sources." },
{ name: "prices", title: "Flower prices", rows: priceRows, about: "One row per sourced flower-price observation: country, date, street or medical market, local price and US dollars per gram." },
src/styles/global.css +1 −0
@@ -288,4 +288,5 @@ th.num-col { text-align: right; }
.d-score .sub { font-size: 13.5px; }
.d-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
+.segrow .d-chips, .cmp-table .d-chips { margin: 8px 0; }
.d-note { color: var(--text-2); }
.d-h { font-size: 17px; margin: 26px 0 4px; display: flex; justify-content: space-between; align-items: baseline; }