420 culture/language/420.md
5
Changelog d662ed6
6 atlas entries written20 atlas entries edited15 files of code, design or docs +715 −40 lines
5
3
6
7
9
1
2
8
10
4
At Holi, the spring festival of colours, much of north India drinks cannabis. The leaf is … n_Amritsar_artist_depicting_the_preparation_and_consumption_of_Indian_hemp_(bhang).jpg") .jpg") ## Not only Hindus, not only Holi The same drink belongs to other calendars. The Nihang S … has received complaints of misuse of prohibition act against those found drinking bhang."
src ../../src/assets/culture/bhang-4381292529and-thandai.jpg · alt Balls of green bhang paste besideA man pours thandai from a bagmetal pot into a row of dried leavesglasses while customers hold out banknotes · caption Bhang paste rolled into ballsThandai poured at a stall during Holi, ready to be mixed into lassiwith customers crowding the counter. Bhang is added for those who ask. · credit Reuben StrayerKaushalspeed, CC BY-SA 24.0, via Wikimedia Commons · credit url https://commons.wikimedia.org/wiki/File:Bhang_(4381292529)Bhang-and-Thandai.jpg
1
4
src ../../src/assets/culture/dr-dou-social-club.webp · alt The lounge of a cannabis social club, with leather armchairs, exposed brick, coloured lanterns and a bar · caption The lounge of Dr. Dou, a cannabis social club in Barcelona. · credit Photo published by Cannabis BCN · credit url https://cannabisbcn.com/
5
8
10
9
2
6
3
7
docs/CULTURE.md +21 @@ -91,4 +91,23 @@ the law so often failed to tell the two apart. Not for Traditions: a single event, a company, a piece of legislation as such. +## Which entries are featured + +Each part has a short ranked list of its most important entries, set with `featured: 1`, `2`, … +in the front matter. They come first on the part's page in that order, and the first five are +shown on the Culture page, the first of them as a large card. Everything else follows +alphabetically. + +An entry deserves a place by importance to a reader who knows nothing yet, in roughly this order: + +1. **Reach.** Words and customs known across many countries (*ganja*, *hashish*, *marijuana*), + or practised by large numbers of people (bhang at Holi, the Rastafari sacrament). +2. **Weight in law.** Entries that a law was written around or because of: an exemption, a + named statute, a court ruling (bhang and the 1961 treaty, the coffeeshop). +3. **Explanatory power.** Entries that make other entries make sense (*cannabis*, the Scythian + vapour bath as the oldest account). + +Keep each list to about ten. Number 1 must have a picture. Change the ranks when a better entry +is written; do not add ties. + ## The shape of an entry @@ -109,4 +128,5 @@ image: # the lead picture, optional credit: Author, licence, via Wikimedia Commons credit_url: https://commons.wikimedia.org/wiki/File:Example.jpg +featured: 3 # optional: rank among the part's most important entries model: opus-5.5 written: 2026-10-14 @@ -173,4 +193,5 @@ The site turns that into a figure with the caption and credit underneath. Rules: `[ganja](/culture/language/ganja/)`, `[Jamaica](/country/jm/)`. Culture entries should link to one another wherever one explains a word the other uses. +- Links to countries, history entries and culture entries get a hover preview on the site (data in `/previews/*.json`), so a reader can check what is behind a link without leaving the page. Link the precise history entry, not the history index. - A link is not a source. What the text states must still rest on the `sources` list. - Only link a page that was opened and exists.
source-archive.json +30 Not shown: a picture, a generated file, or past the length shown per change.
src/assets/culture/bhang-and-thandai.jpg +0 Not shown: a picture, a generated file, or past the length shown per change.
src/assets/culture/dr-dou-social-club.webp +0 Not shown: a picture, a generated file, or past the length shown per change.
src/components/CountryArticle.astro +11 @@ -11,4 +11,5 @@ import { SHOW_PRICES } from "../lib/config.js"; import { PLANTING_STATUS, STATUS_SECTIONS, PLANTING_TRADE } from "../lib/segment-ids.js"; import { answersFor } from "../lib/answers.js"; +import SegmentScale from "./SegmentScale.astro"; import { Image } from "astro:assets"; import { cultureOf, cultureSection } from "../lib/culture.js"; @@ -44,4 +45,7 @@ const travel = sub ? null : [ ].filter(Boolean); const answers = answersFor(v.s); +// The methodology lives on the home page: its section, or the description of one segment there. +const method = (segId) => `${base}#${segId ? `method-${segId}` : "methodology"}`; + // 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]. A single state has only its score breakdown. @@ -175,6 +179,6 @@ const culture = sub ? [] : cultureOf(c); <h2 class="d-h">Score breakdown <span>▏world average <button type="button" class="seg-toggle" data-seg-toggle aria-expanded="false">Open all</button></span></h2> {sub - ? <p class="d-hint">{group.intro} Select a segment to show or hide the rules.</p> - : <p class="d-hint">Select a segment to show or hide the exact rules and sources.</p>} + ? <p class="d-hint">{group.intro} Select a segment to show or hide the rules. <a href={method()}>How the scores are made →</a></p> + : <p class="d-hint">Select a segment to show or hide the exact rules and sources. <a href={method()}>How the scores are made →</a></p>} {sub && SEGMENTS.map((s, i) => ( <details class="segrow" data-seg={s.id}> @@ -190,5 +194,8 @@ const culture = sub ? [] : cultureOf(c); <div class="pts">adds {segmentPoints(i, sub.s[i]).toFixed(1)} of {segmentMax(i).toFixed(0)} points</div> </summary> - {sub.seg && <div class="why"><p>{sub.seg[i]}</p></div>} + <div class="why"> + {sub.seg && <p>{sub.seg[i]}</p>} + <SegmentScale segment={s} score={sub.s[i]} /> + </div> </details> ))} @@ -222,4 +229,5 @@ const culture = sub ? [] : cultureOf(c); {seg.src.map((source) => <SourceCitation source={source} />)} </ul> + <SegmentScale segment={s} score={c.s[i]} /> </div> )}
src/components/CultureCards.astro +10 @@ -1,19 +1,22 @@ --- // Entries of the cultural atlas as cards: the picture when there is one, the title, a line of -// explanation and the countries it belongs to. +// explanation and the countries it belongs to. With `lead`, the first card is drawn large: the +// entries come most important first (see `featured` in docs/CULTURE.md). import { Image } from "astro:assets"; import { cultureSection } from "../lib/culture.js"; -const { entries, label = false } = Astro.props; // label: say which section each card is from +const { entries, label = false, lead = false, id } = Astro.props; // label: say which section each card is from const base = import.meta.env.BASE_URL; --- -<ol class="cu-cards"> - {entries.map((e) => ( - <li> +<ol class:list={["cu-cards", { "cu-lead": lead }]} {...(id && { id })}> + {entries.map((e, i) => ( + <li class={lead && i === 0 ? "cu-first-card" : undefined} data-rank={i} data-name={e.title.toLowerCase()}> <a href={`${base}${e.url}`}> {e.image - ? <Image src={e.image.src} alt={e.image.alt} width={600} height={400} fit="cover" widths={[300, 600]} sizes="(max-width: 600px) 100vw, 300px" /> + ? (lead && i === 0 + ? <Image src={e.image.src} alt={e.image.alt} width={1200} height={760} fit="cover" widths={[600, 900, 1200]} sizes="(max-width: 900px) 100vw, 600px" loading="eager" /> + : <Image src={e.image.src} alt={e.image.alt} width={600} height={400} fit="cover" widths={[300, 600]} sizes="(max-width: 600px) 100vw, 300px" />) : <span class="cu-word" aria-hidden="true">{e.title}</span>} - <p class="ev-label">{label ? cultureSection(e.section).name : (e.origin ?? e.countries[0].name)}</p> + <p class="ev-label">{lead && i === 0 && e.featured && <span class="cu-pick">Start here · </span>}{label ? cultureSection(e.section).name : (e.origin ?? e.countries[0].name)}</p> <h3>{e.title}{e.spelling && e.spelling[0].text.toLowerCase() !== e.title.toLowerCase() && <span class="cu-native"> {e.spelling[0].text}</span>}</h3> <p class="cu-sum">{e.summary}</p>
src/components/HomePage.astro +11 @@ -370,4 +370,14 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", </section> + {!view && ( + <script is:inline> + // Arriving from a country's score breakdown at #method-<segment> (or #methodology): the map + // and charts above change the page's height as they load, so scroll again once they have. + if (/^#method/.test(location.hash)) window.addEventListener("load", function () { + var t = document.getElementById(location.hash.slice(1)); + if (t) requestAnimationFrame(function () { t.scrollIntoView({ block: "start" }); }); + }); + </script> + )} {!view && <section class="section" id="methodology"> <div class="section-head"> @@ -388,5 +398,5 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", <div class="seg-grid"> {SEGMENTS.map((s) => ( - <div class="seg"> + <div class="seg" id={`method-${s.id}`}> <div class="seg-top"><h3>{s.name}</h3><span class="seg-w">{s.weight}%</span></div> <p>{s.desc}</p>
src/components/SegmentScale.astro +16 @@ -0,0 +1,16 @@ +--- +// Where a score sits on its segment's fixed scale: the level it reaches and the next one up, with +// a link to the whole scale in the methodology on the home page. Shown at the foot of each opened +// segment of a country's score breakdown. +const { segment, score } = Astro.props; +const base = import.meta.env.BASE_URL; +const reached = [...segment.levels].reverse().find(([n]) => n <= score) ?? segment.levels[0]; +const next = segment.levels.find(([n]) => n > score); +--- +<div class="seg-scale"> + <p class="ev-label">On the scale <a href={`${base}#method-${segment.id}`}>All levels of {segment.name.toLowerCase()} →</a></p> + <dl> + <div class="here"><dt>{reached[0]}</dt><dd>{reached[1]}</dd></div> + {next && <div><dt>{next[0]}</dt><dd>{next[1]}</dd></div>} + </dl> +</div>
src/content.config.ts +3 @@ -229,4 +229,7 @@ const culture = defineCollection({ history: z.array(z.string()).default([]), // ids in changes.yaml image: picture(image).optional(), + // the entry's rank among the most important of its part (1 = first); see docs/CULTURE.md. + // Featured entries lead the Culture page and their section; the rest follow alphabetically. + featured: z.number().int().positive().optional(), model: z.string().min(1).optional(), written: z.coerce.date().optional(),
src/layouts/Base.astro +91 @@ -79,5 +79,4 @@ const schemas = jsonld ? [jsonld].flat() : []; <a href={`${base}#map`}>Map</a> <a href={`${base}#rankings`}>Rankings</a> - <a href={`${base}#methodology`}>Methodology</a> <a href={`${base}history/`}>History</a> <a href={`${base}culture/`}>Culture</a> @@ -333,4 +332,95 @@ const schemas = jsonld ? [jsonld].flat() : []; if (/^#seg-[a-z_-]+$/.test(location.hash)) showSegment(document.querySelector("article.country"), location.hash.slice(5)); </script> + <script is:inline> + // Hover previews. A link in the page to a country, a history entry or a culture entry shows a + // small card with what is behind it. The data is /previews/<kind>.json, fetched the first time a + // link of that kind is hovered or focused. Not on touch, not in the menus, and not on cards or list rows that already show the same. + (function () { + var base = (document.getElementById("siteSearch") || {}).dataset ? document.getElementById("siteSearch").dataset.base : "/"; + var cache = {}, tip = null, timer = 0, current = null; + var esc = function (t) { return String(t == null ? "" : t).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }; + function kindOf(a) { + if (!a || a.closest("header, footer, nav, svg, .cu-cards, .reads, .search, .hc, .avg-chart, .c-chart, [data-no-preview]")) return null; + // A row of a list (history, changes, the side lists) already shows what the card would show: + // only links in running text and short labels get a preview. + if (a.querySelector("h1, h2, h3, h4, p, div, time, ul, ol, figure, img")) return null; + var u; try { u = new URL(a.href, location.href); } catch (e) { return null; } + if (u.origin !== location.origin || u.pathname.indexOf(base) !== 0 || u.pathname === location.pathname) return null; + var p = u.pathname.slice(base.length); + if (/^country\/[a-z]{2}\/$/.test(p)) return ["country", p]; + if (/^history\/[^/]+\/$/.test(p)) return ["history", p]; + if (/^culture\/(language|traditions)\/[^/]+\/$/.test(p)) return ["culture", p]; + return null; + } + function load(kind) { + return cache[kind] || (cache[kind] = fetch(base + "previews/" + kind + ".json").then(function (r) { return r.json(); }).catch(function () { delete cache[kind]; return {}; })); + } + function card(kind, d) { + if (kind === "country") return '<p class="hc-eyebrow">' + esc(d.r) + ' · ' + (d.k ? "#" + d.k + " of " + d.of : "not ranked") + '</p>' + + '<div class="hc-head"><span class="hc-flag">' + d.f + '</span><b class="hc-title">' + esc(d.n) + '</b><span class="hc-score" style="--c:' + d.c + '">' + d.s + '<small>/100</small></span></div>' + + '<div class="hc-meter" style="--c:' + d.c + '"><i style="width:' + d.s + '%"></i></div>' + + '<p class="hc-chips"><span class="chip s-' + d.rec[1] + '">Recreational: ' + esc(d.rec[0]) + '</span><span class="chip s-' + d.med[1] + '">Medical: ' + esc(d.med[0]) + '</span></p>' + + '<p class="hc-text">' + esc(d.x) + '</p><p class="hc-foot">' + esc(d.t) + (d.h ? " · " + d.h + " history entries" : "") + '</p>'; + if (kind === "history") return '<p class="hc-eyebrow">' + esc(d.k) + ' · ' + esc(d.d) + '</p>' + + '<p class="hc-place">' + esc(d.p) + '</p><b class="hc-title">' + esc(d.t) + '</b>' + + (d.b ? '<p class="hc-move"><b style="--c:' + d.b[1] + '">' + d.b[0] + '</b><span>→</span><b style="--c:' + d.a[1] + '">' + d.a[0] + '</b><small>index score</small></p>' : '') + + '<p class="hc-text">' + esc(d.x) + '</p><p class="hc-foot"><span class="conf-mark c-' + d.cf[2] + '">' + esc(d.cf[0]) + '</span> ' + esc(d.cf[1]) + (d.ar ? ' · <span class="he-article">Article</span>' : '') + '</p>'; + return (d.i ? '<img class="hc-img" src="' + esc(d.i) + '" alt="" width="360" height="180">' : '') + + '<p class="hc-eyebrow">Culture · ' + esc(d.sec) + (d.o ? ' · ' + esc(d.o) : '') + '</p>' + + '<b class="hc-title">' + esc(d.t) + (d.sp ? ' <span class="hc-native">' + esc(d.sp) + '</span>' : '') + '</b>' + + '<p class="hc-text">' + esc(d.x) + '</p><p class="hc-foot hc-flags">' + d.fl + (d.n > 10 ? " +" + (d.n - 10) : "") + '</p>'; + } + // Place the card under the line of the link that the pointer is on, starting where the pointer + // is, so that a link spanning a whole row (as in the lists of changes) does not throw the card + // to the row's far edge. A tall block link uses the pointer's height instead of its bottom. + // Keyboard focus has no pointer, so then the card goes under the start of the link. + var pt = null; + document.addEventListener("pointermove", function (e) { pt = { x: e.clientX, y: e.clientY }; }, { passive: true }); + function place(a, byPointer) { + var rects = a.getClientRects(), r = null, i, w = tip.offsetWidth, h = tip.offsetHeight, m = 10; + if (byPointer && pt) for (i = 0; i < rects.length; i++) if (pt.y >= rects[i].top - 2 && pt.y <= rects[i].bottom + 2) r = rects[i]; + r = r || rects[0] || a.getBoundingClientRect(); + var top = r.top, bottom = r.bottom, x = r.left; + if (byPointer && pt) { + x = pt.x - 24; + if (r.height > 48) { top = pt.y - 14; bottom = pt.y + 18; } + } + x = Math.min(Math.max(m, x), window.innerWidth - w - m); + var below = bottom + 8 + h < window.innerHeight || top - 8 - h < 0; + tip.style.left = x + "px"; + tip.style.top = (below ? bottom + 8 : top - 8 - h) + "px"; + } + function show(a, byPointer) { + var k = kindOf(a); if (!k) return; + current = a; + load(k[0]).then(function (data) { + var d = data[k[1]]; if (!d || current !== a) return; + if (!tip) { tip = document.createElement("div"); tip.className = "hc"; tip.setAttribute("role", "tooltip"); } + (a.closest("dialog[open]") || document.body).appendChild(tip); + tip.className = "hc hc-" + k[0]; + tip.innerHTML = card(k[0], d); + tip.hidden = false; + place(a, byPointer); + var img = tip.querySelector("img"); if (img) img.onload = function () { if (current === a) place(a, byPointer); }; + }); + } + function hide() { clearTimeout(timer); current = null; if (tip) tip.hidden = true; } + document.addEventListener("pointerover", function (e) { + if (e.pointerType === "touch") return; + var a = e.target.closest && e.target.closest("a[href]"); + if (!a || a === current) return; + clearTimeout(timer); hide(); + if (kindOf(a)) timer = setTimeout(function () { show(a, true); }, 220); + }); + document.addEventListener("pointerout", function (e) { + var a = e.target.closest && e.target.closest("a[href]"); + if (a && !a.contains(e.relatedTarget)) hide(); + }); + document.addEventListener("focusin", function (e) { var a = e.target.closest && e.target.closest("a[href]"); if (a && kindOf(a) && !a.matches(":hover")) { hide(); show(a, false); } }); + document.addEventListener("focusout", hide); + document.addEventListener("keydown", function (e) { if (e.key === "Escape") hide(); }); + window.addEventListener("scroll", hide, { passive: true, capture: true }); + })(); + </script> </body> </html>
src/lib/culture.js +2 @@ -40,5 +40,6 @@ export const cultureEntries = (await getCollection("culture")) return { entry, section, slug, url: `culture/${section}/${slug}/`, ...d, countries, history, sources: d.sources.map(sourceTuple) }; }) - .sort((a, b) => a.title.localeCompare(b.title)); + // featured entries first, by rank; then the rest alphabetically + .sort((a, b) => (a.featured ?? Infinity) - (b.featured ?? Infinity) || a.title.localeCompare(b.title)); // and so must links from one entry to another for (const e of cultureEntries) for (const [, path] of (e.entry.body ?? "").matchAll(/\]\(\/(culture\/(?:language|traditions)\/[^/)#]+)\/?[^)]*\)/g)) {
src/pages/culture/[section]/index.astro +41 @@ -27,6 +27,46 @@ const byCountry = [...new Set(entries.flatMap((e) => e.countries))] <section class="section cu-first"> - <CultureCards entries={entries} /> + {/* Order and view: by importance (the `featured` ranks, then the rest A–Z) or by name, as + cards or as a compact list. The choice is kept in the browser for the next visit. */} + <div class="cu-controls" id="cuControls"> + <div class="hist-order" role="group" aria-label="Order of the entries"> + <span class="cu-ctl-label">Order</span> + <button type="button" data-order="rank" aria-pressed="true">Importance</button> + <button type="button" data-order="name" aria-pressed="false">Name A–Z</button> + </div> + <div class="hist-order" role="group" aria-label="How to show the entries"> + <span class="cu-ctl-label">Show</span> + <button type="button" data-view="cards" aria-pressed="true">Cards</button> + <button type="button" data-view="list" aria-pressed="false">List</button> + </div> + </div> + <CultureCards entries={entries} lead id="cuEntries" /> </section> + <script is:inline> + (function () { + var list = document.getElementById("cuEntries"), box = document.getElementById("cuControls"); + var state = { order: "rank", view: "cards" }; + try { Object.assign(state, JSON.parse(localStorage.getItem("cfi-culture-view") || "{}")); } catch (e) {} + function apply() { + var items = Array.prototype.slice.call(list.children); + items.sort(function (a, b) { + return state.order === "name" ? a.dataset.name.localeCompare(b.dataset.name) : a.dataset.rank - b.dataset.rank; + }).forEach(function (li) { list.appendChild(li); }); + // the large first card only makes sense for the most important entry, shown as cards + list.classList.toggle("cu-lead", state.order === "rank" && state.view === "cards"); + list.classList.toggle("cu-as-list", state.view === "list"); + box.querySelectorAll("[data-order]").forEach(function (b) { b.setAttribute("aria-pressed", String(b.dataset.order === state.order)); }); + box.querySelectorAll("[data-view]").forEach(function (b) { b.setAttribute("aria-pressed", String(b.dataset.view === state.view)); }); + } + box.addEventListener("click", function (e) { + var b = e.target.closest("button"); if (!b) return; + if (b.dataset.order) state.order = b.dataset.order; + if (b.dataset.view) state.view = b.dataset.view; + try { localStorage.setItem("cfi-culture-view", JSON.stringify(state)); } catch (e) {} + apply(); + }); + apply(); + })(); + </script> <section class="section">
src/pages/culture/index.astro +4 @@ -9,6 +9,7 @@ import { CULTURE_SECTIONS, cultureEntries, cultureIn } from "../../lib/culture.j const base = import.meta.env.BASE_URL; const countryCount = new Set(cultureEntries.flatMap((e) => e.countries)).size; -// The hub shows four entries of each part, those with a picture first; the rest are a click away. -const preview = (id) => [...cultureIn(id)].sort((a, b) => Boolean(b.image) - Boolean(a.image)).slice(0, 4); +// The hub shows the five most important entries of each part (their `featured` rank), the first +// as a large lead card; the rest are a click away. +const preview = (id) => cultureIn(id).slice(0, 5); const count = (s) => (s.entries ? `${cultureIn(s.id).length} ${cultureIn(s.id).length === 1 ? "entry" : "entries"}` : s.soon ? "In preparation" : ""); --- @@ -39,5 +40,5 @@ const count = (s) => (s.entries ? `${cultureIn(s.id).length} ${cultureIn(s.id).l <p class="sub">{s.intro}</p> </div></div> - <CultureCards entries={preview(s.id)} /> + <CultureCards entries={preview(s.id)} lead /> <p class="cu-all front-go"><a class="go" href={`${base}culture/${s.id}/`}>All {cultureIn(s.id).length} {s.id === "language" ? "words" : "traditions"} <span aria-hidden="true">→</span></a></p> </section>
src/pages/previews/[kind].json.js +45 @@ -0,0 +1,45 @@ +// What the hover previews show (see the script in Base.astro): one file per kind of page, fetched +// the first time a link of that kind is hovered. Keys are the part of the address after the base. +// country: country/de/ history: history/<id>/ culture: culture/language/bhang/ +import { getImage } from "astro:assets"; +import { places, changes, articles, changeDate, REC_LABEL, MED_LABEL } from "../../lib/countries.js"; +import { cultureEntries, cultureSection } from "../../lib/culture.js"; +import { tierOf, tierColor } from "../../lib/tiers.js"; +import { file } from "../../lib/exports.js"; + +export const getStaticPaths = () => ["country", "history", "culture"].map((kind) => ({ params: { kind } })); + +// cut a text at a word boundary +const cut = (t, n) => (t.length <= n ? t : t.slice(0, t.lastIndexOf(" ", n)).replace(/[,;:.]$/, "") + "…"); + +async function build(kind) { + if (kind === "country") { + const entries = new Map(); + for (const ch of changes) if (ch.country && !ch.estimate) entries.set(ch.country.slug, (entries.get(ch.country.slug) ?? 0) + 1); + return Object.fromEntries(places.map((c) => [`country/${c.slug}/`, { + f: c.flag, n: c.name, r: c.region, s: c.score, c: tierColor(c.raw), t: tierOf(c.raw).name, + k: c.disputed ? null : c.rank, of: places.filter((x) => !x.disputed).length, + rec: [REC_LABEL[c.rec], c.rec], med: [MED_LABEL[c.med], c.med], x: cut(c.note, 230), h: entries.get(c.slug) ?? 0, + }])); + } + if (kind === "history") { + return Object.fromEntries(changes.map((ch) => [`history/${ch.id}/`, { + d: changeDate(ch), k: `${ch.kind.emoji} ${ch.kind.label}`, p: `${ch.place.flag} ${ch.place.name}`, t: ch.title, x: cut(ch.description, 240), + ...(ch.score && { b: [ch.score.before, tierColor(ch.score.before)], a: [ch.score.after, tierColor(ch.score.after)] }), + ...(articles.has(ch.id) && { ar: 1 }), cf: [ch.confidence.mark, ch.confidence.label, ch.confidence.key], + }])); + } + const out = {}; + for (const e of cultureEntries) { + const img = e.image && (await getImage({ src: e.image.src, width: 360, height: 180, fit: "cover", format: "webp" })); + out[e.url] = { + sec: cultureSection(e.section).name, t: e.title, o: e.origin ?? null, sp: e.spelling?.[0]?.text.toLowerCase() !== e.title.toLowerCase() ? e.spelling?.[0]?.text ?? null : null, + x: cut(e.summary, 220), i: img?.src ?? null, fl: e.countries.slice(0, 10).map((c) => c.flag).join(" "), n: e.countries.length, + }; + } + return out; +} + +export async function GET({ params }) { + return file(JSON.stringify(await build(params.kind)), "application/json"); +}
src/styles/global.css +77 @@ -1319,4 +1319,13 @@ details.segrow .segrow-top > span, .segrow:not(details) .segrow-top > span { fon .cu-cards a:hover h3 { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; } .cu-all { margin: 22px 0 0; } +/* the first card large, beside the others: four columns, the lead spanning two by two */ +@media (min-width: 900px) { + .cu-cards.cu-lead { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .cu-lead .cu-first-card { grid-column: span 2; grid-row: span 2; border-right: 0; } + .cu-lead .cu-first-card img, .cu-lead .cu-first-card .cu-word { aspect-ratio: 30 / 19; } + .cu-lead .cu-first-card h3 { font-size: clamp(40px, 4vw, 56px); line-height: .98; margin-top: 8px; } + .cu-lead .cu-first-card .cu-sum { font-size: 17px; max-width: 46ch; } +} +.cu-pick { color: var(--text); } .cu-sum { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text-2); } .cu-flags { margin: 12px 0 0; font-size: 16px; letter-spacing: .1em; color: var(--muted); } @@ -1755,2 +1764,70 @@ button.sv-row .sv-chip { font: 400 10.5px var(--mono); text-transform: uppercase .year-note a { color: var(--text-2); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; } #table.past-year [data-col], #table.past-year .trend { display: none; } + +/* hover previews of links to countries, history entries and culture entries (script in Base.astro) */ +.hc { position: fixed; z-index: 60; width: min(340px, calc(100vw - 20px)); padding: 14px 16px 12px; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-top: 2px solid var(--text); box-shadow: 0 12px 32px rgb(0 0 0 / .28); pointer-events: none; font-size: 13.5px; line-height: 1.45; animation: hc-in .12s ease-out; } +.hc[hidden] { display: none; } +@keyframes hc-in { from { opacity: 0; transform: translateY(3px); } } +@media (prefers-reduced-motion: reduce) { .hc { animation: none; } } +.hc p { margin: 0; } +.hc-eyebrow { font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +.hc-title { display: block; margin-top: 6px; font: 400 22px/1.1 var(--display); letter-spacing: -.01em; text-wrap: balance; } +.hc-head { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; } +.hc-head .hc-title { margin: 0; flex: 1; } +.hc-flag { font-size: 20px; line-height: 1; } +.hc-score { font: 400 30px/1 var(--mono); letter-spacing: -.04em; color: var(--text); } +.hc-score small { font-size: 11px; color: var(--muted); letter-spacing: 0; margin-left: 2px; } +.hc-meter { height: 3px; margin: 8px 0 10px; background: var(--border); } +.hc-meter i { display: block; height: 100%; background: var(--c); } +.hc-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px !important; } +.hc-chips .chip { font-size: 11px; } +.hc-place { margin-top: 4px !important; font-size: 12.5px; color: var(--text-2); } +.hc-move { display: flex; align-items: baseline; gap: 8px; margin-top: 8px !important; font: 400 20px var(--mono); } +.hc-move b { font-weight: 400; color: var(--c); } +.hc-move span { color: var(--muted); font-size: 14px; } +.hc-move small { font: 400 10px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-left: 4px; } +.hc-text { margin-top: 8px !important; color: var(--text-2); } +.hc-foot { margin-top: 10px !important; padding-top: 8px; border-top: 1px solid var(--border); font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.hc-foot .he-article { margin-left: 2px; } +.hc-flags { font-family: inherit; font-size: 15px; letter-spacing: .1em; text-transform: none; } +.hc-img { display: block; width: calc(100% + 32px); height: auto; aspect-ratio: 2 / 1; object-fit: cover; margin: -14px -16px 12px; background: var(--surface-2); } +.hc-native { font: 400 14px Inter, sans-serif; color: var(--muted); margin-left: 4px; } + +/* inside an opened segment of a country's score breakdown: the level the score reaches on the + segment's fixed scale and the next one up, linked to the methodology */ +.seg-scale { margin-top: 14px; padding-top: 10px; border-top: 1px dashed var(--border); } +.why .seg-scale .ev-label { display: flex; justify-content: space-between; gap: 12px; margin: 0; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +.seg-scale .ev-label a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } +.seg-scale .ev-label a:hover { color: var(--text); } +.seg-scale dl { margin: 8px 0 0; display: grid; gap: 6px; } +.seg-scale dl > div { display: grid; grid-template-columns: 2.4ch 1fr; gap: 10px; align-items: baseline; color: var(--muted); font-size: 13.5px; line-height: 1.45; } +.seg-scale dl > div.here { color: var(--text-2); } +.seg-scale dt { font: 400 13px var(--mono); text-align: right; } +.seg-scale .here dt { color: var(--text); font-weight: 600; } +.seg-scale dd { margin: 0; } +.d-hint a { white-space: nowrap; } +/* a segment of the methodology reached from a country's breakdown: clear of the sticky header, and marked */ +.seg[id] { scroll-margin-top: 84px; } +.seg:target { outline: 2px solid var(--text); outline-offset: 6px; } +#methodology { scroll-margin-top: 60px; } + +/* a part of the cultural atlas: order and view switches, and the entries as a compact list */ +.cu-controls { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 32px; margin-bottom: 18px; } +.cu-controls .hist-order { margin: 0; align-items: baseline; } +.cu-ctl-label { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-right: -6px; } +.cu-cards.cu-as-list { display: block; } +.cu-as-list li { border-bottom: 1px solid var(--border); } +.cu-as-list a { display: grid; grid-template-columns: 64px minmax(0, 15rem) minmax(0, 1fr) auto; gap: 4px 20px; align-items: center; padding: 10px 0; } +.cu-as-list img, .cu-as-list .cu-word { grid-row: span 2; width: 64px; height: 44px; aspect-ratio: auto; margin: 0; object-fit: cover; } +.cu-as-list .cu-word { font-size: 0; } +.cu-as-list .ev-label { grid-column: 2; grid-row: 1; align-self: end; } +.cu-as-list h3 { grid-column: 2; grid-row: 2; align-self: start; margin: 0; font-size: 21px; } +.cu-as-list .cu-sum { grid-column: 3; grid-row: 1 / span 2; font-size: 13.5px; } +.cu-as-list .cu-flags { grid-column: 4; grid-row: 1 / span 2; margin: 0; font-size: 14px; white-space: nowrap; } +.cu-as-list .cu-pick { display: none; } +@media (max-width: 720px) { + .cu-as-list a { grid-template-columns: 56px minmax(0, 1fr); } + .cu-as-list img, .cu-as-list .cu-word { width: 56px; height: 40px; } + .cu-as-list .cu-sum, .cu-as-list .cu-flags { display: none; } +} +.cu-cards:not(.cu-lead) .cu-pick { display: none; }