Changelog d26f8betree, with Claude Opus 5.5
Regions index: every region's average score over time in one chart
Grey lines since 1900, the world average dashed, each named at its end; the line or name under the pointer is drawn in full and the others fade.
2 files of code, design or docs +65 −1 lines
Code, design and documents 2 files
src/pages/region/index.astro +53 −1
@@ -7,5 +7,5 @@ import RegionMenu from "../../components/RegionMenu.astro"; import { regions } from "../../lib/regions.js"; import { TIERS, tierColor, tierOf } from "../../lib/tiers.js"; -import { worldAvg } from "../../lib/countries.js"; +import { worldAvg, averageHistory, dateTime, lastUpdated } from "../../lib/countries.js"; const base = import.meta.env.BASE_URL; @@ -13,4 +13,25 @@ const fmt = (v) => v.toFixed(1); // how a region's countries divide by score band, strictest first const bands = (r) => [...TIERS].reverse().map((t) => ({ t, n: r.members.filter((c) => tierOf(c.raw) === t).length })).filter((x) => x.n); + +// One chart, every region's average score at the end of each year since 1900, one line each, +// with the world average dashed. Grey lines, named at their right end; the one under the pointer +// is drawn in full (script below). +const Y0 = 1900, Y1 = +lastUpdated.slice(0, 4); +const yearEnd = (history, year) => { const t = Date.UTC(year, 11, 31, 23, 59); let v = history[0].before; for (const p of history) { if (dateTime(p.date) > t) break; v = p.after; } return v; }; +const years = Array.from({ length: Y1 - Y0 + 1 }, (_, i) => Y0 + i); +const lines = [ + ...regions.map((r) => ({ slug: r.slug, name: r.short, values: years.map((y) => yearEnd(r.series.history, y)) })), + { slug: "world", name: "World", values: years.map((y) => yearEnd(averageHistory, y)), world: true }, +]; +const CW = 960, CH = 360, PL = 34, PR = 120, PT = 12, PB = 28; +const top = Math.ceil(Math.max(...lines.flatMap((l) => l.values)) / 10) * 10; +const cx = (y) => PL + ((y - Y0) / (Y1 - Y0)) * (CW - PL - PR); +const cy = (v) => PT + (1 - v / top) * (CH - PT - PB); +for (const l of lines) l.d = l.values.map((v, i) => `${i ? "L" : "M"}${cx(years[i]).toFixed(1)},${cy(v).toFixed(1)}`).join(""); +// the names at the right end, pushed apart where lines end close together +const labels = [...lines].sort((a, b) => b.values.at(-1) - a.values.at(-1)).map((l) => ({ l, y: cy(l.values.at(-1)) })); +for (let i = 1; i < labels.length; i++) labels[i].y = Math.max(labels[i].y, labels[i - 1].y + 15); +const gridY = Array.from({ length: top / 10 + 1 }, (_, i) => i * 10); +const gridX = years.filter((y) => y % 20 === 0); --- <Base title="Regions" description="Cannabis laws by region: Europe, the European Union, Africa, Asia, the Americas and Oceania, each with its countries' scores, legal status, a map and the latest changes."> @@ -23,4 +44,23 @@ const bands = (r) => [...TIERS].reverse().map((t) => ({ t, n: r.members.filter(( </section> + <section class="section" aria-labelledby="rgpTrend"> + <div class="section-head"><div> + <h2 id="rgpTrend">Every region over time</h2> + <p class="sub">The average score of each region's countries at the end of each year, rebuilt from the recorded history. Point at a line to pick it out.</p> + </div></div> + <figure class="rgp-trend" data-rgp-trend> + <svg viewBox={`0 0 ${CW} ${CH}`} role="img" aria-label={`Average score by region, ${Y0} to ${Y1}: ${lines.map((l) => `${l.name} ${l.values.at(-1).toFixed(1)}`).join(", ")}`}> + {gridY.map((v) => <g class="rgp-grid"><line x1={PL} x2={CW - PR} y1={cy(v)} y2={cy(v)} /><text x={PL - 8} y={cy(v) + 4} text-anchor="end">{v}</text></g>)} + {gridX.map((y) => <text class="rgp-axis" x={cx(y)} y={CH - 8} text-anchor="middle">{y}</text>)} + {lines.map((l) => <path class:list={["rgp-line", { "rgp-world": l.world }]} d={l.d} data-r={l.slug}><title>{l.name}: {l.values.at(-1).toFixed(1)} in {Y1}</title></path>)} + {labels.map(({ l, y }) => ( + <a href={l.world ? `${base}charts/` : `${base}region/${l.slug}/`} data-r={l.slug} class="rgp-label"> + <text x={CW - PR + 8} y={y + 4}>{l.name} <tspan>{l.values.at(-1).toFixed(1)}</tspan></text> + </a> + ))} + </svg> + </figure> + </section> + {[["Continents", "continent"], ["Groups of countries", "grouping"]].map(([title, kind]) => ( <section class="section"> @@ -44,2 +84,14 @@ const bands = (r) => [...TIERS].reverse().map((t) => ({ t, n: r.members.filter(( </main> </Base> + +<script> + // point at a line or its name: that region is drawn in full, the others fade + for (const fig of document.querySelectorAll("[data-rgp-trend]")) { + const set = (slug) => fig.querySelectorAll("[data-r]").forEach((el) => el.classList.toggle("on", el.dataset.r === slug)); + fig.querySelectorAll("[data-r]").forEach((el) => { + el.addEventListener("mouseenter", () => { fig.classList.add("picking"); set(el.dataset.r); }); + el.addEventListener("focus", () => { fig.classList.add("picking"); set(el.dataset.r); }); + }); + fig.addEventListener("mouseleave", () => { fig.classList.remove("picking"); set(null); }); + } +</script>
src/styles/global.css +12 −0
@@ -3611,2 +3611,14 @@ td.rgp-seg i { display: inline-block; width: 7px; height: 7px; margin-right: 5px .rgp-seg { display: none; } } +/* the regions index: every region's average over time, grey lines, the pointed one in full */ +.rgp-trend { margin: 0; } +.rgp-trend svg { display: block; width: 100%; height: auto; overflow: visible; } +.rgp-grid line { stroke: var(--border); stroke-width: 1; } +.rgp-grid text, .rgp-axis { font: 400 11px var(--mono); fill: var(--muted); } +.rgp-line { fill: none; stroke: var(--muted); stroke-width: 1.6; stroke-linejoin: round; transition: opacity .15s, stroke .15s; } +.rgp-line.rgp-world { stroke: var(--text-2); stroke-dasharray: 5 4; } +.rgp-label text { font: 400 12px var(--mono); fill: var(--text-2); text-transform: uppercase; letter-spacing: .03em; } +.rgp-label tspan { fill: var(--muted); } +.rgp-trend.picking .rgp-line:not(.on), .rgp-trend.picking .rgp-label:not(.on) { opacity: .25; } +.rgp-line.on { stroke: var(--text); stroke-width: 2.6; } +.rgp-label.on text { fill: var(--text); }