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 { "&": "&", "<": "<", ">": ">", '"': """ }[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