Cannabis Freedom Index

Changelog 2de4535tree, with Claude Opus 5.5

Country profile: one page instead of tabs

The profile shows everything in order under a sticky menu that marks the part in view: at a glance, states, score, context, history, datasets, culture, did you know, sources. The score has a radar shape and the score over time beside all six segments, open. Context reads as tiles. Citation numbers show their source in a card on hover; the Sources section sums the list up and keeps it folded. Score and context share one numbering. Old #history/#context links still land. Also stop drawing a sparkline for a headline in words (religion).

14 files of code, design or docs +539 −371 lines

Code, design and documents 14 files

docs/DATA-MAINTENANCE.md +2 −2
@@ -737,5 +737,5 @@ group) and are never part of the score.
   publishers' terms, not the site's CC BY licence.
 - Every dataset is kept as a series, one row per country and year, so that each profile's
-  Datasets tab can chart it; the map, the headline figures and the side column use the
+  Datasets section can chart it; the map, the headline figures and "At a glance" use the
   latest year. Keep every year an extract has: do not filter to the latest when adding one.
 - A dataset view on the map with more than one year gets a year slider and a play button.
@@ -946,5 +946,5 @@ tried there. Leave it out for federal law, national bodies and courts (a US Supr
 on a case from one state is still federal), and for events that span several states. The entry
 keeps its `country`, and a `before` on it is still about the national score. It is listed on the
-country's History tab, labelled with its state, and on the state's own page.
+country's History section, labelled with its state, and on the state's own page.
 
 - **`before`** is the six scores *before* the event. The scores after it are the `before` of the

docs/WRITING.md +3 −2
@@ -139,6 +139,7 @@ references. The same source keeps its number wherever it is cited again.
   Convert them to per-sentence citations whenever a text is written or revised; there is no need
   to convert everything at once.
-- On a country page the score breakdown and the Context tab each end with their own numbered
-  references; history, article and culture pages end with one list for the whole page.
+- On a country page the score breakdown and the context share one numbering; each number shows
+  its source in a card on hover, and the full list sits folded in the Sources section at the end.
+  History, article and culture pages end with one list for the whole page.
 
 ## Words to avoid

src/components/CountryArticle.astro +360 −211
@@ -16,5 +16,4 @@ import SourceTip from "./SourceTip.astro";
 import SourceCitation from "./SourceCitation.astro";
 import Cited from "./Cited.astro";
-import References from "./References.astro";
 import { references, citeParts, citedKeys } from "../lib/cite.js";
 import { SHOW_PRICES } from "../lib/config.js";
@@ -27,4 +26,7 @@ import { answersFor } from "../lib/answers.js";
 import CultureList from "./CultureList.astro";
 import { cultureOf } from "../lib/culture.js";
+import { factsOf } from "../lib/facts.js";
+import { priceHistoryFor } from "../lib/prices.js";
+import FactText from "./FactText.astro";
 
 // `sub` is optional: one state, province or territory of `c`. Without it the national view is shown.
@@ -50,11 +52,11 @@ const history = changes.filter((ch) => ch.country === c && (!sub || ch.unit?.id
 const nationalCount = sub ? changes.filter((ch) => ch.country === c).length : 0;
 const scored = sub ? [] : history.filter((ch) => ch.score); // the ones that moved the score (the national one)
-// Inline citations (src/lib/cite.js): the score breakdown and the context each number their sources
-// in reading order, and list them at their end.
-const scoreRefs = references("sc"), ctxRefs = references("cx");
+// Inline citations (src/lib/cite.js): the score breakdown and the context share one numbering, in
+// reading order. A number shows its source in a card on hover (Base.astro); the list itself sits
+// folded in the Sources section at the end, under a summary.
+const refs = references("src");
 const partsOf = (block, refs, where) => citeParts(block.cite.text, refs, (k) => block.cite.keys.includes(k) && block.cite.of(k), block.cite.keys, `countries/${c.slug}.yaml: ${where}`);
-const segParts = d ? d.segments.map((seg, i) => seg && partsOf(seg, scoreRefs, SEGMENTS[i].id)) : [];
-const unitRefs = references("unit");
-const unitParts = sub ? sub.segCite.map((text, i) => citeParts(text, unitRefs,
+const segParts = d ? d.segments.map((seg, i) => seg && partsOf(seg, refs, SEGMENTS[i].id)) : [];
+const unitParts = sub ? sub.segCite.map((text, i) => citeParts(text, refs,
   (k) => /^[1-9]\d*$/.test(k) && sub.sources[+k - 1],
   citedKeys(text) ?? sub.sources.map((_, i) => String(i + 1)),
@@ -64,5 +66,5 @@ const unitParts = sub ? sub.segCite.map((text, i) => citeParts(text, unitRefs,
 const ctxList = sub ? c.details.context.map((nat, i) => sub.context[i] ?? (nat && { ...nat, national: true })) : d?.context ?? null;
 const srcList = sub ? sub.sources : d?.sources ?? [];
-const ctxParts = ctxList ? Object.fromEntries(ctxList.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, ctxRefs, `${x.key}[${i}]`)) : partsOf(x, ctxRefs, x.key)])) : {};
+const ctxParts = ctxList ? Object.fromEntries(ctxList.filter(Boolean).map((x) => [x.key, x.items ? x.items.map((item, i) => partsOf(item, refs, `${x.key}[${i}]`)) : partsOf(x, refs, x.key)])) : {};
 // a maximum fine in the profile's currency: "€5,500", "$300"
 const money = (n, currency) => new Intl.NumberFormat("en-GB", { style: "currency", currency, maximumFractionDigits: 0 }).format(n);
@@ -90,39 +92,51 @@ const travel = sub ? null : [
     : `A prescription or medical card from another country is generally not recognised. Travellers who need a cannabis-based medicine should ask ${c.name}'s embassy before travelling.`,
 ].filter(Boolean);
-const incbRef = travel ? ctxRefs.cite(INCB) : null; // the crossing-the-border note cites one guide
+const incbRef = travel ? refs.cite(INCB) : null; // the crossing-the-border note cites one guide
 const answers = answersFor(v);
 // The methodology lives on the home page: its section, or the description of one segment there.
 const method = (segId) => `${base}#${segId ? `method-${segId}` : "methodology"}`;
 
-// outside datasets with figures for the country, listed in their own tab
+// outside datasets with figures for the country, in their own section
 // a state shows its country's datasets, marked as national
 const datasetCount = Object.keys(datasetsOf(c.slug)).length;
-// The right-hand side is split into tabs so that the history is one click away instead of at
-// the bottom of a long page: [id, label, count]. Labels are kept short so that the row fits; the
-// panels' headings say it in full ("provinces and territories", "national estimate"). A single state has only its score breakdown.
-// A federation opens on its states or provinces, the national estimate after them; a single state
-// or province has its own breakdown, its history and the others of its country.
-const tabs = (sub
-  ? [
-      ["score", "Score breakdown"],
-      ["context", "Context"],
-      history.length > 0 && ["history", "History", history.length],
-      datasetCount > 0 && ["datasets", "Datasets", datasetCount],
-      ["sources", "Sources", srcList.length],
-      ["units", `Other ${group.plural.split(" ")[0]}`, group.units.length - 1],
-    ]
-  : [
-      group && ["units", group.plural[0].toUpperCase() + group.plural.split(" ")[0].slice(1), group.units.length],
-      ["score", group ? "National" : "Score breakdown"],
-      d && ["context", "Context"],
-      history.length > 0 && ["history", "History", history.length],
-      d && datasetCount > 0 && ["datasets", "Datasets", datasetCount],
-      d && ["sources", "Sources", d.sources.length],
-    ]).filter(Boolean);
-const firstTab = tabs[0][0];
 // words and traditions of the cultural atlas that belong to this country; a state shows its country's
 const culture = cultureOf(c);
+const facts = sub ? [] : factsOf(c);
+// The figures at a glance: users, how people judge it, how free the country is in general, prices.
+const glance = Boolean(use || unitUse || (!sub && (c.datasets.pew_marijuana || c.datasets.hfi)) || (SHOW_PRICES && !sub && priceHistoryFor(c.id).length));
+// The whole profile is one page; a sticky row of links under the masthead jumps to each part and
+// marks the one in view: [id, label, count]. The ids are the old tab names, so links like #history
+// still land.
+const plural = group ? group.plural.split(" ")[0] : "";
+const sections = [
+  glance && ["overview", "At a glance"],
+  group && !sub && ["units", plural[0].toUpperCase() + plural.slice(1), group.units.length],
+  ["score", group && !sub ? "National estimate" : "Score"],
+  ctxList && ["context", "Context", ctxList.filter(Boolean).length + (travel ? 1 : 0)],
+  history.length > 0 && ["history", "History", history.length],
+  group && sub && ["units", `Other ${plural}`, group.units.length - 1],
+  datasetCount > 0 && ["datasets", "Datasets", datasetCount],
+  culture.length > 0 && ["culture", "Culture", culture.length],
+  facts.length > 0 && ["did-you-know", "Did you know", facts.length],
+  refs.list.length > 0 && ["sources", "Sources", refs.list.length],
+].filter(Boolean);
+
+// The score's shape: the six segments as a radar, over the world average (and, for a state, its country).
+const radarPt = (i, val, r = 40) => { const a = -Math.PI / 2 + (i * 2 * Math.PI) / SEGMENTS.length; return [50 + Math.cos(a) * r * (val / 10), 50 + Math.sin(a) * r * (val / 10)]; };
+const radarPoly = (vals) => vals.map((val, i) => radarPt(i, val).map((n) => n.toFixed(2)).join(",")).join(" ");
+// a label's side of the shape: right of the corner, left of it, or centred above or below
+const radarSide = (i) => { const [lx] = radarPt(i, 10); return lx > 55 ? "r" : lx < 45 ? "l" : "c"; };
+
+// The Sources section sums the list up: how many, from how many websites, and the most cited ones.
+const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return null; } };
+const hostCounts = [...refs.list.reduce((m, r) => { const h = r.source[1] && hostOf(r.source[1]); if (h) m.set(h, (m.get(h) ?? 0) + 1); return m; }, new Map())]
+  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
+const printCount = refs.list.filter((r) => !r.source[1]).length;
+// sources in the profile that no text cites, listed after the numbered ones
+const refKey = (x) => x[3] ?? x[1] ?? x[0];
+const citedSet = new Set(refs.list.map((r) => refKey(r.source)));
+const uncited = srcList.filter((x) => !citedSet.has(refKey(x)));
 ---
-<article class="country" data-id={c.id} data-default-tab={firstTab}>
+<article class="country" data-id={c.id}>
   {/* The masthead: name, verdict and the one big number; then the five plain answers. */}
   <div class="c-top">
@@ -198,65 +212,69 @@ const culture = cultureOf(c);
   </div>
 
-  {/* Figures at a glance: the score over time, users, other datasets and prices. A sticky rail on the full page. */}
-  <div class="c-rail">
-    {scored.length > 0 && <CountryChart history={scored} context={history.filter((ch) => !ch.score)} />}
-    {use && (
-      <dl class="c-use">
-        {use.users != null && (
-          <div>
-            <dt>Cannabis users</dt>
-            <dd>{use.estimate || use.users_approximate ? "about " : ""}{people(use.users)}</dd>
-          </div>
+  {/* The rest is one page: a row of links to every part, then the parts in order. */}
+  {sections.length > 2 && (
+    <nav class="c-nav" aria-label={`${c.name}: on this page`}>
+      <ol>
+        {sections.map(([id, label, count]) => (
+          <li><a href={`#${id}`} data-nav={id}>{label}{count != null && <small>{count}</small>}</a></li>
+        ))}
+      </ol>
+    </nav>
+  )}
+
+  {glance && (
+    <section class="c-sec c-glance" id="overview">
+      <h2 class="d-h">At a glance <span>not part of the score{datasetCount > 0 && <> · <a href="#datasets">all {datasetCount} datasets ↓</a></>}</span></h2>
+      <div class="glance-grid">
+        {use && (
+          <dl class="c-use">
+            {use.users != null && (
+              <div>
+                <dt>Cannabis users</dt>
+                <dd>{use.estimate || use.users_approximate ? "about " : ""}{people(use.users)}</dd>
+              </div>
+            )}
+            <div>
+              <dt>Past-year share</dt>
+              <dd>{use.estimate ? "about " : ""}{use.prevalence}%</dd>
+            </div>
+            <p>{use.estimate ? "This site’s estimate" : `UNODC · survey ${use.period ?? use.year}`}</p>
+            <SourceTip><span>Past-year cannabis use among people aged {useAges(use)}. </span>{useFrom
+              ? `This site's figure: there is no survey of ${c.name} alone, so ${useFrom.name}'s rate from its ${useFrom.use.period ?? useFrom.use.year} survey is applied to an estimate of the people aged 15–64 here.`
… 474 more lines
src/components/CountryDatasetTab.astro +3 −2
@@ -1,4 +1,4 @@
 ---
-// The profile's Datasets tab: every outside dataset that covers the country (src/lib/datasets.js),
+// The profile's Datasets section: every outside dataset that covers the country (src/lib/datasets.js),
 // as cards grouped like the map's views (Use, Law, Society, Context). A card has the dataset's
 // name, one headline figure and a line of further figures, written as set under `profile` in
@@ -26,5 +26,6 @@ const trend = (d, series) => {
   const name = headField(d);
   const points = series.filter((r) => r.year != null && figure(d, r, name) != null)
-    .map((r) => ({ year: r.year, v: name === "per" ? d.map.value(r, c) : r[name], t: figure(d, r, name) }));
+    .map((r) => ({ year: r.year, v: name === "per" ? d.map.value(r, c) : r[name], t: figure(d, r, name) }))
+    .filter((p) => Number.isFinite(p.v)); // a headline in words (a main religion) has no line to draw
   if (new Set(points.map((p) => p.year)).size < 2) return null;
   const years = points.map((p) => p.year), vs = points.map((p) => p.v);

src/components/CountryDatasets.astro +5 −7
@@ -2,6 +2,6 @@
 // What the most important outside datasets say about a country (src/lib/datasets.js, `rail` in
 // datasets.yaml), beside the index: how its people judge using marijuana (Pew) and how free it is
-// by the Human Freedom Index. Every dataset is in the profile's Datasets tab. Each is placed
-// among the countries the dataset covers. Shown in the profile's rail; nothing here is scored.
+// by the Human Freedom Index. Every dataset is in the profile's Datasets section. Each is placed
+// among the countries the dataset covers. Two tiles in the profile's "At a glance"; nothing here is scored.
 import { places } from "../lib/countries.js";
 import { DATASETS } from "../lib/datasets.js";
@@ -10,6 +10,5 @@ import SourceTip from "./SourceTip.astro";
 import { theName } from "../lib/seo.js";
 
-// `all`: how many datasets cover the country, all of them in the profile's Datasets tab
-const { c, all } = Astro.props;
+const { c } = Astro.props;
 const base = import.meta.env.BASE_URL;
 const mapHref = (key) => `${base}map/${viewByMetric[`data:${key}`].slug}/`;
@@ -48,6 +47,5 @@ const gapText = !hfi ? "" : Math.abs(gap) < hfiList.length / 8 ? "about as expec
 ---
 {(pew || hfi) && (
-  <section class="c-data" aria-labelledby={`cd-h-${c.id}`}>
-    <h2 id={`cd-h-${c.id}`}>Beyond the law<a href="#datasets" data-tab-link="datasets">All {all} datasets →</a></h2>
+  <>
     {pew && (
       <div class="cd-block">
@@ -74,4 +72,4 @@ const gapText = !hfi ? "" : Math.abs(gap) < hfiList.length / 8 ? "about as expec
       </div>
     )}
-  </section>
+  </>
 )}

src/components/CountryFacts.astro +0 −29
@@ -1,29 +0,0 @@
----
-// "Did you know?" at the foot of a country page: every case that belongs to the country, all
-// open across the full width. Nothing is shown for a country without one.
-import { factsOf } from "../lib/facts.js";
-
-const { c } = Astro.props;
-const base = import.meta.env.BASE_URL;
-const facts = factsOf(c);
-import FactText from "./FactText.astro";
----
-{facts.length > 0 && (
-  <section class="dyk dyk-country" aria-label={`Did you know? ${c.name}`}>
-    <div class="dyk-top">
-      <h2>Did you know? <span class="dyk-n">{facts.length}</span></h2>
-      <div class="dyk-nav"><a class="dyk-all-link" href={`${base}did-you-know/`}>All cases →</a></div>
-    </div>
-    <ol class="dyk-facts dyk-all">
-      {facts.map((f) => (
-        <li class="dyk-fact">
-          <p class="dyk-kicker">{f.kicker}</p>
-          <p class="dyk-text">
-            <FactText parts={f.parts} here={c.id} />
-          </p>
-          {f.culture ? <a class="dyk-more" href={`${base}${f.culture.url}`}>Read the entry →</a> : f.history && <a class="dyk-more" href={`${base}history/${f.history.id}/`}>The full story →</a>}
-        </li>
-      ))}
-    </ol>
-  </section>
-)}

src/components/CultureList.astro +2 −2
@@ -6,5 +6,5 @@ import { Image } from "astro:assets";
 import { cultureSection } from "../lib/culture.js";
 
-const { entries, heading, more, limit, note } = Astro.props; // `note`: one line under the heading
+const { entries, heading, more, limit, note } = Astro.props; // `note`: one line under the heading; without `heading` the list has none of its own
 const first = limit && entries.length > limit + 1 ? entries.slice(0, limit) : entries;
 const groups = [first, entries.slice(first.length)];
@@ -12,5 +12,5 @@ const base = import.meta.env.BASE_URL;
 ---
 <section class="c-culture">
-  <h2>{heading}{more && <a href={more[1]}>{more[0]}</a>}</h2>
+  {heading && <h2>{heading}{more && <a href={more[1]}>{more[0]}</a>}</h2>}
   {note && <p class="c-culture-note">{note}</p>}
   {groups.map((list, g) => list.length > 0 && (() => {

src/content.config.ts +1 −1
@@ -358,5 +358,5 @@ const datasets = defineCollection({
       file: z.union([csvFile, z.array(z.object({ file: csvFile, edition: z.number().int() }).strict()).min(1)]),
       rail: z.boolean().default(false), // also shown in the profile's side column
-      // how it reads in the profile's Datasets tab: {field} templates, see datasets.yaml
+      // how it reads in the profile's Datasets section: {field} templates, see datasets.yaml
       // optional for a worldwide dataset (no `country`), which no profile shows
       profile: z.object({ headline: z.string().min(1), details: z.string().optional(), years: z.array(z.number().int()).optional(), each: z.string().optional() }).strict().optional(),

src/layouts/Base.astro +49 −58
@@ -238,13 +238,10 @@ const schemas = jsonld ? [jsonld].flat() : [];
     });
 
-    // "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).
+    // "In short" answers on a country article, and the labels of its score shape: go to the
+    // segment they were read from 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;
-      var panel = row.closest(".c-panel");
-      if (panel) showTab(article, panel.dataset.panel);
-      row.open = true;
       row.classList.remove("flash");
       void row.offsetWidth; // restart the animation when the same answer is chosen twice
@@ -252,44 +249,4 @@ const schemas = jsonld ? [jsonld].flat() : [];
       row.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" });
     }
-    // The tabs of a country article: score breakdown, more context, history, sources. One panel
-    // shows at a time. On the country's own page the tab is kept in the address (#history), so it
-    // can be linked and survives a reload; in the drawer the address belongs to the drawer.
-    function showTab(article, id, focus) {
-      var tab = article && article.querySelector('.c-tabs [data-tab="' + id + '"]');
-      if (!tab) return false;
-      article.querySelectorAll(".c-tabs [data-tab]").forEach(function (t) {
-        var on = t === tab;
-        t.setAttribute("aria-selected", String(on)); t.tabIndex = on ? 0 : -1;
-      });
-      article.querySelectorAll(".c-panel").forEach(function (p) { p.hidden = p.dataset.panel !== id; });
-      if (focus) tab.focus();
-      return true;
-    }
-    document.addEventListener("click", function (e) {
-      var tab = e.target.closest(".c-tabs [data-tab]");
-      if (!tab) return;
-      var article = tab.closest("article.country");
-      showTab(article, tab.dataset.tab);
-      if (!article.closest("dialog")) history.replaceState(null, "", tab.dataset.tab === (article.dataset.defaultTab || "score") ? location.pathname + location.search : "#" + tab.dataset.tab);
-      // a tab chosen from far down a long panel: bring the top of the new one into view
-      var main = article.querySelector(".c-main");
-      if (main.getBoundingClientRect().top < 0) main.scrollIntoView({ block: "start" });
-    });
-    // a link to a tab from elsewhere in the article ("All datasets →" in the side column)
-    document.addEventListener("click", function (e) {
-      var link = e.target.closest && e.target.closest("a[data-tab-link]");
-      if (!link || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
-      var article = link.closest("article.country");
-      if (!showTab(article, link.dataset.tabLink)) return;
-      e.preventDefault();
-      if (!article.closest("dialog")) history.replaceState(null, "", "#" + link.dataset.tabLink);
-      article.querySelector(".c-main").scrollIntoView({ block: "start" });
-    });
-    document.addEventListener("keydown", function (e) {
-      var tab = e.target.closest && e.target.closest(".c-tabs [data-tab]");
-      if (!tab || (e.key !== "ArrowRight" && e.key !== "ArrowLeft")) return;
-      var all = Array.prototype.slice.call(tab.parentElement.children), next = all[(all.indexOf(tab) + (e.key === "ArrowRight" ? 1 : all.length - 1)) % all.length];
-      e.preventDefault(); showTab(tab.closest("article.country"), next.dataset.tab, true);
-    });
     document.addEventListener("click", function (e) {
       var link = e.target.closest("a[data-seg-link]");
@@ -298,13 +255,4 @@ const schemas = jsonld ? [jsonld].flat() : [];
       showSegment(link.closest("article.country"), link.dataset.segLink);
     });
-    // "Open all" above the score breakdown opens or closes every segment at once.
-    document.addEventListener("click", function (e) {
-      var btn = e.target.closest("[data-seg-toggle]");
-      if (!btn) return;
-      var open = btn.getAttribute("aria-expanded") !== "true";
-      btn.closest("article.country").querySelectorAll(".segrow[data-seg]").forEach(function (row) { row.open = open; });
-      btn.setAttribute("aria-expanded", String(open));
-      btn.textContent = open ? "Close all" : "Open all";
-    });
     // "Score over time" on a country article and the world average on the home page: a vertical seeker follows the pointer and says
     // what the score was then and which history entries sit at that point. It snaps to an entry
@@ -375,7 +323,5 @@ const schemas = jsonld ? [jsonld].flat() : [];
     document.addEventListener("pointerleave", hideSeek);
 
-    // A link straight to a segment, e.g. /country/de/#seg-possession
-    // or to a tab: /country/de/#history
-    if (/^#(context|history|datasets|sources)$/.test(location.hash) && showTab(document.querySelector("article.country"), location.hash.slice(1))) document.querySelector("article.country .c-main").scrollIntoView({ block: "start" });
+    // A link straight to a segment, e.g. /country/de/#seg-possession: lit up as well as reached
     if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5));
   </script>
@@ -499,4 +445,49 @@ const schemas = jsonld ? [jsonld].flat() : [];
     })();
   </script>
+  <script is:inline>
+    // Citation numbers ([3]) show their source in a card in the style of the preview cards, read
+    // from the numbered list the number links to, so the list can stay folded away (the country
+    // profile) or far below. The card stays open while the pointer is on it, for its link; a click
+    // or tap on the number pins it. Without JavaScript the number is a plain link to the list.
+    (function () {
+      var tip = null, current = null, timer = 0;
+      var esc = function (t) { return String(t).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; }); };
+      var citeOf = function (e) { return e.target.closest && e.target.closest("sup.cite a[href^='#']"); };
+      function show(a) {
+        var li = document.getElementById(a.getAttribute("href").slice(1));
+        if (!li) return;
+        clearTimeout(timer);
+        if (!tip) {
+          tip = document.createElement("div"); tip.className = "hc hc-cite"; tip.setAttribute("role", "tooltip"); document.body.appendChild(tip);
+          tip.addEventListener("pointerenter", function () { clearTimeout(timer); });
+          tip.addEventListener("pointerleave", function () { hideSoon(); });
+        }
+        current = a;
+        var link = li.querySelector("a[href]:not(.src-archived)"), cite = li.querySelector("cite"), meta = li.querySelector(".src-meta");
+        var host = ""; try { host = link ? new URL(link.href).hostname.replace(/^www\./, "") : ""; } catch (err) {}
+        tip.innerHTML = '<p class="hc-eyebrow">Source ' + esc(li.value || "") + (host ? " · " + esc(host) : link ? "" : " · print") + "</p>"
+          + (link ? '<a class="hc-title hc-src" href="' + esc(link.href) + '" target="_blank" rel="noopener noreferrer">' + esc(link.textContent) + " ↗</a>" : '<b class="hc-title hc-src">' + esc((cite || li).textContent) + "</b>")
+          + (meta ? '<p class="hc-text">' + esc(meta.textContent) + "</p>" : "");
+        tip.hidden = false;
+        var r = a.getBoundingClientRect(), m = 10, w = tip.offsetWidth, h = tip.offsetHeight;
+        var below = r.bottom + 6 + h < window.innerHeight || r.top - 6 - h < 0;
+        tip.style.left = Math.min(Math.max(m, r.left - 12), window.innerWidth - w - m) + "px";
+        tip.style.top = (below ? r.bottom + 6 : r.top - 6 - h) + "px";
+      }
+      function hide() { clearTimeout(timer); current = null; if (tip) tip.hidden = true; }
+      function hideSoon() { clearTimeout(timer); timer = setTimeout(hide, 220); }
+      document.addEventListener("pointerover", function (e) { var a = citeOf(e); if (a && e.pointerType !== "touch") show(a); });
+      document.addEventListener("pointerout", function (e) { var a = citeOf(e); if (a && !a.contains(e.relatedTarget)) hideSoon(); });
+      document.addEventListener("click", function (e) {
+        var a = citeOf(e);
+        if (a && !e.metaKey && !e.ctrlKey && !e.shiftKey && document.getElementById(a.getAttribute("href").slice(1))) { e.preventDefault(); if (current === a && !tip.hidden && e.pointerType === "touch") hide(); else show(a); }
+        else if (current && !(tip && tip.contains(e.target))) hide();
+      });
+      document.addEventListener("focusin", function (e) { var a = citeOf(e); if (a) show(a); });
+      document.addEventListener("focusout", function (e) { if (citeOf(e) && !(tip && tip.contains(e.relatedTarget))) hideSoon(); });
+      document.addEventListener("keydown", function (e) { if (e.key === "Escape") hide(); });
+      window.addEventListener("scroll", function () { if (current) hide(); }, { passive: true, capture: true });
+    })();
+  </script>
   <script is:inline>
     // Side panes stick beside the text only when the whole pane fits the window; otherwise its end

src/lib/dataset-text.js +1 −1
@@ -2,5 +2,5 @@
 // under `profile`, the headline figure over the years, where the country stands among all the
 // countries a dataset covers, and which group and colours it takes. Shared by the profile's
-// Datasets tab (CountryDatasetTab.astro) and the compare page's data (src/pages/api/[id].json.js).
+// Datasets section (CountryDatasetTab.astro) and the compare page's data (src/pages/api/[id].json.js).
 import { places } from "./countries.js";
 import { useAges } from "./use-labels.js";

src/pages/compare.astro +1 −1
@@ -29,5 +29,5 @@ const meta = {
   med: MED_LABEL,
   total: countries.length,
-  // the outside datasets, in the groups of the profiles' Datasets tab: [id, name, group, polarity, its map or chart]
+  // the outside datasets, in the groups of the profiles' Datasets section: [id, name, group, polarity, its map or chart]
   datasets: GROUPS.flatMap((g) => Object.values(DATASETS).filter((d) => d.profile && groupOf(d) === g)
     .map((d) => [d.id, d.name, g, POLARITY[d.id] ?? 0, d.map ? `map/${viewByMetric[`data:${d.id}`].slug}/` : CHART_OF[d.id] ? `charts/#${CHART_OF[d.id]}` : `sources/#dataset-${d.id}`])),

src/pages/country/[id].astro +0 −2
@@ -3,5 +3,4 @@ import { tierColor } from "../../lib/tiers.js";
 import Base from "../../layouts/Base.astro";
 import CountryArticle from "../../components/CountryArticle.astro";
-import CountryFacts from "../../components/CountryFacts.astro";
 import { countries, places } from "../../lib/countries.js";
 import { countrySeo } from "../../lib/seo.js";
@@ -26,5 +25,4 @@ const seo = countrySeo(c, Astro.site);
     </div>
     <CountryArticle c={c} />
-    <CountryFacts c={c} />
     <nav class="pager" aria-label="Neighbouring countries by rank">
       {prev

src/pages/sources/index.astro +1 −1
@@ -135,5 +135,5 @@ const parts = [
               <div><dt>How the file was made</dt><dd>{d.origin}</dd></div>
               <div><dt>Shown on</dt><dd>{d.map
-                ? <><a href={`${base}map/${viewByMetric[`data:${d.id}`].slug}/`}>the world map</a> and the Datasets tab of each country it covers</>
+                ? <><a href={`${base}map/${viewByMetric[`data:${d.id}`].slug}/`}>the world map</a> and the Datasets section of each country it covers</>
                 : <>{(d.pages ?? []).map((p, i, all) => <><a href={`${base}${p.path}`}>{p.label}</a>{i < all.length - 1 && ", "}</>)}{d.also && ` ${d.also}`}</>}</dd></div>
             </dl>

src/styles/global.css +111 −52
@@ -355,6 +355,4 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
 .d-h { font-size: 17px; margin: 26px 0 4px; display: flex; justify-content: space-between; align-items: baseline; }
 .d-h span { color: var(--muted); font-size: 12.5px; font-family: Inter, sans-serif; font-weight: 400; letter-spacing: 0; }
-.seg-toggle { margin-left: 10px; padding: 3px 10px; font: inherit; font-size: 12.5px; color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); cursor: pointer; }
-.seg-toggle:hover { color: var(--text); border-color: var(--muted); }
 .segrow { border-bottom: 1px solid var(--border); }
 .segrow:last-child { border: 0; }
@@ -411,13 +409,5 @@ a.chip:hover { border-color: var(--muted); color: var(--text); }
 .back { color: var(--text-2); font-weight: 500; }
 .back:hover { color: var(--text); }
-.country-page .c-main > .d-h:first-child { margin-top: 0; }
-/* Score over time: beside the head in the drawer, under the score breakdown on the full page */
 .country-page .why p, .country-page .ctx { font-size: 15px; max-width: 72ch; }
-.c-sources ol { margin: 6px 0 0; padding-left: 20px; color: var(--muted); font-size: 13.5px; }
-.c-sources li { padding: 3px 0; }
-.c-sources a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; overflow-wrap: anywhere; }
-.c-sources a:hover { color: var(--text); text-decoration-color: currentColor; }
-.c-sources cite { color: var(--text-2); font-style: normal; }
-.c-sources .src-meta { display: block; color: var(--muted); line-height: 1.45; }
 .pager { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 44px; }
 .pager a { display: grid; gap: 2px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); }
@@ -844,5 +834,5 @@ sup.cite a:hover { text-decoration: underline; }
 .answer .t { color: var(--muted); font-size: 12.5px; line-height: 1.35; }
 /* The segment an answer points to: scrolled to, opened and lit up for a moment */
-.segrow[data-seg] { scroll-margin-top: 84px; }
+.segrow[data-seg] { scroll-margin-top: calc(var(--hdr, 57px) + 64px); }
 .segrow.flash { animation: seg-flash 2.6s ease-out; border-radius: var(--r); }
 @keyframes seg-flash {
@@ -1120,5 +1110,5 @@ h3 { font-size: 22px; }
 
 /* controls: outlined, squared */
-.btn, select, input[type="search"], .metric-group a, .metric-group button, .seg-toggle, .kinds a, .periods a, .col-menu > summary { background: none; }
+.btn, select, input[type="search"], .metric-group a, .metric-group button, .kinds a, .periods a, .col-menu > summary { background: none; }
 .metric-group a, .metric-group button { padding: 4px 10px; font-size: 12.5px; }
 .metric-group a[aria-current], .metric-group button[aria-pressed="true"], .periods a[aria-current="page"], .btn-primary { background: var(--text); border-color: var(--text); color: var(--bg); font-weight: 500; }
@@ -1222,9 +1212,5 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 .cd-more a:hover { color: var(--text); text-decoration-color: currentColor; }
 /* other datasets in the rail (CountryDatasets.astro): one block per dataset */
-.c-data h2 { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin: 0; padding-bottom: 4px; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
-.c-data h2 span { color: var(--muted); opacity: .8; }
-.c-data h2 a { color: var(--text-2); }
-.c-data h2 a:hover { color: var(--text); }
-/* the Datasets tab (CountryDatasetTab.astro): cards in groups. Each card has the dataset's
+/* the Datasets section (CountryDatasetTab.astro): cards in groups. Each card has the dataset's
    name, one headline figure, the figure over the years, where the country stands among the
    countries the dataset covers, a line of further figures and links to its map and source. */
@@ -1290,29 +1276,5 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
 .cd-key .sw { width: 10px; height: 10px; }
 .cd-key b { color: var(--text); font: 500 12px var(--mono); }
-/* the tabs over a country's right-hand side; they stay in reach while the panel scrolls */
-.c-main { scroll-margin-top: var(--hdr, 57px); }
-.drawer .c-main { scroll-margin-top: 0; }
-/* in one column the tabs follow the score chart: give them room */
-.c-rail + .c-main { margin-top: clamp(28px, 5vw, 44px); }
-.c-tabs { position: sticky; top: var(--hdr, 57px); z-index: 5; display: flex; gap: 4px 26px; overflow-x: auto; scrollbar-width: none; background: var(--bg); border-top: 1px solid var(--text); border-bottom: 1px solid var(--border); }
-.c-tabs::-webkit-scrollbar { display: none; }
-/* wide enough for the row to wrap rather than hide a tab; narrower, it scrolls, faded at the edge */
-@media (min-width: 761px) { .c-tabs { flex-wrap: wrap; overflow-x: visible; column-gap: 20px; } }
-@media (max-width: 760px) { .c-tabs { mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent); padding-right: 28px; } }
-.c-tabs button { flex: none; padding: 13px 0 12px; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; cursor: pointer; font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
-.c-tabs button:hover { color: var(--text); }
-.c-tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--text); }
-.c-tabs small { margin-left: 7px; font-size: 10.5px; color: var(--muted); }
-.c-tabs + .c-panel > .d-h:first-child, .c-tabs ~ .c-panel > .d-h:first-child, .c-tabs ~ .c-panel > :first-child > .d-h:first-child { margin-top: 26px; padding-top: 0; border-top: 0; }
-.c-tabs ~ .c-panel .c-changes, .c-tabs ~ .c-panel .c-sources { margin-top: 0; }
-.drawer .c-tabs { top: 0; }
-@media print { .c-tabs { display: none; } .c-panel[hidden] { display: block !important; } }
-.c-rail > * + * { margin-top: 22px; }
-.c-rail .c-chart { margin-top: 28px; }
-.c-rail .c-chart:first-child { margin-top: 0; }
-.c-rail .c-chart + .c-use { margin-top: 28px; }
-
 @media (min-width: 900px) {
-  .country-page .country { display: grid; grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); gap: 0 clamp(40px, 6vw, 96px); align-items: start; }
   .country-page .c-top { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: 0 clamp(40px, 6vw, 96px); align-items: start; margin-bottom: 40px; }
   .country-page .c-top > * { grid-column: 1; }
@@ -1328,8 +1290,107 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon
   .country-page .d-score .tier { font-size: 36px; }
   .country-page .answers { grid-template-columns: repeat(5, minmax(0, 1fr)); }
-  .country-page .c-main > .d-h:first-child { margin-top: 0; }
-  .country-page .c-rail + .c-main { margin-top: 0; }
 }
 
+/* ── One page: everything under the masthead in order, with a sticky row of links ── */
+.c-nav { position: sticky; top: var(--hdr, 57px); z-index: 5; margin-top: clamp(32px, 5vw, 56px); overflow-x: auto; scrollbar-width: none; background: var(--bg); border-top: 1px solid var(--text); border-bottom: 1px solid var(--border); }
+.c-nav::-webkit-scrollbar { display: none; }
+.c-nav ol { display: flex; gap: 4px clamp(18px, 2.4vw, 30px); margin: 0; padding: 0; list-style: none; }
+.c-nav li { flex: none; }
+.c-nav a { display: block; padding: 13px 0 11px; border-bottom: 2px solid transparent; margin-bottom: -1px; font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); white-space: nowrap; }
+.c-nav a:hover { color: var(--text); }
+.c-nav a[aria-current] { color: var(--text); border-bottom-color: var(--text); }
+.c-nav small { margin-left: 6px; font-size: 10.5px; color: var(--muted); }
+@media (max-width: 760px) { .c-nav { mask-image: linear-gradient(90deg, #000 calc(100% - 32px), transparent); } .c-nav ol { padding-right: 28px; } }
+.drawer .c-nav { display: none; }
+@media print { .c-nav { display: none; } }
+.c-sec { scroll-margin-top: calc(var(--hdr, 57px) + 44px); }
+.country .c-sec > .d-h, .country .c-sec > :first-child > .d-h:first-child { margin-top: clamp(48px, 6vw, 80px); font-size: clamp(26px, 3vw, 36px); }
+.c-nav + .c-sec > .d-h:first-child, .c-nav + .c-sec > :first-child > .d-h:first-child { margin-top: clamp(28px, 3vw, 40px); }
+.d-h span a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; }
+.d-h span a:hover { color: var(--text); text-decoration-color: currentColor; }
+
+/* At a glance: the figures beside the law, as tiles in one row */
+.glance-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 clamp(24px, 3vw, 48px); }
+.glance-grid > * { margin: 0; padding: 14px 0 18px; border: 0; }
+.glance-grid > .c-use { align-content: start; }
+
+/* The score: its shape and its path over time beside the six segments, all open */
+.score-side { display: grid; gap: 28px; align-content: start; margin-bottom: 28px; }
+.c-radar { margin: 0; }
+.c-radar-box { position: relative; aspect-ratio: 1; width: min(230px, calc(100% - 172px)); margin: 34px auto 30px; }
+.c-radar-box svg { display: block; width: 100%; height: 100%; overflow: visible; }
+.c-radar-ring { fill: none; stroke: var(--border); stroke-width: .5; }
+.c-radar-avg { fill: none; stroke: var(--muted); stroke-width: .7; stroke-dasharray: 2 2; }
+.c-radar-nat { fill: none; stroke: var(--text-2); stroke-width: .7; stroke-dasharray: .8 1.6; stroke-linecap: round; }
+.c-radar-shape { fill: color-mix(in srgb, var(--c) 38%, transparent); stroke: var(--c); stroke-width: 1.3; stroke-linejoin: round; }
+.c-radar-dot { fill: var(--c); stroke: var(--bg); stroke-width: .6; transform-box: fill-box; transform-origin: center; transition: transform .15s; }
+.c-radar-label { position: absolute; transform: translate(-50%, -50%); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); width: max-content; max-width: 86px; line-height: 1.3; text-align: center; }
+.c-radar-label.side-r { transform: translate(4px, -50%); text-align: left; }
+.c-radar-label.side-l { transform: translate(calc(-100% - 4px), -50%); text-align: right; }
+.c-radar-label b { margin-left: 3px; font-weight: 500; color: var(--text); }
+.c-radar-label:hover { color: var(--text); }
+.c-radar-key { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 16px; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
+.c-radar-key span { display: inline-flex; align-items: center; gap: 6px; }
+.c-radar-key i { display: inline-block; width: 14px; height: 0; border-top: 1.5px dashed var(--muted); }
+.c-radar-key .sw-shape { height: 10px; border: 1.5px solid var(--c); background: color-mix(in srgb, var(--c) 38%, transparent); }
+.c-radar-key .sw-nat { border-top: 1.5px dotted var(--text-2); }
+.score-side .c-chart { margin: 0; }
+/* a segment read in the text lights its corner of the shape */
+.score-grid:has(.segrow[data-seg="possession"]:hover) [data-seg-dot="possession"],
+.score-grid:has(.segrow[data-seg="cultivation"]:hover) [data-seg-dot="cultivation"],
+.score-grid:has(.segrow[data-seg="enforcement"]:hover) [data-seg-dot="enforcement"],
+.score-grid:has(.segrow[data-seg="sharing"]:hover) [data-seg-dot="sharing"],
+.score-grid:has(.segrow[data-seg="consumption"]:hover) [data-seg-dot="consumption"],
+.score-grid:has(.segrow[data-seg="access"]:hover) [data-seg-dot="access"] { transform: scale(2); }
+.score-rows .segrow { padding: 18px 0 8px; }
+.score-rows .segrow:first-child { padding-top: 6px; }
+.score-rows .why { padding: 6px 0 10px; }
+@media (min-width: 900px) {
+  .country-page .score-grid { display: grid; grid-template-columns: minmax(280px, 380px) minmax(0, 1fr); gap: 0 clamp(40px, 6vw, 96px); align-items: start; }
+  .country-page .score-side { position: sticky; top: calc(var(--hdr, 57px) + 64px); margin-bottom: 0; }
… 73 more lines

    ↑↓ to move, ↵ to open.