scripts/chart-semantics.test.cjs +47 −0
@@ -48,2 +48,49 @@ test('chart claims match categorical religion data and observational price data'
assert.match(page, /Across \{priced.length\} countries, cannabis price and the score show/);
});
+
+const csv = (file) => {
+ const [head, ...lines] = fs.readFileSync(`datasets/${file}`, 'utf8').trim().split(/\r?\n/).map((r) => r.split(','));
+ return lines.map((r) => Object.fromEntries(head.map((h, i) => [h, h === 'country' ? r[i] : r[i] === '' ? null : +r[i]])));
+};
+
+test('affordability uses an actual common price/wage year, including stale-price countries (#136)', async () => {
+ const { matchedPriceWage } = await semantics;
+ const prices = csv('euda-cannabis-prices.csv'), wages = csv('eurostat-minimum-wages.csv');
+ let n = 0;
+ for (const id of new Set(prices.map((r) => r.country))) {
+ const pair = matchedPriceWage(prices.filter((r) => r.country === id), wages.filter((r) => r.country === id));
+ if (pair) { n++; assert.equal(pair.price.year, pair.wage.year); }
+ if (['lv', 'ie', 'lt'].includes(id) && pair) assert.ok(pair.wage.year < 2026);
+ }
+ assert.ok(n > 10);
+ assert.equal(matchedPriceWage([{ year: 2018, herb_eur: 7 }], [{ year: 2026, monthly_eur: 780 }]), undefined);
+});
+
+test('THC trend holds the endpoint cohort constant and omits incomplete years (#136)', async () => {
+ const { matchedThcPanel } = await semantics;
+ const prices = csv('euda-cannabis-prices.csv'), potency = csv('euda-cannabis-potency.csv');
+ const years = Array.from({ length: 23 }, (_, i) => 2002 + i).map((year) => ({ year, rows: prices.filter((p) => p.year === year && p.herb_eur > 0).flatMap((p) => {
+ const t = potency.find((t) => t.country === p.country && t.year === year && t.herb_thc > 0);
+ return t ? [{ c: { id: p.country }, mg: t.herb_thc * 10 / p.herb_eur }] : [];
+ }) }));
+ assert.equal(years[0].rows.length, 9);
+ assert.equal(years.at(-1).rows.length, 16);
+ const panel = matchedThcPanel(years);
+ assert.deepEqual([...panel.cohort].sort(), ['es', 'nl', 'pt', 'tr']);
+ assert.ok(panel.years.length > 2);
+ for (const y of panel.years) assert.deepEqual(y.rows.map((r) => r.c.id).sort(), [...panel.cohort].sort());
+});
+
+test('police comparisons are within country and treatment stocks are excluded (#136)', async () => {
+ const { policeTrend, annualTreatment } = await semantics;
+ assert.deepEqual(policeTrend([{ year: 2023, offences: 100 }, { year: 2024, offences: 150 }]).change, 50);
+ assert.equal(policeTrend([{ year: 2024, offences: 150 }]), null);
+ assert.equal(annualTreatment({ id: 'HR', datasets: { treatment: { cannabis: 474 } } }), null);
+ const page = fs.readFileSync('src/pages/charts.astro', 'utf8');
+ assert.doesNotMatch(page, /punishSpread|punishMore|Treats more than it punishes|read the order, not the decimals|its people looked it up/);
+ assert.match(page, /not geolocated/);
+ assert.match(page, /w\.r\.served/);
+ assert.match(page, /w\.r\.cities/);
+ assert.match(page, /w\.r\.year/);
+ assert.match(page, /Croatia is excluded/);
+});
scripts/profile-claims.test.mjs +48 −0
@@ -17,4 +17,5 @@ const texts = files.map((file) => [path.relative(root, file), fs.readFileSync(fi
const CLAIMS = [
+ { claim: /Before Law 242 Panama had no lawful cannabis of any kind/i, why: "the 2021 framework did not establish operational supply (#128)" },
// Malta (2021) and Germany (2024) license non-profit associations: issue #127
{ claim: /prohibited throughout the European Union/i, why: "clubs are not prohibited EU-wide: Malta and Germany license associations (#127)" },
@@ -31,2 +32,49 @@ test("no profile, article or history entry repeats a sentence known to be false"
assert.deepEqual(found, []);
});
+
+import yaml from 'js-yaml';
+const country = (id) => yaml.load(fs.readFileSync(path.join(root, 'countries', `${id}.yaml`), 'utf8'));
+const events = () => yaml.load(fs.readFileSync(path.join(root, 'changes.yaml'), 'utf8'));
+
+test('India remains visible to death-penalty filters while bhang and scores stay explicit (#133)', () => {
+ const india = country('in');
+ assert.equal(india.recreational, 'death');
+ assert.match(india.summary, /bhang/i);
+ assert.match(india.context.death_penalty.text, /20 kg.*500 g/);
+ assert.deepEqual(india.scores, { possession: 1, cultivation: 1, enforcement: 2, sharing: 1, consumption: 1, access: 2 });
+ const home = fs.readFileSync(path.join(root, 'src/components/HomePage.astro'), 'utf8');
+ assert.match(home, /count\(\(c\) => c.rec === "death"\)/);
+ assert.match(home, /death: 0/);
+});
+
+test('medical access dates actual supply rather than enabling statutes (#128–129)', () => {
+ const history = events(), get = (id) => history.find((e) => e.id === id);
+ assert.equal(get('panama-medical-2021').before, undefined);
+ assert.equal(country('pa').scores.access, 0);
+ assert.equal(get('greece-medical-2018').before, undefined);
+ assert.equal(get('greece-prescriptions-2024').date, '2024-02');
+ assert.equal(get('greece-prescriptions-2024').before.access, 0);
+ assert.equal(country('gr').scores.access, 1);
+});
+
+test('hemp crop and product limits and conditional Kenyan forfeiture remain distinct (#130, #132)', () => {
+ const hemp = country('lt').context.planting.text;
+ assert.match(hemp, /0.3% THC/);
+ assert.match(hemp, /finished hemp goods.*0.2% total THC/);
+ const kenya = country('ke');
+ assert.match(kenya.segments.cultivation.text, /without that involvement.*returns to the owner/);
+ assert.match(kenya.context.consequences.text, /notice.*heard/);
+ assert.doesNotMatch(kenya.segments.cultivation.text, /court must also order the land forfeited/);
+});
+
+test('Trinidad quick answers retain the location offence and US firearm claims retain Hemani (#134–135)', () => {
+ const tt = country('tt');
+ assert.equal(tt.answers.carry.answer, 'limited');
+ assert.match(tt.answers.caught.text, /school bus.*five years/);
+ assert.match(tt.segments.possession.text, /5C\(b\).*TT\$250,000.*five years/);
+ assert.match(tt.segments.possession.text, /5\(7\).*excludes.*5\(2\)/);
+ const us = country('us/index').context.consequences.text;
+ assert.match(us, /Hemani/);
+ assert.match(us, /did not decide purchases or transfers/);
+ assert.match(country('se').context.products.text, /14 July 2026/);
+});
src/pages/charts.astro +61 −91
@@ -5,5 +5,5 @@
// Every chart leads on: a year to the history, a country to its profile, a region to the rankings,
// a dataset to its map. A small script at the end adds hover cards with the detail behind a mark.
-import { firstRestrictionSample, HISTORY_TOPICS, topicCounts, DEVELOPMENT_MEASURES } from "../lib/chart-semantics.js";
+import { firstRestrictionSample, HISTORY_TOPICS, topicCounts, DEVELOPMENT_MEASURES, matchedPriceWage, matchedThcPanel, policeTrend, annualTreatment } from "../lib/chart-semantics.js";
import Base from "../layouts/Base.astro";
import AverageChart from "../components/AverageChart.astro";
@@ -259,15 +259,12 @@ const PX1 = Math.ceil(Math.max(...priced.map((p) => p.x)) / 10) * 10, PY1 = Math
const priceNamed = pickLabels([...priced].sort((a, b) => Math.abs(b.y - median(priced.map((q) => q.y))) - Math.abs(a.y - median(priced.map((q) => q.y)))).map((p) => ({ key: p.c.id, x: pct(p.x, 0, PX1), y: 100 - pct(p.y, 0, PY1) })));
-// ── Who gets punished: cannabis offences recorded per 1,000 people who use it ──
+// ── Within-country changes in recorded use/possession counts ──
const OFF = DATASETS.cannabis_offences;
-const punished = countries.map((c) => ({ c, r: latestSince(c.series.cannabis_offences, "offences", 2020) }))
- .filter((p) => p.r && c_users(p.c) > 0).map((p) => ({ ...p, rate: (p.r.offences / c_users(p.c)) * 1000 })).sort((a, b) => b.rate - a.rate);
-function c_users(c) { return c.use && !c.use.estimate ? c.use.users : 0; }
-const punishMax = punished[0].rate;
-const punishSpread = Math.round(punished[0].rate / punished.at(-1).rate);
+const punished = countries.map((c) => ({ c, trend: policeTrend(c.series.cannabis_offences) }))
+ .filter((p) => p.trend).sort((a, b) => a.c.name.localeCompare(b.c.name));
// ── Stronger, cheaper: how much THC a euro buys in Europe, year by year ──
// A gram at p% THC holds p × 10 mg; a euro buys that divided by the price.
-const thcYears = [];
+const reportingThcYears = [];
for (let y = 2002; y <= NOW; y++) {
const rows = countries.map((c) => {
@@ -275,6 +272,8 @@ for (let y = 2002; y <= NOW; y++) {
return pr && th ? { c, mg: (th * 10) / pr, th, pr } : null;
}).filter(Boolean);
- if (rows.length >= 5) thcYears.push({ year: y, rows, mg: median(rows.map((r) => r.mg)), th: median(rows.map((r) => r.th)), pr: median(rows.map((r) => r.pr)) });
+ if (rows.length >= 5) reportingThcYears.push({ year: y, rows, mg: median(rows.map((r) => r.mg)), th: median(rows.map((r) => r.th)), pr: median(rows.map((r) => r.pr)) });
}
+const matchedThc = matchedThcPanel(reportingThcYears);
+const thcYears = matchedThc.years.map((t) => ({ ...t, mg: median(t.rows.map((r) => r.mg)), th: median(t.rows.map((r) => r.th)), pr: median(t.rows.map((r) => r.pr)) }));
const T0 = thcYears[0].year, T1 = thcYears.at(-1).year;
const MG1 = Math.ceil(Math.max(...thcYears.map((t) => t.mg)) * 1.6 / 10) * 10;
@@ -282,12 +281,9 @@ const mgOf = (v) => 100 - pct(Math.min(v, MG1), 0, MG1);
const thcLine = (pts) => pts.map(([x, v], i) => `${i ? "L" : "M"}${pct(x, T0, T1).toFixed(2)},${mgOf(v).toFixed(2)}`).join("");
const thcMedianPath = thcLine(thcYears.map((t) => [t.year, t.mg]));
-// a faint line for each country with at least five years of both figures
-const thcCountries = [...new Set(thcYears.flatMap((t) => t.rows.map((r) => r.c)))]
- .map((c) => ({ c, pts: thcYears.flatMap((t) => t.rows.filter((r) => r.c === c).map((r) => [t.year, r.mg])) })).filter((x) => x.pts.length >= 5);
-const thcFirst = thcYears.slice(0, 3), thcLast = thcYears.slice(-3);
-const mgThen = mean(thcFirst.map((t) => t.mg)), mgNow = mean(thcLast.map((t) => t.mg));
-const thThen = mean(thcFirst.map((t) => t.th)), thNow = mean(thcLast.map((t) => t.th));
-const prThen = mean(thcFirst.map((t) => t.pr)), prNow = mean(thcLast.map((t) => t.pr));
-const mgChange = mgNow / mgThen - 1;
+const thcCountries = matchedThc.cohort.map((id) => {
+ const c = countries.find((c) => c.id === id);
+ return { c, pts: thcYears.flatMap((t) => t.rows.filter((r) => r.c.id === id).map((r) => [t.year, r.mg])) };
+});
+const mgThen = thcYears[0].mg, mgNow = thcYears.at(-1).mg;
// ── Criminalising a crop: countries that grow hemp commercially, and their law on cannabis ──
@@ -345,5 +341,4 @@ const WX1 = Math.ceil(Math.max(...sewage.map((w) => w.x)) / 10) * 10;
const sewageFit = corr(sewage.map((w) => ({ x: w.x, y: Math.log10(w.y) })));
const sewageNamed = pickLabels([...sewage].sort((a, b) => b.y - a.y).map((w) => ({ key: w.c.id, x: pct(w.x, 0, WX1), y: wy(w.y) })));
-const sewageTop = [...sewage].sort((a, b) => b.y - a.y)[0], sewageLow = [...sewage].sort((a, b) => a.y - b.y)[0];
// ── Canada after legalisation: where people get cannabis, and what governments earn ──
@@ -427,6 +422,8 @@ const usThen = usScore.find((r) => r.year === +pollLast.date.slice(0, 4))?.score
const MW = DATASETS.minimum_wage;
const workGram = countries.map((c) => {
- const price = latestSince(c.series.prices_europe, "herb_eur", 2018), wage = latestSince(c.series.minimum_wage, "monthly_eur", 2020);
- return price && wage ? { c, price: price.herb_eur, priceYear: price.year, wage: wage.monthly_eur, wageYear: wage.year, minutes: (price.herb_eur / (wage.monthly_eur / 173.3)) * 60 } : null;
+ const pair = matchedPriceWage(c.series.prices_europe, c.series.minimum_wage);
+ if (!pair) return null;
+ const { price, wage } = pair;
+ return { c, price: price.herb_eur, priceYear: price.year, wage: wage.monthly_eur, wageYear: wage.year, minutes: (price.herb_eur / (wage.monthly_eur / 173.3)) * 60 };
}).filter(Boolean).sort((a, b) => b.minutes - a.minutes);
const workMax = workGram[0]?.minutes ?? 1;
@@ -716,12 +713,7 @@ const P0 = 1990;
const TREAT = DATASETS.treatment;
const punishTreat = countries.map((c) => {
- const off = latestSince(c.series.cannabis_offences, "offences", 2020), tr = c.datasets.treatment;
- const users = c.use && !c.use.estimate ? c.use.users : 0;
- return off && tr && users ? { c, punish: (off.offences / users) * 1000, treat: (tr.cannabis / users) * 1000, offYear: off.year, trYear: tr.year, share: tr.cannabis_share } : null;
-}).filter(Boolean);
-const PT0 = -1, PT1 = 3; // 0.1 to 1,000 per 1,000 users, tenfold steps
-const ptx = (v) => pct(Math.log10(Math.max(v, 0.1)), PT0, PT1), pty = (v) => 100 - pct(Math.log10(Math.max(v, 0.1)), PT0, PT1);
-const punishMore = punishTreat.filter((d) => d.punish > d.treat);
-const ptNamed = pickLabels([...punishTreat].sort((a, b) => Math.abs(Math.log10(b.punish / b.treat)) - Math.abs(Math.log10(a.punish / a.treat))).map((d) => ({ key: d.c.id, x: ptx(d.punish), y: pty(d.treat) })), 20);
+ const off = latestSince(c.series.cannabis_offences, "offences", 2020), tr = annualTreatment(c);
+ return off && tr ? { c, punish: off.offences, treat: tr.cannabis, offYear: off.year, trYear: tr.year, share: tr.cannabis_share } : null;
+}).filter(Boolean).sort((a, b) => a.c.name.localeCompare(b.c.name));
// ── In the news: the share of each country's news articles that mention cannabis ──
@@ -772,5 +764,5 @@ const chartData = {
moral: Object.fromEntries(moral.map((m) => [m.c.slug, [m.c.flag + " " + m.c.name, m.d.acceptable, m.d.not_moral, m.d.unacceptable, Math.round(m.y), tierOf(m.y).name, REC_LABEL[m.c.rec]]])),
price: Object.fromEntries(priced.map((p) => [p.c.slug, [p.c.flag + " " + p.c.name, p.y, p.r.year, Math.round(p.x), tierOf(p.x).name]])),
- punish: Object.fromEntries(punished.map((p) => [p.c.slug, [p.c.flag + " " + p.c.name, Math.round(p.rate), p.r.offences, p.r.year, p.c.users ?? p.c.use.users, p.c.use.prevalence]])),
+ punish: Object.fromEntries(punished.map((p) => [p.c.slug, [p.c.flag + " " + p.c.name, p.trend.first.offences, p.trend.first.year, p.trend.last.offences, p.trend.last.year, p.trend.change]])),
thc: thcYears.map((t) => [t.year, +t.mg.toFixed(1), +t.th.toFixed(1), +t.pr.toFixed(2), t.rows.length]),
thcRange: [T0, T1, MG1],
@@ -779,5 +771,5 @@ const chartData = {
research: Object.fromEntries(research.map((r) => [r.c.slug, [r.c.flag + " " + r.c.name, fmtRate(r.per), r.total, Math.round(r.c.raw), tierOf(r.c.raw).name]])),
easy: Object.fromEntries(easy.map((e) => [e.c.slug, [e.c.flag + " " + e.c.name, e.d.easy, e.d.easy_boys, e.d.easy_girls, Math.round(e.x), tierOf(e.x).name]])),
- sewage: Object.fromEntries(sewage.map((w) => [w.c.slug, [w.c.flag + " " + w.c.name, w.y, w.r.year, w.r.cities, Math.round(w.x), tierOf(w.x).name, w.trend]])),
+ sewage: Object.fromEntries(sewage.map((w) => [w.c.slug, [w.c.flag + " " + w.c.name, w.y, w.r.year, w.r.cities, Math.round(w.x), tierOf(w.x).name, w.trend, w.r.served]])),
sourcing: sourcing.map((r) => [r.year, ...SOURCE_KINDS.map((k) => r[k.id] ?? null)]),
sourceLabels: SOURCE_KINDS.map((k) => [k.label, k.color]),
@@ -831,5 +823,5 @@ const sections = [
["buy-grow", "Buy it or grow it"],
["punished", "Who the police record"],
- ["punish-or-treat", "Punish or treat"],
+ ["punish-or-treat", "Police records and treatment entries"],
["use", "Use and the law"],
["teen-trends", "Did prohibition protect children?"],
@@ -1303,46 +1295,25 @@ const kick = (id) => { const i = sections.findIndex(([x]) => x === id); return `
<section class="ch" id="punished">
<p class="ch-kicker"><a href="#punished">{kick("punished")}</a></p>
- <h2>{punished[0].c.name} records {Math.round(punished[0].rate)} offences of cannabis use or possession for every 1,000 people who use it; {punished.at(-1).c.name}, {fmtRate(punished.at(-1).rate)}</h2>
- <p class="ch-dek">How often the law reaches the people who use cannabis: offences of using or possessing cannabis for one's own use that the police record in a year, divided by the number of adults who used it in the past year. Cannabis only, and only use or possession: not dealing or growing for sale, and not other drugs. An offence is not a conviction: depending on the country it may end in a fine, a caution, treatment or a criminal case, and some countries count offenders rather than offences. A {punishSpread}-fold gap, with laws on paper that often look alike.</p>
- <ol class="ch-bars" data-no-preview>
- {punished.map((p) => (
- <li>
- <a href={`${base}country/${p.c.slug}/#datasets`} data-punish={p.c.slug}>
- <span class="ch-bar-name">{p.c.flag} {p.c.name}</span>
- <span class="ch-bar-track"><span class="ch-bar" style={`width:${(p.rate / punishMax) * 100}%;--c:${tierColor(p.c.raw)}`}></span></span>
- <span class="ch-bar-num">{fmtRate(p.rate)}</span>
- </a>
- </li>
- ))}
- </ol>
- <p class="ch-source">Offences: {OFF.title}, latest year since 2020. Users: the <a href={`${base}sources/#dataset-cannabis_use`}>UNODC</a> estimate of adults who used cannabis in the past year. Bars coloured by the country's band. Countries record and define offences differently, so read the order, not the decimals. <a href={`${base}sources/#dataset-cannabis_offences`}>About the dataset</a>.</p>
- <p class="ch-more"><a href={`${base}map/cannabis-offences-europe/`}>Offences on the map →</a> <a href={`${base}map/drug-possession-police/`}>Possession arrests worldwide →</a></p>
+ <h2>Cannabis police records: how each country's latest count changed</h2>
+ <p class="ch-dek">Compare each country with its own previous reporting year. These are recorded use or possession offences or persons, depending on the country, rather than punishments. Different counting units and procedural stages make both cross-country ranks and magnitudes unreliable as enforcement comparisons. Rows are alphabetical.</p>
+ <ul class="ch-bars" data-no-preview>
+ {punished.map((p) => <li><a href={`${base}country/${p.c.slug}/#datasets`} data-punish={p.c.slug}>
+ <span class="ch-bar-name">{p.c.flag} {p.c.name}</span>
+ <span>{num(p.trend.first.offences)} ({p.trend.first.year}) → {num(p.trend.last.offences)} ({p.trend.last.year})</span>
+ <span class="ch-bar-num">{p.trend.change == null ? "—" : `${p.trend.change > 0 ? "+" : ""}${p.trend.change.toFixed(1)}%`}</span>
+ </a></li>)}
+ </ul>
+ <p class="ch-source">{OFF.title}. Latest two available years since 2020; reporting changes can also affect a country's trend. <a href="https://www.euda.europa.eu/data/stats2026/methods/dlo_en">EUDA counting methods</a>. <a href={`${base}sources/#dataset-cannabis_offences`}>About the dataset</a>.</p>
</section>
{chapterAt.has("punish-or-treat") && <h2 class="ch-chapter">{chapterAt.get("punish-or-treat")}</h2>}
<section class="ch" id="punish-or-treat">
<p class="ch-kicker"><a href="#punish-or-treat">{kick("punish-or-treat")}</a></p>
- <h2>Punish or treat: in {punishMore.length} of {punishTreat.length} European countries, the police record more cannabis offences than people enter treatment for cannabis</h2>
- <p class="ch-dek">Each dot is a country, both figures per 1,000 people who use cannabis. Across: offences of cannabis use or possession the police record. Up: people who enter drug treatment mainly for cannabis. Above the diagonal a country treats more than it records; below it, it records more than it treats. Some of those in treatment were sent there by a court. Both scales rise tenfold at each line.</p>
- <div class="ch-frame ch-scatter ch-wide-y ch-ptreat" data-no-preview>
- <div class="ch-y">{[0.1, 1, 10, 100, 1000].map((t) => <span style={`top:${pty(t)}%`}>{num(t)}</span>)}</div>
- <div class="ch-plot">
- <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
- {[0.1, 1, 10, 100, 1000].map((t) => <><line class="ch-grid" x1="0" x2="100" y1={pty(t)} y2={pty(t)} /><line class="ch-grid" y1="0" y2="100" x1={ptx(t)} x2={ptx(t)} /></>)}
- <line class="ch-diag" x1="0" y1="100" x2="100" y2="0" />
- </svg>
- <span class="ch-corner tl">Treats more than it punishes</span>
- <span class="ch-corner br">Punishes more than it treats</span>
- {punishTreat.map((d) => (
- <a class:list={["ch-dot", { named: ptNamed.has(d.c.id), left: ptx(d.punish) > 75 }]} href={`${base}country/${d.c.slug}/#datasets`} data-ptreat={d.c.slug}
- style={`left:${ptx(d.punish)}%;top:${pty(d.treat)}%;--c:${tierColor(d.c.raw)}`} aria-label={`${d.c.name}: ${d.punish.toFixed(1)} offences and ${d.treat.toFixed(1)} treatment entrants per 1,000 users`}>
- {ptNamed.has(d.c.id) && <span>{d.c.name}</span>}
- </a>
- ))}
- </div>
- <div class="ch-x">{[0.1, 1, 10, 100, 1000].map((t, i) => <span class:list={{ first: i === 0, last: t === 1000 }} style={`left:${ptx(t)}%`}>{num(t)}</span>)}</div>
… 129 more lines