Changelog 99e7570
better chart
4 files of code, design or docs +146 −119 lines
Code, design and documents 4 files
source-archive.json +6 −2
Not shown: a picture, a generated file, or past the length shown per change.
src/components/AverageChart.astro +111 −110
@@ -1,23 +1,18 @@ --- -// World average score over time: a step line that moves at each recorded change. -// Four views are rendered: the whole history or the last fifteen years, each averaged per country -// or weighted by population. Two switches pick which one is shown. -// The plot is a stretched SVG; labels and markers are HTML so they stay readable at any width. -import { averageHistory, averageHistoryByPopulation, formatDate, dateTime, lastUpdated } from "../lib/countries.js"; +// The world over time in one chart: stacked bands, the share of countries (or of people) in each +// score tier year by year, with the world average score drawn over them as a line. The bands and +// the score share one 0–100 scale. Four views: the whole history or the last fifteen years, each +// per country or weighted by population; two switches pick which one is shown. All four are in +// the page, as they are small. +// The plot is a stretched SVG; labels are HTML so they stay readable at any width. +import { averageHistory, averageHistoryByPopulation, bandHistory, bandHistoryByPopulation, countries, dateTime, lastUpdated } from "../lib/countries.js"; import { TIERS, tierOf, tierColor } from "../lib/tiers.js"; // `note` adds the caveat under the chart. -// Only the view visible before any click is part of the page. The other three, about 400 kB of -// markers between them, are served by src/pages/fragments/avg-chart.astro (`part="rest"`) and -// fetched when a switch is first used. -const { note = false, part = "main" } = Astro.props; -const base = import.meta.env.BASE_URL; +const { note = false } = Astro.props; const fmt = (v) => v.toFixed(1); const time = dateTime; - const endYear = +lastUpdated.slice(0, 4); -const t1 = time(lastUpdated); -// one chart for each combination of period and weighting const RANGES = [ { id: "all", label: "All", startYear: 1860 }, // the handful of older bans are already counted in the starting value @@ -25,87 +20,80 @@ const RANGES = [ ]; const WEIGHTS = [ - { id: "country", label: "By country", title: "Every country counts the same", history: averageHistory }, - { id: "people", label: "By population", title: "Each country counts in proportion to its population today", history: averageHistoryByPopulation }, + { id: "country", label: "By country", title: "Every country counts the same", history: averageHistory, bands: bandHistory }, + { id: "people", label: "By population", title: "Each country counts in proportion to its population", history: averageHistoryByPopulation, bands: bandHistoryByPopulation }, ]; +const TIER_ORDER = [...TIERS].reverse(); // severe … free, as in bandHistory +// a date as a fractional year, so the line's steps fall where the bands' years do +const yearOf = (iso) => { const t = time(iso), y = new Date(t).getUTCFullYear(); return y + (t - Date.UTC(y, 0, 1)) / (Date.UTC(y + 1, 0, 1) - Date.UTC(y, 0, 1)); }; function view(range, weighting) { - const { startYear } = range; - const history = weighting.history; - const now = history.at(-1).after; - const t0 = Date.UTC(startYear, 0, 1); - const x = (t) => ((t - t0) / (t1 - t0)) * 100; - const inRange = history.filter((p) => time(p.date) >= t0); - const start = inRange[0].before; // the average when the view begins + const rows = weighting.bands.filter(([yr]) => yr >= range.startYear); + const [y0, y1] = [rows[0][0], rows.at(-1)[0]]; + const x = (yr) => Math.min(100, ((yr - y0) / (y1 - y0)) * 100); + + // the bands, freest at the bottom and strictest on top, one point per year + const layers = []; + let below = rows.map(() => 0); + for (let k = TIER_ORDER.length - 1; k >= 0; k--) { + const top = below.map((b, i) => b + rows[i][1][k]); + const pts = top.map((v, i) => `${x(rows[i][0]).toFixed(2)},${(100 - v * 100).toFixed(2)}`) + .concat(below.map((v, i) => `${x(rows[i][0]).toFixed(2)},${(100 - v * 100).toFixed(2)}`).reverse()); + layers.push({ color: tierColor(TIER_ORDER[k].min), points: pts.join(" ") }); + below = top; + } - // y axis: round steps that enclose the data (the range is small, so it does not start at zero) - const values = inRange.flatMap((p) => [p.before, p.after]); - const spread = Math.max(...values) - Math.min(...values); - const tick = spread > 50 ? 20 : spread > 25 ? 10 : spread > 12 ? 5 : spread > 6 ? 2 : spread > 3 ? 1 : 0.5; - const yMin = Math.floor(Math.min(...values) / tick) * tick; - const yMax = Math.ceil(Math.max(...values) / tick) * tick; - const y = (v) => (1 - (v - yMin) / (yMax - yMin)) * 100; - const ticks = Array.from({ length: Math.round((yMax - yMin) / tick) + 1 }, (_, i) => yMin + i * tick); - // the score tiers behind the line, cut to the visible range, so the reader sees which band the world is in - const bands = TIERS.map((t, i) => [Math.max(t.min, yMin), Math.min(i ? TIERS[i - 1].min : 100, yMax), t]) - .filter(([lo, hi]) => hi > lo) - .map(([lo, hi, t]) => ({ name: t.name, color: tierColor(t.min), top: y(hi), height: y(lo) - y(hi) })); + // the world average, a step at each recorded change + const steps = weighting.history.filter((p) => yearOf(p.date) >= y0); + const start = steps[0].before, now = weighting.history.at(-1).after; + const line = `M0,${(100 - start).toFixed(2)}` + steps.map((p) => `H${x(yearOf(p.date)).toFixed(2)}V${(100 - p.after).toFixed(2)}`).join("") + "H100"; + // the average at the end of each year, for the readout + const avgAt = rows.map(([yr]) => { let v = start; for (const p of steps) { if (yearOf(p.date) >= yr + 1) break; v = p.after; } return Math.round(v * 10); }); - // at most about a dozen year labels, on round steps - const span = endYear - startYear; + const span = y1 - y0; const step = span > 120 ? 20 : span > 50 ? 10 : span > 20 ? 5 : span > 14 ? 2 : 1; - const years = Array.from({ length: span + 1 }, (_, i) => startYear + i) - .filter((yr) => yr % step === 0) - .map((yr) => ({ yr, x: x(Date.UTC(yr, 0, 1)) })); + const years = rows.map(([yr]) => yr).filter((yr) => yr % step === 0).map((yr) => ({ yr, x: x(yr) })); - const steps = inRange.map((p) => ({ ...p, x: x(time(p.date)), y: y(p.after) })); - const line = `M0,${y(start)}` + steps.map((p) => `H${p.x}V${p.y}`).join("") + "H100"; - const points = steps.filter((p) => p.change); // estimated changes move the line but get no dot - // for the seeker (the script is in Base.astro): the axis and the steps of the line; the entries - // it lists are read from the dots - const seek = JSON.stringify({ t0, t1, start, unit: "average", dp: 1, dots: true, y: [yMin, yMax], steps: steps.map((p) => [p.x, p.x, p.before, p.after]) }); - return { seek, range: range.id, weight: weighting.id, startYear, now, bands, whole: tick >= 1, delta: now - start, ticks: ticks.map((t) => ({ t, y: y(t) })), years, points, line }; + // the two red tiers: prohibition and severe prohibition + const banned = (r) => r[1][0] + r[1][1]; + const bannedNow = banned(rows.at(-1)), bannedThen = banned(rows[0]); + const sub = { big: `${Math.round(bannedNow * 100)}%`, label: weighting.id === "country" ? "Countries under prohibition or worse" : "People under prohibition or worse", + text: weighting.id === "country" ? `${Math.round(bannedNow * countries.length)} of ${countries.length}` : "of the world's people", delta: Math.round((bannedNow - bannedThen) * 100) }; + // for the readout: each year's shares in tenths of a per cent, and the average in tenths + const data = JSON.stringify({ y0, unit: weighting.id === "country" ? countries.length : 0, avg: avgAt, rows: rows.map(([, sh]) => sh.map((v) => Math.round(v * 1000))) }); + return { range: range.id, weight: weighting.id, startYear: range.startYear, now, delta: now - start, layers, line, years, sub, data }; } const views = WEIGHTS.flatMap((w) => RANGES.map((r) => view(r, w))); const shown = (v) => v.range === RANGES[0].id && v.weight === WEIGHTS[0].id; // the view visible before any click +const signed = (n, unit = "") => `${n > 0 ? "+" : n < 0 ? "−" : "±"}${Math.abs(n)}${unit}`; --- -{part === "rest" ? views.filter((v) => !shown(v)).map((v) => ( - <div class="avg-chart" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}> - <div class="avg-y" aria-hidden="true"> - {v.ticks.map(({ t, y }) => <span style={`top:${y}%`}>{v.whole ? t : fmt(t)}</span>)} - </div> - <div class="avg-plot" data-seek={v.seek}> - <svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"> - {v.bands.map((b) => <rect class="band" x="0" width="100" y={b.top} height={b.height} style={`fill:${b.color}`} />)} - {v.ticks.map(({ y }) => <line class="grid" x1="0" x2="100" y1={y} y2={y} />)} - <path class="line" d={v.line} /> - </svg> - {v.points.map((p) => ( - <a class="avg-dot" href={`${base}history/${p.change.id}/`} style={`left:${p.x}%;top:${p.y}%`} - data-date={formatDate(p.date)} data-title={`${p.change.country.flag} ${p.change.title}`} - data-avg={`World average ${fmt(p.before)} → ${fmt(p.after)}`} - aria-label={`${formatDate(p.date)}: ${p.change.title}. World average ${fmt(p.before)} to ${fmt(p.after)}.`}></a> +<section class="card avg" id="avgChart" aria-labelledby="avgTitle"> + {/* two figures: the world average score, and the share under prohibition or worse */} + <div class="avg-head"> + <h2 id="avgTitle" class="visually-hidden">Cannabis freedom in the world over time</h2> + <div class="avg-stat"> + <p class="avg-label">World average score</p> + <p class="avg-now"> + {views.map((v) => ( + <span class="avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}> + <b>{fmt(v.now)}</b> + <span class="avg-tier" style={`--c:${tierColor(v.now)}`}>{tierOf(v.now).name}</span> + <span class={`delta ${v.delta >= 0 ? "up" : "down"}`}>{v.delta >= 0 ? "+" : "−"}{fmt(Math.abs(v.delta))} since {v.startYear}</span> + </span> ))} - {v.bands.filter((b) => b.height >= 9).map((b) => <span class="avg-band" aria-hidden="true" style={`top:${b.top + b.height / 2}%;--c:${b.color}`}>{b.name}</span>)} - <span class="c-seek" hidden></span> - <div class="c-seek-tip" hidden></div> - </div> - <div class="avg-x" aria-hidden="true"> - {v.years.map(({ yr, x }) => <span style={`left:${x}%`}>{yr}</span>)} - </div> + </p> + </div> + <div class="avg-stat"> + {views.map((v) => <p class="avg-label avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}>{v.sub.label}</p>)} + <p class="avg-now"> + {views.map((v) => ( + <span class="avg-figure" data-range={v.range} data-weight={v.weight} hidden={!shown(v)}> … 121 more lines
src/pages/fragments/avg-chart.astro +0 −7
@@ -1,7 +0,0 @@ ---- -// The three views of the world-average chart that the pages leave out (see AverageChart.astro). -// A fragment, not a page: no layout, fetched by the chart's script on the first switch click. -import AverageChart from "../../components/AverageChart.astro"; -export const partial = true; ---- -<AverageChart part="rest" />
src/styles/global.css +29 −0
@@ -563,4 +563,33 @@ a.chip:hover { border-color: var(--muted); color: var(--text); } .avg-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border); } .avg-chart[hidden] { display: none; } +/* the stacked bands (AverageChart.astro): shares of countries or people in each tier */ +.avg-bands .avg-plot svg polygon { stroke: none; } +/* the chart's two figures, side by side, wrapping on a narrow screen */ +.avg-head .avg-stat { display: grid; gap: 4px; min-width: 0; } +.avg-label { margin: 0; font: 400 11.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-2); } +.avg-label[hidden] { display: none; } +.avg-now .avg-figure { flex-wrap: wrap; row-gap: 4px; } +/* side by side: each number with its band and change on the line below it */ +#avgChart .avg-head { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px 28px; } +#avgChart .avg-now .avg-figure { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 6px 10px; } +#avgChart .avg-now .avg-figure b { grid-column: 1 / -1; } +#avgChart .avg-now .avg-figure .delta { grid-column: 1 / -1; } +#avgChart .avg-figure[hidden] { display: none; } +#avgChart .avg-now b { font-size: clamp(40px, 4.2vw, 60px); } +#avgChart .avg-now .delta, #avgChart .avg-tier { font-size: 12px; } +/* the world average drawn over the bands */ +.avg-plot .avg-over { fill: none; stroke: var(--text); stroke-width: 2; stroke-linejoin: round; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 2px rgb(0 0 0 / .45)); } +.avg-legend .avg-over-key { width: 16px; height: 2px; border-radius: 1px; background: var(--text); vertical-align: 3px; } +.bands-seek { position: absolute; top: 0; bottom: 0; width: 1.5px; margin-left: -.75px; background: var(--text); pointer-events: none; } +.bands-tip { position: absolute; top: 8px; display: grid; gap: 3px; min-width: 200px; padding: 9px 11px; border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); box-shadow: var(--shadow); font-size: 12.5px; pointer-events: none; z-index: 3; } +.bands-tip[hidden], .bands-seek[hidden] { display: none; } +.bands-tip b { font: 500 13px var(--mono); margin-bottom: 2px; } +.bands-tip span { display: flex; align-items: center; gap: 7px; color: var(--text-2); } +.bands-tip i { width: 9px; height: 9px; border-radius: 2px; flex: none; } +.bands-tip em { margin-left: auto; padding-left: 14px; font: 500 12px var(--mono); font-style: normal; color: var(--text); } +.bands-tip .tip-avg { color: var(--text); padding-bottom: 4px; margin-bottom: 2px; border-bottom: 1px solid var(--border); } +.avg-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 12px 0 0 34px; font: 400 10.5px var(--mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); } +.avg-legend[hidden] { display: none; } +.avg-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; } .avg-now { margin: 0; display: flex; align-items: baseline; gap: 10px; } .avg-now b { font-size: 32px; line-height: 1; font-variant-numeric: tabular-nums; }