Code, design and documents 12 files
README.md +2 −0
@@ -41,4 +41,6 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag
| `changes.yaml` | The History page: laws and events that moved a country's score, with the scores before, plus treaties; the home page shows the latest four |
| `src/` | The website: pages, components, styles |
+| `docs/DATA-MAINTENANCE.md` | How the country profiles and the history are checked and extended, and what is still open |
+| `scripts/` | Helpers for that work: apply a batch of history edits, list what still needs a source |
The build checks every data file against a schema (`src/content.config.ts`) and fails with a clear message if, say, a score is out of range or a segment cites a source that isn't defined.
docs/DATA-MAINTENANCE.md +173 −0
@@ -0,0 +1,173 @@
+# Maintaining the data: country profiles and history
+
+How to check, correct and extend the two data sets behind the site. Written as a handover: it
+records how the work has been done so far, what was learned, and what is still open.
+
+## The task in one paragraph
+
+The site scores cannabis freedom per country (`countries/*.yaml`) and keeps a history of every
+law, ruling and treaty that shaped it (`changes.yaml`). The job is to make both **complete and
+trustworthy**: every country should have its whole cannabis history, from the first restriction
+to today, each entry backed by a source that was actually read; and every country profile should
+say what the law is *now*. When research turns up something that changes a country's current
+situation, the profile and its scores are updated too, not only the history.
+
+## Where things are
+
+| Path | What |
+|---|---|
+| `countries/<id>.yaml`, `countries/{us,ca,au}/index.yaml` | One profile per country: status, six scores, a text and sources per segment, context, sources |
+| `changes.yaml` | The history, newest first. The header comment documents every field |
+| `segments.yaml` | The six segments, their weights and what each score level means |
+| `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, and lists of what is unchecked, estimated, thin or weakly sourced |
+
+Run everything from the project root. `npm run build` validates all data; run it after every
+batch. The dev server (`npm run dev`) does **not** pick up edits to `changes.yaml` or the schema
+until it is restarted.
+
+## How a history entry works
+
+```yaml
+- id: tunisia-law-52-amended-2017 # also the page address; never reuse or rename lightly
+ date: "2017-04-25" # YYYY-MM-DD, YYYY-MM or YYYY, as precise as the source
+ category: easing # ban tightening death easing legalisation medical debate international other
+ country: tn # omit for international entries
+ title: Tunisia gives judges discretion in drug cases
+ description: >-
+ One to three sentences. Only what the source states.
+ before: {possession: 0, cultivation: 0, enforcement: 0, sharing: 0, consumption: 1, access: 0}
+ checked: true # only when the source was opened and read
+ sources:
+ - title: TalkingDrugs — Tunisia parliament approves …
+ url: https://…
+```
+
+- **`before`** is the six scores *before* the event. The scores after it are the `before` of the
+ same country's next scored entry, or the country's current scores. So the chain must be
+ consistent: the newest scored entry's "after" is the profile's current scores.
+- Leave `before` out for a **context entry**: a debate, a failed bill, a law that changed
+ nothing a user would feel. Most entries are context entries.
+- If the law or the reality changed, the entry **should move the score**, by an honest small
+ amount (usually one point in one or two segments).
+- **`estimate: true`** (with an optional `window: [from, to]`) marks a first restriction whose
+ date is unknown. Replace these with a dated law whenever one is found.
+- **`checked: true`** means: the date and the event were compared with a source that was read.
+ A search-result summary is not a read source (see "What went wrong").
+
+## Workflow that has worked
+
+1. **Pick countries.** `node scripts/data-status.cjs thin` lists places with two or fewer
+ entries; `unchecked` and `estimates` list what still needs a source. Go by population, or by
+ region in batches of ten or so.
+2. **Read sources.** In rough order of usefulness:
+ - Wikipedia "Cannabis in <country>", "Drug policy of <country>", "Legal history of cannabis in
+ <country>" (also the local-language edition). Ask for *every dated legal event the page
+ states*, and for exact quotes when checking one claim.
+ - The statute itself on a national law site (legislation.gov.*, *lii.org, PacLII, kenyalaw,
+ lawphil, BOE, sejm, slov-lex, legifrance …) or UNODC SHERLOC.
+ - Library of Congress Global Legal Monitor, Transnational Institute country guides, IDPC,
+ Human Rights Watch, Amnesty, Harm Reduction International, EMCDDA/EUDA country reports.
+ - National or regional news for recent events.
+3. **Write a batch file** and apply it:
+
+ ```bash
+ node scripts/history-batch.cjs batch.json
+ npm run build
+ ```
+
+ ```json
+ {
+ "checks": [["existing-entry-id", ["Source title", "https://…"], "1998-04-17"]],
+ "entries": [{
+ "id": "norway-21-years-1984", "date": "1984", "c": "no", "cat": "tightening",
+ "title": "Norway raises the drug maximum to 21 years",
+ "desc": "Parliament lifted the upper limit …",
+ "before": null,
+ "page": ["FHI — Historisk oversikt …", "https://…"]
+ }]
+ }
+ ```
+
+ - `checks`: mark an existing entry checked, add the source, optionally correct the date.
+ - `before`: `null` for a context entry; `"FREE"` for the unregulated state before a first
+ ban; an array of six scores; or a delta object such as `{"enforcement": 1}`, meaning "one
+ point higher before this event than after it". Deltas are resolved against the chain.
+ - `page`: a Wikipedia page name, or `[title, url]`.
+ - `replaces`: id of an estimated entry this dated one replaces.
+ - `unchecked: true`: add the entry without the checked mark (source not actually read).
+ - Omit `c` for an international entry.
+ - The script re-sorts the file and refuses duplicate ids.
+4. **Compare with the profile.** If a recent event changes what is true today, edit the country
+ file: `recreational` / `medical`, the six `scores`, the `summary`, the segment or context
+ texts, add the source under `sources`, and set `updated` to today. Keep the history chain
+ consistent with the new scores.
+5. **Build, restart the preview, spot-check** one country page and `/history/`.
+
+## Rules learned the hard way
+
+- **Never trust a search summary for a fact you will mark as checked.** One summary attributed
+ Bosnia's December 2025 medical approval to Kosovo; others invented sentence lengths or dates.
+ Open the page and read it. When the page will not load (403, PDF), add the entry unchecked.
+- **Write only what the source says.** Do not add colour from memory to a description, even if
+ you are sure. Several such sentences had to be removed afterwards.
+- **Distrust `cannabisregulations.ai` and `tripbase.com`.** The first one described a 2024
+ legalisation in Palau that never happened. Do not use either as the only source for anything.
+ Sensi Seeds country blogs are weak too.
+- **Wikipedia is fine for dates and events, weak for first bans.** Colonial first restrictions
+ usually need a statute database, a UN document or an academic paper.
+- **A statute's date is not always the first ban.** A country's "first narcotics law" may come
+ long after cannabis was already restricted (Oman 1999, Zimbabwe 1956, Iran 1959). Then add it
+ as a context entry and leave the estimate in place.
+- **Federations:** state-level changes (US, Australia, Canada, India) are context entries on the
+ national timeline unless the national picture really shifts.
+- **Search tool limits:** more than about ten web searches in one go start failing with
+ rate-limit errors. Batches of five work.
+- **Blocked sources:** the UN Digital Library (403), UNODC Bulletin on Narcotics pages (404),
+ Library of Congress (403), TNI Spanish pages (403) and most PDFs cannot be read by the fetch
+ tool. If the user saves such a PDF into the project it can be read from disk.
+
+## What is still open
+
+Run `node scripts/data-status.cjs` for current numbers. As of 5 October 2026: 784 entries,
+673 checked, 38 unchecked, 73 estimated first bans.
+
+1. **Profiles resting on unreliable sites — the most important item.**
+ `node scripts/data-status.cjs weak` lists 61 profiles where at least one scored segment
+ cites only `cannabisregulations.ai` or `tripbase.com`. Worst: Azerbaijan, Cameroon, Cabo
+ Verde, Chad (all six segments); Angola, Burkina Faso, Central African Republic, Congo,
+ Eritrea, Micronesia, Comoros, Libya, Montenegro, Mongolia, Niger, Sudan, Yemen. For several
+ the real statute is already cited in the history (Cameroon Law 97/019, Burkina Faso Law
+ 017-99/AN, Niger Ordinance 99-42, Angola Law 3/99, Libya Law 7/1990, Yemen Law 3/1993) and
+ can be reused. Re-source, and correct scores if the statute says something different.
+2. **Entries that rest on summaries only.** Many entries added in October 2026 are marked
+ checked on the strength of a search summary. Open their sources; downgrade or correct.
+ Known to be unconfirmed: Senegal 1997/2007, Sierra Leone 2008, The Gambia 2003/2011/2014,
+ Liberia 2014/2023, El Salvador 2003, Kazakhstan 2011, Eswatini 1922, Botswana 1922,
+ Mozambique 1914, Zambia 1926, Ghana 1935, Hungary 1930, Costa Rica 1928, Kiribati/Tuvalu 1948,
+ St Kitts 1937, Saint Lucia 1938, Iraq 1933.
+3. **Unchecked entries** (`data-status.cjs unchecked`): Singapore and Papua New Guinea (blocked
+ sources), the Baltic states 1940, French Equatorial Africa 1926, Laos 1996, Canada 1954,
+ Jamaica 1941, UK 1967, Germany 1872, Bahamas 2024 (passage unconfirmed), Antigua's December
+ 2018 Act, Saudi flogging (February vs April 2020), Taiwan 1955, Iran 1959, Kuwait's in-force
+ date, Norway's June 2025 passage.
+4. **Estimated first bans** (`data-status.cjs estimates`): mostly African colonies, Gulf states,
+ Central America and small islands. Leads not yet usable: Tanzania (Noxious Plants
+ (Prohibition) Ordinance, 1926?), Lesotho (1922 proclamation), Belgian Congo (1903 decree),
+ Uruguay (Law 9.692 of 1937).
+5. **Thin countries** (`data-status.cjs thin`): about twenty places still have one entry.
… 15 more linesscripts/data-status.cjs +30 −0
@@ -0,0 +1,30 @@
+// Where the data stands: node scripts/data-status.cjs [unchecked|estimates|thin|weak]
+// Run from the project root. Needs js-yaml (already in node_modules).
+const fs = require("fs"), y = require("js-yaml");
+const changes = y.load(fs.readFileSync("changes.yaml", "utf8"));
+const file = (id) => (fs.existsSync(`countries/${id}.yaml`) ? `countries/${id}.yaml` : `countries/${id}/index.yaml`);
+const ids = fs.readdirSync("countries").map((f) => f.replace(".yaml", ""));
+const country = Object.fromEntries(ids.map((id) => [id, y.load(fs.readFileSync(file(id), "utf8"))]));
+const what = process.argv[2];
+
+const unchecked = changes.filter((e) => !e.checked && !e.estimate);
+const estimates = changes.filter((e) => e.estimate);
+console.log(`history: ${changes.length} entries | checked ${changes.filter((e) => e.checked).length} | unchecked ${unchecked.length} | estimated first bans ${estimates.length}`);
+
+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 === "thin") {
+ const n = {};
+ for (const e of changes) if (e.country) n[e.country] = (n[e.country] || 0) + 1;
+ for (const k of [0, 1, 2]) console.log(`${k} entries:`, ids.filter((id) => (n[id] || 0) === k).map((id) => `${id}:${country[id].name}`).join(", "));
+}
+if (what === "weak") {
+ // Profiles whose scored segments rest only on sites that proved unreliable.
+ const weak = /cannabisregulations\.ai|tripbase\.com/;
+ for (const id of ids) {
+ const c = country[id];
+ const bad = Object.entries(c.sources || {}).filter(([, s]) => weak.test(s.url)).map(([k]) => k);
+ const only = Object.entries(c.segments || {}).filter(([, s]) => s.sources?.length && s.sources.every((k) => bad.includes(k))).map(([k]) => k);
+ if (only.length) console.log(`${id} ${c.name}: ${only.join(", ")}`);
+ }
+}
scripts/history-batch.cjs +43 −0
@@ -0,0 +1,43 @@
+// Generic runner: node fill.cjs data.json
+// data = { checks: [[id, wikiPage, newDate?]], entries: [{id,date,c,cat,title,desc,before,page,replaces}] }
+// `before`: "FREE", "REG", an array, a delta object applied to the scores that follow, or null (context).
+const fs=require("fs"),y=require("js-yaml");
+const data=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));
+let text=fs.readFileSync("changes.yaml","utf8");
+const K=["possession","cultivation","enforcement","sharing","consumption","access"];
+const cur={};for(const f of fs.readdirSync("countries")){const id=f.replace(".yaml","");const p="countries/"+f+(f.endsWith(".yaml")?"":"/index.yaml");cur[id]=y.load(fs.readFileSync(p,"utf8"))}
+const T=d=>Date.parse(d.length===4?d+"-07-01":d.length===7?d+"-15":d);
+const block=id=>{const i=text.indexOf(`- id: ${id}\n`);if(i<0)throw new Error("missing "+id);let j=text.indexOf("\n- id:",i);j=j<0?text.length:j+1;return [i,j]};
+const drop=id=>{const [i,j]=block(id);text=text.slice(0,i)+text.slice(j)};
+const edit=(id,fn)=>{const [i,j]=block(id);text=text.slice(0,i)+fn(text.slice(i,j))+text.slice(j)};
+const wrap=(t,w=96)=>{const out=[];let line="";for(const word of t.split(" ")){if((line+" "+word).trim().length>w){out.push(line);line=word}else line=(line+" "+word).trim()}out.push(line);return out.map(l=>" "+l).join("\n")};
+const q=t=>(/: |^["'\[{>|*&!%@`]| #/.test(t)?JSON.stringify(t):t);
+const src=(page)=>/^https?:/.test(page[1]||"")?page:["Wikipedia — "+decodeURIComponent(page).replace(/_/g," "),"https://en.wikipedia.org/wiki/"+page];
+const PRESET={FREE:[10,10,10,10,10,8],REG:[6,3,6,3,5,6]};
+const fmt=b=>"{"+K.map((k,i)=>`${k}: ${b[i]}`).join(", ")+"}";
+for(const [id,page,date] of data.checks||[]) edit(id,b=>{
+ if(date) b=b.replace(/ date: "[^"]+"/,` date: "${date}"`);
+ if(!b.includes("\n checked: true\n")) b=b.replace("\n sources:\n","\n checked: true\n sources:\n");
+ const [t,u]=src(page); if(!b.includes(u)) b=b.replace(/\n*$/,"\n")+` - title: ${q(t)}\n url: ${u}\n\n`;
+ return b});
+const N=(data.entries||[]).map(n=>({...n,before:typeof n.before==="string"?PRESET[n.before]:n.before,src:src(n.page)}));
+for(const n of N){ if(n.c&&!cur[n.c]) throw new Error("no country "+n.c); if(n.replaces) drop(n.replaces); }
+const existing=y.load(text);
+const scored=existing.filter(e=>e.before).map(e=>({c:e.country,t:T(e.date),b:K.map(k=>e.before[k])}));
+const afterOf=(c,t)=>{const next=scored.filter(s=>s.c===c&&s.t>t).sort((a,b)=>a.t-b.t)[0];return next?next.b:K.map(k=>cur[c].scores[k])};
+N.sort((a,b)=>T(b.date)-T(a.date));
+let flat=0;
+for(const n of N){
+ if(n.before&&!Array.isArray(n.before)){const after=afterOf(n.c,T(n.date));const b=after.map((v,i)=>Math.max(0,Math.min(10,v+(n.before[K[i]]||0))));
+ if(b.every((v,i)=>v===after[i])){n.before=null;flat++}else n.before=b}
+ if(n.before){const after=afterOf(n.c,T(n.date));if(n.before.every((v,i)=>v===after[i]))throw new Error("no change: "+n.id);scored.push({c:n.c,t:T(n.date),b:n.before})}
+}
+for(const n of N){ if(text.includes(`- id: ${n.id}\n`)) throw new Error("dup "+n.id);
+ text=text.replace(/\n*$/,"\n")+`\n- id: ${n.id}\n date: "${n.date}"\n category: ${n.cat}\n`+(n.c?` country: ${n.c}\n`:"")+` title: ${q(n.title)}\n description: >-\n${wrap(n.desc)}\n`+(n.before?` before: ${fmt(n.before)}\n`:"")+(n.unchecked?"":" checked: true\n")+` sources:\n - title: ${q(n.src[0])}\n url: ${n.src[1]}\n`}
+const head=text.slice(0,text.indexOf("- id:"));
+const blocks=text.slice(text.indexOf("- id:")).split(/\n(?=- id: )/).map(b=>b.replace(/\n+$/,""));
+const d=b=>b.match(/ date: "([^"]+)"/)[1];
+blocks.sort((a,b)=>T(d(b))-T(d(a)));
+fs.writeFileSync("changes.yaml",head+blocks.join("\n\n")+"\n");
+const all=y.load(fs.readFileSync("changes.yaml","utf8"));
+console.log("added",N.length,"(flat deltas kept as context:",flat+")","| total",all.length,"| checked",all.filter(e=>e.checked).length,"| unchecked",all.filter(e=>!e.checked&&!e.estimate).length,"| estimates",all.filter(e=>e.estimate).length);
src/components/CountryArticle.astro +13 −11
@@ -75,13 +75,16 @@ const circ = 2 * Math.PI * r;
)}
<h2 class="d-h">In short <span><a href={`${base}can-i/`}>all countries →</a></span></h2>
- <dl class="answers">
+ <ul class="answers">
{answers.map((x) => (
- <div class={`answer a-${x.answer}`}>
- <dt>{x.q}</dt>
- <dd><b>{x.label}</b><span>{x.text}</span></dd>
- </div>
+ <li>
+ <a class={`answer a-${x.answer}`} href={`#seg-${x.seg}`} data-seg-link={x.seg} title="Show the rules and sources">
+ <span class="q">{x.q}</span>
+ <b>{x.label}</b>
+ <span class="t">{x.text}</span>
+ </a>
+ </li>
))}
- </dl>
- <p class="d-hint">A rough guide read from the scores below, not legal advice. The details and the exceptions are in each segment.</p>
+ </ul>
+ <p class="d-hint">A rough guide read from the scores below, not legal advice. The details and the exceptions are in each segment: select an answer to jump to it.</p>
{use && (
<dl class="c-use">
@@ -101,5 +104,4 @@ const circ = 2 * Math.PI * r;
)}
{scored.length > 0 && <CountryChart history={scored} />}
- {history.length > 0 && <CountryChanges history={history} place="side" />}
{group && (
<label class="field sub-select">
@@ -119,5 +121,5 @@ const circ = 2 * Math.PI * r;
: <p class="d-hint">Select a segment to show or hide the exact rules and sources.</p>}
{sub && SEGMENTS.map((s, i) => (
- <details class="segrow" open>
+ <details class="segrow" data-seg={s.id} open>
<summary>
<div class="segrow-top">
@@ -145,5 +147,5 @@ const circ = 2 * Math.PI * r;
const seg = d?.segments[i];
return (
- <details class="segrow" open>
+ <details class="segrow" data-seg={s.id} open>
<summary>
<div class="segrow-top">
@@ -170,5 +172,4 @@ const circ = 2 * Math.PI * r;
);
})}
- {history.length > 0 && <CountryChanges history={history} place="main" />}
{group && (
@@ -231,4 +232,5 @@ const circ = 2 * Math.PI * r;
</>
)}
+ {history.length > 0 && <CountryChanges history={history} />}
{updated && <p class="c-updated">Last updated: <time datetime={updated}>{updatedText}</time>{d && ` · ${d.sources.length} sources`}{scored.length > 0 && ` · history: ${history.filter((ch) => ch.confidence.key === "checked").length} of ${history.length} entries checked`}</p>}
</div>
src/components/CountryChanges.astro +18 −16
@@ -1,26 +1,28 @@
---
-// A country's recorded changes, newest first. Placed twice in the country article: in the side
-// pane on wide screens, and under the score breakdown where the layout is a single column.
+// A country's recorded changes, newest first, at the end of the country article.
+// Drawn as the same timeline as the History page.
+import ScoreChange from "./ScoreChange.astro";
import { changeDate } from "../lib/countries.js";
+import { tierColor } from "../lib/tiers.js";
-const { history, place } = Astro.props;
+const { history } = Astro.props;
const base = import.meta.env.BASE_URL;
---
-<div class={`c-changes c-changes-${place}`}>
- <h2>History</h2>
- <ul>
+<div class="c-changes">
+ <h2 class="d-h">History <span>{history.filter((ch) => ch.confidence.key === "checked").length} of {history.length} checked against a source</span></h2>
+ <ol class="chl-list">
{history.map((ch) => (
- <li>
- <a href={`${base}history/${ch.id}/`}>
- <time datetime={ch.date}>{changeDate(ch)} <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span></time>
- <b><span class="kind" title={ch.kind.label} aria-hidden="true">{ch.kind.emoji}</span><span class="visually-hidden">{ch.kind.label}: </span>{ch.title}</b>
- {ch.score && <span class="c-change-score">
- {ch.score.before} <span aria-hidden="true">→</span><span class="visually-hidden">to</span> {ch.score.after}
- <span class={`delta ${ch.score.after >= ch.score.before ? "up" : "down"}`}>{ch.score.after >= ch.score.before ? "+" : "−"}{Math.abs(ch.score.after - ch.score.before)}</span>
- </span>}
+ <li class="chl-item" style={`--c:${ch.score ? tierColor(ch.score.after) : "var(--muted)"}`}>
+ <a class="card chl-card" href={`${base}history/${ch.id}/`}>
+ <p class="change-meta">
+ <time datetime={ch.date}>{changeDate(ch)}</time>
+ <span class={`conf-mark c-${ch.confidence.key}`} title={`${ch.confidence.label}: ${ch.confidence.help}`}>{ch.confidence.mark}</span>
+ </p>
+ <h3><span class="kind" title={ch.kind.label} aria-hidden="true">{ch.kind.emoji}</span><span class="visually-hidden">{ch.kind.label}: </span>{ch.title}</h3>
+ <p class="chl-text">{ch.description}</p>
+ {ch.score && <ScoreChange score={ch.score} />}
</a>
</li>
))}
- </ul>
- <p class="c-changes-note">{history.filter((ch) => ch.confidence.key === "checked").length} of {history.length} checked against a source</p>
+ </ol>
</div>
src/layouts/Base.astro +21 −0
@@ -67,4 +67,25 @@ const fullTitle = title ? `${title} — Cannabis Freedom Index` : "Cannabis Free
try { localStorage.setItem("cfi-theme", root.dataset.theme); } catch (e) {}
});
+
+ // "In short" answers on a country article: go to the segment they were read from,
+ // open it and light it up. Works on the country page and inside the home page drawer,
+ // so the address is left alone (the drawer uses it to know which country is open).
+ function showSegment(article, id) {
+ var row = article && article.querySelector('.segrow[data-seg="' + id + '"]');
+ if (!row) return;
+ row.open = true;
+ row.classList.remove("flash");
+ void row.offsetWidth; // restart the animation when the same answer is chosen twice
+ row.classList.add("flash");
+ row.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" });
+ }
+ document.addEventListener("click", function (e) {
+ var link = e.target.closest("a[data-seg-link]");
+ if (!link || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+ e.preventDefault();
+ showSegment(link.closest("article.country"), link.dataset.segLink);
+ });
+ // A link straight to a segment, e.g. /country/de/#seg-possession
+ if (/^#seg-[a-z]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
</script>
</body>
src/lib/answers.js +4 −1
@@ -3,4 +3,6 @@
// sharing, consumption, access. See segments.yaml for what each level means.
+import { SEGMENT_IDS } from "./segment-ids.js";
+
export const ANSWER_LABEL = { yes: "Yes", limited: "Limited", no: "No" };
@@ -42,8 +44,9 @@ export const QUESTIONS = [
// `s` is the array of six segment scores of a country, state or province.
+// `seg` in the result is the id of the segment the answer was read from.
export const answersFor = (s) =>
QUESTIONS.map(({ id, q, seg, steps }) => {
const [answer, text, label = ANSWER_LABEL[answer]] = pick(s[seg], steps);
- return { id, q, answer, text, label };
+ return { id, q, answer, text, label, seg: SEGMENT_IDS[seg] };
});
src/pages/can-i.astro +41 −4
@@ -4,9 +4,18 @@ import Base from "../layouts/Base.astro";
import { places, regions } from "../lib/countries.js";
import { answersFor, QUESTIONS, ANSWER_LABEL } from "../lib/answers.js";
+import { SEGMENTS } from "../lib/countries.js";
+import { SEGMENT_IDS } from "../lib/segment-ids.js";
const base = import.meta.env.BASE_URL;
const rows = [...places]
.sort((a, b) => a.name.localeCompare(b.name))
- .map((c) => ({ c, answers: answersFor(c.s) }));
+ .map((c) => ({
+ c,
+ // Each answer also carries the explanation of the segment it was read from, for the tooltip.
+ answers: answersFor(c.s).map((x) => {
+ const i = SEGMENT_IDS.indexOf(x.seg);
+ return { ...x, name: SEGMENTS[i].name, score: c.s[i], why: c.details?.segments[i]?.t ?? "" };
+ }),
+ }));
---
<Base title="Can I?" description="Can I carry, grow, buy or use cannabis in public, and what happens if I'm caught? Five plain answers for every country.">
@@ -43,7 +52,9 @@ const rows = [...places]
<th scope="row"><a href={`${base}country/${c.slug}/`}><span class="flag">{c.flag}</span> {c.name}</a></th>
{answers.map((x) => (
- <td class={`a-${x.answer}`} title={x.text}>
- <b>{x.label}</b>
- <small>{x.text}</small>
+ <td class={`a-${x.answer}`}>
+ <a href={`${base}country/${c.slug}/#seg-${x.seg}`} data-tip={x.why} data-tip-head={`${c.name} · ${x.name} ${x.score}/10`}>
+ <b>{x.label}</b>
+ <small>{x.text}</small>
+ </a>
</td>
))}
@@ -54,4 +65,5 @@ const rows = [...places]
</div>
</div>
+ <div class="cani-tip" id="caniTip" role="tooltip" hidden><b></b><p></p><small>Select to open the details</small></div>
<p class="table-updated" id="caniCount">{rows.length} countries and territories</p>
</main>
@@ -72,4 +84,29 @@ const rows = [...places]
count.textContent = `${shown} of ${rows.length} countries and territories`;
};
+ // Hovering or focusing an answer shows the explanation from the country's score breakdown.
+ const tip = document.getElementById("caniTip");
+ const table = document.getElementById("caniTable");
+ const showTip = (a) => {
+ tip.querySelector("b").textContent = a.dataset.tipHead;
+ tip.querySelector("p").textContent = a.dataset.tip;
+ tip.hidden = false;
+ const cell = a.getBoundingClientRect();
+ const box = tip.getBoundingClientRect();
+ const left = Math.min(Math.max(8, cell.left), innerWidth - box.width - 8);
+ // Below the cell, or above it when there is no room underneath.
+ const top = cell.bottom + box.height + 8 > innerHeight ? cell.top - box.height - 6 : cell.bottom + 6;
+ tip.style.left = `${left}px`;
+ tip.style.top = `${Math.max(8, top)}px`;
+ };
+ const hideTip = () => { tip.hidden = true; };
+ table.addEventListener("mouseover", (e) => {
+ const a = e.target.closest("a[data-tip]");
+ if (a) showTip(a); else hideTip();
+ });
+ table.addEventListener("mouseleave", hideTip);
+ table.addEventListener("focusin", (e) => { const a = e.target.closest("a[data-tip]"); if (a) showTip(a); });
+ table.addEventListener("focusout", hideTip);
+ addEventListener("scroll", hideTip, { passive: true });
+
// The filter bar and the table's header row stay in view while scrolling;
// they need to know how tall the bars above them are.
src/pages/history/[id].astro +55 −3
@@ -2,6 +2,6 @@
import Base from "../../layouts/Base.astro";
import ScoreChange from "../../components/ScoreChange.astro";
-import { changes, formatDate, changeDate } from "../../lib/countries.js";
-import { tierColor } from "../../lib/tiers.js";
+import { changes, countries, changeDate, REC_LABEL, REC_HELP, MED_LABEL } from "../../lib/countries.js";
+import { tierOf, tierColor } from "../../lib/tiers.js";
export function getStaticPaths() {
@@ -13,4 +13,10 @@ const base = import.meta.env.BASE_URL;
const c = ch.country;
const diff = (seg) => seg.after - seg.before;
+// The country's other entries closest in time to this one, for the side panel.
+const own = c ? changes.filter((x) => x.country === c && !x.estimate) : [];
+const at = own.indexOf(ch);
+const nearby = own.slice(Math.max(0, at - 3), at + 4);
+const r = 30;
+const circ = 2 * Math.PI * r;
---
<Base title={ch.title} description={ch.score ? `${ch.title}: ${c.name} moved from ${ch.score.before} to ${ch.score.after} on the Cannabis Freedom Index. ${ch.description}` : `${ch.title}. ${ch.description}`}>
@@ -18,7 +24,7 @@ const diff = (seg) => seg.after - seg.before;
<div class="page-bar">
<a class="back" href={`${base}history/`}>← History</a>
- {c && <a class="btn" href={`${base}country/${c.slug}/`}>{c.flag} {c.name} in full</a>}
</div>
+ <div class={`change-layout${c ? "" : " no-aside"}`}>
<article class="change-detail">
<p class="eyebrow"><span title={ch.kind.help}>{ch.kind.emoji} {ch.kind.label}</span> · <time datetime={ch.date}>{changeDate(ch)}</time> · {ch.place.name}</p>
@@ -62,4 +68,50 @@ const diff = (seg) => seg.after - seg.before;
</article>
+ {c && (
+ <aside class="change-aside" aria-label={`About ${c.name}`}>
+ <div class="card ca-card">
+ <a class="ca-head" href={`${base}country/${c.slug}/`}>
+ <span class="ca-flag">{c.flag}</span>
+ <span><b>{c.name}</b><small>{c.region}</small></span>
+ </a>
+ <div class="ca-score">
+ <svg class="ring" width="76" height="76" viewBox="0 0 76 76" aria-hidden="true">
+ <circle cx="38" cy="38" r={r} fill="none" stroke="var(--border)" stroke-width="7" />
+ <circle cx="38" cy="38" r={r} fill="none" stroke={tierColor(c.raw)} stroke-width="7" stroke-linecap="round"
+ stroke-dasharray={`${(c.raw / 100) * circ} ${circ}`} transform="rotate(-90 38 38)" />
+ <text x="38" y="46" text-anchor="middle">{c.score}</text>
+ </svg>
+ <div>
+ <div class="tier">{tierOf(c.raw).name}</div>
+ <p class="sub">{c.disputed ? "Not ranked: disputed territory" : `#${c.rank} of ${countries.length} today`}</p>
+ </div>
+ </div>
+ <div class="d-chips">
+ <span class={`chip s-${c.rec}`} title={REC_HELP[c.rec]}>Recreational: {REC_LABEL[c.rec]}</span>
+ <span class={`chip s-${c.med}`}>Medical: {MED_LABEL[c.med]}</span>
+ </div>
+ <p class="ca-note">{c.note}</p>
+ <a class="btn btn-primary ca-open" href={`${base}country/${c.slug}/`}>Open {c.name} <span aria-hidden="true">→</span></a>
+ </div>
+
+ {nearby.length > 1 && (
+ <div class="ca-more">
+ <h2>More from {c.name}</h2>
+ <ol>
+ {nearby.map((x) => (
+ <li class={x === ch ? "current" : undefined}>
+ {x === ch
+ ? <span><time datetime={x.date}>{changeDate(x)}</time><b>{x.kind.emoji} {x.title}</b></span>
+ : <a href={`${base}history/${x.id}/`}><time datetime={x.date}>{changeDate(x)}</time><b>{x.kind.emoji} {x.title}</b></a>}
+ </li>
+ ))}
+ </ol>
+ {own.length > nearby.length && <p class="ca-all"><a href={`${base}country/${c.slug}/`}>All {own.length} entries for {c.name} →</a></p>}
+ </div>
+ )}
+ </aside>
+ )}
+ </div>
+
<nav class="pager" aria-label="Other entries">
{newer
src/pages/index.astro +9 −7
@@ -202,7 +202,4 @@ const USE_SCALES = {
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20V9M12 20V4M19 20v-7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
</a>
- <a class="icon-btn" id="dExpand" href={base} aria-label="Open full page" title="Open full page">
- <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
- </a>
<button class="icon-btn" id="dClose" aria-label="Close">✕</button>
</div>
@@ -459,9 +456,15 @@ const USE_SCALES = {
return cache.get(url).cloneNode(true);
}
+ // The drawer is a preview: a big button under the summary leads to the full page.
+ function withOpenButton(article, url) {
+ const open = Object.assign(document.createElement("a"), { className: "btn btn-primary drawer-open", href: url });
+ open.innerHTML = 'Open full page <span aria-hidden="true">→</span>';
+ (article.querySelector(".c-disputed") ?? article.querySelector(".d-note")).after(open);
+ return article;
+ }
// Swap the drawer to another view of the same country (a state, or back to national).
async function showInDrawer(url) {
try {
- body.replaceChildren(await loadArticle(url));
- $("#dExpand").href = url;
+ body.replaceChildren(withOpenButton(await loadArticle(url), url));
dialog.scrollTop = 0;
} catch (err) {
@@ -502,10 +505,9 @@ const USE_SCALES = {
markActive();
if (!id) return dialog.open && dialog.close();
- $("#dExpand").href = pageUrl(id);
$("#dCompare").href = `${base}compare/?c=${id.toLowerCase()}`;
try {
const article = await loadArticle(pageUrl(id));
if (currentId() !== id) return;
- body.replaceChildren(article);
+ body.replaceChildren(withOpenButton(article, pageUrl(id)));
} catch (err) {
location.href = pageUrl(id);
src/styles/global.css +64 −21
@@ -257,5 +257,6 @@ th.num-col { text-align: right; }
.drawer-bar .icon-btn { background: var(--surface-2); box-shadow: 0 2px 10px rgba(0, 0, 0, .25); }
.drawer-inner { padding: 22px 24px 40px; }
-.drawer-inner .d-head { padding-right: 134px; }
+.drawer-inner .d-head { padding-right: 92px; }
+.drawer-inner .drawer-open { display: flex; justify-content: center; padding: 12px 16px; font-size: 15px; margin: 16px 0 4px; }
.drawer-inner .c-sources { display: none; }
.d-head { display: flex; align-items: flex-start; gap: 14px; }
@@ -538,18 +539,8 @@ th.num-col { text-align: right; }
/* ── Changes on a country page ──────────────────────── */
-.c-changes { margin-top: 18px; }
-/* single column (phones, the drawer): under the score breakdown; two columns: in the side pane */
-.c-changes-side { display: none; }
-.c-changes-main { margin-top: 26px; }
-@media (min-width: 861px) {
… 91 more lines