Changelog 9bd9dc2tree, with Claude Opus 5.5
Context pages: /context/ and one page per topic
Each context topic compared across every place (src/lib/contexts.js): - /context/ lists the 16 topics with their coverage (written, explained as not applicable, not researched) and, for a topic with a status, how the places divide. - /context/<topic>/ has the figures, a world map coloured by the topic's status or by coverage, with the home map's zoom buttons and hover cards, a status table, and a filterable table of every profile's summary linked to its section. - A profile's context titles link to their topic page; the pages are in the sitemap and the site search.
8 files of code, design or docs +412 −4 lines
Code, design and documents 8 files
src/components/CountryArticle.astro +2 −2
@@ -367,5 +367,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x))); {ctxList && ( <section class="c-sec c-context" id="context"> - <h2 class="d-h">Context <span>not part of the score</span></h2> + <h2 class="d-h">Context <span>not part of the score · <a href={`${base}context/`}>every country, by topic →</a></span></h2> {sub && <p class="d-hint">Sections written for {sub.name} come first in their place; the others hold for all of {c.name} and are marked so.</p>} {[ctxList.filter((x) => x && !x.items), ctxList.filter((x) => x && x.items)].map((list, g) => (list.length > 0 || (g === 0 && travel)) && ( @@ -373,5 +373,5 @@ const uncited = srcList.filter((x) => !citedSet.has(refKey(x))); {list.map((x) => ( <div class="segrow ctx-tile" data-seg={`ctx-${x.key}`} id={`seg-ctx-${x.key}`}> - <div class="segrow-top"><span>{CONTEXT_ICONS[x.key] && <Icon name={CONTEXT_ICONS[x.key]} class="ctx-icon" aria-hidden="true" />}{x.name}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div> + <div class="segrow-top"><span>{CONTEXT_ICONS[x.key] && <Icon name={CONTEXT_ICONS[x.key]} class="ctx-icon" aria-hidden="true" />}{x.key === "products" ? x.name : <a class="ctx-topic" href={`${base}context/${x.key.replace(/_/g, "-")}/`} title={`${x.name} in every country`}>{x.name}</a>}<SourceTip label="What this covers" inline>{x.help}</SourceTip></span></div> {x.national && <p class="ctx ctx-general">For all of {c.name}; nothing written for {sub.name} yet.</p>} {x.key === "recreational" && (
src/lib/contexts.js +91 −0
@@ -0,0 +1,91 @@ +// The context topics as pages of their own (/context/ and /context/<key>/): for each topic, every +// place with what its profile says, the status it carries where the topic has one, how far the +// research has got, and a colour for the world map. Built from the country files through +// countries.js (texts and statuses) and research.js (written, not applicable or missing). +import { CONTEXT, CONTEXT_ICONS, places, REC_LABEL, REC_HELP, MED_LABEL, MED_HELP } from "./countries.js"; +import { STATUS_SECTIONS, PLANTING_STATUS } from "./segment-ids.js"; +import { research } from "./research.js"; +import { viewByMetric } from "./map-views.js"; + +const researchOf = new Map(research.map((r) => [r.slug, r])); +// a tone of the status chips (s-legal …) as a map colour, the same scale as the chips' dots +const TONE = { legal: 5, yes: 5, partial: 4, decrim: 3, limited: 3, illegal: 0, no: 0, death: 0 }; +const toneTier = (tone) => (tone in TONE ? TONE[tone] : null); + +// The opening of a section's text, cut at a sentence end near `max` characters: the table's summary. +const excerpt = (text, max = 230) => { + if (!text) return ""; + const t = text.replace(/\s+/g, " ").trim(); + if (t.length <= max) return t; + const cut = t.slice(0, max); + const end = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("; ")); + return end > max * 0.45 ? cut.slice(0, end + 1) : `${cut.replace(/\s+\S*$/, "")}…`; +}; + +// How a topic's places are grouped and coloured. A topic with a status (clubs, driving, the hemp +// limit…) is mapped by it; recreational and medical by the country's legal status; seeds and +// clones by the seed status; the death penalty marks where the law provides for it; the rest +// show how far the research has got. +function scheme(key) { + if (STATUS_SECTIONS[key]) { + const sec = STATUS_SECTIONS[key]; + return { + kind: "status", title: sec.name, + values: Object.entries(sec.statuses).map(([value, s]) => ({ value, label: s.label, help: s.help, tone: s.tone, tier: sec.tiers[value] ?? null })), + of: (c) => c[key] ?? null, + }; + } + if (key === "recreational") return { + kind: "status", title: "Recreational status", + values: Object.entries(REC_LABEL).map(([value, label]) => ({ value, label, help: REC_HELP[value], tone: value, tier: toneTier(value) })), + of: (c) => c.rec, + }; + if (key === "medical") return { + kind: "status", title: "Medical status", + values: Object.entries(MED_LABEL).map(([value, label]) => ({ value, label, help: MED_HELP[value], tone: value, tier: toneTier(value) })), + of: (c) => c.med, + }; + if (key === "planting") return { + kind: "status", title: "Seeds", + values: Object.entries(PLANTING_STATUS).map(([value, s]) => ({ value, label: s.label, help: s.help[0], tone: s.tone, tier: toneTier(s.tone) })), + of: (c) => c.planting?.seeds ?? null, + }; + return { kind: "coverage" }; +} + +// the interactive map view for a topic, where the home page's map can show it +const MAP_METRIC = { recreational: "status:rec", medical: "status:med", planting: "plant:seeds", culture: "culture" }; +const mapView = (key) => viewByMetric[MAP_METRIC[key] ?? `status:${key}`] ?? null; + +export const contextTopics = CONTEXT.filter(([key]) => key !== "products").map(([key, name, help]) => { + const sc = scheme(key); + const rows = places.map((c) => { + const block = c.details.context.find((x) => x?.key === key) ?? null; + const section = researchOf.get(c.slug)?.contextCoverage.sections.find((s) => s.key === key); + const state = section?.state === "filled" || section?.state === "not_applicable" ? section.state : block ? "filled" : "missing"; + const value = sc.kind === "status" ? sc.of(c) : null; + const status = value != null ? sc.values.find((v) => v.value === value) ?? null : null; + const text = block ? (block.items ? block.items.map((it) => it.title).join(" · ") : block.t) : ""; + return { c, state, status, summary: excerpt(text), reason: state === "not_applicable" ? section.reason : "" }; + }).sort((a, b) => a.c.name.localeCompare(b.c.name)); + const count = (state) => rows.filter((r) => r.state === state).length; + const statuses = sc.kind === "status" + ? sc.values.map((v) => ({ ...v, rows: rows.filter((r) => r.status?.value === v.value) })).filter((v) => v.rows.length) + : []; + return { + key, name, help, icon: CONTEXT_ICONS[key], slug: key.replace(/_/g, "-"), + scheme: sc, statuses, rows, + filled: count("filled"), notApplicable: count("not_applicable"), missing: count("missing"), total: rows.length, + unknown: sc.kind === "status" ? rows.filter((r) => !r.status).length : 0, + mapView: mapView(key), + }; +}); + +// The map colour of a place on a topic's map, as a CSS colour: a status tier on the --t scale, the +// accent blue where the topic is written, grey where it is explained as not applicable. +export const mapFill = (topic, row) => { + if (!row) return "var(--map-none)"; + if (topic.key === "death_penalty") return row.state === "filled" ? "var(--t0)" : row.state === "not_applicable" ? "var(--map-na)" : "var(--map-none)"; + if (topic.scheme.kind === "status") return row.status?.tier != null ? `var(--t${row.status.tier})` : "var(--map-none)"; + return row.state === "filled" ? "var(--u3)" : row.state === "not_applicable" ? "var(--map-na)" : "var(--map-none)"; +};
src/lib/search-icons.js +1 −1
@@ -13,5 +13,5 @@ export const PAGE_ICONS = { "did-you-know/": "iconoir:light-bulb", "survey/": "iconoir:graph-up", "survey/take/": "iconoir:edit-pencil", "culture/": "iconoir:palette", "culture/people/": "iconoir:community", "culture/stories/": "iconoir:book", "sources/": "iconoir:bookmark-book", "research/": "iconoir:percentage-circle", - "glossary/": "iconoir:book-stack", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image", + "glossary/": "iconoir:book-stack", "context/": "iconoir:list", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image", }; // pages that come in families: a part of the cultural atlas, a map view, a history period, a history tag, an exhibition
src/pages/context/[topic].astro +193 −0
@@ -0,0 +1,193 @@ +--- +// One context topic across every place, e.g. /context/clubs/: what it covers, a world map coloured +// by its status (or by how far the research has got), the figures, and every profile's summary +// with a link to its section. The data is in src/lib/contexts.js. +import Base from "../../layouts/Base.astro"; +import { Icon } from "astro-icon/components"; +import { contextTopics, mapFill } from "../../lib/contexts.js"; +import { worldMap, MAP_RATIO } from "../../lib/world-map.js"; +import { tierColor } from "../../lib/tiers.js"; + +export function getStaticPaths() { + return contextTopics.map((topic) => ({ params: { topic: topic.slug }, props: { topic } })); +} + +const { topic } = Astro.props; +const base = import.meta.env.BASE_URL; +const W = 1000, H = Math.round(W / MAP_RATIO); +const map = worldMap(W, H, 0); +const rowOf = new Map(topic.rows.map((r) => [r.c.id, r])); +const pct = (n) => Math.round((n / topic.total) * 100); +const isStatus = topic.scheme.kind === "status"; +const isDeath = topic.key === "death_penalty"; +// what a country's entry on the map says on hover +const tipOf = (row) => !row ? "" : `${row.c.name}: ${isStatus ? row.status?.label ?? "not researched" : row.state === "filled" ? "written" : row.state === "not_applicable" ? "not applicable" : "not researched yet"}`; +// the hover box's line: the section's first sentence, or its opening if that runs long +const firstLine = (t) => { if (!t) return ""; const m = t.match(/^.{20,170}?[.;](?=\s|$)/); return m ? m[0] : t.length > 150 ? `${t.slice(0, 150).replace(/\s+\S*$/, "")}…` : t; }; +const sectionHref = (c) => `${base}country/${c.slug}/#seg-ctx-${topic.key}`; +// the map's key +const legend = isDeath + ? [["var(--t0)", "Provided for by law"], ["var(--map-na)", "Explained as not applicable"], ["var(--map-none)", "Not researched yet"]] + : isStatus + ? [...topic.scheme.values.filter((v) => v.tier != null).map((v) => [`var(--t${v.tier})`, v.label]), ["var(--map-none)", "Not researched yet"]] + : [["var(--u3)", "Written"], ["var(--map-na)", "Explained as not applicable"], ["var(--map-none)", "Not researched yet"]]; +const figures = [ + [topic.filled, "profiles with this section"], + [topic.notApplicable, "explained as not applicable"], + [topic.missing, "not researched yet"], + [`${pct(topic.filled + topic.notApplicable)}%`, `of ${topic.total} places covered`], +]; +const others = contextTopics.filter((t) => t.key !== topic.key); +const description = `${topic.name} in every country: ${topic.help.charAt(0).toLowerCase()}${topic.help.slice(1)}. A world map, the figures and each country's summary.`; +--- +<Base title={`${topic.name}: country by country`} description={description}> + <main class="changes-page ctxp"> + <section class="hero hero-sm"> + <p class="eyebrow"><a href={`${base}context/`}>Context</a> <span>not part of the score</span></p> + <h1><Icon name={topic.icon} class="ctxp-icon" aria-hidden="true" /> {topic.name}</h1> + <p class="lede">{topic.help}.</p> + </section> + + <dl class="hist-stats ctxp-stats"> + {figures.map(([n, label]) => <div><dt>{label}</dt><dd>{n}</dd></div>)} + </dl> + + <section class="section ctxp-map-sec" aria-labelledby="ctxpMap"> + <div class="section-head"><div> + <h2 id="ctxpMap">{isStatus ? topic.scheme.title : isDeath ? "Where the law provides for it" : "Where it is written"}, on the map</h2> + <p class="sub">Select a country for its section.{topic.mapView && <> <a href={`${base}map/${topic.mapView.slug}/`}>The interactive map →</a></>}</p> + </div></div> + {/* the same hover box and zoom buttons as the home page's map (src/lib/unit-map-tip.js, d3-zoom) */} + <figure class="unit-map ctxp-map" data-ctxp-map> + <svg viewBox={`0 0 ${W} ${H}`} role="group" aria-label={`World map: ${topic.name}`}> + <g class="ctxp-zoom"> + {map.shapes.map((s) => { + const row = s.c ? rowOf.get(s.c.id) : null; + return row + ? <a href={sectionHref(row.c)} data-tip={row.c.id} aria-label={tipOf(row)}><path d={s.d} style={`fill:${mapFill(topic, row)}`} /></a> + : <path d={s.d} class="ctxp-blank" />; + })} + {map.dots.map((d) => { + const row = rowOf.get(d.c.id); + return row && <a href={sectionHref(row.c)} data-tip={row.c.id} aria-hidden="true" tabindex="-1"><circle cx={d.x} cy={d.y} r={map.r * 1.6} style={`fill:${mapFill(topic, row)}`} /></a>; + })} + </g> + </svg> + <div class="map-zoom"> + <button type="button" class="icon-btn" data-zoom="in" aria-label="Zoom in">+</button> + <button type="button" class="icon-btn" data-zoom="out" aria-label="Zoom out">−</button> + <button type="button" class="icon-btn" data-zoom="reset" aria-label="Reset zoom">⟲</button> + </div> + <div class="tooltip" hidden></div> + {topic.rows.map((r) => ( + <template data-tip-for={r.c.id}> + <div class="tip-head"><strong><span class="tip-flag">{r.c.flag}</span>{r.c.name}</strong><span class="tip-chip" style={`--chip:${tierColor(r.c.raw)}`}>{r.c.score}</span></div> + <div class="tip-meta">{isStatus ? (r.status ? <span class={`chip s-${r.status.tone}`}>{r.status.label}</span> : "No status yet") : r.state === "filled" ? "Section written" : r.state === "not_applicable" ? "Explained as not applicable" : "Not researched yet"}</div> + {r.state !== "missing" && <p class="tip-note">{firstLine(r.state === "filled" ? r.summary : r.reason)}</p>} + <div class="tip-foot">Click for the section</div> + </template> + ))} + <figcaption class="ctxp-legend"> + {legend.map(([color, label]) => <span><i style={`background:${color}`}></i>{label}</span>)} + </figcaption> + </figure> + </section> + + {isStatus && ( + <section class="section" aria-labelledby="ctxpStatuses"> + <div class="section-head"><div> + <h2 id="ctxpStatuses">{topic.scheme.title}: how the {topic.total - topic.unknown} researched places divide</h2> + {topic.unknown > 0 && <p class="sub">{topic.unknown} places have no status yet.</p>} + </div></div> + <table class="src-table ctxp-status"> + <thead><tr><th>Status</th><th>What it means</th><th class="num">Places</th><th class="ctxp-bar-col" aria-hidden="true"></th></tr></thead> + <tbody> + {topic.statuses.map((v) => ( + <tr> + <td><span class={`chip s-${v.tone}`}>{v.label}</span></td> + <td>{v.help}</td> + <td class="num">{v.rows.length}</td> + <td class="ctxp-bar-col"><i class="ctxp-bar" style={`--w:${(v.rows.length / Math.max(...topic.statuses.map((x) => x.rows.length))) * 100}%;--c:${v.tier != null ? `var(--t${v.tier})` : "var(--muted)"}`}></i></td> + </tr> + ))} + </tbody> + </table> + </section> + )} + + <section class="section" aria-labelledby="ctxpTable" data-ctxp-table> + <div class="section-head"><div> + <h2 id="ctxpTable">Country by country</h2> + <p class="sub">The opening of each profile's section; the country's page has it in full, with sources.</p> + </div></div> + <div class="ctxp-filter"> + <input type="search" placeholder="Filter by country or word" aria-label="Filter the table" data-ctxp-q /> + <label><input type="checkbox" data-ctxp-written checked /> Only places with a section</label> + <span class="ctxp-count" aria-live="polite" data-ctxp-count></span> + </div> + <table class="src-table ctxp-table"> + <thead><tr><th>Country</th>{isStatus && <th>{topic.scheme.title}</th>}<th>What the profile says</th></tr></thead> + <tbody> + {topic.rows.map((r) => ( + <tr data-state={r.state} data-region={r.c.region} hidden={r.state !== "filled"}> + <td class="ctxp-country"><a href={sectionHref(r.c)}><span aria-hidden="true">{r.c.flag}</span> {r.c.name}</a><small>{r.c.region}</small></td> + {isStatus && <td>{r.status ? <span class={`chip s-${r.status.tone}`}>{r.status.label}</span> : <span class="ctxp-none">—</span>}</td>} + <td class="ctxp-summary">{r.state === "filled" ? r.summary : r.state === "not_applicable" ? <span class="ctxp-na">Not applicable: {r.reason}</span> : <span class="ctxp-none">Not researched yet</span>}</td> + </tr> + ))} + </tbody> + </table> + </section> + + <nav class="section ctxp-others" aria-label="Other context topics"> + <div class="section-head"><div><h2>Other topics</h2></div></div> + <ul> + {others.map((t) => <li><a href={`${base}context/${t.slug}/`}><Icon name={t.icon} class="ctxp-icon" aria-hidden="true" /> {t.name}</a></li>)} + </ul> + </nav> + </main> +</Base> + +<script> + import "../../lib/unit-map-tip.js"; + import { select } from "d3-selection"; + import { zoom, zoomIdentity } from "d3-zoom"; + import "d3-transition"; + + // zoom and pan, by wheel, drag or the buttons, as on the home page's map + for (const figure of document.querySelectorAll("[data-ctxp-map]")) { + const svg = select(figure.querySelector("svg")); … 35 more lines
src/pages/context/index.astro +45 −0
@@ -0,0 +1,45 @@ +--- +// Context: the topics every profile covers beyond its score, each with its own page across all +// countries (/context/<topic>/). For each: what it covers, how far the research has got, and for a +// topic with a status, how the places divide. The data is in src/lib/contexts.js. +import Base from "../../layouts/Base.astro"; +import { Icon } from "astro-icon/components"; +import { contextTopics } from "../../lib/contexts.js"; + +const base = import.meta.env.BASE_URL; +const total = contextTopics[0].total; +const written = contextTopics.reduce((n, t) => n + t.filled, 0); +--- +<Base title="Context" description="Beyond the score: medical cannabis, clubs, seeds, hemp, the black market, driving, visitors, arrests and more, each compared across every country with a map and each profile's summary."> + <main class="changes-page ctxp"> + <section class="hero hero-sm"> + <p class="eyebrow">Context <span>{contextTopics.length} topics · {total} places · {written.toLocaleString("en-GB")} sections written</span></p> + <h1>Beyond the score</h1> + <p class="lede">The index scores six things the law does to people who use cannabis. Each profile also explains what the score does not: how patients get medicine, whether clubs and seeds are allowed, what the black market looks like, what happens to a driver or a visitor. Here each topic is compared across every country, with a map.</p> + </section> + + <section class="section"> + <ol class="ctxp-topics"> + {contextTopics.map((t) => ( + <li> + <a href={`${base}context/${t.slug}/`}> + <span class="ctxp-topic-name"><Icon name={t.icon} class="ctxp-icon" aria-hidden="true" /> {t.name}</span> + <span class="ctxp-topic-help">{t.help}</span> + <span class="ctxp-cover" role="img" aria-label={`${t.filled} written, ${t.notApplicable} not applicable, ${t.missing} not researched, of ${t.total}`}> + <i style={`width:${(t.filled / t.total) * 100}%;background:var(--u3)`}></i> + <i style={`width:${(t.notApplicable / t.total) * 100}%;background:var(--map-na)`}></i> + </span> + <span class="ctxp-topic-n">{t.filled} written{t.notApplicable > 0 && ` · ${t.notApplicable} n/a`}</span> + {t.statuses.length > 0 && ( + <span class="ctxp-split" role="img" aria-label={t.statuses.map((v) => `${v.label}: ${v.rows.length}`).join(", ")}> + {t.statuses.map((v) => <i style={`flex:${v.rows.length};background:${v.tier != null ? `var(--t${v.tier})` : "var(--muted)"}`} title={`${v.label}: ${v.rows.length}`}></i>)} + </span> + )} + </a> + </li> + ))} + </ol> + <p class="ctxp-key"><span><i style="background:var(--u3)"></i>Written</span><span><i style="background:var(--map-na)"></i>Explained as not applicable</span><span><i style="background:var(--map-none)"></i>Not researched yet</span><span><i class="ctxp-key-split"></i>Status, where the topic has one</span></p> + </section> + </main> +</Base>
src/pages/search.json.js +4 −1
@@ -6,4 +6,5 @@ // n: what is shown and matched s: the smaller second line, matched too u: address under the site root // g: words matched but not shown (a history entry's tags) +import { contextTopics } from "../lib/contexts.js"; import { getCollection } from "astro:content"; import { termAnchor } from "../lib/terms.js"; @@ -25,6 +26,8 @@ const rows = [ ["History", "Laws, rulings and treaties", "history/"], ["Can I?", "Plain answers per country", "can-i/"], ["Compare", "Countries side by side", "compare/"], ["Download the data", "Scores, history and yearly panel", "data/"], ["Score changes", "Every score change", "score-changes/"], ["Changelog", "Every change to the site, data and text", "changelog/"], - ["Articles", "The longer reads on single events", "articles/"], ["About", "What the project is, its aims and commitments", "about/"], ["Did you know?", "Curious cases from the index", "did-you-know/"], ["State of Cannabis", "The yearly survey and its results", "survey/"], ["Take the survey", "Anonymous, about ten minutes", "survey/take/"], ["Culture", "A cultural atlas of cannabis", "culture/"], ["Culture: People", "Officials, preachers, scientists and presidents", "culture/people/"], ["Culture: Stories", "The people behind the laws", "culture/stories/"], ["Sources", "Every source the index cites", "sources/"], ["Research progress", "How much of each country is researched, and what is missing", "research/"], ["Glossary", "The words of cannabis law, explained", "glossary/"], ["Charts", "The history and the scores in charts", "charts/"], ["World map", "Every country coloured by its score, full screen", "map/"], ["Map poster", "The world map as a printable poster", "map/poster/"], + ["Articles", "The longer reads on single events", "articles/"], ["About", "What the project is, its aims and commitments", "about/"], ["Did you know?", "Curious cases from the index", "did-you-know/"], ["State of Cannabis", "The yearly survey and its results", "survey/"], ["Take the survey", "Anonymous, about ten minutes", "survey/take/"], ["Culture", "A cultural atlas of cannabis", "culture/"], ["Culture: People", "Officials, preachers, scientists and presidents", "culture/people/"], ["Culture: Stories", "The people behind the laws", "culture/stories/"], ["Sources", "Every source the index cites", "sources/"], ["Research progress", "How much of each country is researched, and what is missing", "research/"], ["Glossary", "The words of cannabis law, explained", "glossary/"], ["Charts", "The history and the scores in charts", "charts/"], ["World map", "Every country coloured by its score, full screen", "map/"], ["Map poster", "The world map as a printable poster", "map/poster/"], ["Context", "Beyond the score: each topic compared across countries", "context/"], ].map(([n, s, u]) => ({ t: "p", n, s, u, ic: PAGE_ICONS[u] })), + // the context topics, each compared across every country + ...contextTopics.map((t) => ({ t: "p", n: `Context: ${t.name}`, s: t.help, u: `context/${t.slug}/`, ic: PAGE_ICONS["context/"] })), // the exhibitions: one row each, its rooms' names matched but not listed { t: "p", n: "Exhibitions", s: "One episode at a time, in rooms of its own", u: "exhibitions/", ic: EXHIBITION_ICON },
src/pages/sitemap.xml.js +3 −0
@@ -15,4 +15,5 @@ import { SITE } from "../lib/exports.js"; import { MAP_VIEWS } from "../lib/map-views.js"; import { CULTURE_SECTIONS, cultureEntries } from "../lib/culture.js"; +import { contextTopics } from "../lib/contexts.js"; import { commits, pageCount, pageOf } from "../lib/gitlog.js"; import { lastChanged } from "../lib/lastmod.js"; @@ -34,4 +35,6 @@ export function GET({ site }) { ["map/poster/", latest], ...MAP_VIEWS.map((v) => [v.slug ? `map/${v.slug}/` : "map/", latest]), + ["context/", latest], + ...contextTopics.map((t) => [`context/${t.slug}/`, latest]), // profiles ...places.map((c) => [`country/${c.slug}/`, c.details.reviewed]),
src/styles/global.css +73 −0
@@ -3437,2 +3437,75 @@ a.ch-db-track:hover { background: color-mix(in srgb, var(--text) 6%, transparent #work-for-a-gram .ch-bars li a { grid-template-columns: 100px minmax(0, 1fr) 115px; } } + +/* ══ Context topics (/context/, /context/<topic>/, src/lib/contexts.js) ═══════════════════════ + Every topic a profile explains beyond its score, compared across countries: a ruled list of + topics with their coverage, and per topic the figures, a world map, a status table and a + filterable table of every profile's summary. */ +.ctxp { --map-none: color-mix(in srgb, var(--text) 9%, var(--bg)); --map-na: color-mix(in srgb, var(--muted) 55%, var(--bg)); } +.ctxp .eyebrow a { color: inherit; text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } +.ctxp-icon { width: .8em; height: .8em; vertical-align: -.05em; color: var(--muted); } +.ctxp h1 .ctxp-icon { width: .7em; height: .7em; margin-right: 4px; } +.ctxp-stats { margin-top: 22px; grid-template-columns: repeat(4, minmax(0, 1fr)); } +@media (max-width: 700px) { .ctxp-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +/* the list of topics */ +.ctxp-topics { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--text); } +.ctxp-topics a { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr) minmax(8rem, 14rem); gap: 6px 28px; align-items: baseline; padding: 16px 0; border-bottom: 1px solid var(--border); color: inherit; } +.ctxp-topic-name { grid-row: span 2; font: 400 22px/1.15 var(--display); } +.ctxp-topic-help { grid-row: span 2; color: var(--text-2); font-size: 14px; line-height: 1.45; } +.ctxp-topics a:hover .ctxp-topic-name { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; } +.ctxp-cover, .ctxp-split { display: flex; height: 6px; overflow: hidden; border-radius: 3px; background: var(--map-none); align-self: center; } +.ctxp-cover i, .ctxp-split i { display: block; height: 100%; } +.ctxp-split i + i { box-shadow: inset 1px 0 0 var(--bg); } +.ctxp-topic-n { grid-column: 3; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.ctxp-topics a > .ctxp-split { grid-column: 3; margin-top: 4px; } +@media (max-width: 760px) { + .ctxp-topics a { grid-template-columns: minmax(0, 1fr); } + .ctxp-topic-name, .ctxp-topic-help { grid-row: auto; } + .ctxp-topic-n, .ctxp-topics a > .ctxp-split { grid-column: 1; } +} +.ctxp-key, .ctxp-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 14px 0 0; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.ctxp-key span, .ctxp-legend span { display: inline-flex; align-items: center; gap: 6px; } +.ctxp-key i, .ctxp-legend i { display: inline-block; width: 12px; height: 12px; border-radius: 2px; } +.ctxp-key .ctxp-key-split { width: 24px; height: 6px; background: linear-gradient(90deg, var(--t5) 0 33%, var(--t3) 33% 66%, var(--t0) 66%); } +/* the map */ +.ctxp-map { margin: 0; } +.ctxp-map { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r); } +.ctxp-map svg { display: block; width: 100%; height: auto; max-height: none; cursor: grab; } +.ctxp-map svg:active { cursor: grabbing; } +.ctxp-map .ctxp-legend { padding: 0 14px 12px; } +.ctxp-map path, .ctxp-map circle { stroke: var(--bg); stroke-width: .6; stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: opacity .15s; } +.ctxp-map a:hover path, .ctxp-map a:hover circle { opacity: .7; } +.ctxp-map .ctxp-blank { fill: var(--map-none); } +.ctxp-map-sec .sub a, .ctxp .sub a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } +/* the tables */ +.ctxp-status td { vertical-align: top; } +.ctxp-bar-col { width: 22%; } +.src-table td.ctxp-bar-col, .src-table th.ctxp-bar-col { padding-left: 18px; } +.ctxp-bar { display: block; height: 6px; width: var(--w); min-width: 2px; margin-top: 8px; border-radius: 3px; background: var(--c); } +.ctxp-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; margin: 0 0 14px; } +.ctxp-filter input[type="search"] { flex: 1 1 260px; max-width: 420px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); color: var(--text); font: inherit; font-size: 14px; } +.ctxp-filter label { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; color: var(--text-2); } +.ctxp-count { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.ctxp-table td { vertical-align: top; } +.ctxp-country { white-space: nowrap; } +.ctxp-country a { color: var(--text); } +.ctxp-country a:hover { text-decoration: underline; text-underline-offset: 3px; } +.ctxp-country small { display: block; margin-top: 2px; font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.ctxp-summary { color: var(--text-2); font-size: 14px; line-height: 1.45; } +.ctxp-na, .ctxp-none { color: var(--muted); font-size: 13px; } +/* on a phone each row stacks: the country and its status on one line, the summary under them */ +@media (max-width: 640px) { + .ctxp-table thead { display: none; } + .ctxp-table tr:not([hidden]) { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 12px 0; border-bottom: 1px solid var(--border); } + .ctxp-table td { padding: 0; border: 0; } + .ctxp-table .ctxp-country { white-space: normal; } + .ctxp-table .ctxp-summary { grid-column: 1 / -1; } + .ctxp-status th:nth-child(2), .ctxp-status td:nth-child(2) { display: none; } +} +/* other topics */ +.ctxp-others ul { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 0; padding: 0; list-style: none; } +.ctxp-others a { color: var(--text-2); font-size: 14px; } +.ctxp-others a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } +/* a context section's title on a profile leads to the topic across every country */ +a.ctx-topic { color: inherit; } +a.ctx-topic:hover { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; }