Changelog 4e1b50dtree, with Claude Opus 5.5
Datasets pages and a three-part map menu
/datasets/ lists every outside dataset by group; /datasets/<slug>/ gives what it measures, its publisher and files, a world map (opening on the region a regional dataset covers), the highest and lowest places, a sortable table of every place and how the file was made. The home map's view picker is now Freedom, Context and Datasets, the last in rows for Use, Law, Society and Plant and market (the datasets' former Context group, renamed so the word means one thing).
13 files of code, design or docs +490 −24 lines
Code, design and documents 13 files
datasets/datasets.yaml +7 −7
@@ -27,5 +27,5 @@ # …"); every dataset is listed in the profile's Datasets section # map optional: how one field colours the world map, on a view of its own in `group` -# (Society by default): `steps` are where each of the five colours starts; `format` +# (Society by default; Use, Law, Society, Plant and market): `steps` are where each of the five colours starts; `format` # is percent, decimal or rate; `per` divides the field by the country's population # and multiplies by it (100000: per 100,000 people) @@ -401,5 +401,5 @@ field: herb_eur format: decimal - group: Context + group: Plant and market since: 2020 slug: cannabis-price-europe @@ -454,5 +454,5 @@ field: herb_thc format: percent - group: Context + group: Plant and market since: 2020 slug: thc-potency-europe @@ -508,5 +508,5 @@ per: 1000000 format: rate - group: Context + group: Plant and market slug: cannabis-shops-mapped label: Shops on the map @@ -561,5 +561,5 @@ field: records_total format: number - group: Context + group: Plant and market slug: cannabis-plant-records label: Plant records @@ -618,5 +618,5 @@ field: hemp_ha format: number - group: Context + group: Plant and market since: 2015 slug: hemp-grown @@ -1533,5 +1533,5 @@ per: 1000000 format: rate - group: Context + group: Plant and market slug: clinical-trials label: Clinical trials per million people
src/components/CountryDatasetTab.astro +2 −2
@@ -61,9 +61,9 @@ const groups = GROUPS.map((name) => ({ name, rows: rows.filter((r) => groupOf(r. {groups.length > 1 && ( <nav class="ds-nav" aria-label="Dataset groups"> - {groups.map((g) => <a href={`#ds-group-${g.name.toLowerCase()}`}>{g.name} <b>{g.rows.length}</b></a>)} + {groups.map((g) => <a href={`#ds-group-${g.name.toLowerCase().replace(/\W+/g, "-")}`}>{g.name} <b>{g.rows.length}</b></a>)} </nav> )} {groups.map((g) => ( - <div class="ds-group" id={`ds-group-${g.name.toLowerCase()}`}> + <div class="ds-group" id={`ds-group-${g.name.toLowerCase().replace(/\W+/g, "-")}`}> <h3 class="ds-group-h">{g.name}</h3> <ol class="ds-grid">
src/components/DatasetMenu.astro +30 −0
@@ -0,0 +1,30 @@ +--- +// The menu at the top of /datasets/ and of each dataset's page: the groups (Use, Law, Society, +// Context) in the first row, and on a dataset's page the datasets of its group in a second row, +// the current one marked. Styled as the context topics' menu (ContextMenu.astro). +import { datasetGroups } from "../lib/dataset-pages.js"; + +const { current = null } = Astro.props; +const base = import.meta.env.BASE_URL; +const group = current ? datasetGroups.find((g) => g.pages.some((p) => p.d.id === current)) : null; +--- +<nav class="ctxp-menu dsp-menu" aria-label="Datasets"> + <ol> + <li><a href={`${base}datasets/`} aria-current={current ? undefined : "page"}>All datasets</a></li> + {datasetGroups.map((g) => ( + <li><a href={`${base}datasets/#${g.slug}`} class:list={{ on: g === group }}>{g.name} <b>{g.pages.length}</b></a></li> + ))} + </ol> + {group && ( + <ol class="dsp-menu-sub"> + {group.pages.map((p) => ( + <li><a href={`${base}datasets/${p.slug}/`} aria-current={p.d.id === current ? "page" : undefined}>{p.d.name}</a></li> + ))} + </ol> + )} +</nav> +<script> + // bring the current dataset into view in its row + const on = document.querySelector('.dsp-menu a[aria-current="page"]'); + if (on) on.parentElement.parentElement.scrollLeft = on.offsetLeft - 40; +</script>
src/components/HomePage.astro +18 −9
@@ -149,14 +149,20 @@ const MAP_SECTIONS = { ...LAW_SECTIONS, ...STATUS_SECTIONS }; const STATUS_SCALES = Object.fromEntries(Object.entries(MAP_SECTIONS).map(([key, section]) => [key, { name: section.name, scale: Object.fromEntries(Object.entries(section.statuses).map(([k, v]) => [k, { label: v.label, help: v.help, color: section.tiers[k] == null ? "url(#hatch)" : `var(--t${section.tiers[k]})` }])) }])); -// the map's views in groups, as [metric, label]: the tabs and rows of the view picker +// The map's views in three groups, the tabs of the view picker: the score (Freedom), what each +// profile explains beyond it (Context, as on /context/), and what others count (Datasets, in the +// groups of /datasets/). A group's views are [metric, label]; Datasets has rows with a label each. const datasetViews = (group) => MAPPED.filter((ds) => ds.map.group === group).map((ds) => [`data:${ds.id}`, ds.map.label]); const MAP_GROUPS = [ { name: "Freedom", views: [["index", "Overall index"], ...SEGMENTS.map((s, i) => [String(i), s.name])] }, - { name: "Use", views: [["users", "Number of users"], ["prevalence", "Survey share"], ["ratio", "Share of users ÷ index"], ...datasetViews("Use")] }, - { name: "Law", views: [...Object.entries(LAW_SECTIONS).map(([key, section]) => [`status:${key}`, section.name]), ...datasetViews("Law")] }, - { name: "Society", views: [...datasetViews("Society"), ["culture", "Atlas entries"]] }, - ...(SHOW_PRICES ? [{ name: "Price", views: PRICE_MARKETS.map((market) => [`price:${market}`, PRICE_MARKET_LABEL[market]]) }] : []), - { name: "Context", views: [...Object.entries(PLANTING_FIELDS).map(([field, label]) => [`plant:${field}`, label]), ...datasetViews("Context"), ...Object.entries(STATUS_SECTIONS).map(([key, section]) => [`status:${key}`, section.name])] }, -]; + { name: "Context", page: ["context/", "All topics"], views: [...Object.entries(LAW_SECTIONS).map(([key, section]) => [`status:${key}`, section.name]), + ...Object.entries(PLANTING_FIELDS).map(([field, label]) => [`plant:${field}`, label]), + ...Object.entries(STATUS_SECTIONS).map(([key, section]) => [`status:${key}`, section.name]), ["culture", "Atlas entries"]] }, + { name: "Datasets", page: ["datasets/", "All datasets"], rows: [ + { name: "Use", views: [["users", "Number of users"], ["prevalence", "Survey share"], ["ratio", "Share of users ÷ index"], ...datasetViews("Use")] }, + { name: "Law", views: datasetViews("Law") }, + { name: "Society", views: datasetViews("Society") }, + { name: "Plant and market", views: [...datasetViews("Plant and market"), ...(SHOW_PRICES ? PRICE_MARKETS.map((market) => [`price:${market}`, PRICE_MARKET_LABEL[market]]) : [])] }, + ].filter((r) => r.views.length) }, +].map((g) => ({ ...g, views: g.views ?? g.rows.flatMap((r) => r.views) })); const currentGroup = (MAP_GROUPS.find((g) => g.views.some(([m]) => m === current)) ?? MAP_GROUPS[0]).name; const streetFlower = (c) => latestPricesFor(c.id).street; @@ -272,6 +278,9 @@ const columnGroups = [["Shown by default", BASE_COLUMNS], ...["Score segments", </div> {MAP_GROUPS.map((g) => ( - <div class="metric-group" data-group={g.name} role="tabpanel" aria-label={g.name} hidden={g.name !== currentGroup}> - {g.views.map(([metric, label]) => <a {...viewAttrs(metric)}>{label}</a>)} + <div class:list={["metric-group", { "metric-rows": g.rows }]} data-group={g.name} role="tabpanel" aria-label={g.name} hidden={g.name !== currentGroup}> + {g.rows + ? g.rows.map((r) => <div class="metric-row"><span class="metric-row-h">{r.name}</span>{r.views.map(([metric, label]) => <a {...viewAttrs(metric)}>{label}</a>)}</div>) + : g.views.map(([metric, label]) => <a {...viewAttrs(metric)}>{label}</a>)} + {g.page && <a class="metric-page" href={`${base}${g.page[0]}`}>{g.page[1]} →</a>} </div> ))}
src/content.config.ts +1 −1
@@ -399,5 +399,5 @@ const datasets = defineCollection({ map: z.object({ field: z.string(), format: z.enum(["number", "percent", "decimal", "rate"]), - per: z.number().positive().optional(), group: z.enum(["Use", "Society", "Law", "Context"]).default("Society"), + per: z.number().positive().optional(), group: z.enum(["Use", "Society", "Law", "Plant and market"]).default("Society"), since: z.number().int().optional(), // a series: its latest year must be this recent to be shown window: z.number().int().positive().optional(), // the year slider: how many years back a figure still counts (5)
src/lib/dataset-pages.js +69 −0
@@ -0,0 +1,69 @@ +// The outside datasets as pages of their own (/datasets/ and /datasets/<slug>/): for each dataset, +// every place it covers with its latest headline figure written as on the profiles, a map colour, +// and the figures the page opens with. Built from datasets.js (the files) and dataset-text.js (how +// a figure reads). +import { places } from "./countries.js"; +import { DATASETS } from "./datasets.js"; +import { viewByMetric } from "./map-views.js"; +import { fill, headline, headField, valueOf, groupOf, GROUPS, CHART_OF } from "./dataset-text.js"; + +const median = (vs) => { const s = [...vs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; + +// How a dataset's places are coloured: by its map view's five steps where it has one; a figure +// without a map view by fifths of the places; a figure in words (a religion, a former ruler) by +// its value, each counted in a table, with every place in one colour. +function colouring(d, rows) { + const numeric = rows.filter((r) => Number.isFinite(r.value)); + if (d.map) { + return { + kind: "steps", legend: d.map.steps.map(([, label], i) => [`var(--u${i})`, label]), + stepOf: (r) => { if (!Number.isFinite(r.mapValue)) return null; let s = 0; while (s < 4 && r.mapValue >= d.map.steps[s + 1][0]) s++; return s; }, + }; + } + if (numeric.length >= 10) { + const sorted = numeric.map((r) => r.value).sort((a, b) => a - b); + const cuts = [1, 2, 3, 4].map((q) => sorted[Math.floor((q / 5) * sorted.length)]); + const stepOf = (r) => (Number.isFinite(r.value) ? cuts.filter((c) => r.value >= c).length : null); + return { kind: "fifths", legend: ["Lowest fifth", "Second fifth", "Middle fifth", "Fourth fifth", "Highest fifth"].map((l, i) => [`var(--u${i})`, l]), stepOf }; + } + return { kind: "covered", legend: [["var(--u3)", "In the dataset"]], stepOf: (r) => (r.head[0] || r.head[1] ? 3 : null) }; +} + +export const datasetPages = Object.values(DATASETS).map((d) => { + const slug = d.id.replace(/_/g, "-"); + const name = d.worldwide ? null : headField(d); + const field = name && d.fields[name]; + const words = field?.type === "text" || field?.format === "text"; + const rows = d.worldwide ? [] : places.filter((c) => d.values[c.slug]).map((c) => { + const series = d.values[c.slug]; + // the latest year with the headline's figure, as on the profile + const row = series.findLast((r) => r[name] != null || name === "per") ?? series.at(-1); + const mapRow = d.map ? series.findLast((r) => r[d.map.field] != null) : null; + const value = words ? null : valueOf(c, d, row, name); + return { + c, row, year: row?.year ?? null, head: headline(c, d, row), details: fill(c, d, row, d.profile?.details), + value: Number.isFinite(value) ? value : null, + mapValue: mapRow && (!d.map.since || mapRow.year >= d.map.since) ? d.map.value(mapRow, c) : null, + years: new Set(series.map((r) => r.year).filter((y) => y != null)).size, + }; + }).sort((a, b) => a.c.name.localeCompare(b.c.name)); + const col = colouring(d, rows); + for (const r of rows) r.step = col.stepOf(r); + // a figure in words: how many places give each answer, the largest first + const answers = words ? [...rows.reduce((m, r) => m.set(r.head[0] || r.head[1], (m.get(r.head[0] || r.head[1]) ?? 0) + 1), new Map())] + .map(([label, n]) => ({ label, n })).sort((a, b) => b.n - a.n) : []; + // the highest and the lowest, where countries can be compared (survey ages differ for cannabis use) + const comparable = !words && field?.format !== "rank" && d.id !== "cannabis_use"; + const ranked = comparable ? rows.filter((r) => r.value != null && !r.c.territory).sort((a, b) => b.value - a.value) : []; + const mapView = d.map ? viewByMetric[`data:${d.id}`] ?? null : null; + const mapPage = mapView ? `map/${mapView.slug}/` : (d.pages ?? []).map((p) => p.path).find((p) => p.startsWith("map/")) ?? null; + return { + d, slug, group: groupOf(d), rows, colouring: col, words, answers, comparable, + highest: ranked.slice(0, 3), lowest: ranked.slice(-3).reverse(), + median: ranked.length >= 5 ? median(ranked.map((r) => r.value)) : null, + series: rows.filter((r) => r.years > 1).length, + mapPage, chart: CHART_OF[d.id] ?? null, + }; +}); + +export const datasetGroups = GROUPS.map((name) => ({ name, slug: name.toLowerCase().replace(/\W+/g, "-"), pages: datasetPages.filter((p) => p.group === name) })).filter((g) => g.pages.length);
src/lib/dataset-text.js +3 −3
@@ -60,8 +60,8 @@ export const CHART_OF = { gallup_us: "opinion-gap", news: "in-the-news", }; -export const GROUPS = ["Use", "Law", "Society", "Context"]; +export const GROUPS = ["Use", "Law", "Society", "Plant and market"]; // datasets without a map view of their own sit with the views they belong to -const GROUP_OF = { cannabis_use: "Use", youth_use: "Use", canada_sourcing: "Use", canada_revenue: "Society", colonial_links: "Society", religion: "Society", gallup_us: "Society", wikipedia_views: "Society", cannabis_parties: "Society" }; -export const groupOf = (d) => GROUP_OF[d.id] ?? d.map?.group ?? "Context"; +const GROUP_OF = { cannabis_use: "Use", youth_use: "Use", canada_sourcing: "Use", canada_revenue: "Society", colonial_links: "Society", religion: "Society", population: "Society", book_words: "Society", gallup_us: "Society", wikipedia_views: "Society", cannabis_parties: "Society" }; +export const groupOf = (d) => GROUP_OF[d.id] ?? d.map?.group ?? "Plant and market"; // Where the country stands among every place the dataset covers, by its headline figure (the
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", "context/": "iconoir:list", "charts/": "iconoir:stats-report", "map/": "iconoir:map", "map/poster/": "iconoir:media-image", + "glossary/": "iconoir:book-stack", "context/": "iconoir:list", "datasets/": "iconoir:database", "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/datasets/[dataset].astro +251 −0
@@ -0,0 +1,251 @@ +--- +// One outside dataset across every place, e.g. /datasets/cannabis-offences/: what it measures, who +// publishes it and how the file was made, the figures it opens with, a world map coloured as its +// map view (or by fifths), and every place's latest figure with a link to its profile. The data +// is in src/lib/dataset-pages.js; the same map, hover box and zoom as the context pages. +import Base from "../../layouts/Base.astro"; +import DatasetMenu from "../../components/DatasetMenu.astro"; +import { datasetPages } from "../../lib/dataset-pages.js"; +import { figure, headField } from "../../lib/dataset-text.js"; +import { siteMap } from "../../lib/world-map.js"; +import { tierColor } from "../../lib/tiers.js"; +import { places } from "../../lib/countries.js"; + +export function getStaticPaths() { + return datasetPages.map((page) => ({ params: { dataset: page.slug }, props: { page } })); +} + +const { page } = Astro.props; +const { d, rows } = page; +const base = import.meta.env.BASE_URL; +const num = (n) => n.toLocaleString("en-GB"); +const map = siteMap(); +const W = map.width, H = map.height; +const rowOf = new Map(rows.map((r) => [r.c.id, r])); +const fillOf = (r) => (r?.step != null ? `var(--u${r.step})` : "url(#hatch)"); +const text = (r) => [r.head[0], r.head[1]].filter(Boolean).join(" "); +const profileHref = (c) => `${base}country/${c.slug}/#ds-${d.id}`; +// a figure written as the dataset writes it: the median of the countries +const name = d.worldwide ? null : headField(d); +const written = (v) => (v == null ? null : name === "per" ? d.map.format(v) : figure(null, d, { [name]: d.fields[name]?.decimals != null ? +v.toFixed(d.fields[name].decimals) : +v.toFixed(v < 10 ? 2 : v < 100 ? 1 : 0) }, name)); +const years = d.fileInfo.years && (d.fileInfo.years[0] === d.fileInfo.years[1] ? `${d.fileInfo.years[0]}` : `${d.fileInfo.years[0]}–${d.fileInfo.years[1]}`); +const figures = d.worldwide + ? [["Worldwide", "not country by country"], [years ?? "—", "years in the file"], [num(d.fileInfo.files.reduce((a, f) => a + f.rows, 0)), "rows"]] + : [ + [num(rows.length), `of ${places.length} places covered`], + [years ?? d.when, "years in the file"], + ...(page.median != null ? [[written(page.median), "the median country"]] : page.words ? [[num(page.answers.length), "different answers"]] : []), + ...(page.series ? [[num(page.series), "places with several years"]] : []), + ]; +const links = [ + page.mapPage && [`${base}${page.mapPage}`, "The interactive map"], + page.chart && [`${base}charts/#${page.chart}`, "The chart"], + [`${base}sources/#dataset-${d.id}`, "The files and their checksums"], +].filter(Boolean); +const description = `${d.name}, country by country: ${d.about.split(/(?<=\.)\s/)[0]} From ${d.credit ?? d.publisher}, with a world map and every country's figure.`.slice(0, 300); +--- +<Base title={`${d.name}: country by country`} description={description}> + <main class="changes-page ctxp dsp"> + <DatasetMenu current={d.id} /> + <section class="hero hero-sm"> + <p class="eyebrow"><a href={`${base}datasets/`}>Datasets</a> <span>{page.group} · not part of the score</span></p> + <h1>{d.name}</h1> + <p class="lede">{d.publisher} · {d.when}</p> + </section> + + <div class="prose ctxp-intro dsp-intro"> + <p>{d.about}</p> + {d.map?.lede && d.map.lede !== d.about && <p>{d.map.lede}</p>} + </div> + <dl class="dsp-facts"> + <div><dt>Published by</dt><dd><a href={d.url} target="_blank" rel="noopener noreferrer">{d.title}</a></dd></div> + {d.data_url && <div><dt>Data</dt><dd><a href={d.data_url} target="_blank" rel="noopener noreferrer">{d.data_label ?? "the publisher's file"}</a></dd></div>} + <div><dt>{d.fileInfo.files.length > 1 ? "Files here" : "File here"}</dt><dd>{d.fileInfo.files.map((f, i) => <>{i > 0 && ", "}<a href={`${base}data/datasets/${f.name}`} download>{f.name}</a></>)}</dd></div> + <div><dt>See also</dt><dd>{links.map(([href, label], i) => <>{i > 0 && " · "}<a href={href}>{label}</a></>)}</dd></div> + </dl> + + <dl class="hist-stats ctxp-stats"> + {figures.map(([n, label]) => <div><dt>{label}</dt><dd>{n}</dd></div>)} + </dl> + + {!d.worldwide && ( + <section class="section ctxp-map-sec" aria-labelledby="dspMap"> + <div class="section-head"><div> + <h2 id="dspMap">{d.map?.label ?? d.name}, on the map</h2> + <p class="sub">{d.map?.sub ?? (page.colouring.kind === "fifths" ? "The stronger the blue, the higher the figure, by fifths of the places covered." : "The places the dataset covers.")} Select a country for its profile.</p> + </div></div> + <figure class="card map-card map-wrap ctxp-map" data-ctxp-map data-map-tips> + <svg viewBox={`0 0 ${W} ${H}`} role="group" aria-label={`World map: ${d.name}`}> + <defs> + <pattern id="hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="var(--nodata)" /><rect width="2" height="5" fill="var(--hatch)" /></pattern> + </defs> + <g class="ctxp-zoom"> + <path class="sphere" d={map.sphere} /> + {map.shapes.map((s) => { + const row = s.c ? rowOf.get(s.c.id) : null; + return row + ? <a href={profileHref(row.c)} data-tip={row.c.id} aria-label={`${row.c.name}: ${text(row)}`}><path class="country-shape has-data" d={s.d} style={`fill:${fillOf(row)}`} /></a> + : <path class:list={["country-shape", { "land-only": s.antarctica }]} d={s.d} style={`fill:${s.antarctica ? "var(--land)" : "url(#hatch)"}`} />; + })} + {map.dots.map((dot) => { + const row = rowOf.get(dot.c.id); + return row && <a href={profileHref(row.c)} data-tip={row.c.id} aria-hidden="true" tabindex="-1"><circle class="country-shape has-data tiny-dot" cx={dot.x} cy={dot.y} r={map.r} style={`fill:${fillOf(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> + {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"><b>{r.head[0]}</b> {r.head[1]}</div> + {r.details && <p class="tip-note">{r.details}</p>} + <div class="tip-foot">Click for the profile</div> + </template> + ))} + <figcaption class="ctxp-legend"> + {page.colouring.legend.map(([color, label]) => <span><i style={`background:${color}`}></i>{label}</span>)} + <span><i class="sw-nodata"></i>{d.map?.missing ?? "Not in the dataset"}</span> + </figcaption> + </figure> + {d.map?.note && <p class="dsp-note">{d.map.note}</p>} + </section> + )} + + {page.words && page.answers.length > 0 && ( + <section class="section" aria-labelledby="dspAnswers"> + <div class="section-head"><div><h2 id="dspAnswers">How the {rows.length} places divide</h2></div></div> + <table class="src-table ctxp-status"> + <thead><tr><th>{d.name}</th><th class="num">Places</th><th class="ctxp-bar-col" aria-hidden="true"></th></tr></thead> + <tbody> + {page.answers.map((a) => ( + <tr><td>{a.label}</td><td class="num">{a.n}</td><td class="ctxp-bar-col"><i class="ctxp-bar" style={`--w:${(a.n / page.answers[0].n) * 100}%;--c:var(--u3)`}></i></td></tr> + ))} + </tbody> + </table> + </section> + )} + + {page.highest.length >= 3 && ( + <section class="section dsp-ends" aria-label="Highest and lowest"> + {[["Highest", page.highest], ["Lowest", page.lowest]].map(([label, list]) => ( + <div> + <h2 class="dsp-ends-h">{label}</h2> + <ol>{list.map((r) => <li><a href={profileHref(r.c)}><span aria-hidden="true">{r.c.flag}</span> {r.c.name}</a><b>{r.head[0]}</b>{r.year != null && <small>{r.year}</small>}</li>)}</ol> + </div> + ))} + </section> + )} + + {!d.worldwide && ( + <section class="section" aria-labelledby="dspTable" data-dsp-table> + <div class="section-head"><div> + <h2 id="dspTable">Country by country</h2> + <p class="sub">Each place's latest figure, as on its profile. Select a heading to sort.</p> + </div></div> + <div class="ctxp-filter"> + <input type="search" placeholder="Filter by country, region or word" aria-label="Filter the table" data-ctxp-q /> + <span class="ctxp-count" aria-live="polite" data-ctxp-count></span> + </div> + <table class="src-table ctxp-table dsp-table"> + <thead><tr> + <th><button type="button" data-sort="name">Country</button></th> + <th class="dsp-fig-h"><button type="button" data-sort="value">{d.map?.label ?? "Figure"}</button></th> + <th>Further figures</th> + <th class="num"><button type="button" data-sort="year">Year</button></th> … 93 more lines
src/pages/datasets/index.astro +47 −0
@@ -0,0 +1,47 @@ +--- +// Datasets: every outside dataset shown beside the index, grouped as the map's views (Use, Law, +// Society, Context), each with its own page across all countries (/datasets/<slug>/). The data is +// in src/lib/dataset-pages.js. +import Base from "../../layouts/Base.astro"; +import DatasetMenu from "../../components/DatasetMenu.astro"; +import { datasetPages, datasetGroups } from "../../lib/dataset-pages.js"; +import { places } from "../../lib/countries.js"; + +const base = import.meta.env.BASE_URL; +const num = (n) => n.toLocaleString("en-GB"); +const figures = datasetPages.reduce((n, p) => n + p.rows.length, 0); +const years = (p) => p.d.fileInfo.years && (p.d.fileInfo.years[0] === p.d.fileInfo.years[1] ? `${p.d.fileInfo.years[0]}` : `${p.d.fileInfo.years[0]}–${p.d.fileInfo.years[1]}`); +--- +<Base title="Datasets" description="Figures other organisations publish about cannabis and the countries around it: use, offences, seizures, prices, potency, opinion, freedom and more, each with a world map and a table of every country."> + <main class="changes-page ctxp dsp"> + <DatasetMenu /> + <section class="hero hero-sm"> + <p class="eyebrow">Datasets <span>{datasetPages.length} datasets · {num(figures)} country figures</span></p> + <h1>Datasets</h1> + <p class="lede">The index scores the law. Beside it the site shows what others count: how many people use cannabis, how many are arrested for it, what it costs and how strong it is, what people think of it, and how free, rich or corrupt a country is. Each dataset has a page with what it measures, how it was made, a world map and every country's figure. None of it is part of the score.</p> + </section> + + {datasetGroups.map((g) => ( + <section class="section" id={g.slug} aria-labelledby={`dsp-${g.slug}`}> + <div class="section-head"><div><h2 id={`dsp-${g.slug}`}>{g.name} <span class="dsp-n">{g.pages.length}</span></h2></div></div> + <ol class="ctxp-topics dsp-list"> + {g.pages.map((p) => ( + <li> + <a href={`${base}datasets/${p.slug}/`}> + <span class="ctxp-topic-name">{p.d.name}</span> + <span class="ctxp-topic-help">{p.d.credit ?? p.d.publisher} · {p.d.when}</span> + {p.d.worldwide + ? <span class="ctxp-topic-n">Worldwide{years(p) && ` · ${years(p)}`}</span> + : <> + <span class="ctxp-cover" role="img" aria-label={`${p.rows.length} of ${places.length} places`}><i style={`width:${(p.rows.length / places.length) * 100}%;background:var(--u3)`}></i></span> + <span class="ctxp-topic-n">{num(p.rows.length)} places{years(p) && ` · ${years(p)}`}</span> + </>} + </a> + </li> + ))} + </ol> + </section> + ))} + <p class="dsp-foot">Every file is published in full, with its source and checksum, on the <a href={`${base}sources/#datasets`}>sources page</a>.</p> + </main> +</Base>
src/pages/search.json.js +4 −1
@@ -7,4 +7,5 @@ // g: words matched but not shown (a history entry's tags) import { contextTopics } from "../lib/contexts.js"; +import { datasetPages } from "../lib/dataset-pages.js"; import { getCollection } from "astro:content"; import { termAnchor } from "../lib/terms.js"; @@ -26,8 +27,10 @@ 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/"], ["Context", "Beyond the score: each topic compared across countries", "context/"], + ["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/"], ["Datasets", "What others count: use, offences, prices, opinion and more, by country", "datasets/"], ].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 outside datasets, each across every country + ...datasetPages.map((p) => ({ t: "p", n: `Dataset: ${p.d.name}`, s: `${p.d.credit ?? p.d.publisher} · ${p.d.when}`, u: `datasets/${p.slug}/`, ic: PAGE_ICONS["datasets/"] })), // 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
@@ -16,4 +16,5 @@ import { MAP_VIEWS } from "../lib/map-views.js"; import { CULTURE_SECTIONS, cultureEntries } from "../lib/culture.js"; import { contextTopics } from "../lib/contexts.js"; +import { datasetPages } from "../lib/dataset-pages.js"; import { commits, pageCount, pageOf } from "../lib/gitlog.js"; import { lastChanged } from "../lib/lastmod.js"; @@ -37,4 +38,6 @@ export function GET({ site }) { ["context/", latest], ...contextTopics.map((t) => [`context/${t.slug}/`, latest]), + ["datasets/", latest], + ...datasetPages.map((p) => [`datasets/${p.slug}/`, latest]), // profiles ...places.map((c) => [`country/${c.slug}/`, c.details.reviewed]),
src/styles/global.css +54 −0
@@ -3517,2 +3517,56 @@ a.ctx-topic:hover { text-decoration: underline; text-decoration-thickness: 1px; .ctxp-intro { max-width: 72ch; margin: 18px 0 8px; } .prose.ctxp-intro > p, .prose.ctxp-intro > p:first-child { font-size: 17px; line-height: 1.6; color: var(--text); } +/* ── Datasets (/datasets/, /datasets/<slug>/): the context pages' layout, with these additions ── */ +.dsp-menu a b { font-weight: 400; color: var(--muted); margin-left: 2px; } +.dsp-menu a.on { color: var(--text); } +.dsp-menu .dsp-menu-sub { border-top: 1px solid var(--border); } +.dsp-menu .dsp-menu-sub a { font-size: 10.5px; letter-spacing: .04em; } +.dsp-n { font: 400 12px var(--mono); color: var(--muted); vertical-align: middle; margin-left: 4px; } +.dsp-list a { grid-template-columns: minmax(0, 20rem) minmax(0, 1fr) minmax(8rem, 14rem); } +.dsp-foot, .dsp-note { color: var(--text-2); font-size: 13.5px; line-height: 1.5; max-width: 80ch; } +.dsp-foot a, .dsp-note a, .dsp-facts a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; } +.dsp-intro p + p { margin-top: 12px; } +.dsp-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 28px; margin: 14px 0 0; max-width: 1000px; } +.dsp-facts dt { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.dsp-facts dd { margin: 3px 0 0; font-size: 13.5px; line-height: 1.45; color: var(--text-2); overflow-wrap: anywhere; } +.dsp-note { margin: 10px 0 0; } +.dsp-ends { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 40px; } +.dsp-ends-h { font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 0 0 6px; } +.dsp-ends ol { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--text); } +.dsp-ends li { display: flex; align-items: baseline; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border); } +.dsp-ends li a { flex: 1; color: var(--text); } +.dsp-ends li b { font: 400 20px/1 var(--display); } +.dsp-ends li small { font: 400 10.5px var(--mono); color: var(--muted); } +.dsp-table th button { all: unset; cursor: pointer; } +.dsp-table th button::after { content: "↕"; margin-left: 5px; color: var(--muted); opacity: .6; } +.dsp-table th[aria-sort="ascending"] button::after { content: "↑"; opacity: 1; } +.dsp-table th[aria-sort="descending"] button::after { content: "↓"; opacity: 1; } +.dsp-fig { white-space: nowrap; padding-right: 24px; } +.src-table .dsp-fig-h { padding-right: 24px; } +.dsp-fig b { font-weight: 500; color: var(--text); } +.dsp-fig span { display: block; font-size: 12px; color: var(--muted); white-space: normal; } +.dsp-sw { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; vertical-align: .05em; } +.dsp-year { color: var(--muted); font: 400 12px var(--mono); } +.dsp-origin { max-width: 80ch; } +.dsp-origin p, .dsp-origin li { font-size: 14.5px; line-height: 1.55; color: var(--text-2); } +.prose.dsp-origin > p:first-of-type { font-size: 14.5px; line-height: 1.55; } +@media (max-width: 700px) { + .dsp-ends { grid-template-columns: minmax(0, 1fr); gap: 24px; } + .dsp-table th:nth-child(3), .dsp-table th:nth-child(4) { display: none; } + .dsp-table .dsp-fig { text-align: right; } + .dsp-table .dsp-year { display: none; } +} +/* the home map's Datasets tab: a row per group of datasets, with its name before the views */ +.metric-rows { display: grid; gap: 8px; } +.metric-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.metric-row-h { flex: 0 0 9.5rem; font: 400 10.5px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.metric-group a.metric-page { border-color: transparent; background: none; color: var(--muted); } +.metric-group a.metric-page:hover { color: var(--text); } +.metric-rows a.metric-page { justify-self: start; padding-left: 0; } +@media (max-width: 640px) { + .metric-group.metric-rows { display: grid; overflow: visible; } + .metric-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; } + .metric-row::-webkit-scrollbar { display: none; } + .metric-row-h { flex: none; min-width: 5.5rem; } + .metric-row a { flex: none; } +}