Cannabis Freedom Index

Changelog 7253cca

world average score graph

12 history entries added1 country profile edited10 files of code, design or docs +411 −59 lines

History changes.yaml

Added 12

Countries and states 1 file

Brazil countries/br.yaml

context › production › sources

groweriq, anvisa

Code, design and documents 10 files

README.md +1 −1
@@ -39,5 +39,5 @@ Built with [Astro](https://astro.build). Pushing to `main` deploys to GitHub Pag
 | `countries/us/` | A federation: `index.yaml` for the country, one file per state |
 | `segments.yaml` | The six segments, their weights and level descriptions |
-| `changes.yaml` | Laws and events that moved a country's score, with the scores before; the home page shows the latest three |
+| `changes.yaml` | Laws and events that moved a country's score, with the scores before; the home page shows the latest four |
 | `src/` | The website: pages, components, styles |
 

src/components/AverageChart.astro +109 −0
@@ -0,0 +1,109 @@
+---
+// World average score over time: a step line that moves at each recorded change.
+// The plot is a stretched SVG; labels and markers are HTML so they stay readable at any width.
+import { averageHistory, formatDate, lastUpdated } from "../lib/countries.js";
+
+// `note` adds the caveat under the chart.
+const { note = false } = Astro.props;
+const base = import.meta.env.BASE_URL;
+const fmt = (v) => v.toFixed(1);
+const time = (iso) => Date.parse(iso.length === 7 ? `${iso}-15` : iso);
+
+const first = averageHistory[0];
+const now = averageHistory.at(-1).after;
+const startYear = +first.date.slice(0, 4);
+const endYear = +lastUpdated.slice(0, 4);
+const t0 = Date.UTC(startYear, 0, 1);
+const t1 = time(lastUpdated);
+const x = (t) => ((t - t0) / (t1 - t0)) * 100;
+
+// y axis: half-point steps that enclose the data (the range is small, so it does not start at zero)
+const values = averageHistory.flatMap((p) => [p.before, p.after]);
+const yMin = Math.floor(Math.min(...values) * 2) / 2;
+const yMax = Math.ceil(Math.max(...values) * 2) / 2;
+const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100;
+const ticks = Array.from({ length: Math.round((yMax - yMin) * 2) + 1 }, (_, i) => yMin + i / 2);
+const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i);
+
+const points = averageHistory.map((p) => ({ ...p, x: x(time(p.date)), y: y(p.after) }));
+const line = `M0,${y(first.before)}` + points.map((p) => `H${p.x}V${p.y}`).join("") + "H100";
+const delta = now - first.before;
+---
+<section class="card avg" aria-labelledby="avgTitle">
+  <div class="avg-head">
+    <h2 id="avgTitle">World average score</h2>
+    <p class="avg-now"><b>{fmt(now)}</b><span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{fmt(Math.abs(delta))} since {startYear}</span></p>
+  </div>
+
+  <div class="avg-chart" id="avgChart">
+    <div class="avg-y" aria-hidden="true">
+      {ticks.map((t) => <span style={`top:${y(t)}%`}>{fmt(t)}</span>)}
+    </div>
+    <div class="avg-plot">
+      <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
+        {ticks.map((t) => <line class="grid" x1="0" x2="100" y1={y(t)} y2={y(t)} />)}
+        <path class="line" d={line} />
+      </svg>
+      {points.map((p) => (
+        <a class="avg-dot" href={`${base}changes/${p.change.id}/`} style={`left:${p.x}%;top:${p.y}%`}
+          data-date={formatDate(p.date)} data-title={`${p.change.country.flag} ${p.change.title}`}
+          data-avg={`World average ${fmt(p.before)} → ${fmt(p.after)}`}
+          aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a>
+      ))}
+      <div class="avg-tip" id="avgTip" hidden><small></small><b></b><span></span></div>
+    </div>
+    <div class="avg-x" aria-hidden="true">
+      {years.map((yr, i) => <span class={i % 2 ? "odd" : ""} style={`left:${x(Date.UTC(yr, 0, 1))}%`}>{yr}</span>)}
+    </div>
+  </div>
+  {note && <p class="avg-note">Rebuilt from the changes on this page, so it is only as complete as that list: a country with no recorded change is held at today's score for the whole period. The scale does not start at zero.</p>}
+</section>
+
+<script>
+  const chart = document.querySelector("#avgChart");
+  const plot = chart?.querySelector(".avg-plot");
+  const tip = chart?.querySelector("#avgTip");
+  if (chart && plot && tip) {
+    const dots = [...plot.querySelectorAll(".avg-dot")];
+    const [date, title, avg] = tip.children;
+    let active = null;
+    const show = (dot) => {
+      if (dot === active) return;
+      active?.classList.remove("on");
+      active = dot;
+      dot.classList.add("on");
+      date.textContent = dot.dataset.date;
+      title.textContent = dot.dataset.title;
+      avg.textContent = dot.dataset.avg;
+      tip.hidden = false;
+      // keep the tooltip inside the plot
+      const box = plot.getBoundingClientRect();
+      const d = dot.getBoundingClientRect();
+      const cx = d.left + d.width / 2 - box.left;
+      const half = tip.offsetWidth / 2;
+      tip.style.left = `${Math.min(Math.max(cx, half), box.width - half)}px`;
+      const above = d.top - box.top - tip.offsetHeight - 10;
+      tip.style.top = `${above >= -30 ? above : d.bottom - box.top + 10}px`;
+    };
+    const hide = () => {
+      active?.classList.remove("on");
+      active = null;
+      tip.hidden = true;
+    };
+    // pointer: the change nearest to the cursor along the time axis
+    plot.addEventListener("pointermove", (e) => {
+      let best = dots[0], dist = Infinity;
+      for (const dot of dots) {
+        const r = dot.getBoundingClientRect();
+        const dx = Math.abs(r.left + r.width / 2 - e.clientX);
+        if (dx < dist) { dist = dx; best = dot; }
+      }
+      show(best);
+    });
+    plot.addEventListener("pointerleave", hide);
+    dots.forEach((dot) => {
+      dot.addEventListener("focus", () => show(dot));
+      dot.addEventListener("blur", hide);
+    });
+  }
+</script>

src/components/ChangeCard.astro +0 −17
@@ -1,17 +0,0 @@
----
-// A change in short: date, country, title and the overall score before → after.
-// The whole card links to the change's own page.
-import { formatDate } from "../lib/countries.js";
-import ScoreChange from "./ScoreChange.astro";
-
-const { ch } = Astro.props;
-const base = import.meta.env.BASE_URL;
----
-<a class="card change" href={`${base}changes/${ch.id}/`}>
-  <p class="change-meta">
-    <time datetime={ch.date}>{formatDate(ch.date)}</time>
-    <span>{ch.country.flag} {ch.country.name}</span>
-  </p>
-  <h3>{ch.title}</h3>
-  <ScoreChange score={ch.score} />
-</a>

src/components/Changes.astro +31 −11
@@ -1,16 +1,36 @@
 ---
-// The three most recent changes that moved a country's score (see /changes.yaml).
+// Home page: the four most recent changes as a small vertical timeline (date on the left,
+// details on the right), next to the world average chart. See /changes.yaml.
 import { changes } from "../lib/countries.js";
-import ChangeCard from "./ChangeCard.astro";
+import { tierColor } from "../lib/tiers.js";
+import AverageChart from "./AverageChart.astro";
+import ScoreChange from "./ScoreChange.astro";
 
 const base = import.meta.env.BASE_URL;
+// "1 Jan 2026", or "Feb 2026" when the day is not known
+const shortDate = (iso) =>
+  new Date(`${iso.length === 7 ? iso + "-01" : iso}T00:00:00Z`).toLocaleDateString("en-GB", { day: iso.length === 7 ? undefined : "numeric", month: "short", year: "numeric", timeZone: "UTC" });
 ---
-<section class="changes" aria-labelledby="changesTitle">
-  <div class="changes-head">
-    <h2 class="changes-title" id="changesTitle">Latest changes</h2>
-    <a href={`${base}changes/`}>All changes →</a>
-  </div>
-  <div class="changes-grid">
-    {changes.slice(0, 3).map((ch) => <ChangeCard ch={ch} />)}
-  </div>
-</section>
+<div class="home-split">
+  <section class="card changes" aria-labelledby="changesTitle">
+    <div class="changes-head">
+      <h2 class="changes-title" id="changesTitle">Latest changes</h2>
+      <a href={`${base}changes/`}>All changes →</a>
+    </div>
+    <ol class="mini-tl">
+      {changes.slice(0, 4).map((ch) => (
+        <li style={`--c:${tierColor(ch.score.after)}`}>
+          <a href={`${base}changes/${ch.id}/`}>
+            <time datetime={ch.date}>{shortDate(ch.date)}</time>
+            <span class="mini-tl-body">
+              <span class="mini-tl-country">{ch.country.flag} {ch.country.name}</span>
+              <b>{ch.title}</b>
+              <ScoreChange score={ch.score} />
+            </span>
+          </a>
+        </li>
+      ))}
+    </ol>
+  </section>
+  <AverageChart />
+</div>

src/components/CountryArticle.astro +22 −1
@@ -2,5 +2,5 @@
 // Full write-up for one country. Rendered on its own page, and fetched into the
 // drawer on the home page — so there is a single template for both views.
-import { SEGMENTS, REC_LABEL, MED_LABEL, countries, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js";
+import { SEGMENTS, REC_LABEL, MED_LABEL, countries, changes, segAvg, segmentPoints, segmentMax, subunitsOf, formatDate } from "../lib/countries.js";
 import { tierOf, tierColor } from "../lib/tiers.js";
 
@@ -17,4 +17,6 @@ const updated = sub ? sub.reviewed : d?.reviewed;
 const updatedText = updated && formatDate(updated);
 const byName = group ? [...group.units].sort((a, b) => a.name.localeCompare(b.name)) : [];
+// Recorded changes to the national score, newest first (not shown for a single state).
+const history = sub ? [] : changes.filter((ch) => ch.country === c);
 const r = 42;
 const circ = 2 * Math.PI * r;
@@ -48,4 +50,23 @@ const circ = 2 * Math.PI * r;
     </div>
     <p class="d-note">{v.note}</p>
+    {history.length > 0 && (
+      <div class="c-changes">
+        <h2>{history.length > 1 ? "Changes" : "Latest change"}</h2>
+        <ul>
+          {history.map((ch) => (
+            <li>
+              <a href={`${base}changes/${ch.id}/`}>
+                <time datetime={ch.date}>{formatDate(ch.date)}</time>
+                <b>{ch.title}</b>
+                <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>
+              </a>
+            </li>
+          ))}
+        </ul>
+      </div>
+    )}
     {group && (
       <label class="field sub-select">

src/components/ScoreChange.astro +1 −1
@@ -10,4 +10,4 @@ const delta = score.after - score.before;
   <span class="arrow" aria-hidden="true">→</span>
   <span class="pill" style={`--c:${tierColor(score.after)}`}>{score.after}</span>
-  <span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{Math.abs(delta)} points</span>
+  <span class={`delta ${delta >= 0 ? "up" : "down"}`}>{delta >= 0 ? "+" : "−"}{Math.abs(delta)} {Math.abs(delta) === 1 ? "point" : "points"}</span>
 </p>

src/lib/countries.js +27 −11
@@ -106,24 +106,40 @@ export function subunitsOf(id) {
 
 // ── Changes: developments that moved a score, newest first ──
-export const changes = (await getCollection("changes"))
-  .map(({ data }) => {
+// Each entry records the segment scores before the change. The scores after it are the "before"
+// of the same country's next change, or the country's current scores if there is none.
+const changeEntries = (await getCollection("changes")).map(({ data }) => data).sort((a, b) => a.date.localeCompare(b.date));
+export const changes = changeEntries
+  .map((data, i) => {
     const country = countries.find((c) => c.slug === data.country);
     if (!country) throw new Error(`changes.yaml: "${data.id}" refers to unknown country "${data.country}"`);
-    const s = scoreList(data.before);
-    const before = Math.round(scoreOf(s));
+    const next = changeEntries.slice(i + 1).find((x) => x.country === data.country);
+    const before = scoreList(data.before);
+    const after = next ? scoreList(next.before) : country.s;
+    if (before.every((v, k) => v === after[k])) throw new Error(`changes.yaml: "${data.id}" has the same scores before and after`);
     return {
       ...data,
       country,
+      latest: !next, // the country's most recent change: "after" is its current score
       source: pair(data.source),
-      score: { before, after: country.score },
+      raw: { before: scoreOf(before), after: scoreOf(after) },
+      score: { before: Math.round(scoreOf(before)), after: Math.round(scoreOf(after)) },
       // every segment, with its score before and after
-      segments: SEGMENTS.map((seg, i) => ({ name: seg.name, weight: seg.weight, before: s[i], after: country.s[i] })),
+      segments: SEGMENTS.map((seg, k) => ({ name: seg.name, weight: seg.weight, before: before[k], after: after[k] })),
     };
   })
-  .sort((a, b) => b.date.localeCompare(a.date));
-for (const ch of changes) {
-  if (changes.filter((x) => x.country === ch.country).length > 1) throw new Error(`changes.yaml: more than one change for "${ch.country.slug}" — keep only the latest`);
-  if (ch.segments.every((seg) => seg.before === seg.after)) throw new Error(`changes.yaml: "${ch.id}" has the same scores before and after`);
-}
+  .reverse();
+
+// World average over time, oldest first, rebuilt by undoing the recorded changes one by one.
+// Only as complete as changes.yaml: countries with no recorded change are held at today's score.
+export const averageHistory = (() => {
+  const n = countries.length;
+  let sum = countries.reduce((a, c) => a + c.raw, 0);
+  const points = changes.map((ch) => {
+    const after = sum / n;
+    sum -= ch.raw.after - ch.raw.before;
+    return { change: ch, date: ch.date, before: sum / n, after };
+  });
+  return points.reverse();
+})();
 
 // Review dates are stored as YYYY-MM-DD; this is how they are shown everywhere.

src/pages/changes/[id].astro +1 −1
@@ -47,5 +47,5 @@ const diff = (seg) => seg.after - seg.before;
         </tbody>
       </table>
-      <p class="change-note">Segments are scored from 0 to 10. The scores after the change are {c.name}'s current scores.</p>
+      <p class="change-note">Segments are scored from 0 to 10. {ch.latest ? `The scores after the change are ${c.name}'s current scores.` : `${c.name}'s scores have changed again since.`}</p>
 
       <h2 class="d-h">Source</h2>

src/pages/index.astro +1 −1
@@ -33,5 +33,4 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) =>
         {stats.map(([n, label]) => <div class="stat"><div class="big">{n}</div><div class="label">{label}</div></div>)}
       </div>
-      <DidYouKnow />
       <Changes />
     </section>
@@ -154,4 +153,5 @@ const mapData = countries.map(({ id, num, name, flag, raw, score, rank, s }) =>
 
     <Lore />
+    <DidYouKnow />
   </main>
 

src/styles/global.css +60 −8
@@ -382,5 +382,5 @@ td.num, th.num { width: 52px; color: var(--muted); }
 
 /* ── Did you know? ──────────────────────────────────── */
-.dyk { margin-top: 12px; padding: 20px 24px 22px; border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
+.dyk { margin-top: 48px; padding: 20px 24px 22px; border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
 .dyk-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
 .dyk-label { margin: 0; color: var(--accent); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .09em; }
@@ -434,15 +434,28 @@ td.num, th.num { width: 52px; color: var(--muted); }
 
 /* ── Changes ────────────────────────────────────────── */
-.changes { margin-top: 28px; }
-.changes-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
-.changes-title { font-size: 20px; margin: 0; }
+.home-split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
+.home-split .avg { margin-top: 0; max-width: none; }
+@media (max-width: 900px) { .home-split { grid-template-columns: 1fr; } }
+.changes { padding: 20px 22px 14px; }
+.changes-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+.changes-title { font-size: 18px; margin: 0; }
 .changes-head a { color: var(--text-2); font-size: 14px; font-weight: 500; white-space: nowrap; }
 .changes-head a:hover { color: var(--text); }
-.changes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
-.change { padding: 18px 20px; display: flex; flex-direction: column; color: inherit; transition: border-color .15s, transform .15s; }
-.change:hover { border-color: var(--muted); transform: translateY(-1px); }
+/* date | rail with a dot | details */
+.mini-tl { list-style: none; margin: 0; padding: 0; }
+.mini-tl a { display: grid; grid-template-columns: 78px 1fr; gap: 0 30px; color: inherit; position: relative; padding: 0 0 16px; }
+.mini-tl li:last-child a { padding-bottom: 6px; }
+.mini-tl a::before { content: ""; position: absolute; left: 92px; top: 6px; bottom: -6px; width: 2px; border-radius: 1px; background: var(--border); }
+.mini-tl li:last-child a::before { display: none; }
+.mini-tl a::after { content: ""; position: absolute; left: 88px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 3px var(--surface); }
+.mini-tl time { color: var(--muted); font-size: 13px; text-align: right; white-space: nowrap; padding-top: 1px; font-variant-numeric: tabular-nums; }
+.mini-tl-body { display: grid; gap: 3px; min-width: 0; }
+.mini-tl-country { color: var(--text-2); font-size: 12.5px; font-weight: 500; }
+.mini-tl b { font-size: 15.5px; line-height: 1.3; }
+.mini-tl a:hover b { text-decoration: underline; text-underline-offset: 3px; }
+.mini-tl .change-score { margin-top: 3px; }
+.mini-tl .pill { font-size: 13px; min-width: 32px; padding: 1px 8px; border-width: 1.5px; }
 .change-meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0 0 8px; color: var(--muted); font-size: 12.5px; }
 .change-meta span { color: var(--text-2); font-weight: 500; }
-.change h3 { font-size: 17px; line-height: 1.25; margin: 0 0 14px; flex: 1; }
 .change-score { display: flex; align-items: center; gap: 8px; margin: 0; font-variant-numeric: tabular-nums; }
 .pill { min-width: 38px; padding: 3px 9px; border-radius: 999px; text-align: center; font-weight: 700; font-size: 15px; color: var(--text); border: 2px solid var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
@@ -482,2 +495,41 @@ td.num, th.num { width: 52px; color: var(--muted); }
 .change-table .delta { font-size: inherit; }
 .change-note { color: var(--muted); font-size: 13px; margin: 10px 0 0; }
+
+/* ── World average chart ────────────────────────────── */
+.avg { margin-top: 24px; padding: 20px 22px 16px; max-width: 760px; display: flex; flex-direction: column; }
+.avg-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 8px 20px; margin-bottom: 18px; }
+.avg-head h2 { font-size: 18px; margin: 0; }
+.avg-now { margin: 0; display: flex; align-items: baseline; gap: 10px; }
+.avg-now b { font-size: 32px; line-height: 1; font-variant-numeric: tabular-nums; }
+.avg-chart { flex: 1; display: grid; grid-template-columns: 34px 1fr; grid-template-rows: minmax(190px, 1fr) 24px; }
+.avg-y, .avg-x { position: relative; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+.avg-y span { position: absolute; right: 8px; transform: translateY(-50%); }
+.avg-x { grid-column: 2; }
+.avg-x span { position: absolute; top: 7px; transform: translateX(-50%); }
+.avg-plot { position: relative; }
+.avg-plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
+.avg-plot .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; }
+.avg-plot .line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
+/* a 24px hit target around a 9px dot with a surface ring */
+.avg-dot { position: absolute; width: 24px; height: 24px; margin: -12px 0 0 -12px; border-radius: 50%; }
+.avg-dot::after { content: ""; position: absolute; inset: 7.5px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--surface); transition: transform .12s; }
+.avg-dot.on::after, .avg-dot:focus-visible::after { transform: scale(1.5); }
+.avg-dot.on { z-index: 1; }
+.avg-tip { position: absolute; z-index: 2; transform: translateX(-50%); width: max-content; max-width: min(260px, 100%); padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); box-shadow: var(--shadow); pointer-events: none; font-size: 13px; line-height: 1.35; }
+.avg-tip[hidden] { display: none; }
+.avg-tip small { display: block; color: var(--muted); }
+.avg-tip b { display: block; }
+.avg-tip span { color: var(--text-2); font-variant-numeric: tabular-nums; }
+.avg-note { margin: 12px 0 0; color: var(--muted); font-size: 12.5px; }
+@media (max-width: 640px) { .avg-x .odd { display: none; } }
+
+/* ── Changes on a country page ──────────────────────── */
+.c-changes { margin-top: 18px; }
+.c-changes h2 { margin: 0 0 8px; color: var(--muted); font-size: 12.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
+.c-changes ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.c-changes a { display: grid; gap: 2px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: inherit; }
+.c-changes a:hover { border-color: var(--muted); }
+.c-changes time { color: var(--muted); font-size: 12.5px; }
+.c-changes b { font-size: 14.5px; line-height: 1.3; }
+.c-change-score { color: var(--text-2); font-size: 13.5px; font-variant-numeric: tabular-nums; }
+.c-change-score .delta { margin-left: 6px; }

    ↑↓ to move, ↵ to open.